mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
723cad24bb
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting Replace flat key-value memories table with structured_memories (migration 014). Four memory types (user/project/feedback/reference), three scopes (global/workstream/user). Consolidate remember/recall/forget into two tools: memory (action-based: save/search/delete/list) and recall (conversation history only). BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5 memories for system message injection based on conversation context. Metacognitive prompting injects ephemeral nudges after corrections, tool denials, workstream resume, and completion signals. Scope isolation enforced: system message injection and nudge counts filtered to visible memories only (global + current workstream + authenticated user). User scope requires authentication. Content capped at 32KB. ILIKE/LIKE metacharacters escaped in both backends. 113 new tests (2053 total). * fix: CI failure + copilot review feedback - Fix time.monotonic() cooldown: use None sentinel instead of 0.0 default (monotonic clock starts at boot, not epoch — fresh CI runners have uptime < 300s so cooldown check always triggered) - Catch sa.exc.IntegrityError specifically in upsert instead of broad Exception (copilot review) - Preserve existing description/type on upsert when caller doesn't explicitly set them (copilot review) - Add last_accessed + access_count columns to schema/migration for future LRU/LFU eviction support
168 lines
5.0 KiB
Plaintext
168 lines
5.0 KiB
Plaintext
@startuml
|
||
!theme plain
|
||
title Turnstone — Conversation Turn Lifecycle
|
||
|
||
skinparam sequenceArrowThickness 1.5
|
||
skinparam sequenceLifeLineBackgroundColor #F5F5F5
|
||
|
||
participant "User /\nHTTP Client" as User
|
||
participant "ChatSession" as CS
|
||
participant "SessionUI" as UI
|
||
participant "LLMProvider\n(OpenAI / Anthropic)" as LLM
|
||
participant "Tool Executor\n(ThreadPool)" as TP
|
||
database "SQLite" as DB
|
||
|
||
== User Input ==
|
||
|
||
User -> CS : send(user_input)
|
||
activate CS
|
||
|
||
CS -> CS : messages.append({role: "user", content: input})
|
||
CS -> DB : save_message(ws_id, "user", input)
|
||
|
||
== LLM Call Loop ==
|
||
|
||
group loop [while tool_calls present]
|
||
|
||
CS -> UI : on_state_change("thinking")
|
||
CS -> UI : on_thinking_start()
|
||
|
||
CS -> LLM : provider.create_streaming(\n client, model, messages, tools, ...)\n (normalized StreamChunk iterator)
|
||
activate LLM
|
||
|
||
note right of CS
|
||
Retry up to 3× on transient errors:
|
||
RateLimitError, APITimeoutError,
|
||
APIConnectionError, InternalServerError,
|
||
ServiceUnavailableError, APIError
|
||
Backoff: 1s, 2s, 4s
|
||
end note
|
||
|
||
== Streaming Response ==
|
||
|
||
loop for each chunk in stream
|
||
LLM --> CS : delta
|
||
note right of CS
|
||
on_thinking_stop() called on first
|
||
delta token via _stop_spinner_once()
|
||
end note
|
||
alt reasoning_content present
|
||
CS -> UI : on_reasoning_token(text)
|
||
else content present
|
||
CS -> UI : on_content_token(text)
|
||
else tool_call delta
|
||
CS -> CS : accumulate in tool_calls_acc
|
||
else info_delta present
|
||
CS -> UI : on_info(text)\n(e.g. server-side web search status)
|
||
end
|
||
end
|
||
|
||
note right of CS
|
||
**Cancellation checkpoint:**
|
||
_check_cancelled() runs per chunk.
|
||
If cancel_event is set, raises
|
||
GenerationCancelled — preserves
|
||
partial content, emits idle state.
|
||
end note
|
||
|
||
LLM --> CS : stream complete (usage stats)
|
||
deactivate LLM
|
||
|
||
CS -> UI : on_thinking_stop() (no-op guard: already called by _stop_spinner_once)
|
||
CS -> UI : on_stream_end()
|
||
|
||
CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
|
||
CS -> CS : messages.append(assistant_msg)
|
||
CS -> DB : save_message(ws_id, "assistant", content)
|
||
CS -> DB : save_message(ws_id, "tool_call", ...) ×N
|
||
|
||
== Tool Dispatch (if tool_calls) ==
|
||
|
||
alt no tool_calls
|
||
CS -> UI : on_status(usage, context_window, effort)
|
||
|
||
opt prompt_tokens > context_window × auto_compact_pct
|
||
CS -> CS : _compact_messages(auto=True)
|
||
CS -> LLM : Non-streaming summarization call
|
||
CS -> CS : Replace messages with [summary]
|
||
end
|
||
|
||
opt first exchange & no title
|
||
CS -> CS : Background thread: _generate_title()
|
||
end
|
||
|
||
CS -> UI : on_state_change("idle")
|
||
CS --> User : return
|
||
|
||
else has tool_calls
|
||
CS -> UI : on_state_change("running")
|
||
|
||
== Phase 1: Prepare ==
|
||
CS -> CS : [_prepare_tool(tc) for tc in tool_calls]\nParse JSON args, validate,\nbuild preview + header
|
||
|
||
== Phase 2: Approve ==
|
||
CS -> UI : on_state_change("attention")
|
||
CS -> UI : approve_tools(items)
|
||
activate UI
|
||
note right of UI
|
||
TerminalUI: input() prompt
|
||
WebUI: _approval_event.wait()
|
||
NullUI: returns (True, None)
|
||
end note
|
||
UI --> CS : (approved: bool, feedback: str?)
|
||
deactivate UI
|
||
CS -> UI : on_state_change("running")
|
||
|
||
== Phase 3: Execute ==
|
||
CS -> TP : ThreadPoolExecutor(max_workers=4)\nrun_one(item) for each tool
|
||
activate TP
|
||
|
||
note right of TP
|
||
Parallel execution:
|
||
bash → Popen + line-by-line streaming
|
||
read_file → open().read() or base64 image
|
||
search → grep subprocess
|
||
edit_file → string replace
|
||
task/plan → _run_agent() sub-loop
|
||
math → sandboxed subprocess
|
||
web_fetch → httpx + LLM summarize
|
||
web_search → provider-native or Tavily fallback
|
||
memory/recall → SQLite
|
||
end note
|
||
|
||
note right of TP
|
||
bash: on_tool_output_chunk(call_id, line)
|
||
called per stdout line,
|
||
then on_tool_result(call_id, name, output).
|
||
call_id routes chunks/results to correct
|
||
tool div during parallel execution.
|
||
Other tools: on_tool_result() only.
|
||
end note
|
||
|
||
TP --> CS : [(call_id, output), ...]
|
||
deactivate TP
|
||
|
||
loop for each result
|
||
CS -> CS : messages.append({role: "tool", ...})
|
||
CS -> DB : save_message(ws_id, "tool_result", ...)
|
||
end
|
||
|
||
opt user_feedback from approval
|
||
CS -> CS : messages.append({role: "user", content: feedback})
|
||
end
|
||
|
||
note right of CS : Loop back for next LLM call
|
||
|
||
else GenerationCancelled
|
||
CS -> CS : Preserve partial content\nor roll back incomplete tools
|
||
CS -> UI : on_info("[Generation cancelled]")
|
||
CS -> UI : on_state_change("idle")
|
||
CS --> User : return (no re-raise)
|
||
end
|
||
|
||
end
|
||
|
||
deactivate CS
|
||
|
||
@enduml
|