mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
f02972c11d
* Add provider-native web search with Tavily fallback Replace client-side Tavily web search with provider-native implementations: - Anthropic: inject web_search_20250305 server-side tool, handle server_tool_use / web_search_tool_result streaming blocks, emit info_delta for search status display - OpenAI: inject web_search_options for gpt-5-search-api, format url_citation annotations as footnote sources - Local/vLLM: preserve existing Tavily-based web_search tool as fallback Add supports_web_search to ModelCapabilities and info_delta to StreamChunk. Remove end-of-life GPT-4o model entries from capability tables. Update docs, diagrams, and README. 88 provider tests (32 new). * Fix Copilot PR #13 review: capture streaming url_citation annotations Accumulate url_citation annotations during OpenAI streaming and emit formatted citations as a final info_delta chunk after the stream ends. Previously annotations were only captured in non-streaming mode, so search model users in the interactive path never saw citation sources.
349 lines
8.4 KiB
Plaintext
349 lines
8.4 KiB
Plaintext
@startuml
|
|
!theme plain
|
|
title Turnstone — Core Engine Classes
|
|
|
|
skinparam classAttributeIconSize 0
|
|
|
|
' SessionUI Protocol
|
|
interface "SessionUI" as SessionUI <<Protocol>> {
|
|
+ on_thinking_start()
|
|
+ on_thinking_stop()
|
|
+ on_reasoning_token(text: str)
|
|
+ on_content_token(text: str)
|
|
+ on_stream_end()
|
|
+ approve_tools(items: list) → (bool, str|None)
|
|
+ on_tool_result(call_id: str, name: str, output: str)
|
|
+ on_tool_output_chunk(call_id: str, chunk: str)
|
|
+ on_status(usage: dict, ctx_window: int, effort: str)
|
|
+ on_plan_review(content: str) → str
|
|
+ on_info(message: str)
|
|
+ on_error(message: str)
|
|
+ on_state_change(state: str)
|
|
+ on_rename(name: str)
|
|
}
|
|
|
|
' Implementations
|
|
class "TerminalUI" as TerminalUI {
|
|
Writes to stdout with ANSI colors
|
|
Prompts for approval via input()
|
|
--
|
|
cli.py
|
|
}
|
|
|
|
class "WorkstreamTerminalUI" as WsTermUI {
|
|
- _output_buffer: list[tuple]
|
|
- ws_id: str
|
|
- manager: WorkstreamManager
|
|
+ flush_buffer()
|
|
--
|
|
Buffers output when workstream
|
|
is not foregrounded
|
|
}
|
|
|
|
class "WebUI" as WebUI {
|
|
- _event_queue: Queue
|
|
- _approval_event: Event
|
|
- _plan_event: Event
|
|
- _ws_prompt_tokens: int
|
|
- _ws_tool_calls: dict
|
|
+ resolve_approval(approved, feedback)
|
|
+ resolve_plan(feedback)
|
|
--
|
|
Enqueues JSON events for SSE.
|
|
Blocks on threading.Event for
|
|
approval/plan review.
|
|
SSE handlers bridge Queue to
|
|
async via run_in_executor().
|
|
--
|
|
server.py
|
|
}
|
|
|
|
class "NullUI" as NullUI {
|
|
approve_tools() → (True, None)
|
|
All other methods: no-op
|
|
--
|
|
eval.py
|
|
}
|
|
|
|
' LLMProvider Protocol
|
|
interface "LLMProvider" as LLMProvider <<Protocol>> {
|
|
+ provider_name: str {property}
|
|
+ get_capabilities(model) → ModelCapabilities
|
|
+ create_streaming(client, model, messages, ...) → Iterator[StreamChunk]
|
|
+ create_completion(client, model, messages, ...) → CompletionResult
|
|
+ convert_tools(tools) → list[dict]
|
|
+ retryable_error_names: frozenset[str] {property}
|
|
--
|
|
core/providers/_protocol.py
|
|
}
|
|
|
|
class "OpenAIProvider" as OpenAIProv {
|
|
Model capability lookup table
|
|
(GPT-5.x, O-series, search)
|
|
Passthrough: messages already
|
|
in OpenAI format.
|
|
Search models: web_search_options
|
|
+ url_citation annotations.
|
|
--
|
|
core/providers/_openai.py
|
|
}
|
|
|
|
class "AnthropicProvider" as AnthropicProv {
|
|
Converts OpenAI messages to
|
|
Anthropic content blocks.
|
|
Adaptive + manual thinking.
|
|
Native web search via
|
|
web_search_20250305 server tool.
|
|
Lazy anthropic SDK import.
|
|
--
|
|
core/providers/_anthropic.py
|
|
}
|
|
|
|
' ModelCapabilities
|
|
class "ModelCapabilities" as ModelCaps <<frozen>> {
|
|
+ context_window: int
|
|
+ max_output_tokens: int
|
|
+ supports_temperature: bool
|
|
+ token_param: str
|
|
+ thinking_mode: str
|
|
+ supports_effort: bool
|
|
+ supports_web_search: bool
|
|
}
|
|
|
|
' ChatSession
|
|
class "ChatSession" as ChatSession {
|
|
- client: Any
|
|
- provider: LLMProvider
|
|
- model: str
|
|
- ui: SessionUI
|
|
- messages: list[dict]
|
|
- _msg_tokens: list[int]
|
|
- _session_id: str
|
|
- _mcp_client: MCPClientManager | None
|
|
- _registry: ModelRegistry | None
|
|
+ model_alias: str | None {property}
|
|
- _tools: list[dict]
|
|
- _task_tools: list[dict]
|
|
- _agent_tools: list[dict]
|
|
- _read_files: set[str]
|
|
- system_messages: list[dict]
|
|
--
|
|
+ send(user_input: str)
|
|
+ handle_command(command: str)
|
|
+ resume_session(session_id: str)
|
|
- _save_config()
|
|
- _stream_response(stream) → dict
|
|
- _create_stream_with_retry(msgs) → Stream (+ fallback)
|
|
- _try_stream(client, model, msgs) → Stream
|
|
- _execute_tools(tool_calls) → (results, feedback)
|
|
- _prepare_tool(tc) → item dict
|
|
- _prepare_mcp_tool(call_id, name, args) → item dict
|
|
- _exec_mcp_tool(item) → (call_id, output)
|
|
- _run_agent(messages, tools, ...) → str
|
|
- _compact_messages(auto: bool)
|
|
- _full_messages() → list[dict]
|
|
- _update_token_table(msg)
|
|
- _emit_state(state: str)
|
|
- _generate_title()
|
|
}
|
|
|
|
' HeadlessSession
|
|
class "HeadlessSession" as HeadlessSession {
|
|
+ tool_call_log: list[dict]
|
|
+ auto_approve: bool = True
|
|
+ send_headless(input, max_turns, ...)
|
|
- _override_system_prompt(content)
|
|
--
|
|
eval.py: non-streaming,
|
|
records all tool calls
|
|
}
|
|
|
|
' WorkstreamManager
|
|
class "WorkstreamManager" as WsMgr {
|
|
- _session_factory: Callable[[SessionUI], ChatSession]
|
|
- _workstreams: dict[str, Workstream]
|
|
- _order: list[str]
|
|
- _active_id: str
|
|
- _on_state_change: Callable
|
|
--
|
|
+ create(name, ui_factory) → Workstream
|
|
+ close(ws_id)
|
|
+ get(ws_id) → Workstream
|
|
+ get_active() → Workstream
|
|
+ switch(ws_id)
|
|
+ set_state(ws_id, state)
|
|
+ close_idle(max_age_seconds)
|
|
- _max_workstreams: int
|
|
}
|
|
|
|
' Workstream
|
|
class "Workstream" as Ws <<dataclass>> {
|
|
+ id: str
|
|
+ name: str
|
|
+ state: WorkstreamState
|
|
+ session: ChatSession
|
|
+ ui: SessionUI
|
|
+ worker_thread: Thread
|
|
+ error_message: str
|
|
+ last_active: float
|
|
- _lock: Lock
|
|
}
|
|
|
|
' WorkstreamState
|
|
enum "WorkstreamState" as WsState {
|
|
IDLE
|
|
THINKING
|
|
RUNNING
|
|
ATTENTION
|
|
ERROR
|
|
}
|
|
|
|
' MCPClientManager
|
|
class "MCPClientManager" as MCPMgr {
|
|
- _sessions: dict[str, ClientSession]
|
|
- _tools: list[dict]
|
|
- _tool_map: dict[str, tuple]
|
|
--
|
|
+ start()
|
|
+ get_tools() → list[dict]
|
|
+ is_mcp_tool(name) → bool
|
|
+ call_tool_sync(name, args) → str
|
|
+ shutdown()
|
|
--
|
|
Background asyncio event loop
|
|
bridges async MCP SDK to
|
|
sync ChatSession dispatch.
|
|
--
|
|
core/mcp_client.py
|
|
}
|
|
|
|
' ModelRegistry
|
|
class "ModelRegistry" as ModelReg {
|
|
- _models: dict[str, ModelConfig]
|
|
- _clients: dict[str, Any]
|
|
- _providers: dict[str, LLMProvider]
|
|
- _client_lock: Lock
|
|
+ default: str
|
|
+ fallback: list[str]
|
|
+ agent_model: str | None
|
|
--
|
|
+ resolve(alias) → (client, model, config)
|
|
+ get_client(alias) → Any
|
|
+ get_provider(alias) → LLMProvider
|
|
+ has_alias(alias) → bool
|
|
+ list_aliases() → list[str]
|
|
+ shutdown()
|
|
--
|
|
Thread-safe lazy client + provider
|
|
creation. Loaded by load_model_registry()
|
|
from CLI args + [models.*] config.
|
|
--
|
|
core/model_registry.py
|
|
}
|
|
|
|
class "ModelConfig" as ModelCfg <<frozen>> {
|
|
+ alias: str
|
|
+ provider: str
|
|
+ base_url: str
|
|
+ model: str
|
|
+ context_window: int
|
|
}
|
|
|
|
' Circuit breaker state
|
|
enum "CircuitState" as CircuitState {
|
|
CLOSED
|
|
OPEN
|
|
HALF_OPEN
|
|
}
|
|
|
|
' BackendHealthMonitor
|
|
class "BackendHealthMonitor" as HealthMon {
|
|
- _state: CircuitState
|
|
- _consecutive_failures: int
|
|
- _probe_interval: float
|
|
- _probe_timeout: float
|
|
- _threshold: int
|
|
- _cooldown: float
|
|
--
|
|
+ start()
|
|
+ stop()
|
|
+ record_success()
|
|
+ record_failure()
|
|
+ should_allow_request() → bool
|
|
--
|
|
Daemon thread probes
|
|
client.models.list() on interval.
|
|
--
|
|
core/healthcheck.py
|
|
}
|
|
|
|
' RateLimiter
|
|
class "RateLimiter" as RateLimiter {
|
|
- _buckets: dict[str, TokenBucket]
|
|
- _rate: float
|
|
- _burst: int
|
|
--
|
|
+ check(client_ip: str, path: str) → (bool, float)
|
|
--
|
|
Per-IP token bucket.
|
|
Returns (allowed, retry_after).
|
|
--
|
|
core/ratelimit.py
|
|
}
|
|
|
|
' TokenBucket
|
|
class "TokenBucket" as TokenBucket {
|
|
- _tokens: float
|
|
- _rate: float
|
|
- _burst: int
|
|
- _last_refill: float
|
|
--
|
|
+ consume() → bool
|
|
+ retry_after: float {property}
|
|
--
|
|
Single-IP bucket with
|
|
refill rate and burst cap.
|
|
}
|
|
|
|
' Relationships
|
|
SessionUI <|.. TerminalUI
|
|
TerminalUI <|-- WsTermUI
|
|
SessionUI <|.. WebUI
|
|
SessionUI <|.. NullUI
|
|
|
|
LLMProvider <|.. OpenAIProv
|
|
LLMProvider <|.. AnthropicProv
|
|
|
|
ChatSession --> SessionUI : uses
|
|
ChatSession --> LLMProvider : delegates LLM calls
|
|
ChatSession --> MCPMgr : optional
|
|
ChatSession --> ModelReg : optional
|
|
ChatSession <|-- HeadlessSession
|
|
|
|
WsMgr --> "*" Ws : manages
|
|
Ws --> "1" ChatSession : wraps
|
|
Ws --> "1" SessionUI : wraps
|
|
Ws --> "1" WsState : has
|
|
|
|
WsMgr ..> ChatSession : creates via\nsession_factory(ui, model_alias)
|
|
|
|
ModelReg --> "*" ModelCfg : holds
|
|
ModelReg --> "*" LLMProvider : caches
|
|
LLMProvider --> ModelCaps : returns
|
|
|
|
ChatSession --> HealthMon : checks circuit
|
|
HealthMon --> "1" CircuitState : has
|
|
RateLimiter --> "*" TokenBucket : per IP
|
|
|
|
note bottom of ChatSession
|
|
Central engine: multi-turn LLM loop
|
|
with tool dispatch, agent sub-sessions,
|
|
context compaction, and memory persistence.
|
|
Provider-agnostic — delegates all LLM
|
|
communication to LLMProvider adapters.
|
|
|
|
core/session.py (~2700 lines)
|
|
end note
|
|
|
|
@enduml
|