mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
1728a4c0af
Drop the Tavily and DuckDuckGo (ddgs) web_search backends for a single self-hosted SearxNG service bundled into the docker-compose stacks. Core: - New SearXNGClient + _format_searxng; rewrite resolve_web_search_client to (backend, searxng_url, searxng_engines, ...). MCP backend + oauth_user guard unchanged. _resolve_search_client follows storage -> toml -> env -> default precedence (explicit "" disables, via ConfigStore.stored_keys()). - Drop the Tavily-era topic=finance (no SearxNG category); topic is now general/news. Settings/config: - Remove tools.tavily_api_key, get_tavily_key, $TAVILY_API_KEY, [api].tavily_key. - Add tools.searxng_url (default http://searxng:8080) + tools.searxng_engines, with get_searxng_url/get_searxng_engines. Compose + bundled config: - Internal-only searxng service (no published API port, :ro config, /healthz healthcheck, persistent searxng-cache volume) in both stacks; bundle turnstone/deploy/searxng/settings.yml (JSON output on, limiter off). - Caddy serves the SearxNG web UI on :8444 (dev: localhost-only; prod: opt-in). - bootstrap extractor + wheel packaging updated. Deps: drop the ddg extra + ddgs mypy override (regenerates uv.lock, removing the lxml/h2/brotli transitives). Docs: tools/docker/architecture/openshell + diagrams + config example + CHANGELOG; docs/docker.md carries the AGPL-3.0 §13 operator note. BREAKING: tools.web_search_backend no longer accepts "tavily"/"ddg"; tools.tavily_api_key and the ddg extra are removed. Run the bundled SearxNG (ships in the compose stacks) or set TURNSTONE_SEARXNG_URL to an external instance. Closes #545
185 lines
5.6 KiB
Plaintext
185 lines
5.6 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_turn_start()
|
||
note right of UI
|
||
SessionUIBase resets the per-turn inflight
|
||
buffers (_ws_inflight_content / reasoning /
|
||
seq) that fuel the SSE in_progress_snapshot
|
||
event for mid-stream refresh resume.
|
||
end note
|
||
|
||
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 -> UI : on_turn_committed()
|
||
note right of UI
|
||
Drops the per-turn inflight buffers — the
|
||
assistant message is now in the history
|
||
list, so the in_progress_snapshot must
|
||
not re-render it during the next tool-
|
||
execution window or the next streaming turn.
|
||
end note
|
||
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 SearxNG 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, is_error).
|
||
is_error=True when execution failed.
|
||
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
|