mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
refactor(tools): remove man, math, and plan_agent built-in tools
`man` and `math` duplicated capabilities already reachable through `bash`; `plan_agent` is better expressed as a `task_agent` running a planning skill, and carried a large amount of special-case machinery (plan-review gate, refinement loop, per-kind model routing). Removing all three shrinks the tool surface and cuts per-call token cost. Also removed, as dead-once-the-tools-are-gone: - the `math` sandbox executor (`turnstone.core.sandbox`) and its `[sandbox]` extra; the eval analyst now runs bash-only - the read-only `AGENT_TOOLS` sub-agent tool set and the `agent` tool-metadata key (`task_agent`/`TASK_AGENT_TOOLS` retained) - the plan-review protocol end to end: the `on_plan_review` UI hook, `resolve_plan`, `POST /v1/api/plan` + `POST /v1/api/route/plan`, the `plan_review`/`plan_resolved` SSE events, and their Python SDK / TypeScript SDK / OpenAPI / frontend / Discord+Slack bindings - the `model.plan_alias` / `model.plan_effort` settings and the registry `plan_model` / `plan_effort` routing fields TOOLS 31->28, TASK_AGENT_TOOLS 13->11; COORDINATOR_TOOLS unchanged. BREAKING CHANGE: removes the `man`, `math`, `plan_agent` tools, the plan-review SSE/HTTP/SDK surface, and the plan_* model-routing settings from the experimental 1.6 line.
This commit is contained in:
@@ -61,6 +61,19 @@ Three release tracks are maintained:
|
|||||||
dependency). Migration: use the bundled SearxNG (it ships in the compose stacks
|
dependency). Migration: use the bundled SearxNG (it ships in the compose stacks
|
||||||
by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No
|
by default) or point `TURNSTONE_SEARXNG_URL` at an existing instance. No
|
||||||
database migration required.
|
database migration required.
|
||||||
|
- **`man`, `math`, and `plan_agent` built-in tools removed** — `man` and
|
||||||
|
`math` duplicated capabilities already available through `bash`; `plan_agent`
|
||||||
|
is better expressed as a `task_agent` running a planning skill. Removing
|
||||||
|
them simplifies the tool surface and cuts per-call token cost. This release
|
||||||
|
also removes: the `math` sandbox executor (`turnstone.core.sandbox`) and the
|
||||||
|
`[sandbox]` extra's role for it; the read-only `AGENT_TOOLS` sub-agent tool
|
||||||
|
set and the `agent` tool-metadata key; the plan-review protocol
|
||||||
|
(`/v1/api/plan` endpoint, `plan_review`/`plan_resolved` SSE events, the
|
||||||
|
`on_plan_review` SDK/UI hook); and the `model.plan_alias` /
|
||||||
|
`model.plan_effort` ConfigStore settings (and the corresponding
|
||||||
|
`[model].plan_model` / `[model].plan_effort` config.toml knobs).
|
||||||
|
**Breaking change** on the experimental 1.6 line. Interactive built-in tool
|
||||||
|
count moves from 19 → 16; `TASK_AGENT_TOOLS` from 13 → 11.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -455,13 +455,6 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
|
|||||||
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
|
| `cache_creation_tokens` | int | Tokens written to prompt cache (Anthropic) |
|
||||||
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
|
| `cache_read_tokens` | int | Tokens served from prompt cache (Anthropic + OpenAI) |
|
||||||
|
|
||||||
**`plan_review`** -- the model is proposing a plan and wants feedback. The
|
|
||||||
client must respond via `POST /v1/api/plan`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type": "plan_review", "content": "Step 1: ...\nStep 2: ..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`info`** -- an informational message (e.g. command output).
|
**`info`** -- an informational message (e.g. command output).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -779,36 +772,6 @@ automatically approved without prompting.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `POST /v1/api/plan`
|
|
||||||
|
|
||||||
Responds to a plan review dialog. The SSE stream must have previously sent a
|
|
||||||
`plan_review` event for the given workstream.
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"feedback": "", "ws_id": "abc123"}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|------------|--------|----------|---------------------------------------------------------|
|
|
||||||
| `feedback` | string | yes | Feedback text; empty string means approval |
|
|
||||||
| `ws_id` | string | yes | Target workstream ID |
|
|
||||||
|
|
||||||
To approve the plan, send an empty string for `feedback`. To reject or request
|
|
||||||
changes, send a non-empty feedback string (e.g. `"reject"` or specific
|
|
||||||
revision instructions).
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"status": "ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error:** `404` with `{"error": "Unknown workstream"}` if `ws_id` is invalid.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `POST /v1/api/command`
|
### `POST /v1/api/command`
|
||||||
|
|
||||||
Executes a slash command in the given workstream.
|
Executes a slash command in the given workstream.
|
||||||
|
|||||||
+23
-41
@@ -3,8 +3,8 @@
|
|||||||
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
|
||||||
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
|
||||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
model 16 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||||
reading, writing, searching, planning, and executing code.
|
reading, writing, searching, and executing code.
|
||||||
|
|
||||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||||
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
|
The engine (`ChatSession`) drives the conversation loop -- streaming, tool
|
||||||
@@ -61,7 +61,6 @@ turnstone/
|
|||||||
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
|
ratelimit.py Per-IP token-bucket rate limiter (RateLimiter, TokenBucket)
|
||||||
edit.py File edit utilities (find_occurrences, pick_nearest)
|
edit.py File edit utilities (find_occurrences, pick_nearest)
|
||||||
safety.py Command safety validation (blocked patterns, sanitization)
|
safety.py Command safety validation (blocked patterns, sanitization)
|
||||||
sandbox.py Math code sandboxing (AST validation, subprocess execution)
|
|
||||||
web.py Web utilities (HTML stripping, SSRF prevention)
|
web.py Web utilities (HTML stripping, SSRF prevention)
|
||||||
api/
|
api/
|
||||||
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
|
schemas.py Shared Pydantic v2 models (auth, errors, WorkstreamState)
|
||||||
@@ -102,7 +101,7 @@ turnstone/
|
|||||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||||
tools/
|
tools/
|
||||||
*.json 19 tool schemas (OpenAI function-calling format + turnstone metadata)
|
*.json 16 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||||
```
|
```
|
||||||
|
|
||||||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||||||
@@ -190,7 +189,6 @@ Phase 3: EXECUTE (parallel)
|
|||||||
(cancel_event also checked per line — kills process group on cancel)
|
(cancel_event also checked per line — kills process group on cancel)
|
||||||
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
Final output (stdout + stderr) delivered via ui.on_tool_result(call_id, name, output)
|
||||||
call_id links tool_info items → streaming chunks → final result
|
call_id links tool_info items → streaming chunks → final result
|
||||||
For plan tool: post-execution gate via ui.on_plan_review()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### State Transitions
|
### State Transitions
|
||||||
@@ -209,7 +207,7 @@ The engine emits state changes via `_emit_state()` which calls
|
|||||||
"running" ---> tool execution
|
"running" ---> tool execution
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
"attention" ---> waiting for user approval / plan review
|
"attention" ---> waiting for user approval
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
"running" ---> executing approved tools
|
"running" ---> executing approved tools
|
||||||
@@ -231,7 +229,7 @@ The engine emits state changes via `_emit_state()` which calls
|
|||||||
|
|
||||||
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
> See also: [Core Engine Classes diagram](diagrams/png/03-core-engine-classes.png)
|
||||||
|
|
||||||
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 16
|
Defined in `turnstone.core.session.SessionUI` as a `typing.Protocol` with 15
|
||||||
methods. Every frontend must implement all of them.
|
methods. Every frontend must implement all of them.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -247,7 +245,6 @@ class SessionUI(Protocol):
|
|||||||
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
|
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
|
||||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||||
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
|
||||||
def on_plan_review(self, content: str) -> str: ...
|
|
||||||
def on_info(self, message: str) -> None: ...
|
def on_info(self, message: str) -> None: ...
|
||||||
def on_error(self, message: str) -> None: ...
|
def on_error(self, message: str) -> None: ...
|
||||||
def on_state_change(self, state: str) -> None: ...
|
def on_state_change(self, state: str) -> None: ...
|
||||||
@@ -269,7 +266,7 @@ the per-workstream events stream in
|
|||||||
| Class | Module | Notes |
|
| Class | Module | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
|
||||||
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||||
|
|
||||||
### WorkstreamTerminalUI
|
### WorkstreamTerminalUI
|
||||||
@@ -281,10 +278,9 @@ awareness:
|
|||||||
are appended to `_output_buffer` instead of written to stdout. When the user
|
are appended to `_output_buffer` instead of written to stdout. When the user
|
||||||
switches to this workstream, `flush_buffer()` replays them.
|
switches to this workstream, `flush_buffer()` replays them.
|
||||||
|
|
||||||
- **Approval blocking**: `approve_tools()` and `on_plan_review()` call
|
- **Approval blocking**: `approve_tools()` calls `_fg_event.wait()` when in
|
||||||
`_fg_event.wait()` when in background, blocking the worker thread until the
|
background, blocking the worker thread until the workstream is foregrounded.
|
||||||
workstream is foregrounded. This ensures the user sees the approval prompt
|
This ensures the user sees the approval prompt in the correct context.
|
||||||
in the correct context.
|
|
||||||
|
|
||||||
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
|
- **Foreground/background toggle**: `set_foreground(bool)` sets or clears
|
||||||
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
|
`_fg_event` (a `threading.Event`). The manager calls this during `/ws <N>`
|
||||||
@@ -421,7 +417,6 @@ turnstone metadata keys:
|
|||||||
|
|
||||||
| Metadata Key | Type | Meaning |
|
| Metadata Key | Type | Meaning |
|
||||||
|-------------|------|---------|
|
|-------------|------|---------|
|
||||||
| `agent` | `bool` | Include this tool when running as a plan/task sub-agent |
|
|
||||||
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
|
| `task_agent` | `bool` | Include this tool when running as a task sub-agent |
|
||||||
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
|
| `auto_approve` | `bool` | Tool is read-only; skip user approval |
|
||||||
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
|
| `primary_key` | `str` | Fallback argument name for bare-string JSON recovery |
|
||||||
@@ -441,7 +436,6 @@ Example (`read_file.json`):
|
|||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
"primary_key": "path"
|
"primary_key": "path"
|
||||||
@@ -452,19 +446,17 @@ At import time, `turnstone.core.tools._load_tools()` strips the metadata keys
|
|||||||
from each schema and builds:
|
from each schema and builds:
|
||||||
|
|
||||||
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
|
- `TOOLS` -- list of `{"type": "function", "function": {...}}` dicts for the API
|
||||||
- `AGENT_TOOLS` -- subset with `agent: true`
|
|
||||||
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
|
- `TASK_AGENT_TOOLS` -- subset with `task_agent: true`
|
||||||
- `AGENT_AUTO_TOOLS` / `TASK_AUTO_TOOLS` -- sets of tool names with `auto_approve: true`
|
- `TASK_AUTO_TOOLS` -- set of tool names with `auto_approve: true`
|
||||||
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
|
||||||
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
|
||||||
|
|
||||||
### 19 Tools by Category
|
### 16 Tools by Category
|
||||||
|
|
||||||
**Read-only (auto-approve)**:
|
**Read-only (auto-approve)**:
|
||||||
- `read_file` -- read file contents with optional offset/limit
|
- `read_file` -- read file contents with optional offset/limit
|
||||||
- `diff_file` -- show diff between two files / versions
|
- `diff_file` -- show diff between two files / versions
|
||||||
- `search` -- ripgrep-based codebase search
|
- `search` -- ripgrep-based codebase search
|
||||||
- `man` -- read man pages
|
|
||||||
- `recall` -- search conversation history
|
- `recall` -- search conversation history
|
||||||
- `read_resource` -- read an MCP resource by URI
|
- `read_resource` -- read an MCP resource by URI
|
||||||
|
|
||||||
@@ -472,7 +464,6 @@ from each schema and builds:
|
|||||||
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
|
||||||
- `write_file` -- create or overwrite a file
|
- `write_file` -- create or overwrite a file
|
||||||
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
|
- `edit_file` -- string replacement in an existing file (requires prior `read_file`)
|
||||||
- `math` -- execute Python in sandboxed subprocess (via `turnstone.core.sandbox`)
|
|
||||||
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
- `web_fetch` -- fetch a URL (with SSRF protection via `turnstone.core.web`)
|
||||||
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
|
- `web_search` -- search the web (provider-native for Anthropic/OpenAI, self-hosted SearxNG fallback for local models)
|
||||||
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
- `notify` -- send a user-facing notification (Discord/Slack, optional reply routing)
|
||||||
@@ -480,15 +471,14 @@ from each schema and builds:
|
|||||||
|
|
||||||
**Agent (delegated sub-sessions)**:
|
**Agent (delegated sub-sessions)**:
|
||||||
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
- `task_agent` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
|
||||||
- `plan_agent` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
|
|
||||||
|
|
||||||
**Memory / skills / prompts**:
|
**Memory / skills / prompts**:
|
||||||
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
- `memory` -- save, search, delete, or list memories (typed and scoped)
|
||||||
- `skill` -- invoke a skill (governed, versioned procedure)
|
- `skill` -- invoke a skill (governed, versioned procedure)
|
||||||
- `use_prompt` -- fetch and apply a prompt template
|
- `use_prompt` -- fetch and apply a prompt template
|
||||||
|
|
||||||
Tool names are `plan_agent` / `task_agent` (not `plan` / `task`); bare words
|
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||||
collide with chat-template channels on some local models.
|
chat-template channels on some local models.
|
||||||
|
|
||||||
### Prepare / Execute Pattern
|
### Prepare / Execute Pattern
|
||||||
|
|
||||||
@@ -507,17 +497,11 @@ separation allows the UI to show previews before any side effects occur.
|
|||||||
|
|
||||||
### Agent Tools
|
### Agent Tools
|
||||||
|
|
||||||
`task_agent` and `plan_agent` invoke `_run_agent()`, which runs a multi-turn
|
`task_agent` invokes `_run_agent()`, which runs a multi-turn loop with a
|
||||||
loop with a subset of tools and its own system prompt. The sub-agent runs
|
subset of tools and its own system prompt. The sub-agent runs independently,
|
||||||
independently, then returns the final content as the tool result.
|
then returns the final content as the tool result.
|
||||||
|
|
||||||
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
- **task_agent**: uses `self._task_tools` (`TASK_AGENT_TOOLS` + MCP tools)
|
||||||
- **plan_agent**: uses `self._agent_tools` (`AGENT_TOOLS` + MCP tools). Writes output
|
|
||||||
to `.plan-<ws_id>.md` — unique per `ChatSession` so concurrent workstreams
|
|
||||||
don't collide. On repeat invocations the prior `plan_agent` tool call and its result
|
|
||||||
are forwarded from `self.messages` so the agent refines the existing plan rather
|
|
||||||
than starting over. Planning instructions are injected as a developer message
|
|
||||||
prepended to the agent's conversation.
|
|
||||||
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
|
- **Turn limit**: controlled by `agent_max_turns` (default: `-1`, unlimited).
|
||||||
When a limit is set and reached, the agent is forced to synthesize a final
|
When a limit is set and reached, the agent is forced to synthesize a final
|
||||||
response without tools. When unlimited, the loop only exits when the model
|
response without tools. When unlimited, the loop only exits when the model
|
||||||
@@ -564,8 +548,8 @@ adds, removes, or reconnects servers as needed.
|
|||||||
|
|
||||||
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
When tools change, `_rebuild_tools()` creates new `_tools`/`_tool_map` objects
|
||||||
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
(copy-on-write for thread safety) and notifies listener callbacks. Each `ChatSession`
|
||||||
rebuilds its merged tool lists and reconstructs `ToolSearchManager` (preserving
|
rebuilds its `_tools` and `_task_tools` lists and reconstructs `ToolSearchManager`
|
||||||
expanded tools).
|
(preserving expanded tools).
|
||||||
|
|
||||||
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
**Tool naming:** `mcp__{server}__{tool}` — double underscore delimiter, validated
|
||||||
at connection time (server names with `__` are rejected).
|
at connection time (server names with `__` are rejected).
|
||||||
@@ -803,7 +787,7 @@ with the same alias in-memory (the DB rows are never modified).
|
|||||||
parameters
|
parameters
|
||||||
6. `_create_stream_with_retry()` tries the primary model, then each fallback
|
6. `_create_stream_with_retry()` tries the primary model, then each fallback
|
||||||
alias in order if the primary is unreachable
|
alias in order if the primary is unreachable
|
||||||
7. `_run_agent()` resolves `registry.agent_model` (if set) for plan/task
|
7. `_run_agent()` resolves `registry.agent_model` (if set) for task
|
||||||
sub-agents, allowing a cheaper model for autonomous loops
|
sub-agents, allowing a cheaper model for autonomous loops
|
||||||
|
|
||||||
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
**Per-workstream selection:** `POST /v1/api/workstreams/new` accepts an optional
|
||||||
@@ -812,7 +796,7 @@ which can override the model before workstream creation.
|
|||||||
|
|
||||||
### Tool Output Truncation
|
### Tool Output Truncation
|
||||||
|
|
||||||
Tool execution results (bash, read_file, search, math, man) are truncated by
|
Tool execution results (bash, read_file, search) are truncated by
|
||||||
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
|
`_truncate_output()` when they exceed `tool_truncation` characters. Truncation
|
||||||
preserves the first half and last half of the output, with a message in
|
preserves the first half and last half of the output, with a message in
|
||||||
between:
|
between:
|
||||||
@@ -1240,7 +1224,6 @@ Starlette ASGI app (served by uvicorn)
|
|||||||
+-- Async request handlers (all under /v1/ prefix)
|
+-- Async request handlers (all under /v1/ prefix)
|
||||||
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
|
| POST /v1/api/workstreams/{ws_id}/send -> starts worker thread per workstream
|
||||||
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
|
| POST /v1/api/workstreams/{ws_id}/approve -> unblocks WebUI._approval_event
|
||||||
| POST /v1/api/plan -> unblocks WebUI._plan_event
|
|
||||||
| POST /v1/api/workstreams/new -> creates workstream + worker
|
| POST /v1/api/workstreams/new -> creates workstream + worker
|
||||||
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
|
| GET /v1/api/workstreams/{ws_id}/events -> SSE via EventSourceResponse (per workstream)
|
||||||
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
| GET /v1/api/events/global -> SSE via EventSourceResponse (fan-out)
|
||||||
@@ -1250,7 +1233,7 @@ Starlette ASGI app (served by uvicorn)
|
|||||||
|
|
|
|
||||||
+-- Worker thread per workstream (daemon)
|
+-- Worker thread per workstream (daemon)
|
||||||
| Runs session.send() synchronously -- ChatSession is fully blocking
|
| Runs session.send() synchronously -- ChatSession is fully blocking
|
||||||
| Blocks on WebUI._approval_event / _plan_event (threading.Event)
|
| Blocks on WebUI._approval_event (threading.Event)
|
||||||
|
|
|
|
||||||
+-- Background daemon threads
|
+-- Background daemon threads
|
||||||
Global SSE fan-out: reads global_queue, copies to per-client queues
|
Global SSE fan-out: reads global_queue, copies to per-client queues
|
||||||
@@ -1274,7 +1257,7 @@ registry).
|
|||||||
|
|
||||||
Each workstream's `WebUI` has:
|
Each workstream's `WebUI` has:
|
||||||
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
- `_listeners` (per-client SSE queues, fan-out on `_enqueue()`)
|
||||||
- `_approval_event` / `_plan_event` (`threading.Event` for blocking)
|
- `_approval_event` (`threading.Event` for blocking)
|
||||||
- `_global_queue` (class variable, shared, for state broadcasts)
|
- `_global_queue` (class variable, shared, for state broadcasts)
|
||||||
|
|
||||||
The SSE handlers bridge these sync queues to async via
|
The SSE handlers bridge these sync queues to async via
|
||||||
@@ -1530,8 +1513,7 @@ implemented in `turnstone/core/judge.py`:
|
|||||||
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
The judge is session-scoped (`IntentJudge`), lazy-initialized on first
|
||||||
approval, and configured via the `[judge]` config section or `--judge` CLI
|
approval, and configured via the `[judge]` config section or `--judge` CLI
|
||||||
flags. By default it uses self-consistency (same model), but supports
|
flags. By default it uses self-consistency (same model), but supports
|
||||||
cross-model and cross-provider configurations. Sub-agents (plan, task)
|
cross-model and cross-provider configurations. Task sub-agents are exempt. All verdicts are persisted to the `intent_verdicts` table
|
||||||
are exempt. All verdicts are persisted to the `intent_verdicts` table
|
|
||||||
(migration 012) with the user's final decision, enabling future calibration.
|
(migration 012) with the user's final decision, enabling future calibration.
|
||||||
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
The console exposes `GET /v1/api/admin/verdicts` for audit queries
|
||||||
(requires `admin.judge` permission).
|
(requires `admin.judge` permission).
|
||||||
|
|||||||
+3
-13
@@ -179,7 +179,6 @@ both and the gateway hosts both adapters in one process.
|
|||||||
see starts a per-user channel session.
|
see starts a per-user channel session.
|
||||||
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
||||||
who owns the workstream can approve/reject.
|
who owns the workstream can approve/reject.
|
||||||
- Plan reviews render as a modal with approve / request-changes actions.
|
|
||||||
- Notifications and reply routing work identically to Discord.
|
- Notifications and reply routing work identically to Discord.
|
||||||
- Session recovery: persisted channel routes are re-subscribed when the
|
- Session recovery: persisted channel routes are re-subscribed when the
|
||||||
bot restarts, so existing Slack conversations keep flowing.
|
bot restarts, so existing Slack conversations keep flowing.
|
||||||
@@ -235,15 +234,6 @@ config, the bot auto-responds with approval and posts a
|
|||||||
field (useful for allowing specific tools like `bash` or `read_file` while
|
field (useful for allowing specific tools like `bash` or `read_file` while
|
||||||
still requiring manual approval for others).
|
still requiring manual approval for others).
|
||||||
|
|
||||||
### Plan Reviews
|
|
||||||
|
|
||||||
Plan review requests are displayed as a blue embed with:
|
|
||||||
|
|
||||||
- **Approve Plan** (green) button — approves the plan with empty feedback
|
|
||||||
- **Request Changes** (gray) button — opens a modal for feedback text
|
|
||||||
(up to 2000 characters)
|
|
||||||
- Feedback is forwarded to the server via HTTP
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration Reference
|
## Configuration Reference
|
||||||
@@ -431,9 +421,9 @@ message with a `ws_id` so that user replies can be routed back to the
|
|||||||
originating workstream. Adapters must track the mapping from outgoing
|
originating workstream. Adapters must track the mapping from outgoing
|
||||||
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
||||||
|
|
||||||
Platform-specific concerns — approval prompts, plan reviews, message
|
Platform-specific concerns — approval prompts, message edits, thread
|
||||||
edits, thread creation — live inside the adapter implementation and are
|
creation — live inside the adapter implementation and are not part of
|
||||||
not part of the protocol surface. Each adapter drives those via its
|
the protocol surface. Each adapter drives those via its
|
||||||
own `_on_ws_event` dispatcher using SDK-native APIs.
|
own `_on_ws_event` dispatcher using SDK-native APIs.
|
||||||
|
|
||||||
To add a new platform:
|
To add a new platform:
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Explicitly **not** in the coordinator set:
|
|||||||
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
|
- `bash` / `edit_file` / `write_file` / `append_file` / `diff_file` — no local FS.
|
||||||
- `read_file` / `search` — no local FS reads.
|
- `read_file` / `search` — no local FS reads.
|
||||||
- `web_fetch` / `web_search` — no direct web access.
|
- `web_fetch` / `web_search` — no direct web access.
|
||||||
- `task_agent` / `plan_agent` — sub-agent tools are zeroed on coord sessions.
|
- `task_agent` — sub-agent tool is zeroed on coord sessions.
|
||||||
- `recall` / `watch` / `read_resource` / `use_prompt` — UX / persistence tools that belong to interactive sessions. The dual-kind `memory` / `skills` / `notify` tools are available on both kinds (see the table above).
|
- `recall` / `watch` / `read_resource` / `use_prompt` — UX / persistence tools that belong to interactive sessions. The dual-kind `memory` / `skills` / `notify` tools are available on both kinds (see the table above).
|
||||||
|
|
||||||
If your skill needs a coordinator to "run a command" or "read a
|
If your skill needs a coordinator to "run a command" or "read a
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ package "turnstone/core/" <<Rectangle>> {
|
|||||||
component [metrics.py\nPrometheus metrics] as metrics <<core>>
|
component [metrics.py\nPrometheus metrics] as metrics <<core>>
|
||||||
component [config.py\nTOML config] as config <<core>>
|
component [config.py\nTOML config] as config <<core>>
|
||||||
component [safety.py\nPath validation] as safety <<core>>
|
component [safety.py\nPath validation] as safety <<core>>
|
||||||
component [sandbox.py\nCommand sandbox] as sandbox <<core>>
|
|
||||||
component [edit.py\nFile editing] as edit <<core>>
|
component [edit.py\nFile editing] as edit <<core>>
|
||||||
component [web.py\nWeb helpers] as web <<core>>
|
component [web.py\nWeb helpers] as web <<core>>
|
||||||
component [auth.py\nAuthentication] as auth <<core>>
|
component [auth.py\nAuthentication] as auth <<core>>
|
||||||
@@ -123,7 +122,6 @@ session --> tools
|
|||||||
session --> memory
|
session --> memory
|
||||||
memory --> storage
|
memory --> storage
|
||||||
session --> safety
|
session --> safety
|
||||||
session --> sandbox
|
|
||||||
session --> edit
|
session --> edit
|
||||||
session --> web
|
session --> web
|
||||||
session --> healthcheck
|
session --> healthcheck
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface "SessionUI" as SessionUI <<Protocol>> {
|
|||||||
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
|
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
|
||||||
+ on_tool_output_chunk(call_id: str, chunk: str)
|
+ on_tool_output_chunk(call_id: str, chunk: str)
|
||||||
+ on_status(usage: dict, ctx_window: int, effort: str)
|
+ on_status(usage: dict, ctx_window: int, effort: str)
|
||||||
+ on_plan_review(content: str) → str
|
|
||||||
+ on_info(message: str)
|
+ on_info(message: str)
|
||||||
+ on_error(message: str)
|
+ on_error(message: str)
|
||||||
+ on_state_change(state: str)
|
+ on_state_change(state: str)
|
||||||
@@ -43,15 +42,13 @@ class "WorkstreamTerminalUI" as WsTermUI {
|
|||||||
class "WebUI" as WebUI {
|
class "WebUI" as WebUI {
|
||||||
- _listeners: list[Queue]
|
- _listeners: list[Queue]
|
||||||
- _approval_event: Event
|
- _approval_event: Event
|
||||||
- _plan_event: Event
|
|
||||||
- _ws_prompt_tokens: int
|
- _ws_prompt_tokens: int
|
||||||
- _ws_tool_calls: dict
|
- _ws_tool_calls: dict
|
||||||
+ resolve_approval(approved, feedback)
|
+ resolve_approval(approved, feedback)
|
||||||
+ resolve_plan(feedback)
|
|
||||||
--
|
--
|
||||||
Enqueues JSON events for SSE.
|
Enqueues JSON events for SSE.
|
||||||
Blocks on threading.Event for
|
Blocks on threading.Event for
|
||||||
approval/plan review.
|
approval.
|
||||||
SSE handlers bridge Queue to
|
SSE handlers bridge Queue to
|
||||||
async via run_in_executor().
|
async via run_in_executor().
|
||||||
--
|
--
|
||||||
@@ -145,7 +142,6 @@ class "ChatSession" as ChatSession {
|
|||||||
+ model_alias: str | None {property}
|
+ model_alias: str | None {property}
|
||||||
- _tools: list[dict]
|
- _tools: list[dict]
|
||||||
- _task_tools: list[dict]
|
- _task_tools: list[dict]
|
||||||
- _agent_tools: list[dict]
|
|
||||||
- _read_files: set[str]
|
- _read_files: set[str]
|
||||||
- system_messages: list[dict]
|
- system_messages: list[dict]
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -139,8 +139,7 @@ group loop [while tool_calls present]
|
|||||||
read_file → open().read() or base64 image
|
read_file → open().read() or base64 image
|
||||||
search → grep subprocess
|
search → grep subprocess
|
||||||
edit_file → string replace
|
edit_file → string replace
|
||||||
task/plan → _run_agent() sub-loop
|
task → _run_agent() sub-loop
|
||||||
math → sandboxed subprocess
|
|
||||||
web_fetch → httpx + LLM summarize
|
web_fetch → httpx + LLM summarize
|
||||||
web_search → provider-native or SearxNG fallback
|
web_search → provider-native or SearxNG fallback
|
||||||
memory/recall → SQLite
|
memory/recall → SQLite
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
|||||||
:Dispatch to _prepare_{func_name}();
|
:Dispatch to _prepare_{func_name}();
|
||||||
|
|
||||||
note right
|
note right
|
||||||
**Dispatch table (19 built-in + tool_search):**
|
**Dispatch table (16 built-in + tool_search):**
|
||||||
┌───────────────┬──────────────────┐
|
┌───────────────┬──────────────────┐
|
||||||
│ Tool │ Needs Approval? │
|
│ Tool │ Needs Approval? │
|
||||||
├───────────────┼──────────────────┤
|
├───────────────┼──────────────────┤
|
||||||
@@ -34,13 +34,10 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
|||||||
│ edit_file │ ✓ Yes │
|
│ edit_file │ ✓ Yes │
|
||||||
│ search │ ✗ Auto-approve │
|
│ search │ ✗ Auto-approve │
|
||||||
│ diff_file │ ✗ Auto-approve │
|
│ diff_file │ ✗ Auto-approve │
|
||||||
│ math │ ✗ Auto-approve │
|
|
||||||
│ man │ ✗ Auto-approve │
|
|
||||||
│ web_fetch │ ✗ Auto-approve │
|
│ web_fetch │ ✗ Auto-approve │
|
||||||
│ web_search │ ✗ Auto-approve │
|
│ web_search │ ✗ Auto-approve │
|
||||||
│ tool_search │ ✗ Auto-approve │
|
│ tool_search │ ✗ Auto-approve │
|
||||||
│ task_agent │ ✓ Yes │
|
│ task_agent │ ✓ Yes │
|
||||||
│ plan_agent │ ✓ Yes │
|
|
||||||
│ memory │ ✗ Auto-approve │
|
│ memory │ ✗ Auto-approve │
|
||||||
│ recall │ ✗ Auto-approve │
|
│ recall │ ✗ Auto-approve │
|
||||||
│ notify │ ✗ Auto-approve │
|
│ notify │ ✗ Auto-approve │
|
||||||
@@ -110,13 +107,10 @@ partition "Phase 3: Execute" #E3F2FD {
|
|||||||
├─ _exec_write_file: makedirs + write
|
├─ _exec_write_file: makedirs + write
|
||||||
├─ _exec_edit_file: find_occurrences + replace
|
├─ _exec_edit_file: find_occurrences + replace
|
||||||
├─ _exec_search: grep subprocess
|
├─ _exec_search: grep subprocess
|
||||||
├─ _exec_math: sandboxed subprocess
|
|
||||||
├─ _exec_man: man/info subprocess
|
|
||||||
├─ _exec_web_fetch: httpx.get + LLM summary
|
├─ _exec_web_fetch: httpx.get + LLM summary
|
||||||
├─ _exec_web_search: SearxNG JSON GET (fallback for local models)
|
├─ _exec_web_search: SearxNG JSON GET (fallback for local models)
|
||||||
├─ _exec_tool_search: BM25 search + expand_visible()
|
├─ _exec_tool_search: BM25 search + expand_visible()
|
||||||
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
|
||||||
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
|
|
||||||
├─ _exec_notify: HTTP POST to channel gateway
|
├─ _exec_notify: HTTP POST to channel gateway
|
||||||
├─ _exec_memory: structured memory save/search/delete/list
|
├─ _exec_memory: structured memory save/search/delete/list
|
||||||
├─ _exec_recall: conversation history FTS5 search
|
├─ _exec_recall: conversation history FTS5 search
|
||||||
@@ -131,11 +125,6 @@ partition "Phase 3: Execute" #E3F2FD {
|
|||||||
|
|
||||||
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
|
||||||
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
:ui.on_tool_result(call_id, name, output, is_error) for each;
|
||||||
|
|
||||||
if (plan tool was executed?) then (yes)
|
|
||||||
:ui.on_plan_review(output);
|
|
||||||
:Block for user review/feedback;
|
|
||||||
endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:Return (results, user_feedback);
|
:Return (results, user_feedback);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ skinparam state {
|
|||||||
state "IDLE" as idle <<idle>> : Waiting for user input.\nNo active LLM call or tool execution.
|
state "IDLE" as idle <<idle>> : Waiting for user input.\nNo active LLM call or tool execution.
|
||||||
state "THINKING" as thinking <<thinking>> : LLM streaming response.\nTokens flowing (reasoning + content).
|
state "THINKING" as thinking <<thinking>> : LLM streaming response.\nTokens flowing (reasoning + content).
|
||||||
state "RUNNING" as running <<running>> : Tools executing.\nThreadPoolExecutor active.
|
state "RUNNING" as running <<running>> : Tools executing.\nThreadPoolExecutor active.
|
||||||
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval or plan review needed.
|
state "ATTENTION" as attention <<attention>> : Blocked on user action.\nTool approval needed.
|
||||||
state "ERROR" as error <<error>> : Exception occurred.\nRecoverable on next send().
|
state "ERROR" as error <<error>> : Exception occurred.\nRecoverable on next send().
|
||||||
|
|
||||||
[*] --> idle : Session created
|
[*] --> idle : Session created
|
||||||
@@ -34,8 +34,6 @@ attention --> running : User denies\n(denial recorded)\n_emit_state("running")
|
|||||||
|
|
||||||
running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking")
|
running --> thinking : Tool results appended,\nnext LLM call\n_emit_state("thinking")
|
||||||
|
|
||||||
running --> attention : Plan tool complete,\non_plan_review()\n_emit_state("attention")
|
|
||||||
|
|
||||||
running --> error : Exception during\ntool execution
|
running --> error : Exception during\ntool execution
|
||||||
|
|
||||||
error --> thinking : New send() call\n_emit_state("thinking")
|
error --> thinking : New send() call\n_emit_state("thinking")
|
||||||
@@ -44,7 +42,7 @@ thinking --> idle : cancel() called\nstream aborted\n_emit_state("idle")
|
|||||||
|
|
||||||
running --> idle : cancel() called\n_emit_state("idle")
|
running --> idle : cancel() called\n_emit_state("idle")
|
||||||
|
|
||||||
attention --> idle : cancel() unblocks\napproval/plan wait\n_emit_state("idle")
|
attention --> idle : cancel() unblocks\napproval wait\n_emit_state("idle")
|
||||||
|
|
||||||
note left of idle
|
note left of idle
|
||||||
**Cancel escalation:**
|
**Cancel escalation:**
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ package "turnstone/sdk/ (Python)" {
|
|||||||
+ close_workstream()
|
+ close_workstream()
|
||||||
+ send(message, ws_id)
|
+ send(message, ws_id)
|
||||||
+ approve()
|
+ approve()
|
||||||
+ plan_feedback()
|
|
||||||
+ command()
|
+ command()
|
||||||
+ cancel(ws_id)
|
+ cancel(ws_id)
|
||||||
+ stream_events(ws_id)
|
+ stream_events(ws_id)
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ note over Session, Judge
|
|||||||
Cross-model: separate provider/client from [judge] config.
|
Cross-model: separate provider/client from [judge] config.
|
||||||
|
|
||||||
**Sub-agent exemption:**
|
**Sub-agent exemption:**
|
||||||
Plan agent and task agent skip intent validation entirely.
|
Task sub-agents skip intent validation entirely.
|
||||||
|
|
||||||
**Output guard:**
|
**Output guard:**
|
||||||
Runs when judge_config.output_guard is true (default).
|
Runs when judge_config.output_guard is true (default).
|
||||||
|
|||||||
+1
-2
@@ -274,8 +274,7 @@ for iteration in 0..max_iterations:
|
|||||||
|
|
||||||
### Phase 1: Analyst (`_run_analyst`)
|
### Phase 1: Analyst (`_run_analyst`)
|
||||||
|
|
||||||
A multi-turn agent with `math` (Python) and `bash` tools for computing
|
A multi-turn agent with a `bash` tool for computing statistics. It receives per-case results with failure classifications and
|
||||||
statistics. It receives per-case results with failure classifications and
|
|
||||||
produces a structured diagnosis:
|
produces a structured diagnosis:
|
||||||
|
|
||||||
- **Failure patterns**: Shared root causes across failing cases
|
- **Failure patterns**: Shared root causes across failing cases
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ last) and returns the first matching rule. Each rule has:
|
|||||||
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/` or `.ssh/`, download-then-execute chains (`curl -o file && chmod +x && bash`) |
|
| Critical | 0.90 | deny | `rm -rf /`, `mkfs`, `dd if=`, pipe-to-shell, chmod 777 on root, write/edit to `/etc/` or `.ssh/`, download-then-execute chains (`curl -o file && chmod +x && bash`) |
|
||||||
| High | 0.80 | review | `sudo`, `kill -9`, destructive git, DROP TABLE, write/edit secrets, HTTP mutations, `ssh`/`scp`, credential file access, browser automation + data export, transitive installs (`npx skills add`, `pip install git+`), control plane mutations (`crontab`, `systemctl enable/start/stop`) |
|
| High | 0.80 | review | `sudo`, `kill -9`, destructive git, DROP TABLE, write/edit secrets, HTTP mutations, `ssh`/`scp`, credential file access, browser automation + data export, transitive installs (`npx skills add`, `pip install git+`), control plane mutations (`crontab`, `systemctl enable/start/stop`) |
|
||||||
| Medium | 0.70 | review | Content ingestion pipelines (`curl \| python3`), interpreter execution (`python3 script.py`, `node build.js`), cloud CLI mutations (`az/gcloud/aws/kubectl/terraform` with create/delete/destroy verbs), package installs, `write_file`, MCP tools, Docker operations |
|
| Medium | 0.70 | review | Content ingestion pipelines (`curl \| python3`), interpreter execution (`python3 script.py`, `node build.js`), cloud CLI mutations (`az/gcloud/aws/kubectl/terraform` with create/delete/destroy verbs), package installs, `write_file`, MCP tools, Docker operations |
|
||||||
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `man`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
| Low | 0.85 | approve | `read_file`, `list_directory`, `search`, `recall`, `use_prompt`, `tool_search`, `read_resource`, `web_search`, read-only bash (`ls`, `cat`, `head`, `grep`, `find`, etc.) |
|
||||||
|
|
||||||
When no rule matches, the heuristic returns a default verdict: medium risk,
|
When no rule matches, the heuristic returns a default verdict: medium risk,
|
||||||
0.50 confidence, "review" recommendation.
|
0.50 confidence, "review" recommendation.
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ Both `TurnstoneServer` (sync) and `AsyncTurnstoneServer` (async) expose:
|
|||||||
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
|
| | `delete_attachment(ws_id, attachment_id)` | `StatusResponse` |
|
||||||
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
| **Chat** | `send(message, ws_id)` | `SendResponse` |
|
||||||
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
| | `approve(*, ws_id, approved, feedback, always)` | `StatusResponse` |
|
||||||
| | `plan_feedback(*, ws_id, feedback)` | `StatusResponse` |
|
|
||||||
| | `command(*, ws_id, command)` | `StatusResponse` |
|
| | `command(*, ws_id, command)` | `StatusResponse` |
|
||||||
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
| | `cancel(ws_id, *, force=False)` | `StatusResponse` |
|
||||||
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
| **Streaming** | `stream_events(ws_id)` | `Iterator[ServerEvent]` |
|
||||||
@@ -134,7 +133,6 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
|
|||||||
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
|
||||||
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
|
||||||
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
|
||||||
| `plan_review` | `PlanReviewEvent` | `content` |
|
|
||||||
| `error` | `ErrorEvent` | `message` |
|
| `error` | `ErrorEvent` | `message` |
|
||||||
| `info` | `InfoEvent` | `message` |
|
| `info` | `InfoEvent` | `message` |
|
||||||
| `stream_end` | `StreamEndEvent` | — |
|
| `stream_end` | `StreamEndEvent` | — |
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ Scopes are hierarchical — higher scopes imply all lower ones.
|
|||||||
| Method | Path pattern | Required scope |
|
| Method | Path pattern | Required scope |
|
||||||
|--------|-------------|----------------|
|
|--------|-------------|----------------|
|
||||||
| GET | Any protected path | `read` |
|
| GET | Any protected path | `read` |
|
||||||
| POST | `/api/plan`, `/api/command` | `write` |
|
| POST | `/api/command` | `write` |
|
||||||
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
|
| POST | `/api/workstreams/new`, `/api/cluster/workstreams/new` | `write` |
|
||||||
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
|
| POST | `/api/workstreams/{ws_id}/{send,cancel,close,delete,open,refresh-title,title,attachments}` | `write` |
|
||||||
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
|
| DELETE | `/api/workstreams/{ws_id}/send` (dequeue), `/api/workstreams/{ws_id}/attachments/{attachment_id}` | `write` |
|
||||||
|
|||||||
+6
-9
@@ -74,20 +74,17 @@ provider-side mechanics (Anthropic `thinking`, OpenAI Responses
|
|||||||
`reasoning` + `include=["reasoning.encrypted_content"]`, synthetic
|
`reasoning` + `include=["reasoning.encrypted_content"]`, synthetic
|
||||||
`reasoning_text` for Chat Completions / vLLM / llama.cpp / Gemini-compat).
|
`reasoning_text` for Chat Completions / vLLM / llama.cpp / Gemini-compat).
|
||||||
|
|
||||||
### Plan / task agent overrides
|
### Task agent overrides
|
||||||
|
|
||||||
`plan_agent` and `task_agent` sub-sessions resolve independently from the
|
`task_agent` sub-sessions resolve independently from the conversation model
|
||||||
conversation model so operators can pick a cheaper/faster model for
|
so operators can pick a cheaper/faster model for autonomous loops:
|
||||||
autonomous loops:
|
|
||||||
|
|
||||||
| Setting | Purpose |
|
| Setting | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `model.plan_alias` | Alias used for `plan_agent` sub-sessions. Falls back to `[model].plan_model` in config.toml, then `[model].agent_model`, then the session's active model. |
|
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Falls back to `[model].agent_model` in config.toml, then the session's active model. |
|
||||||
| `model.task_alias` | Alias used for `task_agent` sub-sessions. Same fallback chain as `plan_alias`. |
|
|
||||||
| `model.plan_effort` | Reasoning effort for `plan_agent` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`). Defaults to `high`. |
|
|
||||||
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
|
| `model.task_effort` | Reasoning effort for `task_agent`. Empty string means "inherit from the session". |
|
||||||
|
|
||||||
All four are live-editable from the Settings tab and take effect on the
|
Both are live-editable from the Settings tab and take effect on the
|
||||||
next sub-agent invocation — no restart required.
|
next sub-agent invocation — no restart required.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -110,7 +107,7 @@ initialization:
|
|||||||
|
|
||||||
| Section | Settings |
|
| Section | Settings |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `model` | default_alias, temperature, max_tokens, reasoning_effort, plan_alias, task_alias, plan_effort, task_effort |
|
| `model` | default_alias, temperature, max_tokens, reasoning_effort, task_alias, task_effort |
|
||||||
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
| `session` | instructions, retention_days, compact_max_tokens, auto_compact_pct |
|
||||||
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
|
||||||
| `server` | workstream_idle_timeout, max_workstreams |
|
| `server` | workstream_idle_timeout, max_workstreams |
|
||||||
|
|||||||
+51
-113
@@ -1,6 +1,6 @@
|
|||||||
# Tools Reference
|
# Tools Reference
|
||||||
|
|
||||||
turnstone exposes 19 built-in tools plus any number of external MCP tools to the
|
turnstone exposes 16 built-in tools plus any number of external MCP tools to the
|
||||||
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
|
||||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||||
MCP tools are discovered from configured MCP servers at startup by
|
MCP tools are discovered from configured MCP servers at startup by
|
||||||
@@ -22,7 +22,6 @@ schema plus turnstone-specific metadata keys:
|
|||||||
"properties": { ... },
|
"properties": { ... },
|
||||||
"required": ["param1"]
|
"required": ["param1"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
"primary_key": "param1"
|
"primary_key": "param1"
|
||||||
@@ -33,8 +32,7 @@ schema plus turnstone-specific metadata keys:
|
|||||||
|
|
||||||
| Key | Type | Meaning |
|
| Key | Type | Meaning |
|
||||||
|----------------|------|---------|
|
|----------------|------|---------|
|
||||||
| `agent` | bool | Tool is available to plan/task sub-agents (read-only subset). |
|
| `task_agent` | bool | Tool is available to task sub-agents. |
|
||||||
| `task_agent` | bool | Tool is available to task sub-agents (broader subset). |
|
|
||||||
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
| `auto_approve` | bool | Tool runs without user confirmation (read-only, safe operations). |
|
||||||
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
| `primary_key` | str | When the model sends a bare string instead of JSON args, map it to this parameter name. |
|
||||||
|
|
||||||
@@ -46,12 +44,10 @@ schema plus turnstone-specific metadata keys:
|
|||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|---------------------|-------------|
|
|---------------------|-------------|
|
||||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
| `TOOLS` | All 28 loaded built-in tool definitions (interactive + coordinator union). Sessions send a kind-specific subset (`INTERACTIVE_TOOLS` or `COORDINATOR_TOOLS`). |
|
||||||
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
|
|
||||||
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
|
||||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
| `TASK_AUTO_TOOLS` | Set of all tool names with `auto_approve: true` -- used by task-agent sub-sessions to skip confirmation for matching available tools. |
|
||||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
| `BUILTIN_TOOL_NAMES`| Frozenset of all 28 built-in tool names (interactive + coordinator union). Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
||||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 19 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
|
|
||||||
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -69,7 +65,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
|||||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||||
to the correct parameter.
|
to the correct parameter.
|
||||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 16
|
||||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||||
- Validates arguments and builds a preview dict containing:
|
- Validates arguments and builds a preview dict containing:
|
||||||
@@ -111,9 +107,6 @@ Each item's `execute` callable is invoked:
|
|||||||
denials are tracked separately. This removes the need for text-prefix heuristics.
|
denials are tracked separately. This removes the need for text-prefix heuristics.
|
||||||
Other tools deliver results atomically via
|
Other tools deliver results atomically via
|
||||||
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
|
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
|
||||||
- Special post-execution gate for `plan`: the plan output is shown to the user
|
|
||||||
for review, and the user can reject or annotate it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tool Approval Flow
|
## Tool Approval Flow
|
||||||
@@ -121,7 +114,6 @@ Each item's `execute` callable is invoked:
|
|||||||
**Auto-approved** (no user confirmation needed at runtime):
|
**Auto-approved** (no user confirmation needed at runtime):
|
||||||
- `read_file` -- reads files, no side effects
|
- `read_file` -- reads files, no side effects
|
||||||
- `search` -- grep-style search, no side effects
|
- `search` -- grep-style search, no side effects
|
||||||
- `man` -- reads man pages, no side effects
|
|
||||||
- `memory` -- structured persistent memory (save/search/delete/list)
|
- `memory` -- structured persistent memory (save/search/delete/list)
|
||||||
- `recall` -- searches conversation history
|
- `recall` -- searches conversation history
|
||||||
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
|
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
|
||||||
@@ -130,16 +122,14 @@ Each item's `execute` callable is invoked:
|
|||||||
- `bash` -- arbitrary command execution
|
- `bash` -- arbitrary command execution
|
||||||
- `write_file` -- creates or overwrites files
|
- `write_file` -- creates or overwrites files
|
||||||
- `edit_file` -- modifies file content
|
- `edit_file` -- modifies file content
|
||||||
- `math` -- sandboxed computation (confirmation required despite being sandboxed)
|
|
||||||
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
|
- `web_fetch` -- fetches a URL (SSRF-protected, but makes network requests)
|
||||||
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
|
- `web_search` -- web search via self-hosted SearxNG (makes network requests)
|
||||||
- `task` -- spawns an autonomous sub-agent
|
- `task_agent` -- spawns an autonomous sub-agent
|
||||||
- `plan` -- spawns a planning sub-agent, plus post-execution review gate
|
|
||||||
|
|
||||||
Note: The JSON schema metadata key `auto_approve` controls membership in
|
Note: The JSON schema metadata key `auto_approve` controls membership in
|
||||||
`AGENT_AUTO_TOOLS`/`TASK_AUTO_TOOLS` (used for agent sub-sessions). The actual
|
`TASK_AUTO_TOOLS` (used for task agent sub-sessions). The actual runtime
|
||||||
runtime approval behavior is determined by the `needs_approval` field set in
|
approval behavior is determined by the `needs_approval` field set in each
|
||||||
each `_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
`_prepare_*` method on `ChatSession`. These two mechanisms can differ.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,12 +155,9 @@ Every tool defines a `primary_key`. The mapping is:
|
|||||||
| `write_file` | `content` |
|
| `write_file` | `content` |
|
||||||
| `edit_file` | `old_string`|
|
| `edit_file` | `old_string`|
|
||||||
| `search` | `query` |
|
| `search` | `query` |
|
||||||
| `math` | `code` |
|
|
||||||
| `man` | `page` |
|
|
||||||
| `web_fetch` | `url` |
|
| `web_fetch` | `url` |
|
||||||
| `web_search` | `query` |
|
| `web_search` | `query` |
|
||||||
| `task_agent` | `prompt` |
|
| `task_agent` | `prompt` |
|
||||||
| `plan_agent` | `goal` |
|
|
||||||
| `memory` | `name` |
|
| `memory` | `name` |
|
||||||
| `recall` | `query` |
|
| `recall` | `query` |
|
||||||
| `notify` | `message` |
|
| `notify` | `message` |
|
||||||
@@ -194,7 +181,7 @@ Execute a bash command and return stdout + stderr.
|
|||||||
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
|
||||||
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
|
||||||
- **Auto-approve**: No -- requires user confirmation.
|
- **Auto-approve**: No -- requires user confirmation.
|
||||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
- **Agent availability**: `task_agent` only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,7 +199,7 @@ base64-encoded image data for supported image formats.
|
|||||||
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
- **What it does**: For text files, reads and returns content with line numbers. For image files (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO), returns image data as multi-part content when the model supports vision, or a text description when it does not. SVG files are read as text. Images larger than 4 MB are rejected. Must be called before `edit_file` on the same path (the session tracks which files have been read).
|
||||||
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
- **Vision support**: Controlled by `ModelCapabilities.supports_vision`. All commercial OpenAI and Anthropic models have vision enabled. Local models (vLLM, llama.cpp, NIM) default to off — enable via `[models.*.capabilities] supports_vision = true` in config.toml.
|
||||||
- **Auto-approve**: Yes.
|
- **Auto-approve**: Yes.
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -268,7 +255,7 @@ Show a unified diff between two files, or between a file and a provided string.
|
|||||||
|
|
||||||
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
- **What it does**: Returns unified diff output using Python's `difflib`. Binary files (containing null bytes) are rejected with a clear error. Files read through `diff_file` satisfy `edit_file`'s read guard — you can diff then edit without a separate `read_file` call. Large diffs are streamed with early cutoff at the tool truncation limit.
|
||||||
- **Auto-approve**: Yes (read-only).
|
- **Auto-approve**: Yes (read-only).
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -283,44 +270,12 @@ Search file contents for a regex pattern.
|
|||||||
|
|
||||||
- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers.
|
- **What it does**: Recursively searches for the pattern using `grep -rn`. Returns matching lines with file paths and line numbers.
|
||||||
- **Auto-approve**: Yes.
|
- **Auto-approve**: Yes.
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Computation
|
|
||||||
|
|
||||||
### math
|
|
||||||
|
|
||||||
Execute Python code for math and computation in a sandbox.
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|--------|----------|-------------|
|
|
||||||
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
|
|
||||||
|
|
||||||
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
|
|
||||||
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
|
|
||||||
- **Auto-approve**: Yes.
|
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Information
|
## Information
|
||||||
|
|
||||||
### man
|
|
||||||
|
|
||||||
Read a man page.
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|--------|----------|-------------|
|
|
||||||
| `page` | string | yes | The man page name (e.g. `grep`, `socket`, `printf`). |
|
|
||||||
| `section` | string | no | Manual section (e.g. `1` commands, `2` syscalls, `3` library). |
|
|
||||||
|
|
||||||
- **What it does**: Returns the full formatted manual entry. Preferred over `bash('man ...')` or `web_search` for command/API documentation.
|
|
||||||
- **Auto-approve**: Yes.
|
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### web_fetch
|
### web_fetch
|
||||||
|
|
||||||
Fetch a URL and extract specific information from it.
|
Fetch a URL and extract specific information from it.
|
||||||
@@ -332,7 +287,7 @@ Fetch a URL and extract specific information from it.
|
|||||||
|
|
||||||
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
|
- **What it does**: Fetches the URL, strips HTML to plain text, and uses the LLM to extract the answer to the question from the page content. Protected against SSRF (blocks private/internal IPs).
|
||||||
- **Auto-approve**: No -- requires user confirmation (makes network requests).
|
- **Auto-approve**: No -- requires user confirmation (makes network requests).
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -351,13 +306,13 @@ Search the web using a text query.
|
|||||||
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
|
- **OpenAI search models** (`gpt-5-search-api`): Replaced with `web_search_options` parameter. The model always searches and returns `url_citation` annotations.
|
||||||
- **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`.
|
- **Local/vLLM models**: Falls back to a self-hosted [SearxNG](https://searxng.org) instance. Set `searxng_url` in `config.toml` `[tools]` or `$TURNSTONE_SEARXNG_URL` (the docker-compose stack bundles a `searxng` service and points at it by default). Operators with a custom MCP search server can instead set `web_search_backend = "mcp:server:tool"`.
|
||||||
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
|
- **Auto-approve**: Yes (auto-approved for all tool dispatch paths).
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agent
|
## Agent
|
||||||
|
|
||||||
Tool names use the `_agent` suffix — bare `plan` / `task` collide with
|
The tool name uses the `_agent` suffix — bare `task` collides with
|
||||||
chat-template channel names on some local models.
|
chat-template channel names on some local models.
|
||||||
|
|
||||||
### task_agent
|
### task_agent
|
||||||
@@ -368,23 +323,9 @@ Delegate a general-purpose task to an autonomous sub-agent.
|
|||||||
|-----------|--------|----------|-------------|
|
|-----------|--------|----------|-------------|
|
||||||
| `prompt` | string | yes | Complete task description for the sub-agent. |
|
| `prompt` | string | yes | Complete task description for the sub-agent. |
|
||||||
|
|
||||||
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, math, man, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
- **What it does**: Spawns a sub-agent that inherits the `TASK_AGENT_TOOLS` set (read, write, edit, search, bash, web tools, memory tools). The sub-agent runs autonomously to completion. Use for work that requires file modifications or command execution.
|
||||||
- **Auto-approve**: No -- requires user confirmation.
|
- **Auto-approve**: No -- requires user confirmation.
|
||||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
- **Agent availability**: Top-level only.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### plan_agent
|
|
||||||
|
|
||||||
Plan before implementing -- an autonomous agent explores the codebase and writes a structured plan.
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|--------|----------|-------------|
|
|
||||||
| `prompt` | string | yes | What to plan -- the goal, constraints, and scope. |
|
|
||||||
|
|
||||||
- **What it does**: Spawns a planning sub-agent with `AGENT_TOOLS` (read-only tools: `read_file`, `search`, `math`, `man`, `web_fetch`, `web_search`). The agent explores the codebase and writes a structured plan to `.plan-<ws_id>.md` (unique per workstream, so concurrent workstreams never collide). If the `plan` tool has been called before in the same session, the prior plan is passed to the agent as context so it refines rather than restarts. After completion, the user is prompted to review and can accept, reject, or annotate the plan.
|
|
||||||
- **Auto-approve**: No -- requires user confirmation, plus post-execution review gate.
|
|
||||||
- **Agent availability**: Not available to sub-agents (top-level only).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -445,7 +386,7 @@ Provide either `username` for user-based targeting or `channel_type` +
|
|||||||
|
|
||||||
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
|
- **What it does**: Sends a notification via the channel gateway's HTTP endpoint (`POST /v1/api/notify`). The server queries the `services` table for healthy channel gateways, authenticates with a service JWT (`aud: turnstone-channel`), and delivers to the first healthy gateway. On failure, retries up to 2 additional times with backoff (1s, 3s). Rate-limited to 5 notifications per turn (counter only increments on success).
|
||||||
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
|
- **Auto-approve**: Yes — notifications are time-sensitive and auto-approved so the model can alert users urgently.
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
> See [Channel Integrations: Notifications](channels.md#notifications)
|
> See [Channel Integrations: Notifications](channels.md#notifications)
|
||||||
> for the full delivery flow, service registry details, and security
|
> for the full delivery flow, service registry details, and security
|
||||||
@@ -521,7 +462,7 @@ data.get("mergedAt") is not None
|
|||||||
- Duplicate names rejected within the same workstream.
|
- Duplicate names rejected within the same workstream.
|
||||||
|
|
||||||
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
|
- **Auto-approve**: `create` requires approval; `list` and `cancel` are auto-approved.
|
||||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||||
|
|
||||||
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
|
> See [Watch Architecture](diagrams/png/18-watch-architecture.png) for the
|
||||||
> full poll → evaluate → dispatch flow.
|
> full poll → evaluate → dispatch flow.
|
||||||
@@ -554,33 +495,30 @@ pre-configure skills at workstream creation.
|
|||||||
|
|
||||||
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
|
- **Auto-approve**: `load` requires approval (changes session behavior); `search`
|
||||||
is auto-approved (read-only).
|
is auto-approved (read-only).
|
||||||
- **Agent availability**: Main session only — not available to plan/task sub-agents.
|
- **Agent availability**: Main session only — not available to task sub-agents.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary Table
|
## Summary Table
|
||||||
|
|
||||||
| Tool | Category | Auto-approve | agent | task_agent | primary_key |
|
| Tool | Category | Auto-approve | task_agent | primary_key |
|
||||||
|--------------|------------|--------------|-------|------------|-------------|
|
|--------------|------------|--------------|------------|-------------|
|
||||||
| `bash` | File Ops | No | No | Yes | `command` |
|
| `bash` | File Ops | No | Yes | `command` |
|
||||||
| `read_file` | File Ops | Yes | Yes | Yes | `path` |
|
| `read_file` | File Ops | Yes | Yes | `path` |
|
||||||
| `write_file` | File Ops | No | No | Yes | `content` |
|
| `write_file` | File Ops | No | Yes | `content` |
|
||||||
| `edit_file` | File Ops | No | No | Yes | `old_string`|
|
| `edit_file` | File Ops | No | Yes | `old_string`|
|
||||||
| `search` | File Ops | Yes | Yes | Yes | `query` |
|
| `search` | File Ops | Yes | Yes | `query` |
|
||||||
| `math` | Compute | No | Yes | Yes | `code` |
|
| `web_fetch` | Info | No | Yes | `url` |
|
||||||
| `man` | Info | Yes | Yes | Yes | `page` |
|
| `web_search` | Info | No | Yes | `query` |
|
||||||
| `web_fetch` | Info | No | Yes | Yes | `url` |
|
| `task_agent` | Agent | No | No | `prompt` |
|
||||||
| `web_search` | Info | No | Yes | Yes | `query` |
|
| `memory` | Memory | Yes | No | `name` |
|
||||||
| `task_agent` | Agent | No | No | No | `prompt` |
|
| `recall` | Memory | Yes | No | `query` |
|
||||||
| `plan_agent` | Agent | No | No | No | `goal` |
|
| `notify` | Notify | Yes | Yes | `message` |
|
||||||
| `memory` | Memory | Yes | No | No | `name` |
|
| `watch` | Monitor | No (create) | No | `command` |
|
||||||
| `recall` | Memory | Yes | No | No | `query` |
|
| `read_resource`| MCP | No | Yes | `uri` |
|
||||||
| `notify` | Notify | Yes | Yes | Yes | `message` |
|
| `use_prompt` | MCP | No | Yes | `name` |
|
||||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
| `skill` | Skills | No (load) | No | `name` |
|
||||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
| `tool_search`| Search | Yes | No | `query` |
|
||||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
|
||||||
| `skill` | Skills | No (load) | No | No | `name` |
|
|
||||||
| `tool_search`| Search | Yes | No | No | `query` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -632,8 +570,9 @@ CLI flags override the config file:
|
|||||||
directly.
|
directly.
|
||||||
|
|
||||||
2. **Partitioning**: When active, tools are split into two sets:
|
2. **Partitioning**: When active, tools are split into two sets:
|
||||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
- **Always-on** -- the built-in tools present in the current session
|
||||||
These are always visible to the model.
|
(interactive sessions currently have 16; `BUILTIN_TOOL_NAMES` is the
|
||||||
|
28-tool built-in union). These are always visible to the model.
|
||||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||||
the model searches for them.
|
the model searches for them.
|
||||||
|
|
||||||
@@ -647,10 +586,10 @@ CLI flags override the config file:
|
|||||||
|
|
||||||
### Agent exemption
|
### Agent exemption
|
||||||
|
|
||||||
Plan and task sub-agents do not use tool search. They operate on scoped tool
|
Task sub-agents do not use tool search. They operate on the scoped tool set
|
||||||
sets (`AGENT_TOOLS` for plan agents, `TASK_AGENT_TOOLS` for task agents) with
|
(`TASK_AGENT_TOOLS`) with MCP tools merged in. Tool search is only active for
|
||||||
MCP tools merged in. Tool search is only active for the top-level session,
|
the top-level session, where the model can interactively search for tools it
|
||||||
where the model can interactively search for tools it needs.
|
needs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -675,7 +614,7 @@ MCP-compatible service.
|
|||||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||||
|
|
||||||
4. **Merging**: MCP tools are appended after the 19 built-in tools via
|
4. **Merging**: MCP tools are appended after the 16 built-in tools via
|
||||||
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
|
||||||
When dynamic tool search is active, MCP tools are deferred rather than directly
|
When dynamic tool search is active, MCP tools are deferred rather than directly
|
||||||
visible -- the model discovers them via search as needed (see
|
visible -- the model discovers them via search as needed (see
|
||||||
@@ -701,7 +640,6 @@ gives per-tool-type granularity (e.g., all `use_prompt` calls).
|
|||||||
MCP tools are available to:
|
MCP tools are available to:
|
||||||
- **Main session** — full access
|
- **Main session** — full access
|
||||||
- **Task sub-agents** — via `self._task_tools` (merged list)
|
- **Task sub-agents** — via `self._task_tools` (merged list)
|
||||||
- **Plan sub-agents** — via `self._agent_tools` (merged list)
|
|
||||||
|
|
||||||
### Naming convention
|
### Naming convention
|
||||||
|
|
||||||
@@ -774,7 +712,7 @@ MCP tool lists stay up-to-date without restart through two mechanisms:
|
|||||||
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
|
||||||
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
|
||||||
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
instances via registered listener callbacks. Each session rebuilds its `_tools`,
|
||||||
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
|
`_task_tools`, and reconstructs its `ToolSearchManager` (if active),
|
||||||
preserving the set of previously expanded (discovered) tools.
|
preserving the set of previously expanded (discovered) tools.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -830,7 +768,7 @@ Use read_resource(uri='...') to access the resources listed above.
|
|||||||
|
|
||||||
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
|
- **What it does**: Reads the resource from its MCP server via `MCPClientManager.read_resource_sync()`. Returns text content for text resources or base64-encoded data for binary resources. Output is truncated by the standard tool output limiter.
|
||||||
- **Auto-approve**: No -- requires user confirmation (reads external data).
|
- **Auto-approve**: No -- requires user confirmation (reads external data).
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
### Capability guards
|
### Capability guards
|
||||||
|
|
||||||
@@ -872,7 +810,7 @@ the `initialize` handshake. Each prompt is stored with its prefixed name
|
|||||||
|
|
||||||
- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
|
- **What it does**: Invokes an MCP prompt by name via `MCPClientManager.get_prompt_sync()`, expanding it into messages. Returns the expanded prompt content formatted as `[role]: content` blocks joined with blank lines. The prompt catalog is listed in the system message so the model knows which prompts are available. Output is truncated by the standard tool output limiter.
|
||||||
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
|
- **Auto-approve**: No -- requires user confirmation (invokes external prompt servers).
|
||||||
- **Agent availability**: `agent` and `task_agent`.
|
- **Agent availability**: `task_agent`.
|
||||||
|
|
||||||
### Invocation
|
### Invocation
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -52,9 +52,8 @@ anthropic = ["anthropic>=0.39"]
|
|||||||
postgres = ["psycopg[binary]>=3.2"]
|
postgres = ["psycopg[binary]>=3.2"]
|
||||||
discord = ["discord.py>=2.4"]
|
discord = ["discord.py>=2.4"]
|
||||||
tls = ["lacme>=1.0.5"]
|
tls = ["lacme>=1.0.5"]
|
||||||
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
|
|
||||||
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
|
slack = ["slack-bolt>=1.18", "aiohttp>=3.9"]
|
||||||
all = ["turnstone[console,anthropic,postgres,discord,tls,sandbox,slack]"]
|
all = ["turnstone[console,anthropic,postgres,discord,tls,slack]"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
turnstone = "turnstone.cli:main"
|
turnstone = "turnstone.cli:main"
|
||||||
@@ -102,7 +101,6 @@ line-length = 100
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "TCH"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
per-file-ignores = { "turnstone/core/sandbox.py" = ["N802"] }
|
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
@@ -132,10 +130,6 @@ exclude_lines = [
|
|||||||
'if __name__ == "__main__"',
|
'if __name__ == "__main__"',
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = ["sympy", "sympy.*", "numpy", "numpy.*"]
|
|
||||||
ignore_missing_imports = true
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["mcp", "mcp.*"]
|
module = ["mcp", "mcp.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List active workstreams",
|
"summary": "List active workstreams",
|
||||||
"operationId": "v1_api_workstreams_get",
|
"operationId": "v1_api_workstreams_get",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -31,9 +29,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Dashboard with workstream details and aggregates",
|
"summary": "Dashboard with workstream details and aggregates",
|
||||||
"operationId": "v1_api_dashboard_get",
|
"operationId": "v1_api_dashboard_get",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -52,9 +48,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Create a new workstream",
|
"summary": "Create a new workstream",
|
||||||
"operationId": "v1_api_workstreams_new_post",
|
"operationId": "v1_api_workstreams_new_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"description": "Accepts two content types. Default is `application/json` with a `CreateWorkstreamRequest` body. Alternatively, `multipart/form-data` with one `meta` field (JSON-encoded `CreateWorkstreamRequest` shape) plus zero-or-more `file` parts saves each file as an attachment under the new workstream. When `initial_message` is also set, attachments are reserved onto that turn before the worker thread dispatches; otherwise they remain pending for a follow-up `POST /v1/api/workstreams/{ws_id}/send`.",
|
"description": "Accepts two content types. Default is `application/json` with a `CreateWorkstreamRequest` body. Alternatively, `multipart/form-data` with one `meta` field (JSON-encoded `CreateWorkstreamRequest` shape) plus zero-or-more `file` parts saves each file as an attachment under the new workstream. When `initial_message` is also set, attachments are reserved onto that turn before the worker thread dispatches; otherwise they remain pending for a follow-up `POST /v1/api/workstreams/{ws_id}/send`.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -114,9 +108,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Close a workstream",
|
"summary": "Close a workstream",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_close_post",
|
"operationId": "v1_api_workstreams_{ws_id}_close_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -175,9 +167,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Send a user message",
|
"summary": "Send a user message",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_send_post",
|
"operationId": "v1_api_workstreams_{ws_id}_send_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -234,9 +224,7 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"summary": "Cancel a queued message",
|
"summary": "Cancel a queued message",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_send_delete",
|
"operationId": "v1_api_workstreams_{ws_id}_send_delete",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"description": "Removes a previously-queued message from the workstream's pending queue. Returns ``status: removed`` when the queue had the entry, ``status: not_found`` otherwise.",
|
"description": "Removes a previously-queued message from the workstream's pending queue. Returns ``status: removed`` when the queue had the entry, ``status: not_found`` otherwise.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -296,9 +284,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Approve or deny a tool call",
|
"summary": "Approve or deny a tool call",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_approve_post",
|
"operationId": "v1_api_workstreams_{ws_id}_approve_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -343,54 +329,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/api/plan": {
|
|
||||||
"post": {
|
|
||||||
"summary": "Respond to a plan review",
|
|
||||||
"operationId": "v1_api_plan_post",
|
|
||||||
"tags": [
|
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PlanFeedbackRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Success",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/StatusResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Error 404",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/v1/api/command": {
|
"/v1/api/command": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Execute a slash command",
|
"summary": "Execute a slash command",
|
||||||
"operationId": "v1_api_command_post",
|
"operationId": "v1_api_command_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -439,9 +382,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Cancel the active generation in a workstream",
|
"summary": "Cancel the active generation in a workstream",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_cancel_post",
|
"operationId": "v1_api_workstreams_{ws_id}_cancel_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -500,9 +441,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Drop the last N conversation turns (emits clear_ui)",
|
"summary": "Drop the last N conversation turns (emits clear_ui)",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_rewind_post",
|
"operationId": "v1_api_workstreams_{ws_id}_rewind_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -561,9 +500,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Drop the last response and re-send the last user message",
|
"summary": "Drop the last response and re-send the last user message",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_retry_post",
|
"operationId": "v1_api_workstreams_{ws_id}_retry_post",
|
||||||
"tags": [
|
"tags": ["Chat"],
|
||||||
"Chat"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -612,9 +549,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Per-workstream SSE event stream",
|
"summary": "Per-workstream SSE event stream",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_events_get",
|
"operationId": "v1_api_workstreams_{ws_id}_events_get",
|
||||||
"tags": [
|
"tags": ["Streaming"],
|
||||||
"Streaming"
|
|
||||||
],
|
|
||||||
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
|
"description": "Opens a Server-Sent Events stream scoped to a single workstream. Returns text/event-stream. See API reference for event types.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -647,9 +582,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Global SSE event stream",
|
"summary": "Global SSE event stream",
|
||||||
"operationId": "v1_api_events_global_get",
|
"operationId": "v1_api_events_global_get",
|
||||||
"tags": [
|
"tags": ["Streaming"],
|
||||||
"Streaming"
|
|
||||||
],
|
|
||||||
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
|
"description": "Server-Sent Events stream for node-level state broadcasts. Emits a node_snapshot event on connect (workstreams, health, aggregate), followed by real-time delta events (ws_state, ws_activity, ws_created, ws_closed, ws_rename, health_changed, aggregate). Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
@@ -662,9 +595,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Permanently delete a saved workstream",
|
"summary": "Permanently delete a saved workstream",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_delete_post",
|
"operationId": "v1_api_workstreams_{ws_id}_delete_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -716,9 +647,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Load a saved workstream into memory",
|
"summary": "Load a saved workstream into memory",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_open_post",
|
"operationId": "v1_api_workstreams_{ws_id}_open_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -770,9 +699,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Set workstream title manually",
|
"summary": "Set workstream title manually",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_title_post",
|
"operationId": "v1_api_workstreams_{ws_id}_title_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -814,9 +741,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Regenerate workstream title via LLM",
|
"summary": "Regenerate workstream title via LLM",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_refresh-title_post",
|
"operationId": "v1_api_workstreams_{ws_id}_refresh-title_post",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -848,9 +773,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Get workstream detail (rehydrates lazily on miss)",
|
"summary": "Get workstream detail (rehydrates lazily on miss)",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_get",
|
"operationId": "v1_api_workstreams_{ws_id}_get",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"description": "Returns the persisted workstream's display fields. If the session isn't currently in memory the manager rehydrates it before responding; ``500`` on rehydrate failure carries a correlation id matching the server log line. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously had no detail endpoint.",
|
"description": "Returns the persisted workstream's display fields. If the session isn't currently in memory the manager rehydrates it before responding; ``500`` on rehydrate failure carries a correlation id matching the server log line. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously had no detail endpoint.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -920,9 +843,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Read the workstream's reconstructed message history",
|
"summary": "Read the workstream's reconstructed message history",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_history_get",
|
"operationId": "v1_api_workstreams_{ws_id}_history_get",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"description": "Returns the tail of the conversation in OpenAI-like message format. Persisted-but-not-loaded workstreams (closed / evicted) serve history without rehydrating. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously only exposed history through the SSE replay on ``/events``.",
|
"description": "Returns the tail of the conversation in OpenAI-like message format. Persisted-but-not-loaded workstreams (closed / evicted) serve history without rehydrating. Lifted from the coord-only surface in the Stage 2 history/detail verb lift \u2014 interactive previously only exposed history through the SSE replay on ``/events``.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -1002,9 +923,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Upload a file (multipart/form-data, field 'file') and attach it to the caller's next user turn on this workstream. Validates size, MIME, and UTF-8 for text; magic-byte sniff for images. Ownership failures are masked as 404 so non-owners cannot enumerate workstream existence; a 403 indicates a scope/auth failure from the middleware layer.",
|
"summary": "Upload a file (multipart/form-data, field 'file') and attach it to the caller's next user turn on this workstream. Validates size, MIME, and UTF-8 for text; magic-byte sniff for images. Ownership failures are masked as 404 so non-owners cannot enumerate workstream existence; a 403 indicates a scope/auth failure from the middleware layer.",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_post",
|
"operationId": "v1_api_workstreams_{ws_id}_attachments_post",
|
||||||
"tags": [
|
"tags": ["Attachments"],
|
||||||
"Attachments"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -1081,9 +1000,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List the caller's pending (unconsumed) attachments for this workstream. Ownership failures are masked as 404.",
|
"summary": "List the caller's pending (unconsumed) attachments for this workstream. Ownership failures are masked as 404.",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_get",
|
"operationId": "v1_api_workstreams_{ws_id}_attachments_get",
|
||||||
"tags": [
|
"tags": ["Attachments"],
|
||||||
"Attachments"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -1132,9 +1049,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Return raw bytes of an attachment with its stored Content-Type. Ownership failures are masked as 404.",
|
"summary": "Return raw bytes of an attachment with its stored Content-Type. Ownership failures are masked as 404.",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_content_get",
|
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_content_get",
|
||||||
"tags": [
|
"tags": ["Attachments"],
|
||||||
"Attachments"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -1184,9 +1099,7 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"summary": "Remove a pending attachment (consumed attachments return 404). Ownership failures are also masked as 404.",
|
"summary": "Remove a pending attachment (consumed attachments return 404). Ownership failures are also masked as 404.",
|
||||||
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_delete",
|
"operationId": "v1_api_workstreams_{ws_id}_attachments_{attachment_id}_delete",
|
||||||
"tags": [
|
"tags": ["Attachments"],
|
||||||
"Attachments"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "ws_id",
|
"name": "ws_id",
|
||||||
@@ -1236,9 +1149,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List saved workstreams",
|
"summary": "List saved workstreams",
|
||||||
"operationId": "v1_api_workstreams_saved_get",
|
"operationId": "v1_api_workstreams_saved_get",
|
||||||
"tags": [
|
"tags": ["Workstreams"],
|
||||||
"Workstreams"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1257,9 +1168,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List available skills (summary)",
|
"summary": "List available skills (summary)",
|
||||||
"operationId": "v1_api_skills_get",
|
"operationId": "v1_api_skills_get",
|
||||||
"tags": [
|
"tags": ["Skills"],
|
||||||
"Skills"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1278,9 +1187,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List available model aliases",
|
"summary": "List available model aliases",
|
||||||
"operationId": "v1_api_models_get",
|
"operationId": "v1_api_models_get",
|
||||||
"tags": [
|
"tags": ["Models"],
|
||||||
"Models"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1299,9 +1206,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Authenticate with a token",
|
"summary": "Authenticate with a token",
|
||||||
"operationId": "v1_api_auth_login_post",
|
"operationId": "v1_api_auth_login_post",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1340,9 +1245,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Create first admin user",
|
"summary": "Create first admin user",
|
||||||
"operationId": "v1_api_auth_setup_post",
|
"operationId": "v1_api_auth_setup_post",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1401,9 +1304,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Return auth state",
|
"summary": "Return auth state",
|
||||||
"operationId": "v1_api_auth_status_get",
|
"operationId": "v1_api_auth_status_get",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1422,9 +1323,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Clear auth cookie",
|
"summary": "Clear auth cookie",
|
||||||
"operationId": "v1_api_auth_logout_post",
|
"operationId": "v1_api_auth_logout_post",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1443,9 +1342,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Redirect to OIDC provider for SSO login",
|
"summary": "Redirect to OIDC provider for SSO login",
|
||||||
"operationId": "v1_api_auth_oidc_authorize_get",
|
"operationId": "v1_api_auth_oidc_authorize_get",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"302": {
|
"302": {
|
||||||
"description": "Success"
|
"description": "Success"
|
||||||
@@ -1477,9 +1374,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
|
"summary": "OIDC callback \u2014 validates code, provisions user, sets JWT cookie, redirects to app",
|
||||||
"operationId": "v1_api_auth_oidc_callback_get",
|
"operationId": "v1_api_auth_oidc_callback_get",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"302": {
|
"302": {
|
||||||
"description": "Success"
|
"description": "Success"
|
||||||
@@ -1491,9 +1386,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Return authenticated user info and permissions",
|
"summary": "Return authenticated user info and permissions",
|
||||||
"operationId": "v1_api_auth_whoami_get",
|
"operationId": "v1_api_auth_whoami_get",
|
||||||
"tags": [
|
"tags": ["Auth"],
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1522,9 +1415,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List structured memories",
|
"summary": "List structured memories",
|
||||||
"operationId": "v1_api_memories_get",
|
"operationId": "v1_api_memories_get",
|
||||||
"tags": [
|
"tags": ["Memories"],
|
||||||
"Memories"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "type",
|
"name": "type",
|
||||||
@@ -1580,9 +1471,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Save (upsert) a structured memory",
|
"summary": "Save (upsert) a structured memory",
|
||||||
"operationId": "v1_api_memories_post",
|
"operationId": "v1_api_memories_post",
|
||||||
"tags": [
|
"tags": ["Memories"],
|
||||||
"Memories"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1621,9 +1510,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Search structured memories by query",
|
"summary": "Search structured memories by query",
|
||||||
"operationId": "v1_api_memories_search_post",
|
"operationId": "v1_api_memories_search_post",
|
||||||
"tags": [
|
"tags": ["Memories"],
|
||||||
"Memories"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1652,9 +1539,7 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"summary": "Delete a structured memory by name and scope",
|
"summary": "Delete a structured memory by name and scope",
|
||||||
"operationId": "v1_api_memories_{name}_delete",
|
"operationId": "v1_api_memories_{name}_delete",
|
||||||
"tags": [
|
"tags": ["Memories"],
|
||||||
"Memories"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "name",
|
"name": "name",
|
||||||
@@ -1711,9 +1596,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "List interface.* settings with values and sources",
|
"summary": "List interface.* settings with values and sources",
|
||||||
"operationId": "v1_api_admin_settings_get",
|
"operationId": "v1_api_admin_settings_get",
|
||||||
"tags": [
|
"tags": ["Admin"],
|
||||||
"Admin"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success"
|
"description": "Success"
|
||||||
@@ -1725,9 +1608,7 @@
|
|||||||
"put": {
|
"put": {
|
||||||
"summary": "Update an interface.* setting",
|
"summary": "Update an interface.* setting",
|
||||||
"operationId": "v1_api_admin_settings_{key}_put",
|
"operationId": "v1_api_admin_settings_{key}_put",
|
||||||
"tags": [
|
"tags": ["Admin"],
|
||||||
"Admin"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "key",
|
"name": "key",
|
||||||
@@ -1767,9 +1648,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"summary": "Update an interface.* setting (alias for PUT)",
|
"summary": "Update an interface.* setting (alias for PUT)",
|
||||||
"operationId": "v1_api_admin_settings_{key}_post",
|
"operationId": "v1_api_admin_settings_{key}_post",
|
||||||
"tags": [
|
"tags": ["Admin"],
|
||||||
"Admin"
|
|
||||||
],
|
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "key",
|
"name": "key",
|
||||||
@@ -1811,9 +1690,7 @@
|
|||||||
"get": {
|
"get": {
|
||||||
"summary": "Server health check",
|
"summary": "Server health check",
|
||||||
"operationId": "health_get",
|
"operationId": "health_get",
|
||||||
"tags": [
|
"tags": ["Observability"],
|
||||||
"Observability"
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@@ -1840,9 +1717,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["error"],
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"title": "ErrorResponse",
|
"title": "ErrorResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1851,9 +1726,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
"default": "ok",
|
"default": "ok",
|
||||||
"examples": [
|
"examples": ["ok"],
|
||||||
"ok"
|
|
||||||
],
|
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -1902,19 +1775,14 @@
|
|||||||
},
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"description": "Legacy role",
|
"description": "Legacy role",
|
||||||
"examples": [
|
"examples": ["full", "read"],
|
||||||
"full",
|
|
||||||
"read"
|
|
||||||
],
|
|
||||||
"title": "Role",
|
"title": "Role",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"scopes": {
|
"scopes": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Comma-separated scopes",
|
"description": "Comma-separated scopes",
|
||||||
"examples": [
|
"examples": ["read,write,approve"],
|
||||||
"read,write,approve"
|
|
||||||
],
|
|
||||||
"title": "Scopes",
|
"title": "Scopes",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -1925,9 +1793,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["role"],
|
||||||
"role"
|
|
||||||
],
|
|
||||||
"title": "AuthLoginResponse",
|
"title": "AuthLoginResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1950,11 +1816,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["username", "display_name", "password"],
|
||||||
"username",
|
|
||||||
"display_name",
|
|
||||||
"password"
|
|
||||||
],
|
|
||||||
"title": "AuthSetupRequest",
|
"title": "AuthSetupRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -1991,10 +1853,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["user_id", "username"],
|
||||||
"user_id",
|
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"title": "AuthSetupResponse",
|
"title": "AuthSetupResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2029,11 +1888,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["auth_enabled", "has_users", "setup_required"],
|
||||||
"auth_enabled",
|
|
||||||
"has_users",
|
|
||||||
"setup_required"
|
|
||||||
],
|
|
||||||
"title": "AuthStatusResponse",
|
"title": "AuthStatusResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2061,9 +1916,7 @@
|
|||||||
"title": "Attachment Ids"
|
"title": "Attachment Ids"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["message"],
|
||||||
"message"
|
|
||||||
],
|
|
||||||
"title": "SendRequest",
|
"title": "SendRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2071,12 +1924,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
"description": "'ok', 'busy', 'queued', or 'queue_full'",
|
"description": "'ok', 'busy', 'queued', or 'queue_full'",
|
||||||
"examples": [
|
"examples": ["ok", "busy", "queued", "queue_full"],
|
||||||
"ok",
|
|
||||||
"busy",
|
|
||||||
"queued",
|
|
||||||
"queue_full"
|
|
||||||
],
|
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2123,9 +1971,7 @@
|
|||||||
"title": "Msg Id"
|
"title": "Msg Id"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["status"],
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"title": "SendResponse",
|
"title": "SendResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2138,9 +1984,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["msg_id"],
|
||||||
"msg_id"
|
|
||||||
],
|
|
||||||
"title": "DequeueRequest",
|
"title": "DequeueRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2171,32 +2015,10 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["approved"],
|
||||||
"approved"
|
|
||||||
],
|
|
||||||
"title": "ApproveRequest",
|
"title": "ApproveRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"PlanFeedbackRequest": {
|
|
||||||
"properties": {
|
|
||||||
"feedback": {
|
|
||||||
"description": "Feedback text; empty string means approval",
|
|
||||||
"title": "Feedback",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ws_id": {
|
|
||||||
"description": "Target workstream ID",
|
|
||||||
"title": "Ws Id",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"feedback",
|
|
||||||
"ws_id"
|
|
||||||
],
|
|
||||||
"title": "PlanFeedbackRequest",
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"CommandRequest": {
|
"CommandRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {
|
"command": {
|
||||||
@@ -2210,10 +2032,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["command", "ws_id"],
|
||||||
"command",
|
|
||||||
"ws_id"
|
|
||||||
],
|
|
||||||
"title": "CommandRequest",
|
"title": "CommandRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2238,9 +2057,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["turns"],
|
||||||
"turns"
|
|
||||||
],
|
|
||||||
"title": "RewindRequest",
|
"title": "RewindRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2337,10 +2154,7 @@
|
|||||||
},
|
},
|
||||||
"WorkstreamKind": {
|
"WorkstreamKind": {
|
||||||
"description": "Classifier for which manager hosts a workstream.\n\nStrEnum so members are drop-in ``str`` replacements for the DB column,\nJSON payloads, and existing ``==`` comparisons against raw strings.\nNarrow internal annotations to this type; wide boundaries (HTTP body,\nDB row) stay ``str`` and parse via ``WorkstreamKind(raw)`` / ``from_raw``\nat the edge.",
|
"description": "Classifier for which manager hosts a workstream.\n\nStrEnum so members are drop-in ``str`` replacements for the DB column,\nJSON payloads, and existing ``==`` comparisons against raw strings.\nNarrow internal annotations to this type; wide boundaries (HTTP body,\nDB row) stay ``str`` and parse via ``WorkstreamKind(raw)`` / ``from_raw``\nat the edge.",
|
||||||
"enum": [
|
"enum": ["interactive", "coordinator"],
|
||||||
"interactive",
|
|
||||||
"coordinator"
|
|
||||||
],
|
|
||||||
"title": "WorkstreamKind",
|
"title": "WorkstreamKind",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2377,10 +2191,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id", "name"],
|
||||||
"ws_id",
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"title": "CreateWorkstreamResponse",
|
"title": "CreateWorkstreamResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2415,9 +2226,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["workstreams"],
|
||||||
"workstreams"
|
|
||||||
],
|
|
||||||
"title": "ListWorkstreamsResponse",
|
"title": "ListWorkstreamsResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2458,11 +2267,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id", "name", "state"],
|
||||||
"ws_id",
|
|
||||||
"name",
|
|
||||||
"state"
|
|
||||||
],
|
|
||||||
"title": "WorkstreamInfo",
|
"title": "WorkstreamInfo",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2508,12 +2313,7 @@
|
|||||||
"description": "Inline approval payload \u2014 same shape as ``DashboardWorkstream.pending_approval_detail``. ``None`` when no approval is pending. Lets a reload paint the action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window."
|
"description": "Inline approval payload \u2014 same shape as ``DashboardWorkstream.pending_approval_detail``. ``None`` when no approval is pending. Lets a reload paint the action row + judge verdicts immediately instead of relying on the SSE approve_request replay timing window."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id", "name", "state", "user_id"],
|
||||||
"ws_id",
|
|
||||||
"name",
|
|
||||||
"state",
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"title": "WorkstreamDetailResponse",
|
"title": "WorkstreamDetailResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2635,9 +2435,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id"],
|
||||||
"ws_id"
|
|
||||||
],
|
|
||||||
"title": "WorkstreamHistoryResponse",
|
"title": "WorkstreamHistoryResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2654,10 +2452,7 @@
|
|||||||
"$ref": "#/components/schemas/DashboardAggregate"
|
"$ref": "#/components/schemas/DashboardAggregate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["workstreams", "aggregate"],
|
||||||
"workstreams",
|
|
||||||
"aggregate"
|
|
||||||
],
|
|
||||||
"title": "DashboardResponse",
|
"title": "DashboardResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2799,11 +2594,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id", "name", "state"],
|
||||||
"ws_id",
|
|
||||||
"name",
|
|
||||||
"state"
|
|
||||||
],
|
|
||||||
"title": "DashboardWorkstream",
|
"title": "DashboardWorkstream",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2851,9 +2642,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["workstreams"],
|
||||||
"workstreams"
|
|
||||||
],
|
|
||||||
"title": "ListSavedWorkstreamsResponse",
|
"title": "ListSavedWorkstreamsResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2953,12 +2742,7 @@
|
|||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["ws_id", "created", "updated", "message_count"],
|
||||||
"ws_id",
|
|
||||||
"created",
|
|
||||||
"updated",
|
|
||||||
"message_count"
|
|
||||||
],
|
|
||||||
"title": "SavedWorkstreamInfo",
|
"title": "SavedWorkstreamInfo",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -2987,10 +2771,7 @@
|
|||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"description": "'image' or 'text'",
|
"description": "'image' or 'text'",
|
||||||
"examples": [
|
"examples": ["image", "text"],
|
||||||
"image",
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"title": "Kind",
|
"title": "Kind",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -3016,9 +2797,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["attachments"],
|
||||||
"attachments"
|
|
||||||
],
|
|
||||||
"title": "ListAttachmentsResponse",
|
"title": "ListAttachmentsResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3046,10 +2825,7 @@
|
|||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"description": "'image' or 'text'",
|
"description": "'image' or 'text'",
|
||||||
"examples": [
|
"examples": ["image", "text"],
|
||||||
"image",
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"title": "Kind",
|
"title": "Kind",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -3067,10 +2843,7 @@
|
|||||||
"HealthResponse": {
|
"HealthResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
"examples": [
|
"examples": ["ok", "degraded"],
|
||||||
"ok",
|
|
||||||
"degraded"
|
|
||||||
],
|
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3134,26 +2907,19 @@
|
|||||||
"default": null
|
"default": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["status"],
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"title": "HealthResponse",
|
"title": "HealthResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"BackendStatus": {
|
"BackendStatus": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
"examples": [
|
"examples": ["up", "down"],
|
||||||
"up",
|
|
||||||
"down"
|
|
||||||
],
|
|
||||||
"title": "Status",
|
"title": "Status",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["status"],
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"title": "BackendStatus",
|
"title": "BackendStatus",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3236,23 +3002,14 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"default": "project",
|
"default": "project",
|
||||||
"description": "Memory type",
|
"description": "Memory type",
|
||||||
"enum": [
|
"enum": ["user", "project", "feedback", "reference"],
|
||||||
"user",
|
|
||||||
"project",
|
|
||||||
"feedback",
|
|
||||||
"reference"
|
|
||||||
],
|
|
||||||
"title": "Type",
|
"title": "Type",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"default": "global",
|
"default": "global",
|
||||||
"description": "Memory scope",
|
"description": "Memory scope",
|
||||||
"enum": [
|
"enum": ["global", "workstream", "user"],
|
||||||
"global",
|
|
||||||
"workstream",
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"title": "Scope",
|
"title": "Scope",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3263,10 +3020,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["name", "content"],
|
||||||
"name",
|
|
||||||
"content"
|
|
||||||
],
|
|
||||||
"title": "SaveMemoryRequest",
|
"title": "SaveMemoryRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3286,21 +3040,12 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"enum": [
|
"enum": ["user", "project", "feedback", "reference"],
|
||||||
"user",
|
|
||||||
"project",
|
|
||||||
"feedback",
|
|
||||||
"reference"
|
|
||||||
],
|
|
||||||
"title": "Type",
|
"title": "Type",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"enum": [
|
"enum": ["global", "workstream", "user"],
|
||||||
"global",
|
|
||||||
"workstream",
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"title": "Scope",
|
"title": "Scope",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3349,9 +3094,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["memories"],
|
||||||
"memories"
|
|
||||||
],
|
|
||||||
"title": "ListMemoriesResponse",
|
"title": "ListMemoriesResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3365,25 +3108,14 @@
|
|||||||
"type": {
|
"type": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Filter by memory type",
|
"description": "Filter by memory type",
|
||||||
"enum": [
|
"enum": ["", "user", "project", "feedback", "reference"],
|
||||||
"",
|
|
||||||
"user",
|
|
||||||
"project",
|
|
||||||
"feedback",
|
|
||||||
"reference"
|
|
||||||
],
|
|
||||||
"title": "Type",
|
"title": "Type",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"scope": {
|
"scope": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Filter by scope",
|
"description": "Filter by scope",
|
||||||
"enum": [
|
"enum": ["", "global", "workstream", "user"],
|
||||||
"",
|
|
||||||
"global",
|
|
||||||
"workstream",
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"title": "Scope",
|
"title": "Scope",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3402,9 +3134,7 @@
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["query"],
|
||||||
"query"
|
|
||||||
],
|
|
||||||
"title": "SearchMemoriesRequest",
|
"title": "SearchMemoriesRequest",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3466,9 +3196,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["name"],
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"title": "SkillSummary",
|
"title": "SkillSummary",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3482,9 +3210,7 @@
|
|||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["skills"],
|
||||||
"skills"
|
|
||||||
],
|
|
||||||
"title": "ListSkillSummaryResponse",
|
"title": "ListSkillSummaryResponse",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -3503,11 +3229,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["alias", "model", "provider"],
|
||||||
"alias",
|
|
||||||
"model",
|
|
||||||
"provider"
|
|
||||||
],
|
|
||||||
"title": "AvailableModelInfo",
|
"title": "AvailableModelInfo",
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,16 +109,6 @@ export interface StatusEvent {
|
|||||||
turn_count?: number;
|
turn_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanReviewEvent {
|
|
||||||
type: "plan_review";
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanResolvedEvent {
|
|
||||||
type: "plan_resolved";
|
|
||||||
feedback: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InfoEvent {
|
export interface InfoEvent {
|
||||||
type: "info";
|
type: "info";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -192,8 +182,6 @@ export type ServerEvent =
|
|||||||
| ToolResultEvent
|
| ToolResultEvent
|
||||||
| ToolOutputChunkEvent
|
| ToolOutputChunkEvent
|
||||||
| StatusEvent
|
| StatusEvent
|
||||||
| PlanReviewEvent
|
|
||||||
| PlanResolvedEvent
|
|
||||||
| InfoEvent
|
| InfoEvent
|
||||||
| ErrorEvent
|
| ErrorEvent
|
||||||
| BusyErrorEvent
|
| BusyErrorEvent
|
||||||
@@ -314,14 +302,6 @@ export function isApprovalResolvedEvent(
|
|||||||
return e.type === "approval_resolved";
|
return e.type === "approval_resolved";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPlanReviewEvent(e: ServerEvent): e is PlanReviewEvent {
|
|
||||||
return e.type === "plan_review";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPlanResolvedEvent(e: ServerEvent): e is PlanResolvedEvent {
|
|
||||||
return e.type === "plan_resolved";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
export function isCancelledEvent(e: ServerEvent): e is CancelledEvent {
|
||||||
return e.type === "cancelled";
|
return e.type === "cancelled";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export type {
|
|||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
ToolOutputChunkEvent,
|
ToolOutputChunkEvent,
|
||||||
StatusEvent,
|
StatusEvent,
|
||||||
PlanReviewEvent,
|
|
||||||
InfoEvent,
|
InfoEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
BusyErrorEvent,
|
BusyErrorEvent,
|
||||||
@@ -71,7 +70,6 @@ export {
|
|||||||
isWsStateEvent,
|
isWsStateEvent,
|
||||||
isApproveRequestEvent,
|
isApproveRequestEvent,
|
||||||
isApprovalResolvedEvent,
|
isApprovalResolvedEvent,
|
||||||
isPlanReviewEvent,
|
|
||||||
isCancelledEvent,
|
isCancelledEvent,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
|
|
||||||
@@ -80,7 +78,6 @@ export type {
|
|||||||
SendRequest,
|
SendRequest,
|
||||||
SendResponse,
|
SendResponse,
|
||||||
ApproveRequest,
|
ApproveRequest,
|
||||||
PlanFeedbackRequest,
|
|
||||||
CommandRequest,
|
CommandRequest,
|
||||||
CreateWorkstreamRequest,
|
CreateWorkstreamRequest,
|
||||||
CreateWorkstreamResponse,
|
CreateWorkstreamResponse,
|
||||||
|
|||||||
@@ -180,15 +180,6 @@ export class TurnstoneServer extends BaseClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async planFeedback(opts: {
|
|
||||||
wsId: string;
|
|
||||||
feedback?: string;
|
|
||||||
}): Promise<StatusResponse> {
|
|
||||||
return this.request("POST", "/v1/api/plan", {
|
|
||||||
json: { ws_id: opts.wsId, feedback: opts.feedback ?? "" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async command(opts: {
|
async command(opts: {
|
||||||
wsId: string;
|
wsId: string;
|
||||||
command: string;
|
command: string;
|
||||||
|
|||||||
@@ -120,11 +120,6 @@ export interface ApproveRequest {
|
|||||||
ws_id: string;
|
ws_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanFeedbackRequest {
|
|
||||||
feedback: string;
|
|
||||||
ws_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandRequest {
|
export interface CommandRequest {
|
||||||
command: string;
|
command: string;
|
||||||
ws_id: string;
|
ws_id: string;
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
isWsStateEvent,
|
isWsStateEvent,
|
||||||
isApproveRequestEvent,
|
isApproveRequestEvent,
|
||||||
isApprovalResolvedEvent,
|
isApprovalResolvedEvent,
|
||||||
isPlanReviewEvent,
|
|
||||||
isPlanResolvedEvent,
|
|
||||||
isReasoningEvent,
|
isReasoningEvent,
|
||||||
} from "../src/events.js";
|
} from "../src/events.js";
|
||||||
import type { ServerEvent } from "../src/events.js";
|
import type { ServerEvent } from "../src/events.js";
|
||||||
@@ -72,14 +70,4 @@ describe("event type guards", () => {
|
|||||||
};
|
};
|
||||||
expect(isApprovalResolvedEvent(e)).toBe(true);
|
expect(isApprovalResolvedEvent(e)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isPlanReviewEvent", () => {
|
|
||||||
const e: ServerEvent = { type: "plan_review", content: "## Plan" };
|
|
||||||
expect(isPlanReviewEvent(e)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isPlanResolvedEvent", () => {
|
|
||||||
const e: ServerEvent = { type: "plan_resolved", feedback: "approved" };
|
|
||||||
expect(isPlanResolvedEvent(e)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
-80
@@ -290,34 +290,6 @@
|
|||||||
"Spin up a README.md with a project title and description in it"
|
"Spin up a README.md with a project title and description in it"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "plan-when-asked",
|
|
||||||
"description": "Call the plan tool when the user asks to plan",
|
|
||||||
"user_prompt": "Plan how to add user authentication to this app.",
|
|
||||||
"setup": {
|
|
||||||
"files": {
|
|
||||||
"app.py": "from flask import Flask, jsonify\n\napp = Flask(__name__)\n\n@app.route('/users')\ndef list_users():\n return jsonify([{'id': 1, 'name': 'Alice'}])\n\nif __name__ == '__main__':\n app.run(port=8000)\n"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"expected_actions": [
|
|
||||||
{
|
|
||||||
"tool": "plan_agent"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"match_mode": "subset",
|
|
||||||
"user_prompts": [
|
|
||||||
"Plan how to add user authentication to this app.",
|
|
||||||
"Make a plan for adding pagination to the API endpoints.",
|
|
||||||
"Plan out how to add error handling to this application.",
|
|
||||||
"I need a plan for adding logging to this codebase.",
|
|
||||||
"Plan the approach for adding unit tests to this app.",
|
|
||||||
"How would you approach adding user authentication to this app? Lay out a plan.",
|
|
||||||
"I'd like you to outline a strategy for implementing user authentication in this application.",
|
|
||||||
"Could you come up with a plan for integrating user authentication into this app?",
|
|
||||||
"Think through the steps needed to add user auth to this app and present a plan.",
|
|
||||||
"Draft a plan for incorporating user authentication functionality into this application."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "edit-not-rewrite",
|
"id": "edit-not-rewrite",
|
||||||
"description": "Use edit_file for small changes, not write_file to rewrite the entire file",
|
"description": "Use edit_file for small changes, not write_file to rewrite the entire file",
|
||||||
@@ -412,58 +384,6 @@
|
|||||||
"Hit https://example.com and let me know what's there in summary form"
|
"Hit https://example.com and let me know what's there in summary form"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "man-page-lookup",
|
|
||||||
"description": "Use man tool to look up command documentation",
|
|
||||||
"user_prompt": "Look up the man page for tar and tell me what the --xattrs flag does",
|
|
||||||
"expected_actions": [
|
|
||||||
{
|
|
||||||
"tool": "man",
|
|
||||||
"args_pattern": {
|
|
||||||
"page": "tar"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"match_mode": "subset",
|
|
||||||
"user_prompts": [
|
|
||||||
"Look up the man page for tar and tell me what the --xattrs flag does",
|
|
||||||
"What does the --xattrs flag do in tar? Check the man page for me.",
|
|
||||||
"Could you pull up the man page for tar and explain the --xattrs option?",
|
|
||||||
"I need to know what --xattrs does in tar \u2014 can you check the man page?",
|
|
||||||
"Check tar's man page and let me know the purpose of the --xattrs flag.",
|
|
||||||
"Please consult the tar man page and describe what the --xattrs flag is for.",
|
|
||||||
"Hey, look at the tar man page real quick \u2014 what's --xattrs do?",
|
|
||||||
"I'd like you to read the tar man page and summarize the --xattrs option for me.",
|
|
||||||
"Would you mind checking the man page for tar to find out what --xattrs means?",
|
|
||||||
"Look into the tar manual and explain the --xattrs flag to me."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "math-calculation",
|
|
||||||
"description": "Use the math tool for precise calculations, not bash or mental math",
|
|
||||||
"user_prompt": "What is 2^64 - 1? Use the math tool to calculate it precisely.",
|
|
||||||
"expected_actions": [
|
|
||||||
{
|
|
||||||
"tool": "math",
|
|
||||||
"args_pattern": {
|
|
||||||
"code": "2.*64"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"match_mode": "subset",
|
|
||||||
"user_prompts": [
|
|
||||||
"What is 2^64 - 1? Use the math tool to calculate it precisely.",
|
|
||||||
"Calculate 2^64 - 1 for me using the math tool, please.",
|
|
||||||
"I need the exact value of 2^64 - 1. Please use the math tool.",
|
|
||||||
"Could you use the math tool to compute 2^64 minus 1 precisely?",
|
|
||||||
"Use the math tool to tell me what 2^64 - 1 equals.",
|
|
||||||
"I'm curious: what's 2^64 - 1? Compute it with the math tool.",
|
|
||||||
"Please precisely determine 2^64 - 1 via the math tool.",
|
|
||||||
"Mind using the math tool to figure out 2^64 - 1 exactly?",
|
|
||||||
"I'd like to know the precise result of 2^64 - 1 \u2014 use the math tool for this.",
|
|
||||||
"Leverage the math tool to give me an exact answer for 2^64 - 1."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "web-search-query",
|
"id": "web-search-query",
|
||||||
"description": "Use web_search for general knowledge lookups, not web_fetch",
|
"description": "Use web_search for general knowledge lookups, not web_fetch",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ def make_replay_mocks(
|
|||||||
don't have to reach into the nested mock; when ``None``
|
don't have to reach into the nested mock; when ``None``
|
||||||
(default), the status replay branch stays inert.
|
(default), the status replay branch stays inert.
|
||||||
**ui_overrides: Additional attributes set directly on the ``ui``
|
**ui_overrides: Additional attributes set directly on the ``ui``
|
||||||
mock (e.g. ``_pending_approval``, ``_pending_plan_review``,
|
mock (e.g. ``_pending_approval``, ``_llm_verdicts``,
|
||||||
``_llm_verdicts``, ``_ws_turn_tool_calls``, ``_ws_messages``).
|
``_ws_turn_tool_calls``, ``_ws_messages``).
|
||||||
"""
|
"""
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.model = "gpt-5"
|
session.model = "gpt-5"
|
||||||
@@ -45,7 +45,6 @@ def make_replay_mocks(
|
|||||||
ui = MagicMock()
|
ui = MagicMock()
|
||||||
ui.auto_approve = False
|
ui.auto_approve = False
|
||||||
ui._pending_approval = None
|
ui._pending_approval = None
|
||||||
ui._pending_plan_review = None
|
|
||||||
ui._llm_verdicts = {}
|
ui._llm_verdicts = {}
|
||||||
ui._ws_lock = threading.Lock()
|
ui._ws_lock = threading.Lock()
|
||||||
ui._ws_turn_tool_calls = 0
|
ui._ws_turn_tool_calls = 0
|
||||||
|
|||||||
@@ -153,9 +153,6 @@ class TestRequiredScope:
|
|||||||
def test_get_events_per_ws_needs_read(self):
|
def test_get_events_per_ws_needs_read(self):
|
||||||
assert required_scope("GET", "/api/workstreams/abc/events") == "read"
|
assert required_scope("GET", "/api/workstreams/abc/events") == "read"
|
||||||
|
|
||||||
def test_post_plan_needs_write(self):
|
|
||||||
assert required_scope("POST", "/api/plan") == "write"
|
|
||||||
|
|
||||||
def test_post_command_needs_write(self):
|
def test_post_command_needs_write(self):
|
||||||
assert required_scope("POST", "/api/command") == "write"
|
assert required_scope("POST", "/api/command") == "write"
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
self.infos.append(message)
|
self.infos.append(message)
|
||||||
|
|
||||||
|
|||||||
@@ -113,28 +113,6 @@ class TestSendApproval:
|
|||||||
mock_approve.assert_awaited_once_with(ws_id="ws-1", approved=True, feedback="", always=True)
|
mock_approve.assert_awaited_once_with(ws_id="ws-1", approved=True, feedback="", always=True)
|
||||||
|
|
||||||
|
|
||||||
class TestSendPlanFeedback:
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_calls_server_plan_feedback(
|
|
||||||
self, router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
assert router._server is not None
|
|
||||||
mock_plan = AsyncMock()
|
|
||||||
monkeypatch.setattr(router._server, "plan_feedback", mock_plan)
|
|
||||||
await router.send_plan_feedback("ws-2", "corr-xyz", "looks good")
|
|
||||||
mock_plan.assert_awaited_once_with(ws_id="ws-2", feedback="looks good")
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_calls_console_route_plan_feedback(
|
|
||||||
self, console_router: ChannelRouter, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
assert console_router._console is not None
|
|
||||||
mock_plan = AsyncMock()
|
|
||||||
monkeypatch.setattr(console_router._console, "route_plan_feedback", mock_plan)
|
|
||||||
await console_router.send_plan_feedback("ws-2", "corr-xyz", "looks good")
|
|
||||||
mock_plan.assert_awaited_once_with(ws_id="ws-2", feedback="looks good")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRoute:
|
class TestDeleteRoute:
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_calls_storage_delete(
|
async def test_calls_storage_delete(
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ def _make_bot() -> tuple[object, MagicMock, MagicMock]:
|
|||||||
router.get_or_create_workstream = AsyncMock(return_value=("ws-1", True))
|
router.get_or_create_workstream = AsyncMock(return_value=("ws-1", True))
|
||||||
router.send_message = AsyncMock()
|
router.send_message = AsyncMock()
|
||||||
router.send_approval = AsyncMock()
|
router.send_approval = AsyncMock()
|
||||||
router.send_plan_feedback = AsyncMock()
|
|
||||||
router.get_node_url = AsyncMock(return_value="http://localhost:8080")
|
router.get_node_url = AsyncMock(return_value="http://localhost:8080")
|
||||||
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
||||||
router.delete_route = AsyncMock()
|
router.delete_route = AsyncMock()
|
||||||
@@ -660,7 +659,6 @@ class TestWsEventDispatch:
|
|||||||
storage = MagicMock()
|
storage = MagicMock()
|
||||||
router = MagicMock()
|
router = MagicMock()
|
||||||
router.send_approval = AsyncMock()
|
router.send_approval = AsyncMock()
|
||||||
router.send_plan_feedback = AsyncMock()
|
|
||||||
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
router.evaluate_tool_policies = AsyncMock(return_value=PolicyVerdict(kind="none"))
|
||||||
router.resolve_user = AsyncMock(return_value="turnstone-user-1")
|
router.resolve_user = AsyncMock(return_value="turnstone-user-1")
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
@@ -836,72 +834,6 @@ class TestWsEventDispatch:
|
|||||||
assert "ws-1" not in bot._pending_approval # type: ignore[attr-defined]
|
assert "ws-1" not in bot._pending_approval # type: ignore[attr-defined]
|
||||||
client.chat_update.assert_awaited_once()
|
client.chat_update.assert_awaited_once()
|
||||||
|
|
||||||
def test_plan_review_event_posts_buttons(self) -> None:
|
|
||||||
from turnstone.channels.slack.routes import SlackRoute
|
|
||||||
from turnstone.sdk.events import PlanReviewEvent
|
|
||||||
|
|
||||||
bot, client = self._make_ws_bot()
|
|
||||||
route = SlackRoute(channel="C1", user_id="U12345", thread_ts="123.456")
|
|
||||||
|
|
||||||
event = PlanReviewEvent(ws_id="ws-1", content="1. do thing\n2. do next thing")
|
|
||||||
_run(bot._on_ws_event("ws-1", route, event)) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
client.chat_postMessage.assert_awaited_once()
|
|
||||||
kwargs = client.chat_postMessage.call_args[1]
|
|
||||||
assert kwargs["text"] == "Plan review required"
|
|
||||||
assert "blocks" in kwargs
|
|
||||||
assert "ws-1" in bot._pending_plan_review_ts # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
def test_plan_approve_sends_feedback_and_updates_message(self) -> None:
|
|
||||||
bot, client = self._make_ws_bot()
|
|
||||||
# Register pending review with an owner so the new sec-2 gate passes.
|
|
||||||
bot._pending_plan_review_ts["ws-1"] = ("C1", "111.222", "U_OWNER") # type: ignore[attr-defined]
|
|
||||||
body = {
|
|
||||||
"actions": [{"value": "ws-1"}],
|
|
||||||
"user": {"id": "U_OWNER"},
|
|
||||||
"container": {"channel_id": "C1", "message_ts": "111.222"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_run(bot._on_plan_approve(AsyncMock(), body)) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
bot.router.send_plan_feedback.assert_awaited_once_with("ws-1", "", "") # type: ignore[attr-defined]
|
|
||||||
client.chat_update.assert_awaited_once()
|
|
||||||
|
|
||||||
def test_plan_approve_rejects_non_owner(self) -> None:
|
|
||||||
bot, client = self._make_ws_bot()
|
|
||||||
bot._pending_plan_review_ts["ws-1"] = ("C1", "111.222", "U_OWNER") # type: ignore[attr-defined]
|
|
||||||
body = {
|
|
||||||
"actions": [{"value": "ws-1"}],
|
|
||||||
"user": {"id": "U_OTHER"},
|
|
||||||
"container": {"channel_id": "C1", "message_ts": "111.222"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_run(bot._on_plan_approve(AsyncMock(), body)) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
bot.router.send_plan_feedback.assert_not_awaited() # type: ignore[attr-defined]
|
|
||||||
client.chat_postEphemeral.assert_awaited_once()
|
|
||||||
|
|
||||||
def test_plan_feedback_modal_sends_feedback_and_updates_message(self) -> None:
|
|
||||||
bot, client = self._make_ws_bot()
|
|
||||||
bot._pending_plan_review_ts["ws-1"] = ("C1", "111.222", "U_OWNER") # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
view = {
|
|
||||||
"private_metadata": "ws-1",
|
|
||||||
"state": {
|
|
||||||
"values": {"feedback_block": {"feedback_input": {"value": "please revise step 2"}}}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body = {"user": {"id": "U_OWNER"}}
|
|
||||||
|
|
||||||
_run(bot._on_plan_feedback_modal(AsyncMock(), body, view)) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
bot.router.send_plan_feedback.assert_awaited_once_with( # type: ignore[attr-defined]
|
|
||||||
"ws-1",
|
|
||||||
"",
|
|
||||||
"please revise step 2",
|
|
||||||
)
|
|
||||||
client.chat_update.assert_awaited_once()
|
|
||||||
|
|
||||||
def test_link_prefix_does_not_hijack_regular_prompt(self) -> None:
|
def test_link_prefix_does_not_hijack_regular_prompt(self) -> None:
|
||||||
"""`/turnstone linking up the docs` must not misroute into
|
"""`/turnstone linking up the docs` must not misroute into
|
||||||
_handle_link with `"ing up the docs"` as the token."""
|
_handle_link with `"ing up the docs"` as the token."""
|
||||||
@@ -931,20 +863,6 @@ class TestWsEventDispatch:
|
|||||||
# Next attempt is blocked.
|
# Next attempt is blocked.
|
||||||
assert not bot._allow_link_attempt("U111") # type: ignore[attr-defined]
|
assert not bot._allow_link_attempt("U111") # type: ignore[attr-defined]
|
||||||
|
|
||||||
def test_plan_feedback_modal_rejects_non_owner(self) -> None:
|
|
||||||
bot, _client = self._make_ws_bot()
|
|
||||||
bot._pending_plan_review_ts["ws-1"] = ("C1", "111.222", "U_OWNER") # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
view = {
|
|
||||||
"private_metadata": "ws-1",
|
|
||||||
"state": {"values": {"feedback_block": {"feedback_input": {"value": "please revise"}}}},
|
|
||||||
}
|
|
||||||
body = {"user": {"id": "U_OTHER"}}
|
|
||||||
|
|
||||||
_run(bot._on_plan_feedback_modal(AsyncMock(), body, view)) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
bot.router.send_plan_feedback.assert_not_awaited() # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Notification tracking
|
# Notification tracking
|
||||||
|
|||||||
@@ -90,16 +90,12 @@ class TestSetGetRoundTrip:
|
|||||||
store.set("model.default_alias", "gpt5-prod")
|
store.set("model.default_alias", "gpt5-prod")
|
||||||
assert store.get("model.default_alias") == "gpt5-prod"
|
assert store.get("model.default_alias") == "gpt5-prod"
|
||||||
|
|
||||||
def test_plan_task_alias(self, store):
|
def test_task_alias(self, store):
|
||||||
store.set("model.plan_alias", "smart")
|
|
||||||
store.set("model.task_alias", "fast")
|
store.set("model.task_alias", "fast")
|
||||||
assert store.get("model.plan_alias") == "smart"
|
|
||||||
assert store.get("model.task_alias") == "fast"
|
assert store.get("model.task_alias") == "fast"
|
||||||
|
|
||||||
def test_plan_task_effort(self, store):
|
def test_task_effort(self, store):
|
||||||
store.set("model.plan_effort", "max")
|
|
||||||
store.set("model.task_effort", "low")
|
store.set("model.task_effort", "low")
|
||||||
assert store.get("model.plan_effort") == "max"
|
|
||||||
assert store.get("model.task_effort") == "low"
|
assert store.get("model.task_effort") == "low"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ class _StubCoordUI:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._approval_event = threading.Event()
|
self._approval_event = threading.Event()
|
||||||
self._approval_result: tuple[bool, str | None] = (True, "initial")
|
self._approval_result: tuple[bool, str | None] = (True, "initial")
|
||||||
self._plan_event = threading.Event()
|
|
||||||
self._plan_result: str = "accept"
|
|
||||||
self._fg_event = threading.Event()
|
self._fg_event = threading.Event()
|
||||||
self._listeners_lock = threading.Lock()
|
self._listeners_lock = threading.Lock()
|
||||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||||
@@ -144,7 +142,6 @@ def test_cleanup_ui_unblocks_events_and_broadcasts_to_listeners() -> None:
|
|||||||
adapter, _ = _make_adapter()
|
adapter, _ = _make_adapter()
|
||||||
ws = _make_ws()
|
ws = _make_ws()
|
||||||
ws.ui._approval_event.clear() # type: ignore[attr-defined]
|
ws.ui._approval_event.clear() # type: ignore[attr-defined]
|
||||||
ws.ui._plan_event.clear() # type: ignore[attr-defined]
|
|
||||||
ws.ui._fg_event.clear() # type: ignore[attr-defined]
|
ws.ui._fg_event.clear() # type: ignore[attr-defined]
|
||||||
lq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=5)
|
lq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=5)
|
||||||
ws.ui._listeners.append(lq) # type: ignore[attr-defined]
|
ws.ui._listeners.append(lq) # type: ignore[attr-defined]
|
||||||
@@ -152,10 +149,8 @@ def test_cleanup_ui_unblocks_events_and_broadcasts_to_listeners() -> None:
|
|||||||
adapter.cleanup_ui(ws)
|
adapter.cleanup_ui(ws)
|
||||||
|
|
||||||
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
|
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
|
||||||
assert ws.ui._plan_event.is_set() # type: ignore[attr-defined]
|
|
||||||
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
|
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
|
||||||
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
|
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
|
||||||
assert ws.ui._plan_result == "reject" # type: ignore[attr-defined]
|
|
||||||
assert lq.get_nowait() == {"type": "ws_closed"}
|
assert lq.get_nowait() == {"type": "ws_closed"}
|
||||||
assert ws.ui._listeners == [] # type: ignore[attr-defined]
|
assert ws.ui._listeners == [] # type: ignore[attr-defined]
|
||||||
assert ws.session.cancelled is True # type: ignore[attr-defined]
|
assert ws.session.cancelled is True # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -1639,28 +1639,25 @@ def test_coord_events_replay_skips_session_block_when_no_session():
|
|||||||
assert out == []
|
assert out == []
|
||||||
|
|
||||||
|
|
||||||
def test_coord_events_replay_yields_pending_approval_then_pending_plan():
|
def test_coord_events_replay_yields_pending_approval():
|
||||||
"""The lifted coord ``events_replay`` callback yields, after the
|
"""The lifted coord ``events_replay`` callback yields, after the
|
||||||
connected preamble: pending approval (if any) + pending plan
|
connected preamble, the pending approval (if any). Pre-lift coord
|
||||||
review (if any). Pre-lift coord pushed both onto the listener
|
pushed it onto the listener queue via ``put_nowait``; the lift
|
||||||
queue via ``put_nowait``; the lift restructures as a generator
|
restructures as a generator the lifted body iterates and yields as
|
||||||
the lifted body iterates and yields as ``data:`` lines, but the
|
``data:`` lines, but the payload identity is preserved. Pure-read —
|
||||||
payload identity is preserved. Pure-read — never mutates ``ui``."""
|
never mutates ``ui``."""
|
||||||
from turnstone.console.server import _coord_events_replay
|
from turnstone.console.server import _coord_events_replay
|
||||||
|
|
||||||
ws, ui, request = _make_coord_replay_mocks(
|
ws, ui, request = _make_coord_replay_mocks(
|
||||||
_pending_approval={"type": "approve_request", "items": []},
|
_pending_approval={"type": "approve_request", "items": []},
|
||||||
_pending_plan_review={"type": "plan_review", "content": "..."},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
out = list(_coord_events_replay(ws, ui, request))
|
out = list(_coord_events_replay(ws, ui, request))
|
||||||
types = [ev["type"] for ev in out]
|
types = [ev["type"] for ev in out]
|
||||||
# Status preamble is yielded first (no last_usage → no status); the
|
# Status preamble is yielded first (no last_usage → no status); the
|
||||||
# pending-approval / plan ordering then matches the pre-lift body.
|
# pending-approval re-injection then matches the pre-lift body.
|
||||||
assert types[0] == "connected"
|
assert types[0] == "connected"
|
||||||
approve_idx = types.index("approve_request")
|
assert "approve_request" in types
|
||||||
plan_idx = types.index("plan_review")
|
|
||||||
assert approve_idx < plan_idx
|
|
||||||
|
|
||||||
|
|
||||||
def test_coord_events_replay_yields_cached_verdicts_after_pending_approval():
|
def test_coord_events_replay_yields_cached_verdicts_after_pending_approval():
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ def test_coordinator_session_uses_coordinator_tools(coord_session):
|
|||||||
# to ship the message. Routing is session-kind-agnostic.
|
# to ship the message. Routing is session-kind-agnostic.
|
||||||
"notify",
|
"notify",
|
||||||
}
|
}
|
||||||
# Sub-agent tool sets are zeroed on coordinator sessions.
|
# Sub-agent tool set is zeroed on coordinator sessions.
|
||||||
assert sess._task_tools == []
|
assert sess._task_tools == []
|
||||||
assert sess._agent_tools == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ class _StubUI:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._approval_event = threading.Event()
|
self._approval_event = threading.Event()
|
||||||
self._approval_result: tuple[bool, str | None] = (True, "initial")
|
self._approval_result: tuple[bool, str | None] = (True, "initial")
|
||||||
self._plan_event = threading.Event()
|
|
||||||
self._plan_result: str = "accept"
|
|
||||||
self._fg_event = threading.Event()
|
self._fg_event = threading.Event()
|
||||||
self._listeners_lock = threading.Lock()
|
self._listeners_lock = threading.Lock()
|
||||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||||
@@ -127,22 +125,19 @@ def test_emit_swallows_queue_full_without_raising() -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_ui_unblocks_pending_approval_plan_fg_events() -> None:
|
def test_cleanup_ui_unblocks_pending_approval_fg_events() -> None:
|
||||||
adapter, _ = _make_adapter()
|
adapter, _ = _make_adapter()
|
||||||
ws = _make_ws()
|
ws = _make_ws()
|
||||||
# Simulate pending events
|
# Simulate pending events
|
||||||
ws.ui._approval_event.clear() # type: ignore[attr-defined]
|
ws.ui._approval_event.clear() # type: ignore[attr-defined]
|
||||||
ws.ui._plan_event.clear() # type: ignore[attr-defined]
|
|
||||||
ws.ui._fg_event.clear() # type: ignore[attr-defined]
|
ws.ui._fg_event.clear() # type: ignore[attr-defined]
|
||||||
|
|
||||||
adapter.cleanup_ui(ws)
|
adapter.cleanup_ui(ws)
|
||||||
|
|
||||||
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
|
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
|
||||||
assert ws.ui._plan_event.is_set() # type: ignore[attr-defined]
|
|
||||||
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
|
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
|
||||||
# Approval result flipped to "deny" so the waiter sees a sensible value.
|
# Approval result flipped to "deny" so the waiter sees a sensible value.
|
||||||
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
|
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
|
||||||
assert ws.ui._plan_result == "reject" # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_ui_broadcasts_ws_closed_to_listener_queues() -> None:
|
def test_cleanup_ui_broadcasts_ws_closed_to_listener_queues() -> None:
|
||||||
|
|||||||
@@ -332,11 +332,6 @@ class TestLowRules:
|
|||||||
_assert_verdict(v, risk_level="low", recommendation="approve")
|
_assert_verdict(v, risk_level="low", recommendation="approve")
|
||||||
assert "list-directory" in v.evidence[0]
|
assert "list-directory" in v.evidence[0]
|
||||||
|
|
||||||
def test_man_tool(self):
|
|
||||||
v = evaluate_heuristic("man", {"topic": "grep"}, "man")
|
|
||||||
_assert_verdict(v, risk_level="low", recommendation="approve")
|
|
||||||
assert "man-tool" in v.evidence[0]
|
|
||||||
|
|
||||||
def test_use_prompt(self):
|
def test_use_prompt(self):
|
||||||
v = evaluate_heuristic("use_prompt", {"name": "mcp__git__commit_msg"}, "use_prompt")
|
v = evaluate_heuristic("use_prompt", {"name": "mcp__git__commit_msg"}, "use_prompt")
|
||||||
_assert_verdict(v, risk_level="low", recommendation="approve")
|
_assert_verdict(v, risk_level="low", recommendation="approve")
|
||||||
|
|||||||
@@ -661,14 +661,6 @@ class TestSessionIntegration:
|
|||||||
session = self._make_session(mcp_client=mock_mcp)
|
session = self._make_session(mcp_client=mock_mcp)
|
||||||
assert len(session._task_tools) == len(TASK_AGENT_TOOLS) + 1
|
assert len(session._task_tools) == len(TASK_AGENT_TOOLS) + 1
|
||||||
|
|
||||||
def test_agent_tools_include_mcp(self, tmp_db):
|
|
||||||
from turnstone.core.tools import AGENT_TOOLS
|
|
||||||
|
|
||||||
mock_mcp = MagicMock()
|
|
||||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
|
||||||
session = self._make_session(mcp_client=mock_mcp)
|
|
||||||
assert len(session._agent_tools) == len(AGENT_TOOLS) + 1
|
|
||||||
|
|
||||||
def test_prepare_mcp_tool(self, tmp_db):
|
def test_prepare_mcp_tool(self, tmp_db):
|
||||||
mock_mcp = MagicMock()
|
mock_mcp = MagicMock()
|
||||||
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
mock_mcp.get_tools.return_value = [_fake_openai_tool()]
|
||||||
|
|||||||
+34
-100
@@ -205,11 +205,9 @@ class TestModelRegistry:
|
|||||||
reg = self._make_registry(agent_model="cheap")
|
reg = self._make_registry(agent_model="cheap")
|
||||||
assert reg.agent_model == "cheap"
|
assert reg.agent_model == "cheap"
|
||||||
|
|
||||||
def test_plan_task_models_default_none(self) -> None:
|
def test_task_model_default_none(self) -> None:
|
||||||
reg = self._make_registry()
|
reg = self._make_registry()
|
||||||
assert reg.plan_model is None
|
|
||||||
assert reg.task_model is None
|
assert reg.task_model is None
|
||||||
assert reg.plan_effort is None
|
|
||||||
assert reg.task_effort is None
|
assert reg.task_effort is None
|
||||||
|
|
||||||
def test_resolve_agent_alias_falls_back_to_agent_model(self) -> None:
|
def test_resolve_agent_alias_falls_back_to_agent_model(self) -> None:
|
||||||
@@ -220,7 +218,6 @@ class TestModelRegistry:
|
|||||||
def test_resolve_agent_alias_per_kind_overrides(self) -> None:
|
def test_resolve_agent_alias_per_kind_overrides(self) -> None:
|
||||||
models = {
|
models = {
|
||||||
"default": ModelConfig("default", "http://x/v1", "k", "m"),
|
"default": ModelConfig("default", "http://x/v1", "k", "m"),
|
||||||
"smart": ModelConfig("smart", "http://x/v1", "k", "m"),
|
|
||||||
"fast": ModelConfig("fast", "http://x/v1", "k", "m"),
|
"fast": ModelConfig("fast", "http://x/v1", "k", "m"),
|
||||||
"shared": ModelConfig("shared", "http://x/v1", "k", "m"),
|
"shared": ModelConfig("shared", "http://x/v1", "k", "m"),
|
||||||
}
|
}
|
||||||
@@ -228,10 +225,8 @@ class TestModelRegistry:
|
|||||||
models=models,
|
models=models,
|
||||||
default="default",
|
default="default",
|
||||||
agent_model="shared",
|
agent_model="shared",
|
||||||
plan_model="smart",
|
|
||||||
task_model="fast",
|
task_model="fast",
|
||||||
)
|
)
|
||||||
assert reg.resolve_agent_alias("plan") == "smart"
|
|
||||||
assert reg.resolve_agent_alias("task") == "fast"
|
assert reg.resolve_agent_alias("task") == "fast"
|
||||||
|
|
||||||
def test_resolve_agent_alias_returns_none_when_unconfigured(self) -> None:
|
def test_resolve_agent_alias_returns_none_when_unconfigured(self) -> None:
|
||||||
@@ -239,16 +234,6 @@ class TestModelRegistry:
|
|||||||
assert reg.resolve_agent_alias("plan") is None
|
assert reg.resolve_agent_alias("plan") is None
|
||||||
assert reg.resolve_agent_alias("task") is None
|
assert reg.resolve_agent_alias("task") is None
|
||||||
|
|
||||||
def test_resolve_agent_effort_plan_back_compat_default(self) -> None:
|
|
||||||
reg = self._make_registry()
|
|
||||||
assert reg.resolve_agent_effort("plan") == ModelRegistry.PLAN_DEFAULT_EFFORT
|
|
||||||
assert reg.resolve_agent_effort("plan") == "high"
|
|
||||||
|
|
||||||
def test_resolve_agent_effort_plan_override(self) -> None:
|
|
||||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
|
||||||
reg = ModelRegistry(models=models, default="a", plan_effort="max")
|
|
||||||
assert reg.resolve_agent_effort("plan") == "max"
|
|
||||||
|
|
||||||
def test_resolve_agent_effort_task_returns_none_to_inherit(self) -> None:
|
def test_resolve_agent_effort_task_returns_none_to_inherit(self) -> None:
|
||||||
reg = self._make_registry()
|
reg = self._make_registry()
|
||||||
assert reg.resolve_agent_effort("task") is None
|
assert reg.resolve_agent_effort("task") is None
|
||||||
@@ -287,11 +272,6 @@ class TestModelRegistryValidation:
|
|||||||
with pytest.raises(ValueError, match="Agent model 'bad'"):
|
with pytest.raises(ValueError, match="Agent model 'bad'"):
|
||||||
ModelRegistry(models=models, default="a", agent_model="bad")
|
ModelRegistry(models=models, default="a", agent_model="bad")
|
||||||
|
|
||||||
def test_invalid_plan_model_raises(self) -> None:
|
|
||||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
|
||||||
with pytest.raises(ValueError, match="Plan model 'bad'"):
|
|
||||||
ModelRegistry(models=models, default="a", plan_model="bad")
|
|
||||||
|
|
||||||
def test_invalid_task_model_raises(self) -> None:
|
def test_invalid_task_model_raises(self) -> None:
|
||||||
models = {"a": ModelConfig("a", "x", "x", "x")}
|
models = {"a": ModelConfig("a", "x", "x", "x")}
|
||||||
with pytest.raises(ValueError, match="Task model 'bad'"):
|
with pytest.raises(ValueError, match="Task model 'bad'"):
|
||||||
@@ -389,67 +369,58 @@ class TestLoadModelRegistry:
|
|||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.agent_model is None
|
assert reg.agent_model is None
|
||||||
|
|
||||||
def test_plan_task_models_from_config(self) -> None:
|
def test_task_model_from_config(self) -> None:
|
||||||
fake_cfg: dict[str, Any] = {
|
fake_cfg: dict[str, Any] = {
|
||||||
"models": {
|
"models": {
|
||||||
"smart": {"base_url": "http://s/v1", "model": "s"},
|
|
||||||
"fast": {"base_url": "http://f/v1", "model": "f"},
|
"fast": {"base_url": "http://f/v1", "model": "f"},
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"plan_model": "smart",
|
|
||||||
"task_model": "fast",
|
"task_model": "fast",
|
||||||
"plan_effort": "max",
|
|
||||||
"task_effort": "low",
|
"task_effort": "low",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_model == "smart"
|
|
||||||
assert reg.task_model == "fast"
|
assert reg.task_model == "fast"
|
||||||
assert reg.plan_effort == "max"
|
|
||||||
assert reg.task_effort == "low"
|
assert reg.task_effort == "low"
|
||||||
|
|
||||||
def test_invalid_plan_task_models_ignored(self) -> None:
|
def test_invalid_task_model_ignored(self) -> None:
|
||||||
fake_cfg: dict[str, Any] = {
|
fake_cfg: dict[str, Any] = {
|
||||||
"model": {"plan_model": "nope", "task_model": "alsonope"},
|
"model": {"task_model": "alsonope"},
|
||||||
}
|
}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_model is None
|
|
||||||
assert reg.task_model is None
|
assert reg.task_model is None
|
||||||
|
|
||||||
def test_invalid_effort_values_dropped_with_warning(self) -> None:
|
def test_invalid_effort_values_dropped_with_warning(self) -> None:
|
||||||
"""Typos in plan_effort/task_effort shouldn't silently flow to providers."""
|
"""Typos in task_effort shouldn't silently flow to providers."""
|
||||||
fake_cfg: dict[str, Any] = {
|
fake_cfg: dict[str, Any] = {
|
||||||
"model": {"plan_effort": "hihg", "task_effort": "extreme"},
|
"model": {"task_effort": "extreme"},
|
||||||
}
|
}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_effort is None
|
|
||||||
assert reg.task_effort is None
|
assert reg.task_effort is None
|
||||||
|
|
||||||
def test_valid_effort_values_accepted(self) -> None:
|
def test_valid_effort_values_accepted(self) -> None:
|
||||||
for level in ("none", "minimal", "low", "medium", "high", "xhigh", "max"):
|
for level in ("none", "minimal", "low", "medium", "high", "xhigh", "max"):
|
||||||
fake_cfg: dict[str, Any] = {"model": {"plan_effort": level}}
|
fake_cfg: dict[str, Any] = {"model": {"task_effort": level}}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_effort == level, f"level={level} not accepted"
|
assert reg.task_effort == level, f"level={level} not accepted"
|
||||||
|
|
||||||
def test_empty_or_whitespace_effort_treated_as_unset(self) -> None:
|
def test_empty_or_whitespace_effort_treated_as_unset(self) -> None:
|
||||||
"""Operators write `plan_effort = ""` to make "unset" explicit;
|
"""Operators write `task_effort = ""` to make "unset" explicit;
|
||||||
warning on benign empty values would be noise."""
|
warning on benign empty values would be noise."""
|
||||||
for value in ("", " ", "\t"):
|
for value in ("", " ", "\t"):
|
||||||
fake_cfg: dict[str, Any] = {"model": {"plan_effort": value, "task_effort": value}}
|
fake_cfg: dict[str, Any] = {"model": {"task_effort": value}}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_effort is None, f"empty value {value!r} not treated as unset"
|
assert reg.task_effort is None, f"empty value {value!r} not treated as unset"
|
||||||
assert reg.task_effort is None
|
|
||||||
|
|
||||||
def test_effort_normalised_to_lowercase(self) -> None:
|
def test_effort_normalised_to_lowercase(self) -> None:
|
||||||
fake_cfg: dict[str, Any] = {"model": {"plan_effort": "HIGH", "task_effort": " Low "}}
|
fake_cfg: dict[str, Any] = {"model": {"task_effort": " Low "}}
|
||||||
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
|
||||||
reg = load_model_registry("http://x/v1", "x", "x")
|
reg = load_model_registry("http://x/v1", "x", "x")
|
||||||
assert reg.plan_effort == "high"
|
|
||||||
assert reg.task_effort == "low"
|
assert reg.task_effort == "low"
|
||||||
|
|
||||||
def test_invalid_default_falls_back(self) -> None:
|
def test_invalid_default_falls_back(self) -> None:
|
||||||
@@ -1016,9 +987,6 @@ class _FakeUI:
|
|||||||
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
|
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
|
||||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
return "approve"
|
|
||||||
|
|
||||||
def on_info(self, message: str) -> None:
|
def on_info(self, message: str) -> None:
|
||||||
self.infos.append(message)
|
self.infos.append(message)
|
||||||
|
|
||||||
@@ -1330,13 +1298,6 @@ class TestSessionAgentModel:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_plan_model_overrides_agent_model(self) -> None:
|
|
||||||
reg = self._three_model_registry(agent_model="fast", plan_model="smart")
|
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
|
||||||
captured = self._capture(reg, "smart")
|
|
||||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
|
||||||
assert captured["model"] == "smart-model"
|
|
||||||
|
|
||||||
def test_task_model_overrides_agent_model(self) -> None:
|
def test_task_model_overrides_agent_model(self) -> None:
|
||||||
reg = self._three_model_registry(agent_model="smart", task_model="fast")
|
reg = self._three_model_registry(agent_model="smart", task_model="fast")
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
session = _make_session(registry=reg, model_alias="main")
|
||||||
@@ -1360,22 +1321,6 @@ class TestSessionAgentModel:
|
|||||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
||||||
assert captured["model"] == "test-model"
|
assert captured["model"] == "test-model"
|
||||||
|
|
||||||
def test_plan_default_reasoning_effort_is_high(self) -> None:
|
|
||||||
"""Back-compat: plan_agent always got "high" before; the default must
|
|
||||||
survive the migration even when no plan_effort is configured."""
|
|
||||||
reg = self._three_model_registry()
|
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
|
||||||
captured = self._capture_on(session.client)
|
|
||||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
|
||||||
assert self._captured_effort(captured) == "high"
|
|
||||||
|
|
||||||
def test_plan_effort_from_registry_overrides_default(self) -> None:
|
|
||||||
reg = self._three_model_registry(plan_effort="max")
|
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
|
||||||
captured = self._capture_on(session.client)
|
|
||||||
session._run_agent([{"role": "user", "content": "x"}], label="plan")
|
|
||||||
assert self._captured_effort(captured) == "max"
|
|
||||||
|
|
||||||
def test_task_effort_inherits_session_when_unset(self) -> None:
|
def test_task_effort_inherits_session_when_unset(self) -> None:
|
||||||
# Task with no task_effort override must inherit whatever the SESSION
|
# Task with no task_effort override must inherit whatever the SESSION
|
||||||
# is configured for — assert against an explicit value rather than
|
# is configured for — assert against an explicit value rather than
|
||||||
@@ -1402,11 +1347,11 @@ class TestSessionAgentModel:
|
|||||||
assert task_captured["model"] == "fast-model"
|
assert task_captured["model"] == "fast-model"
|
||||||
|
|
||||||
def test_explicit_effort_wins_over_registry(self) -> None:
|
def test_explicit_effort_wins_over_registry(self) -> None:
|
||||||
reg = self._three_model_registry(plan_effort="low")
|
reg = self._three_model_registry(task_effort="low")
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
session = _make_session(registry=reg, model_alias="main")
|
||||||
captured = self._capture_on(session.client)
|
captured = self._capture_on(session.client)
|
||||||
session._run_agent(
|
session._run_agent(
|
||||||
[{"role": "user", "content": "x"}], label="plan", reasoning_effort="minimal"
|
[{"role": "user", "content": "x"}], label="task", reasoning_effort="minimal"
|
||||||
)
|
)
|
||||||
assert self._captured_effort(captured) == "minimal"
|
assert self._captured_effort(captured) == "minimal"
|
||||||
|
|
||||||
@@ -1420,15 +1365,6 @@ class TestSessionAgentModel:
|
|||||||
session._run_agent([{"role": "user", "content": "x"}], label="task", agent_alias="fast")
|
session._run_agent([{"role": "user", "content": "x"}], label="task", agent_alias="fast")
|
||||||
assert captured["model"] == "fast-model"
|
assert captured["model"] == "fast-model"
|
||||||
|
|
||||||
def test_explicit_alias_overrides_registry_plan_model(self) -> None:
|
|
||||||
"""Per-call alias wins over the configured per-kind plan_model."""
|
|
||||||
reg = self._three_model_registry(plan_model="smart")
|
|
||||||
session = _make_session(registry=reg, model_alias="main")
|
|
||||||
# Without override the call would route to "smart"; we ask for "fast".
|
|
||||||
captured = self._capture(reg, "fast")
|
|
||||||
session._run_agent([{"role": "user", "content": "x"}], label="plan", agent_alias="fast")
|
|
||||||
assert captured["model"] == "fast-model"
|
|
||||||
|
|
||||||
def test_session_fallback_inherits_primary_alias_for_caps(self) -> None:
|
def test_session_fallback_inherits_primary_alias_for_caps(self) -> None:
|
||||||
"""When _run_agent has no registry agent route, it must fall back to
|
"""When _run_agent has no registry agent route, it must fall back to
|
||||||
the session's primary alias for capability and server_compat lookup —
|
the session's primary alias for capability and server_compat lookup —
|
||||||
@@ -1849,22 +1785,22 @@ class TestEffectiveRouting:
|
|||||||
def test_returns_base_when_cs_is_none(self) -> None:
|
def test_returns_base_when_cs_is_none(self) -> None:
|
||||||
from turnstone.server import _effective_routing
|
from turnstone.server import _effective_routing
|
||||||
|
|
||||||
result = _effective_routing(None, self._models(), "default", "smart", "fast", "high", "low")
|
result = _effective_routing(None, self._models(), "default", "fast", "low")
|
||||||
assert result == ("default", "smart", "fast", "high", "low")
|
assert result == ("default", "fast", "low")
|
||||||
|
|
||||||
def test_cs_alias_overrides_base(self) -> None:
|
def test_cs_alias_overrides_base(self) -> None:
|
||||||
from turnstone.server import _effective_routing
|
from turnstone.server import _effective_routing
|
||||||
|
|
||||||
cs = _FakeCS(**{"model.plan_alias": "fast", "model.task_alias": "smart"})
|
cs = _FakeCS(**{"model.task_alias": "smart"})
|
||||||
result = _effective_routing(cs, self._models(), "default", "smart", "fast", "high", "low")
|
result = _effective_routing(cs, self._models(), "default", "fast", "low")
|
||||||
assert result == ("default", "fast", "smart", "high", "low")
|
assert result == ("default", "smart", "low")
|
||||||
|
|
||||||
def test_cs_alias_silently_dropped_when_unknown(self) -> None:
|
def test_cs_alias_silently_dropped_when_unknown(self) -> None:
|
||||||
from turnstone.server import _effective_routing
|
from turnstone.server import _effective_routing
|
||||||
|
|
||||||
cs = _FakeCS(**{"model.plan_alias": "nonexistent"})
|
cs = _FakeCS(**{"model.task_alias": "nonexistent"})
|
||||||
result = _effective_routing(cs, self._models(), "default", "smart", None, None, None)
|
result = _effective_routing(cs, self._models(), "default", "smart", None)
|
||||||
assert result == ("default", "smart", None, None, None) # falls back to base
|
assert result == ("default", "smart", None) # falls back to base
|
||||||
|
|
||||||
def test_cs_empty_string_treated_as_unset(self) -> None:
|
def test_cs_empty_string_treated_as_unset(self) -> None:
|
||||||
from turnstone.server import _effective_routing
|
from turnstone.server import _effective_routing
|
||||||
@@ -1872,21 +1808,19 @@ class TestEffectiveRouting:
|
|||||||
cs = _FakeCS(
|
cs = _FakeCS(
|
||||||
**{
|
**{
|
||||||
"model.default_alias": "",
|
"model.default_alias": "",
|
||||||
"model.plan_alias": "",
|
|
||||||
"model.task_alias": "",
|
"model.task_alias": "",
|
||||||
"model.plan_effort": "",
|
|
||||||
"model.task_effort": "",
|
"model.task_effort": "",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _effective_routing(cs, self._models(), "default", "smart", "fast", "high", "low")
|
result = _effective_routing(cs, self._models(), "default", "fast", "low")
|
||||||
assert result == ("default", "smart", "fast", "high", "low")
|
assert result == ("default", "fast", "low")
|
||||||
|
|
||||||
def test_cs_effort_overrides_base(self) -> None:
|
def test_cs_effort_overrides_base(self) -> None:
|
||||||
from turnstone.server import _effective_routing
|
from turnstone.server import _effective_routing
|
||||||
|
|
||||||
cs = _FakeCS(**{"model.plan_effort": "max", "model.task_effort": "minimal"})
|
cs = _FakeCS(**{"model.task_effort": "minimal"})
|
||||||
result = _effective_routing(cs, self._models(), "default", None, None, "high", None)
|
result = _effective_routing(cs, self._models(), "default", None, "high")
|
||||||
assert result == ("default", None, None, "max", "minimal")
|
assert result == ("default", None, "minimal")
|
||||||
|
|
||||||
|
|
||||||
class TestApplyRoutingOverrides:
|
class TestApplyRoutingOverrides:
|
||||||
@@ -1906,8 +1840,8 @@ class TestApplyRoutingOverrides:
|
|||||||
def test_no_reload_when_cs_matches_registry(self) -> None:
|
def test_no_reload_when_cs_matches_registry(self) -> None:
|
||||||
from turnstone.server import _apply_routing_overrides
|
from turnstone.server import _apply_routing_overrides
|
||||||
|
|
||||||
reg = self._registry(plan_model="smart", task_model="fast")
|
reg = self._registry(task_model="fast")
|
||||||
cs = _FakeCS(**{"model.plan_alias": "smart", "model.task_alias": "fast"})
|
cs = _FakeCS(**{"model.task_alias": "fast"})
|
||||||
# Patch reload to detect calls
|
# Patch reload to detect calls
|
||||||
called = {"count": 0}
|
called = {"count": 0}
|
||||||
original_reload = reg.reload
|
original_reload = reg.reload
|
||||||
@@ -1924,10 +1858,10 @@ class TestApplyRoutingOverrides:
|
|||||||
def test_reload_when_cs_differs(self) -> None:
|
def test_reload_when_cs_differs(self) -> None:
|
||||||
from turnstone.server import _apply_routing_overrides
|
from turnstone.server import _apply_routing_overrides
|
||||||
|
|
||||||
reg = self._registry() # plan_model=None
|
reg = self._registry() # task_model=None
|
||||||
cs = _FakeCS(**{"model.plan_alias": "smart"})
|
cs = _FakeCS(**{"model.task_alias": "fast"})
|
||||||
assert _apply_routing_overrides(reg, cs) is True
|
assert _apply_routing_overrides(reg, cs) is True
|
||||||
assert reg.plan_model == "smart"
|
assert reg.task_model == "fast"
|
||||||
|
|
||||||
def test_no_reload_when_cs_is_none(self) -> None:
|
def test_no_reload_when_cs_is_none(self) -> None:
|
||||||
from turnstone.server import _apply_routing_overrides
|
from turnstone.server import _apply_routing_overrides
|
||||||
@@ -1940,6 +1874,6 @@ class TestApplyRoutingOverrides:
|
|||||||
from turnstone.server import _apply_routing_overrides
|
from turnstone.server import _apply_routing_overrides
|
||||||
|
|
||||||
reg = self._registry()
|
reg = self._registry()
|
||||||
cs = _FakeCS(**{"model.plan_alias": "nonexistent"})
|
cs = _FakeCS(**{"model.task_alias": "nonexistent"})
|
||||||
assert _apply_routing_overrides(reg, cs) is False
|
assert _apply_routing_overrides(reg, cs) is False
|
||||||
assert reg.plan_model is None # unchanged
|
assert reg.task_model is None # unchanged
|
||||||
|
|||||||
@@ -190,8 +190,6 @@ def test_reload_emits_models_changed(storage: SQLiteBackend) -> None:
|
|||||||
_EXPECTED_AFFECTING_KEYS = frozenset(
|
_EXPECTED_AFFECTING_KEYS = frozenset(
|
||||||
{
|
{
|
||||||
"model.default_alias",
|
"model.default_alias",
|
||||||
"model.plan_alias",
|
|
||||||
"model.plan_effort",
|
|
||||||
"model.task_alias",
|
"model.task_alias",
|
||||||
"model.task_effort",
|
"model.task_effort",
|
||||||
"coordinator.model_alias",
|
"coordinator.model_alias",
|
||||||
@@ -208,11 +206,7 @@ def _value_for_key(key: str) -> str:
|
|||||||
``reasoning_effort`` keys have a fixed choice list; alias-shaped
|
``reasoning_effort`` keys have a fixed choice list; alias-shaped
|
||||||
keys accept arbitrary strings. Avoids per-key custom payloads.
|
keys accept arbitrary strings. Avoids per-key custom payloads.
|
||||||
"""
|
"""
|
||||||
if (
|
if key.endswith("reasoning_effort") or key.endswith("task_effort"):
|
||||||
key.endswith("reasoning_effort")
|
|
||||||
or key.endswith("plan_effort")
|
|
||||||
or key.endswith("task_effort")
|
|
||||||
):
|
|
||||||
return "low"
|
return "low"
|
||||||
return "anything"
|
return "anything"
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class TestServerSpec:
|
|||||||
"/v1/api/workstreams/{ws_id}/events",
|
"/v1/api/workstreams/{ws_id}/events",
|
||||||
"/v1/api/dashboard",
|
"/v1/api/dashboard",
|
||||||
"/v1/api/workstreams/saved",
|
"/v1/api/workstreams/saved",
|
||||||
"/v1/api/plan",
|
|
||||||
"/v1/api/command",
|
"/v1/api/command",
|
||||||
"/v1/api/events/global",
|
"/v1/api/events/global",
|
||||||
"/v1/api/workstreams/new",
|
"/v1/api/workstreams/new",
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Every successful ``/v1/api/route/*`` hop emits an ``audit_events`` row
|
Every successful ``/v1/api/route/*`` hop emits an ``audit_events`` row
|
||||||
with action ``route.workstream.{create,send,close,delete}`` /
|
with action ``route.workstream.{create,send,close,delete}`` /
|
||||||
``route.{approve,cancel,command,plan}`` and ``detail`` carrying
|
``route.{approve,cancel,command}`` and ``detail`` carrying
|
||||||
``{src, node_id, coord_ws_id?}``. Failure paths (4xx/5xx) MUST NOT
|
``{src, node_id, coord_ws_id?}``. Failure paths (4xx/5xx) MUST NOT
|
||||||
emit, and audit-emission failure MUST NOT break the proxied call.
|
emit, and audit-emission failure MUST NOT break the proxied call.
|
||||||
"""
|
"""
|
||||||
@@ -271,7 +271,6 @@ class TestRouteProxyAudit:
|
|||||||
("/v1/api/route/workstreams/abc123/rewind", "route.rewind"),
|
("/v1/api/route/workstreams/abc123/rewind", "route.rewind"),
|
||||||
("/v1/api/route/workstreams/abc123/retry", "route.retry"),
|
("/v1/api/route/workstreams/abc123/retry", "route.retry"),
|
||||||
("/v1/api/route/command", "route.command"),
|
("/v1/api/route/command", "route.command"),
|
||||||
("/v1/api/route/plan", "route.plan"),
|
|
||||||
("/v1/api/route/workstreams/abc123/close", "route.workstream.close"),
|
("/v1/api/route/workstreams/abc123/close", "route.workstream.close"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -283,8 +282,8 @@ class TestRouteProxyAudit:
|
|||||||
client = TestClient(app, raise_server_exceptions=False)
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
|
||||||
# ws_id in body is still required by the surviving body-keyed
|
# ws_id in body is still required by the surviving body-keyed
|
||||||
# mounts (/route/plan, /route/command); for the path-keyed
|
# mount (/route/command); for the path-keyed workstreams routes
|
||||||
# workstreams routes the proxy reads ws_id from path_params.
|
# the proxy reads ws_id from path_params.
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
path,
|
path,
|
||||||
json={"ws_id": "abc123", "message": "hi"},
|
json={"ws_id": "abc123", "message": "hi"},
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class _BrokenStorage(_MockStorage):
|
|||||||
class TestBuiltinsOnly:
|
class TestBuiltinsOnly:
|
||||||
def test_builtin_heuristic_rules_loaded(self) -> None:
|
def test_builtin_heuristic_rules_loaded(self) -> None:
|
||||||
reg = RuleRegistry(storage=None)
|
reg = RuleRegistry(storage=None)
|
||||||
assert len(reg.heuristic_rules) == 37
|
assert len(reg.heuristic_rules) == 36
|
||||||
|
|
||||||
def test_builtin_output_patterns_loaded(self) -> None:
|
def test_builtin_output_patterns_loaded(self) -> None:
|
||||||
reg = RuleRegistry(storage=None)
|
reg = RuleRegistry(storage=None)
|
||||||
@@ -102,7 +102,7 @@ class TestHeuristicMerge:
|
|||||||
names = [r.name for r in reg.heuristic_rules]
|
names = [r.name for r in reg.heuristic_rules]
|
||||||
assert "my-custom-rule" in names
|
assert "my-custom-rule" in names
|
||||||
# Built-ins still present
|
# Built-ins still present
|
||||||
assert len(reg.heuristic_rules) == 38
|
assert len(reg.heuristic_rules) == 37
|
||||||
|
|
||||||
def test_builtin_overridden(self) -> None:
|
def test_builtin_overridden(self) -> None:
|
||||||
storage = _MockStorage(
|
storage = _MockStorage(
|
||||||
@@ -143,7 +143,7 @@ class TestHeuristicMerge:
|
|||||||
reg = RuleRegistry(storage=storage)
|
reg = RuleRegistry(storage=storage)
|
||||||
names = [r.name for r in reg.heuristic_rules]
|
names = [r.name for r in reg.heuristic_rules]
|
||||||
assert "rm-root" not in names
|
assert "rm-root" not in names
|
||||||
assert len(reg.heuristic_rules) == 36
|
assert len(reg.heuristic_rules) == 35
|
||||||
|
|
||||||
def test_custom_rule_disabled_excluded(self) -> None:
|
def test_custom_rule_disabled_excluded(self) -> None:
|
||||||
storage = _MockStorage(
|
storage = _MockStorage(
|
||||||
@@ -167,12 +167,12 @@ class TestHeuristicMerge:
|
|||||||
reg = RuleRegistry(storage=storage)
|
reg = RuleRegistry(storage=storage)
|
||||||
names = [r.name for r in reg.heuristic_rules]
|
names = [r.name for r in reg.heuristic_rules]
|
||||||
assert "my-disabled-rule" not in names
|
assert "my-disabled-rule" not in names
|
||||||
assert len(reg.heuristic_rules) == 37
|
assert len(reg.heuristic_rules) == 36
|
||||||
|
|
||||||
def test_reload_updates_rules(self) -> None:
|
def test_reload_updates_rules(self) -> None:
|
||||||
storage = _MockStorage()
|
storage = _MockStorage()
|
||||||
reg = RuleRegistry(storage=storage)
|
reg = RuleRegistry(storage=storage)
|
||||||
assert len(reg.heuristic_rules) == 37
|
assert len(reg.heuristic_rules) == 36
|
||||||
|
|
||||||
# Simulate admin adding a rule
|
# Simulate admin adding a rule
|
||||||
storage._heuristic_rows.append(
|
storage._heuristic_rows.append(
|
||||||
@@ -192,7 +192,7 @@ class TestHeuristicMerge:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
reg.reload()
|
reg.reload()
|
||||||
assert len(reg.heuristic_rules) == 38
|
assert len(reg.heuristic_rules) == 37
|
||||||
assert "late-addition" in [r.name for r in reg.heuristic_rules]
|
assert "late-addition" in [r.name for r in reg.heuristic_rules]
|
||||||
|
|
||||||
def test_version_increments_on_reload(self) -> None:
|
def test_version_increments_on_reload(self) -> None:
|
||||||
@@ -292,7 +292,7 @@ class TestEdgeCases:
|
|||||||
def test_storage_error_falls_back_to_builtins(self) -> None:
|
def test_storage_error_falls_back_to_builtins(self) -> None:
|
||||||
storage = _BrokenStorage()
|
storage = _BrokenStorage()
|
||||||
reg = RuleRegistry(storage=storage)
|
reg = RuleRegistry(storage=storage)
|
||||||
assert len(reg.heuristic_rules) == 37
|
assert len(reg.heuristic_rules) == 36
|
||||||
total = sum(len(pats) for pats in reg.output_patterns.values())
|
total = sum(len(pats) for pats in reg.output_patterns.values())
|
||||||
assert total == 19
|
assert total == 19
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"""Tests for turnstone.core.sandbox — validate_math_code and auto_print_wrap."""
|
|
||||||
|
|
||||||
from turnstone.core.sandbox import auto_print_wrap, validate_math_code
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateMathCode:
|
|
||||||
def test_safe_code_no_errors(self):
|
|
||||||
assert validate_math_code("x = 1 + 2\nprint(x)") == []
|
|
||||||
|
|
||||||
def test_safe_math_import(self):
|
|
||||||
assert validate_math_code("import math\nprint(math.pi)") == []
|
|
||||||
|
|
||||||
def test_blocked_import_os(self):
|
|
||||||
errors = validate_math_code("import os")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "os" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_import_sys(self):
|
|
||||||
errors = validate_math_code("import sys")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "sys" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_import_subprocess(self):
|
|
||||||
errors = validate_math_code("import subprocess")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "subprocess" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_from_import(self):
|
|
||||||
errors = validate_math_code("from os.path import join")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "os" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_builtin_exec(self):
|
|
||||||
errors = validate_math_code("exec('print(1)')")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "exec" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_builtin_eval(self):
|
|
||||||
errors = validate_math_code("eval('1+1')")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "eval" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_builtin_open(self):
|
|
||||||
errors = validate_math_code("open('file.txt')")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "open" in errors[0]
|
|
||||||
|
|
||||||
def test_blocked_dunder_access(self):
|
|
||||||
errors = validate_math_code("x.__dict__")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "__dict__" in errors[0]
|
|
||||||
|
|
||||||
def test_allowed_dunder_name(self):
|
|
||||||
# __name__, __doc__, __class__ are allowed
|
|
||||||
assert validate_math_code("print(int.__name__)") == []
|
|
||||||
|
|
||||||
def test_syntax_error_caught(self):
|
|
||||||
errors = validate_math_code("def f(\n")
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "Syntax error" in errors[0]
|
|
||||||
|
|
||||||
def test_multiple_violations(self):
|
|
||||||
code = "import os\nimport sys\nexec('x')"
|
|
||||||
errors = validate_math_code(code)
|
|
||||||
assert len(errors) == 3
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoPrintWrap:
|
|
||||||
def test_bare_expression_wrapped(self):
|
|
||||||
result = auto_print_wrap("1 + 2")
|
|
||||||
assert "print(" in result
|
|
||||||
assert "1 + 2" in result
|
|
||||||
|
|
||||||
def test_assignment_not_wrapped(self):
|
|
||||||
code = "x = 1 + 2"
|
|
||||||
assert auto_print_wrap(code) == code
|
|
||||||
|
|
||||||
def test_code_with_print_not_wrapped(self):
|
|
||||||
code = "x = 1\nprint(x)"
|
|
||||||
assert auto_print_wrap(code) == code
|
|
||||||
|
|
||||||
def test_code_with_result_assignment_not_wrapped(self):
|
|
||||||
code = "result = 42"
|
|
||||||
assert auto_print_wrap(code) == code
|
|
||||||
|
|
||||||
def test_multiline_with_bare_expression_last(self):
|
|
||||||
code = "x = 2\ny = 3\nx + y"
|
|
||||||
result = auto_print_wrap(code)
|
|
||||||
assert "print(" in result
|
|
||||||
# The assignments should still be there
|
|
||||||
assert "x = 2" in result
|
|
||||||
assert "y = 3" in result
|
|
||||||
|
|
||||||
def test_empty_code(self):
|
|
||||||
assert auto_print_wrap("") == ""
|
|
||||||
|
|
||||||
def test_syntax_error_returns_original(self):
|
|
||||||
code = "def f(\n"
|
|
||||||
assert auto_print_wrap(code) == code
|
|
||||||
@@ -598,23 +598,6 @@ async def test_route_approve_omits_defaults():
|
|||||||
assert captured["body"] == {"approved": True}
|
assert captured["body"] == {"approved": True}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_route_plan_feedback():
|
|
||||||
captured: dict = {}
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
captured["path"] = request.url.path
|
|
||||||
captured["body"] = json.loads(request.content)
|
|
||||||
return _json_response({"status": "ok"})
|
|
||||||
|
|
||||||
transport = httpx.MockTransport(handler)
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
||||||
client = AsyncTurnstoneConsole(httpx_client=hc)
|
|
||||||
await client.route_plan_feedback(ws_id="ws1", feedback="approved")
|
|
||||||
assert captured["path"] == "/v1/api/route/plan"
|
|
||||||
assert captured["body"] == {"ws_id": "ws1", "feedback": "approved"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_route_close():
|
async def test_route_close():
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ from turnstone.sdk.events import (
|
|||||||
NodeJoinedEvent,
|
NodeJoinedEvent,
|
||||||
NodeLostEvent,
|
NodeLostEvent,
|
||||||
OutputWarningEvent,
|
OutputWarningEvent,
|
||||||
PlanResolvedEvent,
|
|
||||||
PlanReviewEvent,
|
|
||||||
ReasoningEvent,
|
ReasoningEvent,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
StatusEvent,
|
StatusEvent,
|
||||||
@@ -204,18 +202,6 @@ def test_status_event():
|
|||||||
assert e.effort == "medium"
|
assert e.effort == "medium"
|
||||||
|
|
||||||
|
|
||||||
def test_plan_review_event():
|
|
||||||
e = ServerEvent.from_dict({"type": "plan_review", "content": "## Plan\n1. Do X"})
|
|
||||||
assert isinstance(e, PlanReviewEvent)
|
|
||||||
assert "Plan" in e.content
|
|
||||||
|
|
||||||
|
|
||||||
def test_plan_resolved_event():
|
|
||||||
e = ServerEvent.from_dict({"type": "plan_resolved", "feedback": "approved"})
|
|
||||||
assert isinstance(e, PlanResolvedEvent)
|
|
||||||
assert e.feedback == "approved"
|
|
||||||
|
|
||||||
|
|
||||||
def test_info_event():
|
def test_info_event():
|
||||||
e = ServerEvent.from_dict({"type": "info", "message": "[compacted]"})
|
e = ServerEvent.from_dict({"type": "info", "message": "[compacted]"})
|
||||||
assert isinstance(e, InfoEvent)
|
assert isinstance(e, InfoEvent)
|
||||||
|
|||||||
@@ -193,15 +193,6 @@ async def test_approve():
|
|||||||
assert resp.status == "ok"
|
assert resp.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_plan_feedback():
|
|
||||||
transport = _mock_transport({"POST /v1/api/plan": _json_response({"status": "ok"})})
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
||||||
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
||||||
resp = await client.plan_feedback(ws_id="ws1", feedback="approved")
|
|
||||||
assert resp.status == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_command():
|
async def test_command():
|
||||||
transport = _mock_transport({"POST /v1/api/command": _json_response({"status": "ok"})})
|
transport = _mock_transport({"POST /v1/api/command": _json_response({"status": "ok"})})
|
||||||
|
|||||||
@@ -72,9 +72,7 @@ class _FakeUI:
|
|||||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||||
self._listeners_lock = threading.Lock()
|
self._listeners_lock = threading.Lock()
|
||||||
self._pending_approval: dict[str, Any] | None = None
|
self._pending_approval: dict[str, Any] | None = None
|
||||||
self._pending_plan_review: dict[str, Any] | None = None
|
|
||||||
self._approval_event = threading.Event()
|
self._approval_event = threading.Event()
|
||||||
self._plan_event = threading.Event()
|
|
||||||
self._fg_event = threading.Event()
|
self._fg_event = threading.Event()
|
||||||
self._ws_lock = threading.Lock()
|
self._ws_lock = threading.Lock()
|
||||||
# Dashboard handler reads these fields under _ws_lock to build
|
# Dashboard handler reads these fields under _ws_lock to build
|
||||||
@@ -170,9 +168,6 @@ class _FakeUI:
|
|||||||
def resolve_approval(self, *_a: Any, **_kw: Any) -> None:
|
def resolve_approval(self, *_a: Any, **_kw: Any) -> None:
|
||||||
self._approval_event.set()
|
self._approval_event.set()
|
||||||
|
|
||||||
def resolve_plan(self, *_a: Any, **_kw: Any) -> None:
|
|
||||||
self._plan_event.set()
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
||||||
@@ -1074,26 +1069,24 @@ class TestInteractiveEventsLifted:
|
|||||||
out = list(_interactive_events_replay(ws, ui, request))
|
out = list(_interactive_events_replay(ws, ui, request))
|
||||||
assert "status" not in {ev["type"] for ev in out}
|
assert "status" not in {ev["type"] for ev in out}
|
||||||
|
|
||||||
def test_events_replay_yields_pending_approval_then_verdicts_then_plan(self):
|
def test_events_replay_yields_pending_approval_then_verdicts(self):
|
||||||
"""When both prompts are pending, the order is approval +
|
"""When an approval is pending, the order is approval +
|
||||||
cached verdicts (so the client renders the prompt and then
|
cached verdicts (so the client renders the prompt and then
|
||||||
the LLM-judge intent verdicts that fired during it), then
|
the LLM-judge intent verdicts that fired during it). Pre-lift
|
||||||
plan-review. Pre-lift ordering preserved."""
|
ordering preserved."""
|
||||||
from turnstone.server import _interactive_events_replay
|
from turnstone.server import _interactive_events_replay
|
||||||
|
|
||||||
ws, ui, request = _make_interactive_replay_mocks(
|
ws, ui, request = _make_interactive_replay_mocks(
|
||||||
_pending_approval={"type": "approve_request", "items": []},
|
_pending_approval={"type": "approve_request", "items": []},
|
||||||
_pending_plan_review={"type": "plan_review", "content": "..."},
|
|
||||||
_llm_verdicts={"v1": {"verdict_id": "v1", "tier": "judge"}},
|
_llm_verdicts={"v1": {"verdict_id": "v1", "tier": "judge"}},
|
||||||
)
|
)
|
||||||
|
|
||||||
out = list(_interactive_events_replay(ws, ui, request))
|
out = list(_interactive_events_replay(ws, ui, request))
|
||||||
types = [ev["type"] for ev in out]
|
types = [ev["type"] for ev in out]
|
||||||
# The approve_request, then the intent_verdict, then the plan_review.
|
# The approve_request, then the intent_verdict.
|
||||||
approve_idx = types.index("approve_request")
|
approve_idx = types.index("approve_request")
|
||||||
verdict_idx = types.index("intent_verdict")
|
verdict_idx = types.index("intent_verdict")
|
||||||
plan_idx = types.index("plan_review")
|
assert approve_idx < verdict_idx
|
||||||
assert approve_idx < verdict_idx < plan_idx
|
|
||||||
|
|
||||||
def test_events_replay_skips_when_session_missing(self):
|
def test_events_replay_skips_when_session_missing(self):
|
||||||
"""Defensive: a placeholder workstream whose session is
|
"""Defensive: a placeholder workstream whose session is
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Mock-based tests verify streaming, tool calling, multi-turn conversation,
|
Mock-based tests verify streaming, tool calling, multi-turn conversation,
|
||||||
and session configuration WITHOUT a running LLM backend. The mocks replace
|
and session configuration WITHOUT a running LLM backend. The mocks replace
|
||||||
only the OpenAI streaming layer -- tool execution (bash, math, read_file)
|
only the OpenAI streaming layer -- tool execution (bash, read_file)
|
||||||
still runs real subprocesses.
|
still runs real subprocesses.
|
||||||
|
|
||||||
The TestBackendConnectivity class is marked @pytest.mark.live and requires a
|
The TestBackendConnectivity class is marked @pytest.mark.live and requires a
|
||||||
@@ -103,9 +103,6 @@ class RecordingUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
self.events.append(("status",))
|
self.events.append(("status",))
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
self.infos.append(message)
|
self.infos.append(message)
|
||||||
|
|
||||||
@@ -410,33 +407,6 @@ class TestStreamingSession:
|
|||||||
class TestToolCalling:
|
class TestToolCalling:
|
||||||
"""Test that mocked tool_calls trigger real tool execution."""
|
"""Test that mocked tool_calls trigger real tool execution."""
|
||||||
|
|
||||||
def test_math_tool(self, tmp_db):
|
|
||||||
"""First call returns tool_call for math(code='2+2'), second returns content."""
|
|
||||||
client = _mock_client()
|
|
||||||
|
|
||||||
# First create() call: model requests math tool
|
|
||||||
stream1 = make_mock_stream(
|
|
||||||
tool_calls=[("call_math_1", "math", json.dumps({"code": "2+2"}))],
|
|
||||||
)
|
|
||||||
# Second create() call: model produces final answer
|
|
||||||
stream2 = make_mock_stream(
|
|
||||||
content_tokens=["The result is ", "4"],
|
|
||||||
)
|
|
||||||
client.chat.completions.create.side_effect = [stream1, stream2]
|
|
||||||
|
|
||||||
session, ui = _make_session(client, "mock-model", tmp_db)
|
|
||||||
session._title_generated = True
|
|
||||||
|
|
||||||
session.send("Calculate 2+2")
|
|
||||||
|
|
||||||
# math tool was invoked and returned a result
|
|
||||||
math_results = [r for r in ui.tool_results if r[1] == "math"]
|
|
||||||
assert len(math_results) > 0
|
|
||||||
assert "4" in math_results[0][2]
|
|
||||||
|
|
||||||
# Final content contains the answer
|
|
||||||
assert "4" in ui.full_content
|
|
||||||
|
|
||||||
def test_bash_tool(self, tmp_db):
|
def test_bash_tool(self, tmp_db):
|
||||||
"""First call returns tool_call for bash, second returns content."""
|
"""First call returns tool_call for bash, second returns content."""
|
||||||
client = _mock_client()
|
client = _mock_client()
|
||||||
|
|||||||
+26
-640
@@ -49,9 +49,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -248,231 +245,6 @@ class TestChatSessionConstruction:
|
|||||||
assert session.reasoning_effort == "medium"
|
assert session.reasoning_effort == "medium"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tests — _exec_plan (session-scoped plan files + existing-plan re-read)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlanExec:
|
|
||||||
"""Tests for _exec_plan: unique session-scoped plan file and existing-plan injection."""
|
|
||||||
|
|
||||||
_VALID_PLAN = (
|
|
||||||
"## Goal\n\nDo the thing.\n\n"
|
|
||||||
"## Current State\n\nFile foo.py has bar().\n\n"
|
|
||||||
"## Plan\n\n1. Edit foo.py line 10.\n\n"
|
|
||||||
"## Risks\n\nNone."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run_plan(self, session, prompt, agent_return=None):
|
|
||||||
"""Invoke _exec_plan with _run_agent patched to avoid LLM calls.
|
|
||||||
|
|
||||||
Returns (call_id_returned, content_returned, captured_messages) where
|
|
||||||
captured_messages is the agent_messages list passed to _run_agent.
|
|
||||||
"""
|
|
||||||
if agent_return is None:
|
|
||||||
agent_return = self._VALID_PLAN
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
captured["messages"] = list(messages)
|
|
||||||
return agent_return
|
|
||||||
|
|
||||||
item = {"call_id": "test-call-1", "prompt": prompt}
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
call_id, content = session._exec_plan(item)
|
|
||||||
|
|
||||||
return call_id, content, captured.get("messages", [])
|
|
||||||
|
|
||||||
def test_plan_file_uses_ws_id(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Plan file is named .plan-<ws_id>.md, not .plan.md."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
self._run_plan(session, "add feature")
|
|
||||||
expected = tmp_path / f".plan-{session._ws_id}.md"
|
|
||||||
assert expected.exists(), f"Expected {expected} to be created"
|
|
||||||
assert not (tmp_path / ".plan.md").exists()
|
|
||||||
|
|
||||||
def test_plan_file_contains_agent_output(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Written plan file contains the agent's output verbatim."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
self._run_plan(session, "add endpoint")
|
|
||||||
plan_file = tmp_path / f".plan-{session._ws_id}.md"
|
|
||||||
assert plan_file.read_text() == self._VALID_PLAN
|
|
||||||
|
|
||||||
def test_two_sessions_produce_different_files(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Two ChatSession instances never collide on the same plan file."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
s1 = _make_session()
|
|
||||||
s2 = _make_session()
|
|
||||||
assert s1._ws_id != s2._ws_id
|
|
||||||
self._run_plan(s1, "feature A")
|
|
||||||
self._run_plan(s2, "feature B")
|
|
||||||
files = list(tmp_path.glob(".plan-*.md"))
|
|
||||||
assert len(files) == 2
|
|
||||||
|
|
||||||
def _seed_prior_plan(self, session, prior_prompt, prior_content):
|
|
||||||
"""Simulate a completed plan tool call in session.messages."""
|
|
||||||
tc_id = "call_prior_plan"
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": tc_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "plan_agent",
|
|
||||||
"arguments": json.dumps({"goal": prior_prompt}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc_id,
|
|
||||||
"content": prior_content,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_prior_plan_no_extra_messages(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""First invocation: no prior plan in history, agent gets no tool pair."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
_, _, messages = self._run_plan(session, "build something")
|
|
||||||
roles = [m["role"] for m in messages]
|
|
||||||
assert "tool" not in roles
|
|
||||||
|
|
||||||
def test_prior_plan_from_messages_injected(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Second invocation: prior plan from session.messages arrives as real tool result."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
self._seed_prior_plan(session, "build feature X", "## Goal\n\nOriginal plan.")
|
|
||||||
|
|
||||||
_, _, messages = self._run_plan(session, "also handle edge case Y")
|
|
||||||
|
|
||||||
# The real assistant tool_calls message is forwarded
|
|
||||||
assistant_with_tc = [
|
|
||||||
m for m in messages if m["role"] == "assistant" and m.get("tool_calls")
|
|
||||||
]
|
|
||||||
assert len(assistant_with_tc) == 1
|
|
||||||
assert assistant_with_tc[0]["tool_calls"][0]["function"]["name"] == "plan_agent"
|
|
||||||
|
|
||||||
# The real tool result is forwarded with its original content
|
|
||||||
tool_msgs = [m for m in messages if m["role"] == "tool"]
|
|
||||||
assert len(tool_msgs) == 1
|
|
||||||
assert "Original plan." in tool_msgs[0]["content"]
|
|
||||||
|
|
||||||
def test_prior_plan_appears_before_user_prompt(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""The prior plan tool pair appears before the new user prompt."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
self._seed_prior_plan(session, "original", "Old plan.")
|
|
||||||
|
|
||||||
_, _, messages = self._run_plan(session, "refinement prompt")
|
|
||||||
|
|
||||||
tool_idx = next(i for i, m in enumerate(messages) if m["role"] == "tool")
|
|
||||||
user_idx = next(i for i, m in enumerate(messages) if m["role"] == "user")
|
|
||||||
assert tool_idx < user_idx
|
|
||||||
|
|
||||||
def test_exec_plan_returns_content(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""_exec_plan returns (call_id, agent_output)."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
call_id, content, _ = self._run_plan(session, "do stuff")
|
|
||||||
assert call_id == "test-call-1"
|
|
||||||
assert content == self._VALID_PLAN
|
|
||||||
|
|
||||||
def test_exec_plan_retries_on_garbage(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""When _run_agent returns garbage, _exec_plan retries once."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
good_plan = (
|
|
||||||
"## Goal\n\nAdd feature X.\n\n"
|
|
||||||
"## Current State\n\nFile foo.py has bar().\n\n"
|
|
||||||
"## Plan\n\n1. Edit foo.py:bar()\n\n"
|
|
||||||
"## Risks\n\nNone."
|
|
||||||
)
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return "Sure, do the thing."
|
|
||||||
return good_plan
|
|
||||||
|
|
||||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
_, content = session._exec_plan(item)
|
|
||||||
|
|
||||||
assert call_count == 2
|
|
||||||
assert "## Goal" in content
|
|
||||||
|
|
||||||
def test_exec_plan_warning_on_double_failure(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""When both attempts produce garbage, content gets a warning prefix."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
return "nope"
|
|
||||||
|
|
||||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
_, content = session._exec_plan(item)
|
|
||||||
|
|
||||||
assert content.startswith("[Warning:")
|
|
||||||
|
|
||||||
def test_retry_continues_agent_conversation(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Retry appends coaching to the same agent_messages list."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
captured_messages: list[list] = []
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
captured_messages.append(list(messages))
|
|
||||||
if len(captured_messages) == 1:
|
|
||||||
return "garbage"
|
|
||||||
return (
|
|
||||||
"## Goal\n\nDone.\n\n## Current State\n\nx\n\n## Plan\n\n1. x\n\n## Risks\n\nNone."
|
|
||||||
)
|
|
||||||
|
|
||||||
item = {"call_id": "c1", "prompt": "add feature X"}
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
session._exec_plan(item)
|
|
||||||
|
|
||||||
assert len(captured_messages) == 2
|
|
||||||
# Second call should have more messages (coaching appended)
|
|
||||||
assert len(captured_messages[1]) > len(captured_messages[0])
|
|
||||||
# Last user message in second call is the coaching message
|
|
||||||
assert "did not follow" in captured_messages[1][-1]["content"]
|
|
||||||
|
|
||||||
def test_plan_includes_skill_content(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Plan agent system message includes skill guardrails."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
session._skill_content = "SAFETY: Do not produce harmful plans."
|
|
||||||
_, _, messages = self._run_plan(session, "build something")
|
|
||||||
sys_content = messages[0]["content"]
|
|
||||||
assert "SAFETY: Do not produce harmful plans." in sys_content
|
|
||||||
assert ChatSession._PLAN_IDENTITY in sys_content
|
|
||||||
# Skill content appears before plan identity
|
|
||||||
tpl_pos = sys_content.index("SAFETY:")
|
|
||||||
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
|
|
||||||
assert tpl_pos < identity_pos
|
|
||||||
|
|
||||||
def test_plan_no_skill_is_identity_only(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Without skills, plan system message is exactly _PLAN_IDENTITY."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
assert session._skill_content is None
|
|
||||||
_, _, messages = self._run_plan(session, "build something")
|
|
||||||
assert messages[0]["content"] == ChatSession._PLAN_IDENTITY
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests — _exec_task (optional skill substitutes the hardcoded identity)
|
# Tests — _exec_task (optional skill substitutes the hardcoded identity)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -711,12 +483,12 @@ class TestTaskExec:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Per-call model override on plan_agent / task_agent
|
# Per-call model override on task_agent
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestAgentModelOverride:
|
class TestAgentModelOverride:
|
||||||
"""Tests for the optional `model` arg on plan_agent / task_agent tools."""
|
"""Tests for the optional `model` arg on the task_agent tool."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _registry():
|
def _registry():
|
||||||
@@ -731,77 +503,6 @@ class TestAgentModelOverride:
|
|||||||
default="default",
|
default="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- _prepare_plan ----
|
|
||||||
|
|
||||||
def test_prepare_plan_extracts_model_override(self, tmp_db) -> None:
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": "smart"})
|
|
||||||
assert item["model_override"] == "smart"
|
|
||||||
assert "error" not in item
|
|
||||||
|
|
||||||
def test_prepare_plan_missing_model_arg_means_no_override(self, tmp_db) -> None:
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x"})
|
|
||||||
assert item["model_override"] is None
|
|
||||||
|
|
||||||
def test_prepare_plan_empty_string_model_means_no_override(self, tmp_db) -> None:
|
|
||||||
# LLMs sometimes echo "" rather than omit the field; treat as unset.
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": ""})
|
|
||||||
assert item["model_override"] is None
|
|
||||||
|
|
||||||
def test_prepare_plan_unknown_model_returns_error(self, tmp_db) -> None:
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": "bogus"})
|
|
||||||
assert item.get("needs_approval") is False
|
|
||||||
assert "error" in item
|
|
||||||
assert "unknown model alias 'bogus'" in item["error"]
|
|
||||||
# Error guidance lists the aliases the LLM may retry, intentionally
|
|
||||||
# excluding ``default`` — that alias is operator-only (see
|
|
||||||
# ``test_prepare_plan_default_model_rejected``). Surfacing it here
|
|
||||||
# would re-enable the per-role-override bypass even though the
|
|
||||||
# tool description hides it.
|
|
||||||
for alias in ("smart", "fast"):
|
|
||||||
assert alias in item["error"]
|
|
||||||
assert "default" not in item["error"]
|
|
||||||
|
|
||||||
def test_prepare_plan_default_model_rejected(self, tmp_db) -> None:
|
|
||||||
"""``model="default"`` is rejected even when the alias exists in
|
|
||||||
the registry — bypasses the operator-configured ``plan_alias``."""
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": "default"})
|
|
||||||
assert item.get("needs_approval") is False
|
|
||||||
assert "error" in item
|
|
||||||
assert "'default' is not a selectable model alias" in item["error"]
|
|
||||||
assert "Omit `model=`" in item["error"]
|
|
||||||
|
|
||||||
def test_prepare_plan_default_model_rejected_with_whitespace(self, tmp_db) -> None:
|
|
||||||
"""The ``default`` rejection runs after ``strip()`` so leading/
|
|
||||||
trailing whitespace can't sneak the alias past the carve-out."""
|
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": " default "})
|
|
||||||
assert item.get("needs_approval") is False
|
|
||||||
assert "'default' is not a selectable model alias" in item["error"]
|
|
||||||
|
|
||||||
def test_prepare_plan_unknown_model_with_only_default_in_registry(self, tmp_db) -> None:
|
|
||||||
"""When the registry holds only the reserved ``default`` alias
|
|
||||||
(single-CLI-model back-compat), the unknown-alias error must say
|
|
||||||
'(no alternative aliases configured — omit `model=`)' — not the
|
|
||||||
misleading '(no registry configured)' that suggests routing isn't
|
|
||||||
wired up at all."""
|
|
||||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
|
||||||
|
|
||||||
reg = ModelRegistry(
|
|
||||||
models={"default": ModelConfig("default", "x", "x", "m")},
|
|
||||||
default="default",
|
|
||||||
)
|
|
||||||
session = _make_session(registry=reg, model_alias="default")
|
|
||||||
item = session._prepare_plan("c1", {"goal": "do x", "model": "bogus"})
|
|
||||||
assert item.get("needs_approval") is False
|
|
||||||
assert "unknown model alias 'bogus'" in item["error"]
|
|
||||||
assert "no alternative aliases configured" in item["error"]
|
|
||||||
assert "no registry configured" not in item["error"]
|
|
||||||
|
|
||||||
# ---- _prepare_task ----
|
# ---- _prepare_task ----
|
||||||
|
|
||||||
def test_prepare_task_extracts_model_override(self, tmp_db) -> None:
|
def test_prepare_task_extracts_model_override(self, tmp_db) -> None:
|
||||||
@@ -823,8 +524,10 @@ class TestAgentModelOverride:
|
|||||||
assert "default" not in item["error"]
|
assert "default" not in item["error"]
|
||||||
|
|
||||||
def test_prepare_task_default_model_rejected(self, tmp_db) -> None:
|
def test_prepare_task_default_model_rejected(self, tmp_db) -> None:
|
||||||
"""Symmetric carve-out for task_agent — see
|
"""``model="default"`` is rejected even when the alias exists in the
|
||||||
``test_prepare_plan_default_model_rejected``."""
|
registry — passing it explicitly would bypass the operator-configured
|
||||||
|
per-role ``task_alias``. The LLM should reach the default by omitting
|
||||||
|
``model=`` instead."""
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
session = _make_session(registry=self._registry(), model_alias="default")
|
||||||
item = session._prepare_task("c1", {"prompt": "do x", "model": "default"})
|
item = session._prepare_task("c1", {"prompt": "do x", "model": "default"})
|
||||||
assert item.get("needs_approval") is False
|
assert item.get("needs_approval") is False
|
||||||
@@ -834,7 +537,7 @@ class TestAgentModelOverride:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _agent_tool(session, name):
|
def _agent_tool(session, name):
|
||||||
"""Return the plan_agent / task_agent dict from the main tool set."""
|
"""Return the task_agent dict from the main tool set."""
|
||||||
for t in session._tools:
|
for t in session._tools:
|
||||||
fn = t.get("function") or {}
|
fn = t.get("function") or {}
|
||||||
if fn.get("name") == name:
|
if fn.get("name") == name:
|
||||||
@@ -843,9 +546,8 @@ class TestAgentModelOverride:
|
|||||||
|
|
||||||
def test_render_injects_alias_list_into_descriptions(self, tmp_db) -> None:
|
def test_render_injects_alias_list_into_descriptions(self, tmp_db) -> None:
|
||||||
session = _make_session(registry=self._registry(), model_alias="default")
|
session = _make_session(registry=self._registry(), model_alias="default")
|
||||||
for name in ("plan_agent", "task_agent"):
|
tool = self._agent_tool(session, "task_agent")
|
||||||
tool = self._agent_tool(session, name)
|
assert tool is not None, "task_agent missing from session tools"
|
||||||
assert tool is not None, f"{name} missing from session tools"
|
|
||||||
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
for alias in ("smart", "fast"):
|
for alias in ("smart", "fast"):
|
||||||
assert f"`{alias}`" in desc, f"alias {alias} missing from {desc!r}"
|
assert f"`{alias}`" in desc, f"alias {alias} missing from {desc!r}"
|
||||||
@@ -856,9 +558,9 @@ class TestAgentModelOverride:
|
|||||||
def test_render_no_op_without_registry(self, tmp_db) -> None:
|
def test_render_no_op_without_registry(self, tmp_db) -> None:
|
||||||
"""No registry → leave the placeholder description untouched."""
|
"""No registry → leave the placeholder description untouched."""
|
||||||
session = _make_session() # no registry
|
session = _make_session() # no registry
|
||||||
plan_tool = self._agent_tool(session, "plan_agent")
|
task_tool = self._agent_tool(session, "task_agent")
|
||||||
assert plan_tool is not None
|
assert task_tool is not None
|
||||||
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "No alternative aliases configured" in desc
|
assert "No alternative aliases configured" in desc
|
||||||
|
|
||||||
def test_refresh_picks_up_new_aliases(self, tmp_db) -> None:
|
def test_refresh_picks_up_new_aliases(self, tmp_db) -> None:
|
||||||
@@ -877,9 +579,9 @@ class TestAgentModelOverride:
|
|||||||
|
|
||||||
session.refresh_agent_tool_schemas()
|
session.refresh_agent_tool_schemas()
|
||||||
|
|
||||||
plan_tool = self._agent_tool(session, "plan_agent")
|
task_tool = self._agent_tool(session, "task_agent")
|
||||||
assert plan_tool is not None
|
assert task_tool is not None
|
||||||
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "`bigboi`" in desc
|
assert "`bigboi`" in desc
|
||||||
|
|
||||||
def test_render_omits_default_alias_from_description(self, tmp_db) -> None:
|
def test_render_omits_default_alias_from_description(self, tmp_db) -> None:
|
||||||
@@ -901,8 +603,7 @@ class TestAgentModelOverride:
|
|||||||
default="default",
|
default="default",
|
||||||
)
|
)
|
||||||
session = _make_session(registry=reg, model_alias="default")
|
session = _make_session(registry=reg, model_alias="default")
|
||||||
for name in ("plan_agent", "task_agent"):
|
tool = self._agent_tool(session, "task_agent")
|
||||||
tool = self._agent_tool(session, name)
|
|
||||||
assert tool is not None
|
assert tool is not None
|
||||||
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "`gh200`" in desc
|
assert "`gh200`" in desc
|
||||||
@@ -920,9 +621,9 @@ class TestAgentModelOverride:
|
|||||||
default="default",
|
default="default",
|
||||||
)
|
)
|
||||||
session = _make_session(registry=reg, model_alias="default")
|
session = _make_session(registry=reg, model_alias="default")
|
||||||
plan_tool = self._agent_tool(session, "plan_agent")
|
task_tool = self._agent_tool(session, "task_agent")
|
||||||
assert plan_tool is not None
|
assert task_tool is not None
|
||||||
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "No alternative aliases configured" in desc
|
assert "No alternative aliases configured" in desc
|
||||||
|
|
||||||
def test_refresh_into_only_default_resets_to_base(self, tmp_db) -> None:
|
def test_refresh_into_only_default_resets_to_base(self, tmp_db) -> None:
|
||||||
@@ -941,9 +642,9 @@ class TestAgentModelOverride:
|
|||||||
)
|
)
|
||||||
session = _make_session(registry=reg, model_alias="default")
|
session = _make_session(registry=reg, model_alias="default")
|
||||||
# Sanity: initial render carries the non-default aliases.
|
# Sanity: initial render carries the non-default aliases.
|
||||||
plan_tool = self._agent_tool(session, "plan_agent")
|
task_tool = self._agent_tool(session, "task_agent")
|
||||||
assert plan_tool is not None
|
assert task_tool is not None
|
||||||
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "`smart`" in desc and "`fast`" in desc
|
assert "`smart`" in desc and "`fast`" in desc
|
||||||
|
|
||||||
# Reload the registry down to only ``default`` (admin removed
|
# Reload the registry down to only ``default`` (admin removed
|
||||||
@@ -951,9 +652,9 @@ class TestAgentModelOverride:
|
|||||||
reg.reload({"default": ModelConfig("default", "x", "x", "m")}, "default")
|
reg.reload({"default": ModelConfig("default", "x", "x", "m")}, "default")
|
||||||
session.refresh_agent_tool_schemas()
|
session.refresh_agent_tool_schemas()
|
||||||
|
|
||||||
plan_tool = self._agent_tool(session, "plan_agent")
|
task_tool = self._agent_tool(session, "task_agent")
|
||||||
assert plan_tool is not None
|
assert task_tool is not None
|
||||||
desc = plan_tool["function"]["parameters"]["properties"]["model"]["description"]
|
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
|
||||||
assert "`smart`" not in desc, f"stale alias survived reload: {desc!r}"
|
assert "`smart`" not in desc, f"stale alias survived reload: {desc!r}"
|
||||||
assert "`fast`" not in desc, f"stale alias survived reload: {desc!r}"
|
assert "`fast`" not in desc, f"stale alias survived reload: {desc!r}"
|
||||||
assert "No alternative aliases configured" in desc
|
assert "No alternative aliases configured" in desc
|
||||||
@@ -968,7 +669,7 @@ class TestAgentModelOverride:
|
|||||||
|
|
||||||
for t in TOOLS:
|
for t in TOOLS:
|
||||||
fn = t.get("function") or {}
|
fn = t.get("function") or {}
|
||||||
if fn.get("name") not in ("plan_agent", "task_agent"):
|
if fn.get("name") != "task_agent":
|
||||||
continue
|
continue
|
||||||
desc = fn["parameters"]["properties"]["model"]["description"]
|
desc = fn["parameters"]["properties"]["model"]["description"]
|
||||||
assert "No alternative aliases configured" in desc, (
|
assert "No alternative aliases configured" in desc, (
|
||||||
@@ -976,321 +677,6 @@ class TestAgentModelOverride:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# man tool
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrepareMan:
|
|
||||||
"""``ChatSession._prepare_man`` argument parsing."""
|
|
||||||
|
|
||||||
def test_plain_page(self, tmp_db) -> None:
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "grep"})
|
|
||||||
assert "error" not in item
|
|
||||||
assert item["page"] == "grep"
|
|
||||||
assert item["section"] == ""
|
|
||||||
|
|
||||||
def test_explicit_section_arg(self, tmp_db) -> None:
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "printf", "section": "3"})
|
|
||||||
assert "error" not in item
|
|
||||||
assert item["page"] == "printf"
|
|
||||||
assert item["section"] == "3"
|
|
||||||
|
|
||||||
def test_parenthesized_section_in_page(self, tmp_db) -> None:
|
|
||||||
# Models commonly emit canonical man-page notation; we should
|
|
||||||
# parse the section out instead of rejecting the call.
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "printf(3)"})
|
|
||||||
assert "error" not in item
|
|
||||||
assert item["page"] == "printf"
|
|
||||||
assert item["section"] == "3"
|
|
||||||
assert "printf(3)" in item["header"]
|
|
||||||
|
|
||||||
def test_parenthesized_section_with_letter_suffix(self, tmp_db) -> None:
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "perlfunc(3pm)"})
|
|
||||||
assert "error" not in item
|
|
||||||
assert item["page"] == "perlfunc"
|
|
||||||
assert item["section"] == "3pm"
|
|
||||||
|
|
||||||
def test_explicit_section_arg_wins_over_parsed(self, tmp_db) -> None:
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "open(2)", "section": "3"})
|
|
||||||
assert "error" not in item
|
|
||||||
assert item["page"] == "open"
|
|
||||||
assert item["section"] == "3"
|
|
||||||
|
|
||||||
def test_invalid_section_in_parens_falls_through_to_error(self, tmp_db) -> None:
|
|
||||||
# Parens that don't match the section pattern aren't parsed away,
|
|
||||||
# so the page-name sanitizer rejects the literal string.
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "grep(bogus)"})
|
|
||||||
assert "error" in item
|
|
||||||
assert "invalid page name" in item["error"]
|
|
||||||
|
|
||||||
def test_empty_page(self, tmp_db) -> None:
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": ""})
|
|
||||||
assert "error" in item
|
|
||||||
assert "no page name" in item["error"]
|
|
||||||
|
|
||||||
def test_parsed_section_reaches_subprocess_argv(self, tmp_db) -> None:
|
|
||||||
# End-to-end check that page="printf(3)" produces the right
|
|
||||||
# ``man`` argv — guards against future drift between
|
|
||||||
# ``_prepare_man``'s output keys and ``_exec_man``'s reads.
|
|
||||||
session = _make_session()
|
|
||||||
item = session._prepare_man("c1", {"page": "printf(3)"})
|
|
||||||
completed = subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout="MAN PAGE TEXT", stderr=""
|
|
||||||
)
|
|
||||||
with patch("subprocess.run", return_value=completed) as mock_run:
|
|
||||||
session._exec_man(item)
|
|
||||||
argv = mock_run.call_args_list[0].args[0]
|
|
||||||
assert argv == ["man", "3", "printf"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plan validation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlanValidation:
|
|
||||||
"""Tests for ChatSession._validate_plan quality gate."""
|
|
||||||
|
|
||||||
GOOD_PLAN = (
|
|
||||||
"## Goal\n\nAdd authentication to the API.\n\n"
|
|
||||||
"## Current State\n\nFile server.py:45 has no auth middleware.\n\n"
|
|
||||||
"## Plan\n\n1. Add AuthMiddleware to server.py.\n"
|
|
||||||
"2. Create auth.py with JWT verification.\n\n"
|
|
||||||
"## Risks\n\nToken expiry handling may need tuning."
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_valid_plan_passes(self):
|
|
||||||
valid, issues = ChatSession._validate_plan(self.GOOD_PLAN, "add auth")
|
|
||||||
assert valid
|
|
||||||
assert issues == []
|
|
||||||
|
|
||||||
def test_too_short_fails(self):
|
|
||||||
valid, issues = ChatSession._validate_plan("Do the thing.", "do stuff")
|
|
||||||
assert not valid
|
|
||||||
assert any("too short" in i for i in issues)
|
|
||||||
|
|
||||||
def test_no_sections_fails(self):
|
|
||||||
content = "A" * 150 # long enough but no sections
|
|
||||||
valid, issues = ChatSession._validate_plan(content, "build it")
|
|
||||||
assert not valid
|
|
||||||
assert any("missing plan sections" in i for i in issues)
|
|
||||||
|
|
||||||
def test_echo_detection(self):
|
|
||||||
goal = "deliver a simpsons quote from a specific episode"
|
|
||||||
content = "Deliver a Simpsons quote from a specific episode"
|
|
||||||
valid, issues = ChatSession._validate_plan(content, goal)
|
|
||||||
assert not valid
|
|
||||||
assert any("echo" in i for i in issues)
|
|
||||||
|
|
||||||
def test_refusal_detection(self):
|
|
||||||
content = "I cannot create a plan for this task because " + "x" * 100
|
|
||||||
valid, issues = ChatSession._validate_plan(content, "do stuff")
|
|
||||||
assert not valid
|
|
||||||
assert any("refusal" in i for i in issues)
|
|
||||||
|
|
||||||
def test_partial_sections_passes(self):
|
|
||||||
"""2 out of 4 sections is enough to pass."""
|
|
||||||
content = (
|
|
||||||
"## Goal\n\nFix the bug in parsing.\n\n"
|
|
||||||
"## Plan\n\n1. Edit parser.py line 42.\n"
|
|
||||||
"2. Add boundary check.\n"
|
|
||||||
"This is enough detail to proceed with confidence."
|
|
||||||
)
|
|
||||||
valid, issues = ChatSession._validate_plan(content, "fix bug")
|
|
||||||
assert valid
|
|
||||||
|
|
||||||
def test_one_section_fails(self):
|
|
||||||
"""Only 1 out of 4 sections is not enough."""
|
|
||||||
content = (
|
|
||||||
"## Goal\n\nFix the bug.\n\n"
|
|
||||||
"We should probably edit parser.py and add some checks "
|
|
||||||
"to the boundary handling code path for safety."
|
|
||||||
)
|
|
||||||
valid, issues = ChatSession._validate_plan(content, "fix bug")
|
|
||||||
assert not valid
|
|
||||||
assert any("missing plan sections" in i for i in issues)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plan refinement loop
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlanRefinement:
|
|
||||||
"""Tests for the iterative plan refinement loop in _execute_tools."""
|
|
||||||
|
|
||||||
GOOD_PLAN = TestPlanValidation.GOOD_PLAN
|
|
||||||
|
|
||||||
def test_feedback_triggers_refinement(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""User feedback causes _refine_plan to run, then approval exits."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
refine_called = []
|
|
||||||
|
|
||||||
review_responses = iter(["add error handling", ""])
|
|
||||||
session.ui = MagicMock(spec_set=NullUI)
|
|
||||||
session.ui.on_plan_review.side_effect = lambda c: next(review_responses)
|
|
||||||
session.ui.on_info = MagicMock()
|
|
||||||
session.ui.on_state_change = MagicMock()
|
|
||||||
|
|
||||||
revised = self.GOOD_PLAN + "\n\n3. Add error handling."
|
|
||||||
|
|
||||||
def fake_refine(content, goal, feedback):
|
|
||||||
refine_called.append(feedback)
|
|
||||||
return revised
|
|
||||||
|
|
||||||
with patch.object(session, "_refine_plan", side_effect=fake_refine):
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"func_name": "plan_agent",
|
|
||||||
"call_id": "c1",
|
|
||||||
"prompt": "add auth",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
results = [("c1", self.GOOD_PLAN)]
|
|
||||||
# Manually invoke the post-plan gate portion of _execute_tools.
|
|
||||||
# We test the loop by calling the gate code directly.
|
|
||||||
session.auto_approve = False
|
|
||||||
|
|
||||||
original_goal = items[0].get("prompt", "")
|
|
||||||
output = results[0][1]
|
|
||||||
refinement_round = 0
|
|
||||||
while refinement_round < session._MAX_PLAN_REFINEMENTS:
|
|
||||||
resp = session.ui.on_plan_review(output)
|
|
||||||
if resp.lower() in ("n", "no", "reject"):
|
|
||||||
break
|
|
||||||
elif resp:
|
|
||||||
output = session._refine_plan(output, original_goal, resp)
|
|
||||||
refinement_round += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
assert len(refine_called) == 1
|
|
||||||
assert refine_called[0] == "add error handling"
|
|
||||||
assert "error handling" in output
|
|
||||||
|
|
||||||
def test_reject_skips_refinement(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Rejection exits immediately without calling _refine_plan."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
session.ui = MagicMock(spec_set=NullUI)
|
|
||||||
session.ui.on_plan_review.return_value = "reject"
|
|
||||||
|
|
||||||
with patch.object(session, "_refine_plan") as mock_refine:
|
|
||||||
output = self.GOOD_PLAN
|
|
||||||
resp = session.ui.on_plan_review(output)
|
|
||||||
if resp.lower() in ("n", "no", "reject"):
|
|
||||||
output += "\n\n---\nUser REJECTED"
|
|
||||||
elif resp:
|
|
||||||
output = session._refine_plan(output, "g", resp)
|
|
||||||
|
|
||||||
mock_refine.assert_not_called()
|
|
||||||
assert "REJECTED" in output
|
|
||||||
|
|
||||||
def test_approve_skips_refinement(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Empty response (enter) approves without refinement."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
session.ui = MagicMock(spec_set=NullUI)
|
|
||||||
session.ui.on_plan_review.return_value = ""
|
|
||||||
|
|
||||||
with patch.object(session, "_refine_plan") as mock_refine:
|
|
||||||
output = self.GOOD_PLAN
|
|
||||||
resp = session.ui.on_plan_review(output)
|
|
||||||
if resp.lower() in ("n", "no", "reject"):
|
|
||||||
output += "\n\n---\nUser REJECTED"
|
|
||||||
elif resp:
|
|
||||||
output = session._refine_plan(output, "g", resp)
|
|
||||||
|
|
||||||
mock_refine.assert_not_called()
|
|
||||||
assert "REJECTED" not in output
|
|
||||||
|
|
||||||
def test_max_refinement_rounds(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""Loop stops after _MAX_PLAN_REFINEMENTS rounds with a final review."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
session.ui = MagicMock(spec_set=NullUI)
|
|
||||||
session.ui.on_plan_review.return_value = "more detail please"
|
|
||||||
session.ui.on_info = MagicMock()
|
|
||||||
|
|
||||||
refine_count = 0
|
|
||||||
|
|
||||||
def fake_refine(content, goal, feedback):
|
|
||||||
nonlocal refine_count
|
|
||||||
refine_count += 1
|
|
||||||
return content + f"\n(revision {refine_count})"
|
|
||||||
|
|
||||||
with patch.object(session, "_refine_plan", side_effect=fake_refine):
|
|
||||||
output = self.GOOD_PLAN
|
|
||||||
original_goal = "add auth"
|
|
||||||
refinement_round = 0
|
|
||||||
while True:
|
|
||||||
resp = session.ui.on_plan_review(output)
|
|
||||||
if (
|
|
||||||
resp.lower() in ("n", "no", "reject")
|
|
||||||
or not resp
|
|
||||||
or refinement_round >= session._MAX_PLAN_REFINEMENTS
|
|
||||||
):
|
|
||||||
break
|
|
||||||
output = session._refine_plan(output, original_goal, resp)
|
|
||||||
refinement_round += 1
|
|
||||||
|
|
||||||
assert refine_count == session._MAX_PLAN_REFINEMENTS
|
|
||||||
# User gets one extra review call after max rounds (the final prompt)
|
|
||||||
assert session.ui.on_plan_review.call_count == session._MAX_PLAN_REFINEMENTS + 1
|
|
||||||
|
|
||||||
def test_refine_plan_message_structure(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""_refine_plan passes system + prior plan + feedback to _run_agent."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
captured["messages"] = list(messages)
|
|
||||||
return self.GOOD_PLAN
|
|
||||||
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
session._refine_plan(self.GOOD_PLAN, "add auth", "add tests too")
|
|
||||||
|
|
||||||
msgs = captured["messages"]
|
|
||||||
assert msgs[0]["role"] == "system"
|
|
||||||
assert msgs[1]["role"] == "assistant"
|
|
||||||
assert msgs[1]["tool_calls"][0]["function"]["name"] == "plan_agent"
|
|
||||||
assert msgs[2]["role"] == "tool"
|
|
||||||
assert msgs[2]["content"] == self.GOOD_PLAN
|
|
||||||
assert msgs[3]["role"] == "user"
|
|
||||||
assert "add tests too" in msgs[3]["content"]
|
|
||||||
|
|
||||||
def test_refine_plan_includes_skill_content(self, tmp_db, tmp_path, monkeypatch):
|
|
||||||
"""_refine_plan system message includes skill guardrails."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
session = _make_session()
|
|
||||||
session._skill_content = "SAFETY: guardrails here"
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_run_agent(messages, **kwargs):
|
|
||||||
captured["messages"] = list(messages)
|
|
||||||
return self.GOOD_PLAN
|
|
||||||
|
|
||||||
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
|
|
||||||
session._refine_plan(self.GOOD_PLAN, "add auth", "add tests too")
|
|
||||||
|
|
||||||
sys_content = captured["messages"][0]["content"]
|
|
||||||
assert "SAFETY: guardrails here" in sys_content
|
|
||||||
assert ChatSession._PLAN_IDENTITY in sys_content
|
|
||||||
tpl_pos = sys_content.index("SAFETY:")
|
|
||||||
identity_pos = sys_content.index(ChatSession._PLAN_IDENTITY)
|
|
||||||
assert tpl_pos < identity_pos
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vision / image support
|
# Vision / image support
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for ``SessionUIBase`` — the shared UI scaffolding.
|
"""Tests for ``SessionUIBase`` — the shared UI scaffolding.
|
||||||
|
|
||||||
Covers listener fan-out, approval / plan blocking gates, intent-judge
|
Covers listener fan-out, approval blocking gates, intent-judge
|
||||||
verdict bookkeeping, and the approval-cycle reset invariant that
|
verdict bookkeeping, and the approval-cycle reset invariant that
|
||||||
prevents a late verdict from inheriting the previous round's
|
prevents a late verdict from inheriting the previous round's
|
||||||
``user_decision``.
|
``user_decision``.
|
||||||
@@ -86,7 +86,7 @@ def test_enqueue_tolerates_full_listener_queue() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Approval / plan gates
|
# Approval gates
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -108,37 +108,6 @@ def test_resolve_approval_broadcasts_approval_resolved() -> None:
|
|||||||
assert event["feedback"] == "nope"
|
assert event["feedback"] == "nope"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_plan_no_pending_signals_but_does_not_broadcast() -> None:
|
|
||||||
"""cancel_generation calls resolve_plan unconditionally — the
|
|
||||||
no-pending path must unblock the event without broadcasting a
|
|
||||||
stale plan_resolved."""
|
|
||||||
ui = _make_ui()
|
|
||||||
ui._pending_plan_review = None
|
|
||||||
ui._plan_event.clear()
|
|
||||||
lq = ui._register_listener()
|
|
||||||
ui.resolve_plan("reject")
|
|
||||||
assert ui._plan_result == "reject"
|
|
||||||
assert ui._plan_event.is_set()
|
|
||||||
assert lq.empty()
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_plan_with_pending_broadcasts_plan_resolved() -> None:
|
|
||||||
ui = _make_ui()
|
|
||||||
ui._pending_plan_review = {"type": "plan_review", "content": "..."}
|
|
||||||
ui._plan_event.clear()
|
|
||||||
lq = ui._register_listener()
|
|
||||||
ui.resolve_plan("accept")
|
|
||||||
event = lq.get_nowait()
|
|
||||||
assert event == {
|
|
||||||
"type": "plan_resolved",
|
|
||||||
"feedback": "accept",
|
|
||||||
"ws_id": "ws-1",
|
|
||||||
"_event_id": 1,
|
|
||||||
}
|
|
||||||
assert ui._pending_plan_review is None
|
|
||||||
assert ui._plan_event.is_set()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Intent-verdict bookkeeping
|
# Intent-verdict bookkeeping
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -161,23 +161,18 @@ class TestValidateValueChoices:
|
|||||||
for ch in ("", "none", "low", "medium", "high", "max"):
|
for ch in ("", "none", "low", "medium", "high", "max"):
|
||||||
assert validate_value("model.reasoning_effort", ch) == ch
|
assert validate_value("model.reasoning_effort", ch) == ch
|
||||||
|
|
||||||
def test_plan_task_alias_accept_any_string(self):
|
def test_task_alias_accept_any_string(self):
|
||||||
# plan/task aliases are validated dynamically against live registry
|
# task aliases are validated dynamically against live registry
|
||||||
# at apply time; here we just confirm the static validator accepts
|
# at apply time; here we just confirm the static validator accepts
|
||||||
# arbitrary strings (including "" for "use server default").
|
# arbitrary strings (including "" for "use server default").
|
||||||
assert validate_value("model.plan_alias", "") == ""
|
|
||||||
assert validate_value("model.task_alias", "") == ""
|
assert validate_value("model.task_alias", "") == ""
|
||||||
assert validate_value("model.plan_alias", "smart") == "smart"
|
|
||||||
assert validate_value("model.task_alias", "fast") == "fast"
|
assert validate_value("model.task_alias", "fast") == "fast"
|
||||||
|
|
||||||
def test_plan_task_effort_choices(self):
|
def test_task_effort_choices(self):
|
||||||
for ch in ("", "none", "minimal", "low", "medium", "high", "xhigh", "max"):
|
for ch in ("", "none", "minimal", "low", "medium", "high", "xhigh", "max"):
|
||||||
assert validate_value("model.plan_effort", ch) == ch
|
|
||||||
assert validate_value("model.task_effort", ch) == ch
|
assert validate_value("model.task_effort", ch) == ch
|
||||||
|
|
||||||
def test_plan_task_effort_invalid(self):
|
def test_task_effort_invalid(self):
|
||||||
with pytest.raises(ValueError, match="not in"):
|
|
||||||
validate_value("model.plan_effort", "extreme")
|
|
||||||
with pytest.raises(ValueError, match="not in"):
|
with pytest.raises(ValueError, match="not in"):
|
||||||
validate_value("model.task_effort", "supercharged")
|
validate_value("model.task_effort", "supercharged")
|
||||||
|
|
||||||
@@ -207,11 +202,9 @@ class TestSerializeDeserialize:
|
|||||||
def test_str_round_trip_empty(self):
|
def test_str_round_trip_empty(self):
|
||||||
assert deserialize_value("model.default_alias", serialize_value("")) == ""
|
assert deserialize_value("model.default_alias", serialize_value("")) == ""
|
||||||
|
|
||||||
def test_plan_task_round_trip(self):
|
def test_task_round_trip(self):
|
||||||
for k in (
|
for k in (
|
||||||
"model.plan_alias",
|
|
||||||
"model.task_alias",
|
"model.task_alias",
|
||||||
"model.plan_effort",
|
|
||||||
"model.task_effort",
|
"model.task_effort",
|
||||||
):
|
):
|
||||||
assert deserialize_value(k, serialize_value("")) == ""
|
assert deserialize_value(k, serialize_value("")) == ""
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ class NullUI:
|
|||||||
def on_status(self, usage, context_window, effort):
|
def on_status(self, usage, context_window, effort):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message):
|
def on_info(self, message):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ class TestToolRegistration:
|
|||||||
# tool that's "action", matching the existing ``tasks`` precedent.
|
# tool that's "action", matching the existing ``tasks`` precedent.
|
||||||
assert PRIMARY_KEY_MAP.get("skills") == "action"
|
assert PRIMARY_KEY_MAP.get("skills") == "action"
|
||||||
|
|
||||||
def test_not_agent_tool(self) -> None:
|
def test_not_task_agent_tool(self) -> None:
|
||||||
from turnstone.core.tools import AGENT_TOOLS
|
from turnstone.core.tools import TASK_AGENT_TOOLS
|
||||||
|
|
||||||
names = {t["function"]["name"] for t in AGENT_TOOLS}
|
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
||||||
assert "skills" not in names
|
assert "skills" not in names
|
||||||
|
|
||||||
def test_dual_kind_visible_to_both_sessions(self) -> None:
|
def test_dual_kind_visible_to_both_sessions(self) -> None:
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from turnstone.core.tools import (
|
from turnstone.core.tools import (
|
||||||
_META,
|
_META,
|
||||||
AGENT_AUTO_TOOLS,
|
|
||||||
AGENT_TOOLS,
|
|
||||||
PRIMARY_KEY_MAP,
|
PRIMARY_KEY_MAP,
|
||||||
TASK_AGENT_TOOLS,
|
TASK_AGENT_TOOLS,
|
||||||
TASK_AUTO_TOOLS,
|
TASK_AUTO_TOOLS,
|
||||||
@@ -47,13 +45,6 @@ class TestToolsSchema:
|
|||||||
names = [t["function"]["name"] for t in TOOLS]
|
names = [t["function"]["name"] for t in TOOLS]
|
||||||
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
|
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
|
||||||
|
|
||||||
def test_agent_tools_subset(self):
|
|
||||||
tool_names = {t["function"]["name"] for t in TOOLS}
|
|
||||||
agent_names = {t["function"]["name"] for t in AGENT_TOOLS}
|
|
||||||
assert agent_names.issubset(tool_names), (
|
|
||||||
f"AGENT_TOOLS has names not in TOOLS: {agent_names - tool_names}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_task_agent_tools_subset(self):
|
def test_task_agent_tools_subset(self):
|
||||||
tool_names = {t["function"]["name"] for t in TOOLS}
|
tool_names = {t["function"]["name"] for t in TOOLS}
|
||||||
task_names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
task_names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
||||||
@@ -61,9 +52,6 @@ class TestToolsSchema:
|
|||||||
f"TASK_AGENT_TOOLS has names not in TOOLS: {task_names - tool_names}"
|
f"TASK_AGENT_TOOLS has names not in TOOLS: {task_names - tool_names}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_agent_tools_not_empty(self):
|
|
||||||
assert len(AGENT_TOOLS) > 0
|
|
||||||
|
|
||||||
def test_task_agent_tools_not_empty(self):
|
def test_task_agent_tools_not_empty(self):
|
||||||
assert len(TASK_AGENT_TOOLS) > 0
|
assert len(TASK_AGENT_TOOLS) > 0
|
||||||
|
|
||||||
@@ -72,16 +60,11 @@ class TestToolsMetadata:
|
|||||||
"""Validate the metadata extracted from JSON files."""
|
"""Validate the metadata extracted from JSON files."""
|
||||||
|
|
||||||
def test_tool_count(self):
|
def test_tool_count(self):
|
||||||
# 19 interactive tools + 12 coordinator tools (was 13 before the
|
# 16 interactive tools + 12 coordinator-only tools.
|
||||||
# skills tool unification merged `skill` + `list_skills` and made
|
assert len(TOOLS) == 28
|
||||||
# the unified `skills` tool dual-kind).
|
|
||||||
assert len(TOOLS) == 31
|
|
||||||
|
|
||||||
def test_agent_tools_count(self):
|
|
||||||
assert len(AGENT_TOOLS) == 10
|
|
||||||
|
|
||||||
def test_task_agent_tools_count(self):
|
def test_task_agent_tools_count(self):
|
||||||
assert len(TASK_AGENT_TOOLS) == 13
|
assert len(TASK_AGENT_TOOLS) == 11
|
||||||
|
|
||||||
def test_coordinator_tools_count(self):
|
def test_coordinator_tools_count(self):
|
||||||
from turnstone.core.tools import COORDINATOR_TOOLS
|
from turnstone.core.tools import COORDINATOR_TOOLS
|
||||||
@@ -123,8 +106,6 @@ class TestToolsMetadata:
|
|||||||
"read_file",
|
"read_file",
|
||||||
"search",
|
"search",
|
||||||
"diff_file",
|
"diff_file",
|
||||||
"math",
|
|
||||||
"man",
|
|
||||||
"web_fetch",
|
"web_fetch",
|
||||||
"web_search",
|
"web_search",
|
||||||
"notify",
|
"notify",
|
||||||
@@ -134,22 +115,18 @@ class TestToolsMetadata:
|
|||||||
"list_nodes",
|
"list_nodes",
|
||||||
"wait_for_workstream",
|
"wait_for_workstream",
|
||||||
}
|
}
|
||||||
assert expected == AGENT_AUTO_TOOLS
|
|
||||||
assert expected == TASK_AUTO_TOOLS
|
assert expected == TASK_AUTO_TOOLS
|
||||||
|
|
||||||
def test_primary_key_map(self):
|
def test_primary_key_map(self):
|
||||||
expected = {
|
expected = {
|
||||||
"bash": "command",
|
"bash": "command",
|
||||||
"math": "code",
|
|
||||||
"read_file": "path",
|
"read_file": "path",
|
||||||
"search": "query",
|
"search": "query",
|
||||||
"write_file": "content",
|
"write_file": "content",
|
||||||
"edit_file": "old_string",
|
"edit_file": "old_string",
|
||||||
"man": "page",
|
|
||||||
"web_fetch": "url",
|
"web_fetch": "url",
|
||||||
"web_search": "query",
|
"web_search": "query",
|
||||||
"task_agent": "prompt",
|
"task_agent": "prompt",
|
||||||
"plan_agent": "goal",
|
|
||||||
"memory": "name",
|
"memory": "name",
|
||||||
"recall": "query",
|
"recall": "query",
|
||||||
"notify": "message",
|
"notify": "message",
|
||||||
@@ -173,7 +150,7 @@ class TestToolsMetadata:
|
|||||||
|
|
||||||
def test_no_metadata_in_function_dicts(self):
|
def test_no_metadata_in_function_dicts(self):
|
||||||
"""Ensure turnstone metadata keys are stripped from the OpenAI schema."""
|
"""Ensure turnstone metadata keys are stripped from the OpenAI schema."""
|
||||||
meta_keys = {"agent", "task_agent", "coordinator", "auto_approve", "primary_key"}
|
meta_keys = {"task_agent", "coordinator", "auto_approve", "primary_key"}
|
||||||
for tool in TOOLS:
|
for tool in TOOLS:
|
||||||
func = tool["function"]
|
func = tool["function"]
|
||||||
leaked = meta_keys & set(func)
|
leaked = meta_keys & set(func)
|
||||||
|
|||||||
@@ -370,9 +370,8 @@ def test_chatsession_coordinator_kind_excludes_interactive_tools(tmp_db):
|
|||||||
assert "edit_file" not in names
|
assert "edit_file" not in names
|
||||||
# Memory is intentionally exposed — see docstring.
|
# Memory is intentionally exposed — see docstring.
|
||||||
assert "memory" in names
|
assert "memory" in names
|
||||||
# Sub-agent tool lists are zeroed for coordinators.
|
# Sub-agent tool list is zeroed for coordinators.
|
||||||
assert sess._task_tools == []
|
assert sess._task_tools == []
|
||||||
assert sess._agent_tools == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_chatsession_coordinator_kind_does_not_merge_mcp_tools(tmp_db):
|
def test_chatsession_coordinator_kind_does_not_merge_mcp_tools(tmp_db):
|
||||||
|
|||||||
+6
-10
@@ -24,20 +24,16 @@
|
|||||||
# context_window = 0 # 0 = auto-detect from provider capabilities
|
# context_window = 0 # 0 = auto-detect from provider capabilities
|
||||||
# max_tokens = 0 # 0 = provider default
|
# max_tokens = 0 # 0 = provider default
|
||||||
#
|
#
|
||||||
# Sub-agent routing (plan_agent, task_agent tools). Each falls back to
|
# Sub-agent routing (task_agent tool). Falls back to agent_model when
|
||||||
# agent_model when unset, then to the session model. Use this to point
|
# unset, then to the session model.
|
||||||
# the rare-but-expensive plan agent at a stronger model than the
|
# agent_model = "" # legacy single-knob alias used as fallback
|
||||||
# frequent task agent.
|
|
||||||
# agent_model = "" # legacy single-knob: both plan and task share this
|
|
||||||
# plan_model = "" # plan_agent override (e.g. "claude" for a smart planner)
|
|
||||||
# task_model = "" # task_agent override (e.g. "local" for cheap subtasks)
|
# task_model = "" # task_agent override (e.g. "local" for cheap subtasks)
|
||||||
# plan_effort = "" # reasoning effort for plan_agent (default: "high")
|
|
||||||
# task_effort = "" # reasoning effort for task_agent (default: inherit session)
|
# task_effort = "" # reasoning effort for task_agent (default: inherit session)
|
||||||
#
|
#
|
||||||
# At call time, the calling LLM may also pass `model="<alias>"` to
|
# At call time, the calling LLM may also pass `model="<alias>"` to
|
||||||
# plan_agent / task_agent to override these per-invocation. Tool
|
# task_agent to override these per-invocation. Tool descriptions list
|
||||||
# descriptions list available aliases dynamically; bad aliases return
|
# available aliases dynamically; bad aliases return an error so the
|
||||||
# an error so the model retries with a valid choice.
|
# model retries with a valid choice.
|
||||||
|
|
||||||
# --- Named Models (turnstone, node, eval) ---
|
# --- Named Models (turnstone, node, eval) ---
|
||||||
# Define model aliases with per-model overrides. Useful for local model
|
# Define model aliases with per-model overrides. Useful for local model
|
||||||
|
|||||||
@@ -110,11 +110,6 @@ class ApproveRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlanFeedbackRequest(BaseModel):
|
|
||||||
feedback: str = Field(description="Feedback text; empty string means approval")
|
|
||||||
ws_id: str = Field(description="Target workstream ID")
|
|
||||||
|
|
||||||
|
|
||||||
class CommandRequest(BaseModel):
|
class CommandRequest(BaseModel):
|
||||||
command: str = Field(description="Slash command (e.g. /clear, /new, /resume)")
|
command: str = Field(description="Slash command (e.g. /clear, /new, /resume)")
|
||||||
ws_id: str = Field(description="Target workstream ID")
|
ws_id: str = Field(description="Target workstream ID")
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from turnstone.api.server_schemas import (
|
|||||||
ListSkillSummaryResponse,
|
ListSkillSummaryResponse,
|
||||||
ListWorkstreamsResponse,
|
ListWorkstreamsResponse,
|
||||||
MemoryInfo,
|
MemoryInfo,
|
||||||
PlanFeedbackRequest,
|
|
||||||
RewindRequest,
|
RewindRequest,
|
||||||
SaveMemoryRequest,
|
SaveMemoryRequest,
|
||||||
SearchMemoriesRequest,
|
SearchMemoriesRequest,
|
||||||
@@ -127,15 +126,6 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
|||||||
error_codes=[404],
|
error_codes=[404],
|
||||||
tags=["Chat"],
|
tags=["Chat"],
|
||||||
),
|
),
|
||||||
EndpointSpec(
|
|
||||||
"/v1/api/plan",
|
|
||||||
"POST",
|
|
||||||
"Respond to a plan review",
|
|
||||||
request_model=PlanFeedbackRequest,
|
|
||||||
response_model=StatusResponse,
|
|
||||||
error_codes=[404],
|
|
||||||
tags=["Chat"],
|
|
||||||
),
|
|
||||||
EndpointSpec(
|
EndpointSpec(
|
||||||
"/v1/api/command",
|
"/v1/api/command",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -505,7 +495,6 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
|||||||
SendResponse,
|
SendResponse,
|
||||||
DequeueRequest,
|
DequeueRequest,
|
||||||
ApproveRequest,
|
ApproveRequest,
|
||||||
PlanFeedbackRequest,
|
|
||||||
CommandRequest,
|
CommandRequest,
|
||||||
CancelRequest,
|
CancelRequest,
|
||||||
RewindRequest,
|
RewindRequest,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Message formatting utilities for channel adapters.
|
"""Message formatting utilities for channel adapters.
|
||||||
|
|
||||||
Handles chunking long messages for platforms with character limits, formatting
|
Handles chunking long messages for platforms with character limits, formatting
|
||||||
tool-approval requests, plan-review prompts, and rich media embeds for
|
tool-approval requests and rich media embeds for platforms that support them
|
||||||
platforms that support them (e.g. Discord).
|
(e.g. Discord).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -445,19 +445,6 @@ class ChannelRouter:
|
|||||||
approved=approved,
|
approved=approved,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def send_plan_feedback(self, ws_id: str, correlation_id: str, feedback: str) -> None:
|
|
||||||
"""Respond to a plan review via the server API."""
|
|
||||||
if self._console:
|
|
||||||
await self._console.route_plan_feedback(ws_id=ws_id, feedback=feedback)
|
|
||||||
else:
|
|
||||||
assert self._server is not None
|
|
||||||
await self._server.plan_feedback(ws_id=ws_id, feedback=feedback)
|
|
||||||
log.debug(
|
|
||||||
"channel_router.send_plan_feedback",
|
|
||||||
ws_id=ws_id,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- route management ----------------------------------------------------
|
# -- route management ----------------------------------------------------
|
||||||
|
|
||||||
async def lookup_ws_id(self, channel_type: str, channel_id: str) -> str | None:
|
async def lookup_ws_id(self, channel_type: str, channel_id: str) -> str | None:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
:class:`TurnstoneBot` extends ``discord.ext.commands.Bot`` and manages the
|
:class:`TurnstoneBot` extends ``discord.ext.commands.Bot`` and manages the
|
||||||
lifecycle of SSE event subscriptions, streaming message edits, and interactive
|
lifecycle of SSE event subscriptions, streaming message edits, and interactive
|
||||||
approval / plan-review views.
|
approval views.
|
||||||
|
|
||||||
Events are consumed from the server's per-workstream SSE endpoint
|
Events are consumed from the server's per-workstream SSE endpoint
|
||||||
(``GET /v1/api/workstreams/{ws_id}/events``) using httpx-sse. Inbound
|
(``GET /v1/api/workstreams/{ws_id}/events``) using httpx-sse. Inbound
|
||||||
@@ -32,7 +32,6 @@ from turnstone.sdk.events import (
|
|||||||
ContentEvent,
|
ContentEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
IntentVerdictEvent,
|
IntentVerdictEvent,
|
||||||
PlanReviewEvent,
|
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
StreamEndEvent,
|
StreamEndEvent,
|
||||||
ThinkingStartEvent,
|
ThinkingStartEvent,
|
||||||
@@ -59,7 +58,7 @@ _THREAD_INVOKER_CAP: int = 4096
|
|||||||
def _thread_owner_id(thread: discord.abc.Messageable) -> str:
|
def _thread_owner_id(thread: discord.abc.Messageable) -> str:
|
||||||
"""Return the Discord user ID who owns the thread / DM target.
|
"""Return the Discord user ID who owns the thread / DM target.
|
||||||
|
|
||||||
Used to gate approval / plan-review button clicks to the session
|
Used to gate approval button clicks to the session
|
||||||
owner. For Discord threads this is the thread creator
|
owner. For Discord threads this is the thread creator
|
||||||
(``thread.owner_id``). For DM channels we use ``recipient.id``.
|
(``thread.owner_id``). For DM channels we use ``recipient.id``.
|
||||||
Returns ``""`` when the owner cannot be determined — the views
|
Returns ``""`` when the owner cannot be determined — the views
|
||||||
@@ -298,14 +297,13 @@ class TurnstoneBot:
|
|||||||
async def _setup_hook(self) -> None:
|
async def _setup_hook(self) -> None:
|
||||||
"""Called by discord.py after login but before connecting to the gateway."""
|
"""Called by discord.py after login but before connecting to the gateway."""
|
||||||
from turnstone.channels.discord.cog import MessageCog
|
from turnstone.channels.discord.cog import MessageCog
|
||||||
from turnstone.channels.discord.views import ApprovalView, PlanReviewView
|
from turnstone.channels.discord.views import ApprovalView
|
||||||
|
|
||||||
msg_cog = MessageCog(self._bot)
|
msg_cog = MessageCog(self._bot)
|
||||||
await self._bot.add_cog(msg_cog._cog)
|
await self._bot.add_cog(msg_cog._cog)
|
||||||
|
|
||||||
# Register persistent views so button callbacks survive restarts.
|
# Register persistent views so button callbacks survive restarts.
|
||||||
self._bot.add_view(ApprovalView(self)._view)
|
self._bot.add_view(ApprovalView(self)._view)
|
||||||
self._bot.add_view(PlanReviewView(self)._view)
|
|
||||||
|
|
||||||
log.info("discord.setup_hook_complete")
|
log.info("discord.setup_hook_complete")
|
||||||
|
|
||||||
@@ -498,8 +496,6 @@ class TurnstoneBot:
|
|||||||
await self._handle_tool_result(ws_id, thread, event)
|
await self._handle_tool_result(ws_id, thread, event)
|
||||||
elif isinstance(event, ApproveRequestEvent):
|
elif isinstance(event, ApproveRequestEvent):
|
||||||
await self._handle_approve_request(ws_id, thread, event)
|
await self._handle_approve_request(ws_id, thread, event)
|
||||||
elif isinstance(event, PlanReviewEvent):
|
|
||||||
await self._handle_plan_review(ws_id, thread, event)
|
|
||||||
elif isinstance(event, IntentVerdictEvent):
|
elif isinstance(event, IntentVerdictEvent):
|
||||||
await self._handle_intent_verdict(ws_id, event)
|
await self._handle_intent_verdict(ws_id, event)
|
||||||
elif isinstance(event, ApprovalResolvedEvent):
|
elif isinstance(event, ApprovalResolvedEvent):
|
||||||
@@ -729,24 +725,6 @@ class TurnstoneBot:
|
|||||||
msg = await thread.send(embed=embed, view=ApprovalView(self)._view)
|
msg = await thread.send(embed=embed, view=ApprovalView(self)._view)
|
||||||
self._pending_approval_msgs[ws_id] = msg
|
self._pending_approval_msgs[ws_id] = msg
|
||||||
|
|
||||||
async def _handle_plan_review(
|
|
||||||
self,
|
|
||||||
ws_id: str,
|
|
||||||
thread: discord.abc.Messageable,
|
|
||||||
event: PlanReviewEvent,
|
|
||||||
) -> None:
|
|
||||||
import discord
|
|
||||||
|
|
||||||
from turnstone.channels.discord.views import PlanReviewView
|
|
||||||
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="Plan Review",
|
|
||||||
description=f"**Plan review requested:**\n\n{event.content}",
|
|
||||||
color=discord.Color.blue(),
|
|
||||||
)
|
|
||||||
embed.set_footer(text=f"{ws_id}||{_thread_owner_id(thread)}")
|
|
||||||
await thread.send(embed=embed, view=PlanReviewView(self)._view)
|
|
||||||
|
|
||||||
async def _handle_intent_verdict(
|
async def _handle_intent_verdict(
|
||||||
self,
|
self,
|
||||||
ws_id: str,
|
ws_id: str,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Persistent interactive views for Discord approval and plan review.
|
"""Persistent interactive views for Discord approval.
|
||||||
|
|
||||||
These views use static ``custom_id`` values so they survive bot restarts.
|
These views use static ``custom_id`` values so they survive bot restarts.
|
||||||
Correlation information (``ws_id`` and ``correlation_id``) is stored in the
|
Correlation information (``ws_id`` and ``correlation_id``) is stored in the
|
||||||
@@ -200,167 +200,3 @@ class ApprovalView:
|
|||||||
approved=approved,
|
approved=approved,
|
||||||
always=always,
|
always=always,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# PlanReviewView
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PlanReviewView:
|
|
||||||
"""Persistent view with Approve Plan / Request Changes buttons."""
|
|
||||||
|
|
||||||
def __init__(self, bot: TurnstoneBot) -> None:
|
|
||||||
import discord
|
|
||||||
|
|
||||||
self.bot = bot
|
|
||||||
view_self = self
|
|
||||||
|
|
||||||
class _FeedbackModal(discord.ui.Modal, title="Request Changes"): # type: ignore[call-arg]
|
|
||||||
feedback: discord.ui.TextInput[_FeedbackModal] = discord.ui.TextInput(
|
|
||||||
label="Feedback",
|
|
||||||
style=discord.TextStyle.paragraph,
|
|
||||||
placeholder="Describe the changes you'd like...",
|
|
||||||
required=True,
|
|
||||||
max_length=2000,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(modal_self, ws_id: str, correlation_id: str) -> None: # noqa: N805
|
|
||||||
super().__init__()
|
|
||||||
modal_self.ws_id = ws_id
|
|
||||||
modal_self.correlation_id = correlation_id
|
|
||||||
|
|
||||||
async def on_submit(modal_self, interaction: discord.Interaction) -> None: # noqa: N805
|
|
||||||
await view_self._send_feedback(
|
|
||||||
interaction,
|
|
||||||
modal_self.ws_id,
|
|
||||||
modal_self.correlation_id,
|
|
||||||
str(modal_self.feedback),
|
|
||||||
)
|
|
||||||
|
|
||||||
self._modal_cls = _FeedbackModal
|
|
||||||
|
|
||||||
class _View(discord.ui.View):
|
|
||||||
def __init__(inner_self) -> None: # noqa: N805
|
|
||||||
super().__init__(timeout=None)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
|
||||||
label="Approve Plan",
|
|
||||||
style=discord.ButtonStyle.green,
|
|
||||||
custom_id="ts:plan_approve",
|
|
||||||
)
|
|
||||||
async def approve_plan(
|
|
||||||
inner_self, # noqa: N805
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
button: discord.ui.Button[_View],
|
|
||||||
) -> None:
|
|
||||||
await view_self._handle_approve(interaction)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
|
||||||
label="Request Changes",
|
|
||||||
style=discord.ButtonStyle.secondary,
|
|
||||||
custom_id="ts:plan_changes",
|
|
||||||
)
|
|
||||||
async def request_changes(
|
|
||||||
inner_self, # noqa: N805
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
button: discord.ui.Button[_View],
|
|
||||||
) -> None:
|
|
||||||
await view_self._handle_changes(interaction)
|
|
||||||
|
|
||||||
self._view = _View()
|
|
||||||
|
|
||||||
async def _handle_approve(self, interaction: discord.Interaction) -> None:
|
|
||||||
"""Approve the plan (empty feedback = approved)."""
|
|
||||||
parsed = _parse_footer(interaction)
|
|
||||||
if parsed is None:
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"Could not determine workstream context.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
ws_id, correlation_id, owner_id = parsed
|
|
||||||
|
|
||||||
if not owner_id or str(interaction.user.id) != owner_id:
|
|
||||||
await _deny_non_owner(interaction, "approve")
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = await self.bot.router.resolve_user("discord", str(interaction.user.id))
|
|
||||||
if user_id is None:
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"Your Discord account is not linked. Use `/link` first.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Defer before doing async work so Discord doesn't time out.
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
|
|
||||||
await self.bot.router.send_plan_feedback(
|
|
||||||
ws_id=ws_id,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
feedback="",
|
|
||||||
)
|
|
||||||
|
|
||||||
await _disable_buttons(interaction, "Approved")
|
|
||||||
await interaction.followup.send("Plan **approved**.", ephemeral=True)
|
|
||||||
log.info(
|
|
||||||
"discord.plan_approved",
|
|
||||||
ws_id=ws_id,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_changes(self, interaction: discord.Interaction) -> None:
|
|
||||||
"""Open a modal for feedback text."""
|
|
||||||
parsed = _parse_footer(interaction)
|
|
||||||
if parsed is None:
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"Could not determine workstream context.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
ws_id, correlation_id, owner_id = parsed
|
|
||||||
|
|
||||||
if not owner_id or str(interaction.user.id) != owner_id:
|
|
||||||
await _deny_non_owner(interaction, "request changes on")
|
|
||||||
return
|
|
||||||
|
|
||||||
modal = self._modal_cls(ws_id, correlation_id)
|
|
||||||
await interaction.response.send_modal(modal)
|
|
||||||
|
|
||||||
async def _send_feedback(
|
|
||||||
self,
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
ws_id: str,
|
|
||||||
correlation_id: str,
|
|
||||||
feedback: str,
|
|
||||||
) -> None:
|
|
||||||
"""Send plan feedback after the modal is submitted."""
|
|
||||||
user_id = await self.bot.router.resolve_user("discord", str(interaction.user.id))
|
|
||||||
if user_id is None:
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"Your Discord account is not linked. Use `/link` first.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Defer before doing async work so Discord doesn't time out.
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
|
|
||||||
await self.bot.router.send_plan_feedback(
|
|
||||||
ws_id=ws_id,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
feedback=feedback,
|
|
||||||
)
|
|
||||||
|
|
||||||
await interaction.followup.send(
|
|
||||||
"Feedback submitted. The plan will be revised.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
"discord.plan_changes_requested",
|
|
||||||
ws_id=ws_id,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from turnstone.sdk.events import (
|
|||||||
ContentEvent,
|
ContentEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
IntentVerdictEvent,
|
IntentVerdictEvent,
|
||||||
PlanReviewEvent,
|
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
StreamEndEvent,
|
StreamEndEvent,
|
||||||
ThinkingStartEvent,
|
ThinkingStartEvent,
|
||||||
@@ -277,9 +276,6 @@ class TurnstoneSlackBot:
|
|||||||
self._streaming: dict[str, StreamingMessage] = {}
|
self._streaming: dict[str, StreamingMessage] = {}
|
||||||
|
|
||||||
self._pending_approval: dict[str, PendingApproval] = {}
|
self._pending_approval: dict[str, PendingApproval] = {}
|
||||||
# (channel, message_ts, owner_user_id) per pending plan review, so
|
|
||||||
# _on_plan_* handlers can reject clicks from non-owners.
|
|
||||||
self._pending_plan_review_ts: dict[str, tuple[str, str, str]] = {}
|
|
||||||
self._notify_ws_map: dict[str, tuple[str, SlackRoute]] = {}
|
self._notify_ws_map: dict[str, tuple[str, SlackRoute]] = {}
|
||||||
# Per-workstream override used to route the next streamed assistant
|
# Per-workstream override used to route the next streamed assistant
|
||||||
# response back into a Slack notification reply thread instead of the
|
# response back into a Slack notification reply thread instead of the
|
||||||
@@ -319,9 +315,6 @@ class TurnstoneSlackBot:
|
|||||||
|
|
||||||
self._app.action("ts_approve")(_approve_cb)
|
self._app.action("ts_approve")(_approve_cb)
|
||||||
self._app.action("ts_deny")(_deny_cb)
|
self._app.action("ts_deny")(_deny_cb)
|
||||||
self._app.action("ts_plan_approve")(self._on_plan_approve)
|
|
||||||
self._app.action("ts_plan_request_changes")(self._on_plan_request_changes)
|
|
||||||
self._app.view("ts_plan_feedback_modal")(self._on_plan_feedback_modal)
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
||||||
@@ -926,166 +919,6 @@ class TurnstoneSlackBot:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.debug(f"slack.{event_key}_message_update_failed", exc_info=True)
|
log.debug(f"slack.{event_key}_message_update_failed", exc_info=True)
|
||||||
|
|
||||||
async def _ensure_plan_review_owner(
|
|
||||||
self,
|
|
||||||
entry: tuple[str, str, str] | None,
|
|
||||||
actor_user_id: str,
|
|
||||||
channel: str,
|
|
||||||
verb: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True when *actor_user_id* owns the pending plan review.
|
|
||||||
|
|
||||||
The gateway forwards ``/plan`` calls with its service-scoped JWT,
|
|
||||||
and the server bypasses ownership checks on service scope — so
|
|
||||||
every plan-review interaction needs an adapter-side owner gate.
|
|
||||||
"""
|
|
||||||
if entry is None:
|
|
||||||
log.warning("slack.plan_review_missing_entry", actor_user_id=actor_user_id)
|
|
||||||
await self._client.chat_postEphemeral(
|
|
||||||
channel=channel,
|
|
||||||
user=actor_user_id,
|
|
||||||
text="This plan review can no longer be verified. Please retry from the active session.",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
_channel, _ts, owner_user_id = entry
|
|
||||||
if not owner_user_id or actor_user_id != owner_user_id:
|
|
||||||
await self._client.chat_postEphemeral(
|
|
||||||
channel=channel,
|
|
||||||
user=actor_user_id,
|
|
||||||
text=f"Only the session owner can {verb} this plan.",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _on_plan_approve(self, ack: Any, body: dict[str, Any]) -> None:
|
|
||||||
await ack()
|
|
||||||
ws_id = body["actions"][0].get("value", "")
|
|
||||||
log.info("slack.plan_approve_clicked", ws_id=ws_id)
|
|
||||||
if not ws_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_user_id = body.get("user", {}).get("id", "")
|
|
||||||
channel = body["container"]["channel_id"]
|
|
||||||
entry = self._pending_plan_review_ts.get(ws_id)
|
|
||||||
if not await self._ensure_plan_review_owner(entry, actor_user_id, channel, "approve"):
|
|
||||||
return
|
|
||||||
|
|
||||||
self._streaming.pop(ws_id, None)
|
|
||||||
await self.router.send_plan_feedback(ws_id, "", "")
|
|
||||||
log.info("slack.plan_feedback_sent", ws_id=ws_id, feedback="")
|
|
||||||
|
|
||||||
ts = body["container"]["message_ts"]
|
|
||||||
|
|
||||||
self._pending_plan_review_ts.pop(ws_id, None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._client.chat_update(
|
|
||||||
channel=channel,
|
|
||||||
ts=ts,
|
|
||||||
text="Plan approved",
|
|
||||||
blocks=[],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.debug("slack.plan_review_approve_update_failed", exc_info=True)
|
|
||||||
|
|
||||||
async def _on_plan_request_changes(self, ack: Any, body: dict[str, Any], client: Any) -> None:
|
|
||||||
await ack()
|
|
||||||
ws_id = body["actions"][0].get("value", "")
|
|
||||||
if not ws_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_user_id = body.get("user", {}).get("id", "")
|
|
||||||
channel = body["container"]["channel_id"]
|
|
||||||
entry = self._pending_plan_review_ts.get(ws_id)
|
|
||||||
if not await self._ensure_plan_review_owner(
|
|
||||||
entry, actor_user_id, channel, "request changes on"
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
trigger_id = body.get("trigger_id", "")
|
|
||||||
if not trigger_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
await client.views_open(
|
|
||||||
trigger_id=trigger_id,
|
|
||||||
view={
|
|
||||||
"type": "modal",
|
|
||||||
"callback_id": "ts_plan_feedback_modal",
|
|
||||||
"private_metadata": ws_id,
|
|
||||||
"title": {"type": "plain_text", "text": "Plan feedback"},
|
|
||||||
"submit": {"type": "plain_text", "text": "Send"},
|
|
||||||
"close": {"type": "plain_text", "text": "Cancel"},
|
|
||||||
"blocks": [
|
|
||||||
{
|
|
||||||
"type": "input",
|
|
||||||
"block_id": "feedback_block",
|
|
||||||
"label": {"type": "plain_text", "text": "Requested changes"},
|
|
||||||
"element": {
|
|
||||||
"type": "plain_text_input",
|
|
||||||
"action_id": "feedback_input",
|
|
||||||
"multiline": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _on_plan_feedback_modal(
|
|
||||||
self, ack: Any, body: dict[str, Any], view: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
await ack()
|
|
||||||
|
|
||||||
ws_id = view.get("private_metadata", "")
|
|
||||||
if not ws_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
actor_user_id = body.get("user", {}).get("id", "")
|
|
||||||
entry = self._pending_plan_review_ts.get(ws_id)
|
|
||||||
if entry is None:
|
|
||||||
# No pending review (stale modal) — silently drop.
|
|
||||||
return
|
|
||||||
_ignored_channel, _ignored_ts, owner_user_id = entry
|
|
||||||
if not owner_user_id or actor_user_id != owner_user_id:
|
|
||||||
# Modal submit can't emit ephemeral; just log and drop.
|
|
||||||
log.warning(
|
|
||||||
"slack.plan_feedback_non_owner",
|
|
||||||
ws_id=ws_id,
|
|
||||||
actor_user_id=actor_user_id,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
feedback = (
|
|
||||||
view.get("state", {})
|
|
||||||
.get("values", {})
|
|
||||||
.get("feedback_block", {})
|
|
||||||
.get("feedback_input", {})
|
|
||||||
.get("value", "")
|
|
||||||
.strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not feedback:
|
|
||||||
feedback = "Please revise the plan."
|
|
||||||
|
|
||||||
log.info("slack.plan_feedback_modal_submitted", ws_id=ws_id, feedback=feedback)
|
|
||||||
|
|
||||||
self._streaming.pop(ws_id, None)
|
|
||||||
await self.router.send_plan_feedback(ws_id, "", feedback)
|
|
||||||
log.info("slack.plan_feedback_sent", ws_id=ws_id, feedback=feedback)
|
|
||||||
|
|
||||||
entry = self._pending_plan_review_ts.pop(ws_id, None)
|
|
||||||
if entry is not None:
|
|
||||||
channel, ts, _owner = entry
|
|
||||||
try:
|
|
||||||
await self._client.chat_update(
|
|
||||||
channel=channel,
|
|
||||||
ts=ts,
|
|
||||||
text="Plan changes requested",
|
|
||||||
blocks=[],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.debug("slack.plan_review_modal_update_failed", exc_info=True)
|
|
||||||
|
|
||||||
async def subscribe_ws(self, ws_id: str, channel_id: str) -> None:
|
async def subscribe_ws(self, ws_id: str, channel_id: str) -> None:
|
||||||
# Recover from a dead SSE task before the early-return check. If the
|
# Recover from a dead SSE task before the early-return check. If the
|
||||||
# prior listener died with an unhandled exception, its ws_id is still
|
# prior listener died with an unhandled exception, its ws_id is still
|
||||||
@@ -1118,7 +951,6 @@ class TurnstoneSlackBot:
|
|||||||
self._subscribed_ws.discard(ws_id)
|
self._subscribed_ws.discard(ws_id)
|
||||||
self._streaming.pop(ws_id, None)
|
self._streaming.pop(ws_id, None)
|
||||||
self._pending_approval.pop(ws_id, None)
|
self._pending_approval.pop(ws_id, None)
|
||||||
self._pending_plan_review_ts.pop(ws_id, None)
|
|
||||||
self._clear_notification_tracking_for_ws(ws_id)
|
self._clear_notification_tracking_for_ws(ws_id)
|
||||||
|
|
||||||
async def unsubscribe_ws(self, ws_id: str) -> None:
|
async def unsubscribe_ws(self, ws_id: str) -> None:
|
||||||
@@ -1191,8 +1023,6 @@ class TurnstoneSlackBot:
|
|||||||
await self._handle_approve_request(ws_id, route, event)
|
await self._handle_approve_request(ws_id, route, event)
|
||||||
elif isinstance(event, IntentVerdictEvent):
|
elif isinstance(event, IntentVerdictEvent):
|
||||||
await self._handle_intent_verdict(ws_id, event)
|
await self._handle_intent_verdict(ws_id, event)
|
||||||
elif isinstance(event, PlanReviewEvent):
|
|
||||||
await self._handle_plan_review(ws_id, route, event)
|
|
||||||
elif isinstance(event, ApprovalResolvedEvent):
|
elif isinstance(event, ApprovalResolvedEvent):
|
||||||
await self._handle_approval_resolved(ws_id, event)
|
await self._handle_approval_resolved(ws_id, event)
|
||||||
elif isinstance(event, StreamEndEvent):
|
elif isinstance(event, StreamEndEvent):
|
||||||
@@ -1307,53 +1137,6 @@ class TurnstoneSlackBot:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.debug("slack.verdict_message_update_failed", ws_id=ws_id, exc_info=True)
|
log.debug("slack.verdict_message_update_failed", ws_id=ws_id, exc_info=True)
|
||||||
|
|
||||||
async def _handle_plan_review(
|
|
||||||
self, ws_id: str, route: SlackRoute, event: PlanReviewEvent
|
|
||||||
) -> None:
|
|
||||||
slack_channel = route.channel
|
|
||||||
thread_ts = route.thread_ts or ""
|
|
||||||
owner_user_id = route.user_id or ""
|
|
||||||
|
|
||||||
log.info("slack.plan_review_received", ws_id=ws_id)
|
|
||||||
plan_preview = _sanitize_slack_preview(event.content, max_length=2000)
|
|
||||||
blocks = [
|
|
||||||
{
|
|
||||||
"type": "section",
|
|
||||||
"text": {
|
|
||||||
"type": "mrkdwn",
|
|
||||||
"text": f"*Plan Review*\n```{plan_preview}```",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "actions",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"type": "button",
|
|
||||||
"text": {"type": "plain_text", "text": "Approve"},
|
|
||||||
"style": "primary",
|
|
||||||
"action_id": "ts_plan_approve",
|
|
||||||
"value": ws_id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "button",
|
|
||||||
"text": {"type": "plain_text", "text": "Request changes"},
|
|
||||||
"style": "danger",
|
|
||||||
"action_id": "ts_plan_request_changes",
|
|
||||||
"value": ws_id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
resp = await self._client.chat_postMessage(
|
|
||||||
channel=slack_channel,
|
|
||||||
thread_ts=thread_ts or None,
|
|
||||||
text="Plan review required",
|
|
||||||
blocks=cast("list[dict[str, Any]]", blocks),
|
|
||||||
)
|
|
||||||
if resp.get("ok"):
|
|
||||||
self._pending_plan_review_ts[ws_id] = (slack_channel, resp["ts"], owner_user_id)
|
|
||||||
|
|
||||||
async def _handle_approval_resolved(self, ws_id: str, event: ApprovalResolvedEvent) -> None:
|
async def _handle_approval_resolved(self, ws_id: str, event: ApprovalResolvedEvent) -> None:
|
||||||
entry = self._pending_approval.pop(ws_id, None)
|
entry = self._pending_approval.pop(ws_id, None)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
|||||||
@@ -294,25 +294,6 @@ class TerminalUI(SessionUI):
|
|||||||
sys.stdout.write(f"\n {DIM}[{' · '.join(parts)}]{RESET}\n")
|
sys.stdout.write(f"\n {DIM}[{' · '.join(parts)}]{RESET}\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
sys.stdout.write(f"\n{DIM}{'─' * 60}{RESET}\n")
|
|
||||||
for line in content.splitlines():
|
|
||||||
sys.stdout.write(f" {line}\n")
|
|
||||||
sys.stdout.write(f"{DIM}{'─' * 60}{RESET}\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
try:
|
|
||||||
prompt_text = (
|
|
||||||
f" \001{BOLD}\002Plan ready.\001{RESET}\002 "
|
|
||||||
f"\001{DIM}\002[enter to approve, feedback to amend, "
|
|
||||||
f"ctrl-c to reject]\001{RESET}\002 "
|
|
||||||
)
|
|
||||||
resp = input(prompt_text).strip()
|
|
||||||
except EOFError:
|
|
||||||
resp = ""
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
resp = "reject"
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def on_info(self, message: str) -> None:
|
def on_info(self, message: str) -> None:
|
||||||
print(message)
|
print(message)
|
||||||
|
|
||||||
@@ -506,16 +487,6 @@ class WorkstreamTerminalUI(TerminalUI):
|
|||||||
if self.is_foreground:
|
if self.is_foreground:
|
||||||
super().on_tool_output_chunk(call_id, chunk)
|
super().on_tool_output_chunk(call_id, chunk)
|
||||||
|
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
# Must wait until foregrounded to show plan review
|
|
||||||
if not self.is_foreground:
|
|
||||||
self._buffer(
|
|
||||||
"info",
|
|
||||||
f"{YELLOW}[Plan ready — switch to this workstream to review]{RESET}",
|
|
||||||
)
|
|
||||||
self._fg_event.wait()
|
|
||||||
return super().on_plan_review(content)
|
|
||||||
|
|
||||||
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||||||
"""Block until foregrounded if in background, then show approval prompt."""
|
"""Block until foregrounded if in background, then show approval prompt."""
|
||||||
if not self.is_foreground:
|
if not self.is_foreground:
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ Mirrors ``turnstone.server.WebUI`` but scoped to the console's needs:
|
|||||||
- Per-session SSE listener fan-out (inherited from
|
- Per-session SSE listener fan-out (inherited from
|
||||||
:class:`SessionUIBase` — same ``threading.Lock`` + queue list
|
:class:`SessionUIBase` — same ``threading.Lock`` + queue list
|
||||||
pattern WebUI uses).
|
pattern WebUI uses).
|
||||||
- ``threading.Event`` + ``_approval_result`` / ``_plan_result`` for
|
- ``threading.Event`` + ``_approval_result`` for blocking the worker
|
||||||
blocking the worker thread until a console endpoint delivers the
|
thread until a console endpoint delivers the decision (inherited).
|
||||||
decision (inherited).
|
|
||||||
- Per-ws metric tracking + turn-content accumulator + activity
|
- Per-ws metric tracking + turn-content accumulator + activity
|
||||||
bookkeeping (inherited from :class:`SessionUIBase` post the rich
|
bookkeeping (inherited from :class:`SessionUIBase` post the rich
|
||||||
``ws_state`` payload lift). Coord populates the same
|
``ws_state`` payload lift). Coord populates the same
|
||||||
@@ -94,8 +93,8 @@ class ConsoleCoordinatorUI(SessionUIBase):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# SessionUI protocol — approvals
|
# SessionUI protocol — approvals
|
||||||
#
|
#
|
||||||
# ``approve_tools`` / ``resolve_approval`` / ``resolve_plan`` are
|
# ``approve_tools`` / ``resolve_approval`` are inherited from
|
||||||
# inherited from :class:`SessionUIBase`. The shared body covers
|
# :class:`SessionUIBase`. The shared body covers
|
||||||
# tool-policy gating, per-tool auto-approve, blanket auto-approve,
|
# tool-policy gating, per-tool auto-approve, blanket auto-approve,
|
||||||
# heuristic-verdict persistence, and activity tagging the same way
|
# heuristic-verdict persistence, and activity tagging the same way
|
||||||
# interactive sessions get them. ``__budget_override__`` is
|
# interactive sessions get them. ``__budget_override__`` is
|
||||||
@@ -103,19 +102,6 @@ class ConsoleCoordinatorUI(SessionUIBase):
|
|||||||
# no-op on coord (coord workstreams don't have token budgets).
|
# no-op on coord (coord workstreams don't have token budgets).
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
# Coordinator sessions don't fire plan_agent (AGENT_TOOLS is []
|
|
||||||
# for coordinator kind) so this path shouldn't normally run.
|
|
||||||
# Implemented defensively for SessionUI protocol compatibility.
|
|
||||||
self._plan_event.clear()
|
|
||||||
self._pending_plan_review = {"type": "plan_review", "content": content}
|
|
||||||
self._enqueue(self._pending_plan_review)
|
|
||||||
if not self._plan_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
|
|
||||||
log.warning("coord_ui.plan_review_timeout ws=%s", self.ws_id)
|
|
||||||
self.resolve_plan("reject")
|
|
||||||
self._pending_plan_review = None
|
|
||||||
return self._plan_result
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# SessionUI protocol — broadcast hook + state change + rename
|
# SessionUI protocol — broadcast hook + state change + rename
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -695,7 +695,6 @@ _ROUTE_PROXY_AUDIT_ACTIONS: dict[str, str] = {
|
|||||||
"rewind": "route.rewind",
|
"rewind": "route.rewind",
|
||||||
"retry": "route.retry",
|
"retry": "route.retry",
|
||||||
"command": "route.command",
|
"command": "route.command",
|
||||||
"plan": "route.plan",
|
|
||||||
"close": "route.workstream.close",
|
"close": "route.workstream.close",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2289,8 +2288,8 @@ async def route_proxy(request: Request) -> Response:
|
|||||||
"""Generic routing proxy for send/approve/cancel/command/close.
|
"""Generic routing proxy for send/approve/cancel/command/close.
|
||||||
|
|
||||||
Path-keyed shape: ``POST/DELETE /v1/api/route/workstreams/{ws_id}/<verb>``
|
Path-keyed shape: ``POST/DELETE /v1/api/route/workstreams/{ws_id}/<verb>``
|
||||||
(or ``POST /v1/api/route/<verb>`` for the body-keyed plan/command
|
(or ``POST /v1/api/route/<verb>`` for the body-keyed ``command``
|
||||||
legacies still in scope). ``verb`` drives the audit action lookup;
|
legacy still in scope). ``verb`` drives the audit action lookup;
|
||||||
DELETE on ``/send`` is treated as dequeue for audit attribution.
|
DELETE on ``/send`` is treated as dequeue for audit attribution.
|
||||||
"""
|
"""
|
||||||
from turnstone.core.auth import require_any_permission
|
from turnstone.core.auth import require_any_permission
|
||||||
@@ -2306,7 +2305,7 @@ async def route_proxy(request: Request) -> Response:
|
|||||||
# check (the upstream server gates again), but failing fast at the
|
# check (the upstream server gates again), but failing fast at the
|
||||||
# proxy avoids a cluster round-trip on a forbidden request and keeps
|
# proxy avoids a cluster round-trip on a forbidden request and keeps
|
||||||
# the 403 attributed to the proxy in audit logs. Only the two verbs
|
# the 403 attributed to the proxy in audit logs. Only the two verbs
|
||||||
# whose perms exist; other verbs (send/cancel/dequeue/command/plan)
|
# whose perms exist; other verbs (send/cancel/dequeue/command)
|
||||||
# remain authenticated-only and pre-existing — leaving them alone
|
# remain authenticated-only and pre-existing — leaving them alone
|
||||||
# rather than expanding scope. ``admin.coordinator`` accepted as
|
# rather than expanding scope. ``admin.coordinator`` accepted as
|
||||||
# an alternative on each so coord sessions driving interactive
|
# an alternative on each so coord sessions driving interactive
|
||||||
@@ -2353,8 +2352,8 @@ async def route_proxy(request: Request) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Path-keyed shape (post-1.5) carries ws_id in the URL; the
|
# Path-keyed shape (post-1.5) carries ws_id in the URL; the
|
||||||
# legacy plan/command routes still mount at body-keyed URLs and
|
# legacy command route still mounts at a body-keyed URL and
|
||||||
# supply ws_id via the JSON body. Try path first, fall back to body.
|
# supplies ws_id via the JSON body. Try path first, fall back to body.
|
||||||
ws_id = request.path_params.get("ws_id", "") or str(body.get("ws_id") or "")
|
ws_id = request.path_params.get("ws_id", "") or str(body.get("ws_id") or "")
|
||||||
if not ws_id:
|
if not ws_id:
|
||||||
return _record_route(
|
return _record_route(
|
||||||
@@ -3260,7 +3259,6 @@ def _coord_events_replay(
|
|||||||
that fired since it surfaced. Without this replay a refresh
|
that fired since it surfaced. Without this replay a refresh
|
||||||
loses the judge chip on the pending approval until the
|
loses the judge chip on the pending approval until the
|
||||||
operator re-invokes the action.
|
operator re-invokes the action.
|
||||||
3. Pending plan-review (if any).
|
|
||||||
|
|
||||||
Coord still skips conversation history — the dashboard fetches it
|
Coord still skips conversation history — the dashboard fetches it
|
||||||
via a separate ``GET /history`` endpoint and doesn't want a
|
via a separate ``GET /history`` endpoint and doesn't want a
|
||||||
@@ -3287,9 +3285,6 @@ def _coord_events_replay(
|
|||||||
cached_verdicts = list(llm_verdicts.values())
|
cached_verdicts = list(llm_verdicts.values())
|
||||||
for v in cached_verdicts:
|
for v in cached_verdicts:
|
||||||
yield {"type": "intent_verdict", **v}
|
yield {"type": "intent_verdict", **v}
|
||||||
pending_plan = getattr(ui, "_pending_plan_review", None)
|
|
||||||
if pending_plan is not None:
|
|
||||||
yield pending_plan
|
|
||||||
|
|
||||||
|
|
||||||
async def _coord_create_validate_request(
|
async def _coord_create_validate_request(
|
||||||
@@ -8454,8 +8449,6 @@ def _emit_models_changed(request: Request) -> None:
|
|||||||
_MODEL_AFFECTING_SETTING_KEYS: frozenset[str] = frozenset(
|
_MODEL_AFFECTING_SETTING_KEYS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"model.default_alias",
|
"model.default_alias",
|
||||||
"model.plan_alias",
|
|
||||||
"model.plan_effort",
|
|
||||||
"model.task_alias",
|
"model.task_alias",
|
||||||
"model.task_effort",
|
"model.task_effort",
|
||||||
"coordinator.model_alias",
|
"coordinator.model_alias",
|
||||||
@@ -10280,7 +10273,7 @@ def _refresh_coord_registry(app_state: Any, storage: Any) -> None:
|
|||||||
new_registry = load_model_registry(storage=storage, strict=True)
|
new_registry = load_model_registry(storage=storage, strict=True)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# ModelRegistry.__init__ raises ValueError for several distinct
|
# ModelRegistry.__init__ raises ValueError for several distinct
|
||||||
# config issues — empty models, default/fallback/agent/plan/task
|
# config issues — empty models, default/fallback/agent/task
|
||||||
# alias not present in the loaded set. Log the actual reason so
|
# alias not present in the loaded set. Log the actual reason so
|
||||||
# operators can tell "no enabled rows" from "default alias typo
|
# operators can tell "no enabled rows" from "default alias typo
|
||||||
# in config.toml". Existing registry stays in place either way.
|
# in config.toml". Existing registry stays in place either way.
|
||||||
@@ -10295,9 +10288,7 @@ def _refresh_coord_registry(app_state: Any, storage: Any) -> None:
|
|||||||
new_registry.default,
|
new_registry.default,
|
||||||
new_registry.fallback,
|
new_registry.fallback,
|
||||||
new_registry.agent_model,
|
new_registry.agent_model,
|
||||||
plan_model=new_registry.plan_model,
|
|
||||||
task_model=new_registry.task_model,
|
task_model=new_registry.task_model,
|
||||||
plan_effort=new_registry.plan_effort,
|
|
||||||
task_effort=new_registry.task_effort,
|
task_effort=new_registry.task_effort,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -12749,7 +12740,6 @@ def create_app(
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
),
|
),
|
||||||
Route("/api/route/command", route_proxy, methods=["POST"]),
|
Route("/api/route/command", route_proxy, methods=["POST"]),
|
||||||
Route("/api/route/plan", route_proxy, methods=["POST"]),
|
|
||||||
Route(
|
Route(
|
||||||
"/api/route/workstreams/{ws_id}/close",
|
"/api/route/workstreams/{ws_id}/close",
|
||||||
route_proxy,
|
route_proxy,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ let _mobileSidebarOpen = false;
|
|||||||
// alias list, and whose empty-string option renders as "(server default)".
|
// alias list, and whose empty-string option renders as "(server default)".
|
||||||
const ALIAS_SETTING_KEYS = [
|
const ALIAS_SETTING_KEYS = [
|
||||||
"model.default_alias",
|
"model.default_alias",
|
||||||
"model.plan_alias",
|
|
||||||
"model.task_alias",
|
"model.task_alias",
|
||||||
"channels.default_model_alias",
|
"channels.default_model_alias",
|
||||||
"audio.stt_model_alias",
|
"audio.stt_model_alias",
|
||||||
@@ -30,7 +29,7 @@ const ALIAS_SETTING_KEYS = [
|
|||||||
// opposed to "no value" — distinct from the literal "none" choice (e.g.
|
// opposed to "no value" — distinct from the literal "none" choice (e.g.
|
||||||
// reasoning_effort="none" actually disables reasoning, very different
|
// reasoning_effort="none" actually disables reasoning, very different
|
||||||
// from leaving it unset).
|
// from leaving it unset).
|
||||||
const INHERIT_EMPTY_LABEL_KEYS = ["model.plan_effort", "model.task_effort"];
|
const INHERIT_EMPTY_LABEL_KEYS = ["model.task_effort"];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// View switching (called from app.js showHome/drillDown pattern)
|
// View switching (called from app.js showHome/drillDown pattern)
|
||||||
@@ -2979,8 +2978,6 @@ function loadSettings() {
|
|||||||
const roleKeys = {
|
const roleKeys = {
|
||||||
"coordinator.model_alias": 1,
|
"coordinator.model_alias": 1,
|
||||||
"coordinator.reasoning_effort": 1,
|
"coordinator.reasoning_effort": 1,
|
||||||
"model.plan_alias": 1,
|
|
||||||
"model.plan_effort": 1,
|
|
||||||
"model.task_alias": 1,
|
"model.task_alias": 1,
|
||||||
"model.task_effort": 1,
|
"model.task_effort": 1,
|
||||||
"channels.default_model_alias": 1,
|
"channels.default_model_alias": 1,
|
||||||
@@ -5144,8 +5141,8 @@ let _modelCreateTrigger = null;
|
|||||||
// ``fallbackKind`` controls how the empty/blank option in the alias
|
// ``fallbackKind`` controls how the empty/blank option in the alias
|
||||||
// dropdown is labelled. Coordinator and Judge fall back to a single
|
// dropdown is labelled. Coordinator and Judge fall back to a single
|
||||||
// well-defined alias (model.default_alias / coordinator alias) so we
|
// well-defined alias (model.default_alias / coordinator alias) so we
|
||||||
// surface that concrete model in the placeholder. Plan/Task agents
|
// surface that concrete model in the placeholder. The Task agent
|
||||||
// cascade through ``[model].plan_model → [model].agent_model →
|
// cascades through ``[model].task_model → [model].agent_model →
|
||||||
// session model`` per turnstone/core/settings_registry.py — there's
|
// session model`` per turnstone/core/settings_registry.py — there's
|
||||||
// no single "default" to advertise, so the blank reads "(inherit)"
|
// no single "default" to advertise, so the blank reads "(inherit)"
|
||||||
// to match the vocabulary of the reasoning-effort dropdowns.
|
// to match the vocabulary of the reasoning-effort dropdowns.
|
||||||
@@ -5172,14 +5169,6 @@ const MODEL_ROLES = [
|
|||||||
aliasKey: "judge.output_guard_model",
|
aliasKey: "judge.output_guard_model",
|
||||||
fallbackKind: "default",
|
fallbackKind: "default",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Plan agent",
|
|
||||||
description:
|
|
||||||
"plan_agent sub-agent — produces high-level plans before task dispatch.",
|
|
||||||
aliasKey: "model.plan_alias",
|
|
||||||
effortKey: "model.plan_effort",
|
|
||||||
fallbackKind: "inherit",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Task agent",
|
label: "Task agent",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ def cleanup_session_ui(ws: Workstream) -> None:
|
|||||||
if hasattr(ui, "_approval_event"):
|
if hasattr(ui, "_approval_event"):
|
||||||
ui._approval_result = False, None # type: ignore[attr-defined]
|
ui._approval_result = False, None # type: ignore[attr-defined]
|
||||||
ui._approval_event.set()
|
ui._approval_event.set()
|
||||||
if hasattr(ui, "_plan_event"):
|
|
||||||
ui._plan_result = "reject" # type: ignore[attr-defined]
|
|
||||||
ui._plan_event.set()
|
|
||||||
if hasattr(ui, "_fg_event"):
|
if hasattr(ui, "_fg_event"):
|
||||||
ui._fg_event.set()
|
ui._fg_event.set()
|
||||||
if hasattr(ui, "_listeners_lock"):
|
if hasattr(ui, "_listeners_lock"):
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ surface is asymmetric on the interactive side and the asymmetry is
|
|||||||
load-bearing — see ``InteractiveAdapter`` docstring.
|
load-bearing — see ``InteractiveAdapter`` docstring.
|
||||||
|
|
||||||
Uses ``WebUI``'s per-UI listener set for ``cleanup_ui`` — the same
|
Uses ``WebUI``'s per-UI listener set for ``cleanup_ui`` — the same
|
||||||
hooks (``_approval_event`` / ``_plan_event`` / ``_fg_event`` + the
|
hooks (``_approval_event`` / ``_fg_event`` + the ``ws_closed``
|
||||||
``ws_closed`` broadcast) the old ``WorkstreamManager._cleanup_ui``
|
broadcast) the old ``WorkstreamManager._cleanup_ui`` touched.
|
||||||
touched.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -304,7 +304,6 @@ PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/", "/acme/")
|
|||||||
|
|
||||||
WRITE_PATHS: frozenset[str] = frozenset(
|
WRITE_PATHS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"/api/plan",
|
|
||||||
"/api/command",
|
"/api/command",
|
||||||
"/api/workstreams/new",
|
"/api/workstreams/new",
|
||||||
"/api/cluster/workstreams/new",
|
"/api/cluster/workstreams/new",
|
||||||
|
|||||||
+1
-11
@@ -532,16 +532,6 @@ _LOW_RULES: list[_HeuristicRule] = [
|
|||||||
intent_template="List directory: {arg_snippet}",
|
intent_template="List directory: {arg_snippet}",
|
||||||
reasoning_template="Listing directory contents is a read-only operation.",
|
reasoning_template="Listing directory contents is a read-only operation.",
|
||||||
),
|
),
|
||||||
_HeuristicRule(
|
|
||||||
name="man-tool",
|
|
||||||
risk_level="low",
|
|
||||||
confidence=0.85,
|
|
||||||
recommendation="approve",
|
|
||||||
tool_pattern="man",
|
|
||||||
arg_patterns=[],
|
|
||||||
intent_template="Manual page lookup: {arg_snippet}",
|
|
||||||
reasoning_template="Looking up a man page is a read-only operation.",
|
|
||||||
),
|
|
||||||
_HeuristicRule(
|
_HeuristicRule(
|
||||||
name="use-prompt",
|
name="use-prompt",
|
||||||
risk_level="low",
|
risk_level="low",
|
||||||
@@ -919,7 +909,7 @@ class IntentJudge:
|
|||||||
# Resolve judge model via ModelRegistry alias, otherwise self-
|
# Resolve judge model via ModelRegistry alias, otherwise self-
|
||||||
# consistency on the session model. ``judge.model`` is alias-only
|
# consistency on the session model. ``judge.model`` is alias-only
|
||||||
# — same contract as ``coordinator.model_alias`` /
|
# — same contract as ``coordinator.model_alias`` /
|
||||||
# ``model.plan_alias`` / ``model.task_alias``. A non-alias value
|
# ``model.task_alias``. A non-alias value
|
||||||
# used to be accepted as a raw model id pinned onto the session
|
# used to be accepted as a raw model id pinned onto the session
|
||||||
# provider, but that path silently broke whenever the session
|
# provider, but that path silently broke whenever the session
|
||||||
# provider didn't speak that model id (e.g. coordinator on
|
# provider didn't speak that model id (e.g. coordinator on
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ def _validate_registry_args(
|
|||||||
default: str,
|
default: str,
|
||||||
fallback: list[str] | None,
|
fallback: list[str] | None,
|
||||||
agent_model: str | None,
|
agent_model: str | None,
|
||||||
plan_model: str | None,
|
|
||||||
task_model: str | None,
|
task_model: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate ModelRegistry construction / reload arguments.
|
"""Validate ModelRegistry construction / reload arguments.
|
||||||
@@ -97,8 +96,6 @@ def _validate_registry_args(
|
|||||||
raise ValueError(f"Fallback model '{alias}' not found in registry")
|
raise ValueError(f"Fallback model '{alias}' not found in registry")
|
||||||
if agent_model and agent_model not in models:
|
if agent_model and agent_model not in models:
|
||||||
raise ValueError(f"Agent model '{agent_model}' not found in registry")
|
raise ValueError(f"Agent model '{agent_model}' not found in registry")
|
||||||
if plan_model and plan_model not in models:
|
|
||||||
raise ValueError(f"Plan model '{plan_model}' not found in registry")
|
|
||||||
if task_model and task_model not in models:
|
if task_model and task_model not in models:
|
||||||
raise ValueError(f"Task model '{task_model}' not found in registry")
|
raise ValueError(f"Task model '{task_model}' not found in registry")
|
||||||
|
|
||||||
@@ -110,14 +107,10 @@ class ModelRegistry:
|
|||||||
models: Mapping of alias → ModelConfig.
|
models: Mapping of alias → ModelConfig.
|
||||||
default: Alias of the default model.
|
default: Alias of the default model.
|
||||||
fallback: Ordered list of aliases to try when the primary model fails.
|
fallback: Ordered list of aliases to try when the primary model fails.
|
||||||
agent_model: Optional alias for plan/task sub-agents (single-knob
|
agent_model: Optional alias for the task_agent sub-agent (single-knob
|
||||||
fallback used when ``plan_model``/``task_model`` are unset).
|
fallback used when ``task_model`` is unset).
|
||||||
plan_model: Optional alias for the plan_agent sub-agent. Overrides
|
|
||||||
``agent_model`` for plan calls; falls back to it when unset.
|
|
||||||
task_model: Optional alias for the task_agent sub-agent. Overrides
|
task_model: Optional alias for the task_agent sub-agent. Overrides
|
||||||
``agent_model`` for task calls; falls back to it when unset.
|
``agent_model`` for task calls; falls back to it when unset.
|
||||||
plan_effort: Reasoning effort for plan_agent. ``None`` means use the
|
|
||||||
built-in default of ``"high"`` (preserves prior behaviour).
|
|
||||||
task_effort: Reasoning effort for task_agent. ``None`` means inherit
|
task_effort: Reasoning effort for task_agent. ``None`` means inherit
|
||||||
the parent session's reasoning effort.
|
the parent session's reasoning effort.
|
||||||
"""
|
"""
|
||||||
@@ -128,20 +121,16 @@ class ModelRegistry:
|
|||||||
default: str,
|
default: str,
|
||||||
fallback: list[str] | None = None,
|
fallback: list[str] | None = None,
|
||||||
agent_model: str | None = None,
|
agent_model: str | None = None,
|
||||||
plan_model: str | None = None,
|
|
||||||
task_model: str | None = None,
|
task_model: str | None = None,
|
||||||
plan_effort: str | None = None,
|
|
||||||
task_effort: str | None = None,
|
task_effort: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
_validate_registry_args(models, default, fallback, agent_model, plan_model, task_model)
|
_validate_registry_args(models, default, fallback, agent_model, task_model)
|
||||||
|
|
||||||
self._models = dict(models)
|
self._models = dict(models)
|
||||||
self.default = default
|
self.default = default
|
||||||
self.fallback = list(fallback) if fallback else []
|
self.fallback = list(fallback) if fallback else []
|
||||||
self.agent_model = agent_model
|
self.agent_model = agent_model
|
||||||
self.plan_model = plan_model
|
|
||||||
self.task_model = task_model
|
self.task_model = task_model
|
||||||
self.plan_effort = plan_effort
|
|
||||||
self.task_effort = task_effort
|
self.task_effort = task_effort
|
||||||
self._clients: dict[str, Any] = {}
|
self._clients: dict[str, Any] = {}
|
||||||
self._providers: dict[str, LLMProvider] = {}
|
self._providers: dict[str, LLMProvider] = {}
|
||||||
@@ -199,33 +188,24 @@ class ModelRegistry:
|
|||||||
def resolve_agent_alias(self, kind: str) -> str | None:
|
def resolve_agent_alias(self, kind: str) -> str | None:
|
||||||
"""Return the configured alias for a sub-agent ``kind``.
|
"""Return the configured alias for a sub-agent ``kind``.
|
||||||
|
|
||||||
Per-kind overrides (``plan_model``/``task_model``) win over the
|
The per-kind override (``task_model``) wins over the legacy
|
||||||
legacy single-knob ``agent_model``. Returns ``None`` when nothing
|
single-knob ``agent_model``. Returns ``None`` when nothing is
|
||||||
is configured (caller should fall back to the session model).
|
configured (caller should fall back to the session model).
|
||||||
|
|
||||||
Recognised kinds: ``"plan"``, ``"task"``. Any other value (e.g.
|
Recognised kind: ``"task"``. Any other value (e.g. ``"agent"``,
|
||||||
``"agent"``, eval/utility paths) returns the legacy ``agent_model``
|
eval/utility paths) returns the legacy ``agent_model`` as-is —
|
||||||
as-is — preserves prior behaviour for non-plan/task callers.
|
preserves prior behaviour for non-task callers.
|
||||||
"""
|
"""
|
||||||
if kind == "plan":
|
|
||||||
return self.plan_model or self.agent_model
|
|
||||||
if kind == "task":
|
if kind == "task":
|
||||||
return self.task_model or self.agent_model
|
return self.task_model or self.agent_model
|
||||||
return self.agent_model
|
return self.agent_model
|
||||||
|
|
||||||
# Built-in default effort for plan_agent — preserves the value the three
|
|
||||||
# plan call sites used to pass explicitly before the split.
|
|
||||||
PLAN_DEFAULT_EFFORT = "high"
|
|
||||||
|
|
||||||
def resolve_agent_effort(self, kind: str) -> str | None:
|
def resolve_agent_effort(self, kind: str) -> str | None:
|
||||||
"""Return the reasoning effort for a sub-agent ``kind``.
|
"""Return the reasoning effort for a sub-agent ``kind``.
|
||||||
|
|
||||||
Plan defaults to :attr:`PLAN_DEFAULT_EFFORT` (back-compat with the
|
Task returns ``None`` to indicate the caller should fall through
|
||||||
previously hardcoded ``"high"``). Task returns ``None`` to indicate
|
to the session default.
|
||||||
the caller should fall through to the session default.
|
|
||||||
"""
|
"""
|
||||||
if kind == "plan":
|
|
||||||
return self.plan_effort or self.PLAN_DEFAULT_EFFORT
|
|
||||||
if kind == "task":
|
if kind == "task":
|
||||||
return self.task_effort
|
return self.task_effort
|
||||||
return None
|
return None
|
||||||
@@ -248,9 +228,7 @@ class ModelRegistry:
|
|||||||
default: str,
|
default: str,
|
||||||
fallback: list[str] | None = None,
|
fallback: list[str] | None = None,
|
||||||
agent_model: str | None = None,
|
agent_model: str | None = None,
|
||||||
plan_model: str | None = None,
|
|
||||||
task_model: str | None = None,
|
task_model: str | None = None,
|
||||||
plan_effort: str | None = None,
|
|
||||||
task_effort: str | None = None,
|
task_effort: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Hot-reload all model configs. Thread-safe; clears cached clients.
|
"""Hot-reload all model configs. Thread-safe; clears cached clients.
|
||||||
@@ -258,16 +236,14 @@ class ModelRegistry:
|
|||||||
Validates arguments before mutating state so a bad reload
|
Validates arguments before mutating state so a bad reload
|
||||||
does not leave the registry in an inconsistent state.
|
does not leave the registry in an inconsistent state.
|
||||||
"""
|
"""
|
||||||
_validate_registry_args(models, default, fallback, agent_model, plan_model, task_model)
|
_validate_registry_args(models, default, fallback, agent_model, task_model)
|
||||||
with self._client_lock:
|
with self._client_lock:
|
||||||
old_models = self._models
|
old_models = self._models
|
||||||
self._models = dict(models)
|
self._models = dict(models)
|
||||||
self.default = default
|
self.default = default
|
||||||
self.fallback = list(fallback) if fallback else []
|
self.fallback = list(fallback) if fallback else []
|
||||||
self.agent_model = agent_model
|
self.agent_model = agent_model
|
||||||
self.plan_model = plan_model
|
|
||||||
self.task_model = task_model
|
self.task_model = task_model
|
||||||
self.plan_effort = plan_effort
|
|
||||||
self.task_effort = task_effort
|
self.task_effort = task_effort
|
||||||
# Selective teardown — close + drop only clients whose
|
# Selective teardown — close + drop only clients whose
|
||||||
# connection target actually changed (alias removed, or
|
# connection target actually changed (alias removed, or
|
||||||
@@ -372,10 +348,9 @@ def load_model_registry(
|
|||||||
3. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
|
3. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
|
||||||
``"default"`` entry.
|
``"default"`` entry.
|
||||||
4. ``[model].default``, ``[model].fallback``, ``[model].agent_model``,
|
4. ``[model].default``, ``[model].fallback``, ``[model].agent_model``,
|
||||||
``[model].plan_model``, ``[model].task_model``,
|
``[model].task_model``, ``[model].task_effort`` control routing.
|
||||||
``[model].plan_effort``, ``[model].task_effort`` control routing.
|
``task_model`` overrides ``agent_model`` for the task sub-agent;
|
||||||
``plan_model``/``task_model`` override ``agent_model`` per sub-agent
|
it falls back to ``agent_model`` when unset.
|
||||||
role; both fall back to it when unset.
|
|
||||||
|
|
||||||
``strict``: when True, a storage read failure during the DB-rows step
|
``strict``: when True, a storage read failure during the DB-rows step
|
||||||
re-raises instead of degrading to a config.toml-only registry.
|
re-raises instead of degrading to a config.toml-only registry.
|
||||||
@@ -519,9 +494,9 @@ def load_model_registry(
|
|||||||
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
|
# 3. Back-compat shim: synthesize a "default" alias from CLI/auto-detected
|
||||||
# ``--base-url`` + ``--model`` only when no DB or config.toml models exist.
|
# ``--base-url`` + ``--model`` only when no DB or config.toml models exist.
|
||||||
# Auto-creating "default" alongside DB models leaks a non-routing alias
|
# Auto-creating "default" alongside DB models leaks a non-routing alias
|
||||||
# into the public list — the LLM picks it in plan_agent / task_agent
|
# into the public list — the LLM picks it in task_agent
|
||||||
# ``model=`` and silently bypasses the operator's per-role
|
# ``model=`` and silently bypasses the operator's per-role
|
||||||
# plan_alias / task_alias overrides (the "default" alias points at
|
# task_alias override (the "default" alias points at
|
||||||
# whatever LLM_BASE_URL was at boot, not at the configured default).
|
# whatever LLM_BASE_URL was at boot, not at the configured default).
|
||||||
if not configs and model:
|
if not configs and model:
|
||||||
configs["default"] = ModelConfig(
|
configs["default"] = ModelConfig(
|
||||||
@@ -573,24 +548,19 @@ def load_model_registry(
|
|||||||
else:
|
else:
|
||||||
log.warning("Fallback alias '%s' not found in models, ignoring", alias)
|
log.warning("Fallback alias '%s' not found in models, ignoring", alias)
|
||||||
|
|
||||||
# Agent model (legacy single-knob shared between plan_agent and task_agent)
|
# Agent model (legacy single-knob fallback for the task_agent sub-agent)
|
||||||
agent_model = model_section.get("agent_model")
|
agent_model = model_section.get("agent_model")
|
||||||
if agent_model and agent_model not in configs:
|
if agent_model and agent_model not in configs:
|
||||||
log.warning("Configured agent_model '%s' not found, ignoring", agent_model)
|
log.warning("Configured agent_model '%s' not found, ignoring", agent_model)
|
||||||
agent_model = None
|
agent_model = None
|
||||||
|
|
||||||
# Per-kind sub-agent models — override agent_model for each role
|
# Per-kind sub-agent model — overrides agent_model for the task role
|
||||||
plan_model = model_section.get("plan_model")
|
|
||||||
if plan_model and plan_model not in configs:
|
|
||||||
log.warning("Configured plan_model '%s' not found, ignoring", plan_model)
|
|
||||||
plan_model = None
|
|
||||||
task_model = model_section.get("task_model")
|
task_model = model_section.get("task_model")
|
||||||
if task_model and task_model not in configs:
|
if task_model and task_model not in configs:
|
||||||
log.warning("Configured task_model '%s' not found, ignoring", task_model)
|
log.warning("Configured task_model '%s' not found, ignoring", task_model)
|
||||||
task_model = None
|
task_model = None
|
||||||
|
|
||||||
# Per-kind reasoning effort. None means: plan defaults to "high" (back-
|
# Per-kind reasoning effort. None means task inherits the session.
|
||||||
# compat with the previous hardcoded value); task inherits the session.
|
|
||||||
# Typos in config.toml shouldn't silently flow to the provider — log and
|
# Typos in config.toml shouldn't silently flow to the provider — log and
|
||||||
# drop unknown values, mirroring the model-not-found warning above.
|
# drop unknown values, mirroring the model-not-found warning above.
|
||||||
valid_efforts = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
valid_efforts = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
||||||
@@ -599,7 +569,7 @@ def load_model_registry(
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
# Treat empty / whitespace as unset. Operators commonly write
|
# Treat empty / whitespace as unset. Operators commonly write
|
||||||
# `plan_effort = ""` to make "leave it default" explicit; warning
|
# `task_effort = ""` to make "leave it default" explicit; warning
|
||||||
# on that benign case would just be noise.
|
# on that benign case would just be noise.
|
||||||
coerced = str(value).strip().lower()
|
coerced = str(value).strip().lower()
|
||||||
if not coerced:
|
if not coerced:
|
||||||
@@ -615,7 +585,6 @@ def load_model_registry(
|
|||||||
return None
|
return None
|
||||||
return coerced
|
return coerced
|
||||||
|
|
||||||
plan_effort = _validate_effort(model_section.get("plan_effort"), "plan_effort")
|
|
||||||
task_effort = _validate_effort(model_section.get("task_effort"), "task_effort")
|
task_effort = _validate_effort(model_section.get("task_effort"), "task_effort")
|
||||||
|
|
||||||
return ModelRegistry(
|
return ModelRegistry(
|
||||||
@@ -623,9 +592,7 @@ def load_model_registry(
|
|||||||
default=default_alias,
|
default=default_alias,
|
||||||
fallback=fallback,
|
fallback=fallback,
|
||||||
agent_model=agent_model,
|
agent_model=agent_model,
|
||||||
plan_model=plan_model,
|
|
||||||
task_model=task_model,
|
task_model=task_model,
|
||||||
plan_effort=plan_effort,
|
|
||||||
task_effort=task_effort,
|
task_effort=task_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
"""Sandboxed Python executor for the math tool."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import multiprocessing
|
|
||||||
import re
|
|
||||||
import traceback
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_MATH_BLOCKED_BUILTINS = {
|
|
||||||
"open",
|
|
||||||
"exec",
|
|
||||||
"eval",
|
|
||||||
"compile",
|
|
||||||
"input",
|
|
||||||
"breakpoint",
|
|
||||||
"memoryview",
|
|
||||||
"globals",
|
|
||||||
"locals",
|
|
||||||
"vars",
|
|
||||||
# Reflection primitives — bypass AST dunder checks via runtime strings
|
|
||||||
"getattr",
|
|
||||||
"setattr",
|
|
||||||
"delattr",
|
|
||||||
# Type system — can reconstruct arbitrary classes
|
|
||||||
"type",
|
|
||||||
# Import — the replaced _safe_import is in the namespace, but block the
|
|
||||||
# name so direct __import__ calls are caught by the AST validator
|
|
||||||
"__import__",
|
|
||||||
}
|
|
||||||
|
|
||||||
_MATH_BLOCKED_MODULES = {
|
|
||||||
"os",
|
|
||||||
"sys",
|
|
||||||
"subprocess",
|
|
||||||
"shutil",
|
|
||||||
"pathlib",
|
|
||||||
"socket",
|
|
||||||
"http",
|
|
||||||
"urllib",
|
|
||||||
"requests",
|
|
||||||
"pickle",
|
|
||||||
"marshal",
|
|
||||||
"shelve",
|
|
||||||
"dbm",
|
|
||||||
"sqlite3",
|
|
||||||
"ctypes",
|
|
||||||
"multiprocessing",
|
|
||||||
"threading",
|
|
||||||
"asyncio",
|
|
||||||
"concurrent",
|
|
||||||
"signal",
|
|
||||||
"pty",
|
|
||||||
"tty",
|
|
||||||
"termios",
|
|
||||||
"fcntl",
|
|
||||||
"resource",
|
|
||||||
"syslog",
|
|
||||||
"tempfile",
|
|
||||||
"io",
|
|
||||||
"builtins",
|
|
||||||
"__builtin__",
|
|
||||||
"importlib",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _ASTValidator(ast.NodeVisitor):
|
|
||||||
"""Validates AST for dangerous constructs."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.errors: list[str] = []
|
|
||||||
|
|
||||||
def visit_Import(self, node: ast.Import) -> None:
|
|
||||||
for alias in node.names:
|
|
||||||
if alias.name.split(".")[0] in _MATH_BLOCKED_MODULES:
|
|
||||||
self.errors.append(f"Import of '{alias.name}' is not allowed")
|
|
||||||
self.generic_visit(node)
|
|
||||||
|
|
||||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
||||||
if node.module and node.module.split(".")[0] in _MATH_BLOCKED_MODULES:
|
|
||||||
self.errors.append(f"Import from '{node.module}' is not allowed")
|
|
||||||
self.generic_visit(node)
|
|
||||||
|
|
||||||
def visit_Call(self, node: ast.Call) -> None:
|
|
||||||
if isinstance(node.func, ast.Name) and node.func.id in _MATH_BLOCKED_BUILTINS:
|
|
||||||
self.errors.append(f"Call to '{node.func.id}' is not allowed")
|
|
||||||
self.generic_visit(node)
|
|
||||||
|
|
||||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
||||||
if (
|
|
||||||
node.attr.startswith("__")
|
|
||||||
and node.attr.endswith("__")
|
|
||||||
and node.attr not in {"__name__", "__doc__", "__class__"}
|
|
||||||
):
|
|
||||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
|
||||||
# Block operator.attrgetter/itemgetter which act as getattr bypasses
|
|
||||||
if node.attr in ("attrgetter", "itemgetter"):
|
|
||||||
self.errors.append(f"Access to '{node.attr}' is not allowed")
|
|
||||||
self.generic_visit(node)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_math_code(code: str) -> list[str]:
|
|
||||||
"""Validate code for dangerous constructs. Returns list of errors."""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(code)
|
|
||||||
except SyntaxError as e:
|
|
||||||
lines = code.split("\n")
|
|
||||||
msg = f"Syntax error on line {e.lineno}: {e.msg}"
|
|
||||||
if e.lineno and e.lineno <= len(lines):
|
|
||||||
msg += f"\n {e.lineno}: {lines[e.lineno - 1]}"
|
|
||||||
if e.offset:
|
|
||||||
msg += f"\n {' ' * (e.offset - 1)}^"
|
|
||||||
return [msg]
|
|
||||||
except (ValueError, UnicodeError) as e:
|
|
||||||
return [f"Code contains invalid characters: {e}"]
|
|
||||||
v = _ASTValidator()
|
|
||||||
v.visit(tree)
|
|
||||||
return v.errors
|
|
||||||
|
|
||||||
|
|
||||||
def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[str, str]]) -> None:
|
|
||||||
"""Execute code in a subprocess, put (status, output) in queue."""
|
|
||||||
import contextlib
|
|
||||||
import signal as _signal
|
|
||||||
import sys as _sys
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
_signal.signal(_signal.SIGTERM, _signal.SIG_DFL)
|
|
||||||
_signal.signal(_signal.SIGINT, _signal.SIG_DFL)
|
|
||||||
_sys.set_int_max_str_digits(100_000)
|
|
||||||
|
|
||||||
try:
|
|
||||||
captured = StringIO()
|
|
||||||
_sys.stdout = captured
|
|
||||||
|
|
||||||
def _safe_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
|
||||||
if name.split(".")[0] in _MATH_BLOCKED_MODULES:
|
|
||||||
raise ImportError(f"Import of '{name}' is blocked")
|
|
||||||
mod = original_import(name, *args, **kwargs)
|
|
||||||
# Strip __builtins__ from every imported module so
|
|
||||||
# module.__builtins__['__import__'] can't bypass _safe_import
|
|
||||||
# (covers operator.attrgetter('__builtins__') and similar).
|
|
||||||
if hasattr(mod, "__builtins__"):
|
|
||||||
with contextlib.suppress(AttributeError, TypeError):
|
|
||||||
mod.__builtins__ = {} # type: ignore[attr-defined]
|
|
||||||
return mod
|
|
||||||
|
|
||||||
original_import = (
|
|
||||||
__builtins__["__import__"]
|
|
||||||
if isinstance(__builtins__, dict)
|
|
||||||
else __builtins__.__import__
|
|
||||||
)
|
|
||||||
safe_builtins = (
|
|
||||||
{k: v for k, v in __builtins__.items() if k not in _MATH_BLOCKED_BUILTINS}
|
|
||||||
if isinstance(__builtins__, dict)
|
|
||||||
else {
|
|
||||||
k: getattr(__builtins__, k)
|
|
||||||
for k in dir(__builtins__)
|
|
||||||
if k not in _MATH_BLOCKED_BUILTINS and not k.startswith("_")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
safe_builtins["__import__"] = _safe_import
|
|
||||||
|
|
||||||
# Pre-import safe modules
|
|
||||||
import collections
|
|
||||||
import decimal
|
|
||||||
import fractions
|
|
||||||
import functools
|
|
||||||
import itertools
|
|
||||||
import math
|
|
||||||
import operator
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import string
|
|
||||||
|
|
||||||
ns: dict[str, Any] = {
|
|
||||||
"__builtins__": safe_builtins,
|
|
||||||
"math": math,
|
|
||||||
"fractions": fractions,
|
|
||||||
"Fraction": fractions.Fraction,
|
|
||||||
"itertools": itertools,
|
|
||||||
"functools": functools,
|
|
||||||
"operator": operator,
|
|
||||||
"collections": collections,
|
|
||||||
"decimal": decimal,
|
|
||||||
"Decimal": decimal.Decimal,
|
|
||||||
"random": random,
|
|
||||||
"re": re,
|
|
||||||
"string": string,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
import sympy
|
|
||||||
|
|
||||||
ns["sympy"] = sympy
|
|
||||||
for name in (
|
|
||||||
"symbols",
|
|
||||||
"Symbol",
|
|
||||||
"solve",
|
|
||||||
"simplify",
|
|
||||||
"expand",
|
|
||||||
"factor",
|
|
||||||
"Eq",
|
|
||||||
"sqrt",
|
|
||||||
"Rational",
|
|
||||||
"pi",
|
|
||||||
"E",
|
|
||||||
"I",
|
|
||||||
"oo",
|
|
||||||
"sin",
|
|
||||||
"cos",
|
|
||||||
"tan",
|
|
||||||
"exp",
|
|
||||||
"log",
|
|
||||||
"factorial",
|
|
||||||
"binomial",
|
|
||||||
"gcd",
|
|
||||||
"lcm",
|
|
||||||
"prime",
|
|
||||||
"isprime",
|
|
||||||
"factorint",
|
|
||||||
"divisors",
|
|
||||||
"totient",
|
|
||||||
"mod_inverse",
|
|
||||||
"Matrix",
|
|
||||||
"integrate",
|
|
||||||
"diff",
|
|
||||||
"limit",
|
|
||||||
"series",
|
|
||||||
"Sum",
|
|
||||||
"Product",
|
|
||||||
"floor",
|
|
||||||
"ceiling",
|
|
||||||
"Abs",
|
|
||||||
):
|
|
||||||
ns[name] = getattr(sympy, name)
|
|
||||||
except ImportError:
|
|
||||||
pass # optional dependency
|
|
||||||
|
|
||||||
try:
|
|
||||||
import numpy as _np
|
|
||||||
|
|
||||||
ns["np"] = ns["numpy"] = _np
|
|
||||||
except ImportError:
|
|
||||||
pass # optional dependency
|
|
||||||
|
|
||||||
try:
|
|
||||||
import scipy # type: ignore[import-untyped]
|
|
||||||
import scipy.integrate # type: ignore[import-untyped]
|
|
||||||
import scipy.linalg # type: ignore[import-untyped]
|
|
||||||
import scipy.optimize # type: ignore[import-untyped]
|
|
||||||
import scipy.special # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
ns["scipy"] = scipy
|
|
||||||
ns["special"] = scipy.special
|
|
||||||
ns["optimize"] = scipy.optimize
|
|
||||||
ns["comb"] = scipy.special.comb
|
|
||||||
ns["perm"] = scipy.special.perm
|
|
||||||
ns["gamma"] = scipy.special.gamma
|
|
||||||
ns["beta"] = scipy.special.beta
|
|
||||||
except ImportError:
|
|
||||||
pass # optional dependency
|
|
||||||
|
|
||||||
# Strip __builtins__ from all pre-imported modules so
|
|
||||||
# module.__builtins__['__import__'] can't bypass _safe_import.
|
|
||||||
for v in list(ns.values()):
|
|
||||||
if hasattr(v, "__builtins__"):
|
|
||||||
with contextlib.suppress(AttributeError, TypeError):
|
|
||||||
v.__builtins__ = {}
|
|
||||||
|
|
||||||
exec(code, ns) # noqa: S102
|
|
||||||
|
|
||||||
_sys.stdout = _sys.__stdout__
|
|
||||||
printed = captured.getvalue()
|
|
||||||
result_var = ns.get("result")
|
|
||||||
if result_var is not None:
|
|
||||||
out = f"{printed.rstrip()}\nresult = {result_var}" if printed else str(result_var)
|
|
||||||
elif printed:
|
|
||||||
out = printed.rstrip()
|
|
||||||
else:
|
|
||||||
out = "No output. Add print() to see results."
|
|
||||||
result_queue.put(("success", out))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_sys.stdout = _sys.__stdout__
|
|
||||||
result_queue.put(("error", f"{type(e).__name__}: {e}\n{traceback.format_exc()}"))
|
|
||||||
|
|
||||||
|
|
||||||
def auto_print_wrap(code: str) -> str:
|
|
||||||
"""If code has no print/result and the last statement is an expression, wrap it in print()."""
|
|
||||||
# Skip if code already has print() or assigns to 'result'
|
|
||||||
if "print(" in code or re.search(r"\bresult\s*=", code):
|
|
||||||
return code
|
|
||||||
try:
|
|
||||||
tree = ast.parse(code)
|
|
||||||
except SyntaxError:
|
|
||||||
return code
|
|
||||||
if not tree.body:
|
|
||||||
return code
|
|
||||||
last = tree.body[-1]
|
|
||||||
if isinstance(last, ast.Expr):
|
|
||||||
# Get the source of the last expression and wrap in print()
|
|
||||||
lines = code.split("\n")
|
|
||||||
last_line_start = last.lineno - 1 # 0-based
|
|
||||||
last_line_end = last.end_lineno # 1-based, exclusive after slicing
|
|
||||||
expr_lines = lines[last_line_start:last_line_end]
|
|
||||||
expr_text = "\n".join(expr_lines)
|
|
||||||
prefix = lines[:last_line_start]
|
|
||||||
wrapped = prefix + [f"print({expr_text})"]
|
|
||||||
return "\n".join(wrapped)
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def execute_math_sandboxed(code: str, timeout: float = 30.0) -> tuple[str, bool]:
|
|
||||||
"""Execute Python code in a sandboxed subprocess. Returns (output, is_error)."""
|
|
||||||
code = auto_print_wrap(code)
|
|
||||||
errors = validate_math_code(code)
|
|
||||||
if errors:
|
|
||||||
return "Validation errors:\n" + "\n".join(f"- {e}" for e in errors), True
|
|
||||||
|
|
||||||
result_queue: multiprocessing.Queue[tuple[str, str]] = multiprocessing.Queue()
|
|
||||||
proc = multiprocessing.Process(target=_math_exec_in_process, args=(code, result_queue))
|
|
||||||
proc.start()
|
|
||||||
proc.join(timeout=timeout)
|
|
||||||
|
|
||||||
if proc.is_alive():
|
|
||||||
proc.terminate()
|
|
||||||
proc.join(timeout=1.0)
|
|
||||||
if proc.is_alive():
|
|
||||||
proc.kill()
|
|
||||||
proc.join()
|
|
||||||
result_queue.close()
|
|
||||||
result_queue.join_thread()
|
|
||||||
return f"Execution timed out after {timeout}s", True
|
|
||||||
|
|
||||||
if result_queue.empty():
|
|
||||||
result_queue.close()
|
|
||||||
result_queue.join_thread()
|
|
||||||
return "Execution failed with no output", True
|
|
||||||
|
|
||||||
status, output = result_queue.get()
|
|
||||||
result_queue.close()
|
|
||||||
result_queue.join_thread()
|
|
||||||
return output, status == "error"
|
|
||||||
+35
-514
@@ -98,7 +98,6 @@ from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, NudgeQueue
|
|||||||
from turnstone.core.providers import create_provider
|
from turnstone.core.providers import create_provider
|
||||||
from turnstone.core.ratelimit import TokenBucket
|
from turnstone.core.ratelimit import TokenBucket
|
||||||
from turnstone.core.safety import is_command_blocked, sanitize_command
|
from turnstone.core.safety import is_command_blocked, sanitize_command
|
||||||
from turnstone.core.sandbox import execute_math_sandboxed
|
|
||||||
from turnstone.core.skill_field_validation import SKILL_RUNTIME_CONFIG_FIELDS
|
from turnstone.core.skill_field_validation import SKILL_RUNTIME_CONFIG_FIELDS
|
||||||
from turnstone.core.skill_parser import MAX_SKILL_DESCRIPTION_LEN
|
from turnstone.core.skill_parser import MAX_SKILL_DESCRIPTION_LEN
|
||||||
from turnstone.core.storage._registry import get_storage
|
from turnstone.core.storage._registry import get_storage
|
||||||
@@ -106,8 +105,6 @@ from turnstone.core.storage._utils import normalize_search_terms
|
|||||||
from turnstone.core.tool_advisory import escape_wrapper_tags, render_system_reminder
|
from turnstone.core.tool_advisory import escape_wrapper_tags, render_system_reminder
|
||||||
from turnstone.core.tool_search import ToolSearchManager
|
from turnstone.core.tool_search import ToolSearchManager
|
||||||
from turnstone.core.tools import (
|
from turnstone.core.tools import (
|
||||||
AGENT_AUTO_TOOLS,
|
|
||||||
AGENT_TOOLS,
|
|
||||||
BUILTIN_TOOL_NAMES,
|
BUILTIN_TOOL_NAMES,
|
||||||
COORDINATOR_TOOLS,
|
COORDINATOR_TOOLS,
|
||||||
INTERACTIVE_TOOLS,
|
INTERACTIVE_TOOLS,
|
||||||
@@ -783,7 +780,6 @@ class SessionUI(Protocol):
|
|||||||
) -> None: ...
|
) -> None: ...
|
||||||
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
|
||||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
|
||||||
def on_plan_review(self, content: str) -> str: ...
|
|
||||||
def on_info(self, message: str) -> None: ...
|
def on_info(self, message: str) -> None: ...
|
||||||
def on_error(self, message: str) -> None: ...
|
def on_error(self, message: str) -> None: ...
|
||||||
def on_user_reminder(
|
def on_user_reminder(
|
||||||
@@ -1132,12 +1128,10 @@ class ChatSession:
|
|||||||
if kind == WorkstreamKind.COORDINATOR:
|
if kind == WorkstreamKind.COORDINATOR:
|
||||||
self._tools = list(COORDINATOR_TOOLS)
|
self._tools = list(COORDINATOR_TOOLS)
|
||||||
self._task_tools = []
|
self._task_tools = []
|
||||||
self._agent_tools = []
|
|
||||||
elif mcp_client:
|
elif mcp_client:
|
||||||
mcp_tools = mcp_client.get_tools(user_id=self._mcp_user_id)
|
mcp_tools = mcp_client.get_tools(user_id=self._mcp_user_id)
|
||||||
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
|
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
|
||||||
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
||||||
self._agent_tools = merge_mcp_tools(AGENT_TOOLS, mcp_tools)
|
|
||||||
# Register for tool-change notifications from MCP servers.
|
# Register for tool-change notifications from MCP servers.
|
||||||
# ``user_id`` is the listener identity component — pool-only
|
# ``user_id`` is the listener identity component — pool-only
|
||||||
# changes for OTHER users must not fire this callback.
|
# changes for OTHER users must not fire this callback.
|
||||||
@@ -1156,9 +1150,8 @@ class ChatSession:
|
|||||||
else:
|
else:
|
||||||
self._tools = INTERACTIVE_TOOLS
|
self._tools = INTERACTIVE_TOOLS
|
||||||
self._task_tools = TASK_AGENT_TOOLS
|
self._task_tools = TASK_AGENT_TOOLS
|
||||||
self._agent_tools = AGENT_TOOLS
|
# Inject the live alias list into the task_agent tool
|
||||||
# Inject the live alias list into plan_agent / task_agent tool
|
# description so the calling LLM sees its `model` parameter options.
|
||||||
# descriptions so the calling LLM sees its `model` parameter options.
|
|
||||||
# Replaces affected tool dicts with deep copies — module-level
|
# Replaces affected tool dicts with deep copies — module-level
|
||||||
# constants are not mutated.
|
# constants are not mutated.
|
||||||
self._render_agent_tool_descriptions()
|
self._render_agent_tool_descriptions()
|
||||||
@@ -1844,13 +1837,12 @@ class ChatSession:
|
|||||||
mcp_tools = self._mcp_client.get_tools(user_id=self._mcp_user_id)
|
mcp_tools = self._mcp_client.get_tools(user_id=self._mcp_user_id)
|
||||||
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
|
self._tools = merge_mcp_tools(INTERACTIVE_TOOLS, mcp_tools)
|
||||||
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
self._task_tools = merge_mcp_tools(TASK_AGENT_TOOLS, mcp_tools)
|
||||||
self._agent_tools = merge_mcp_tools(AGENT_TOOLS, mcp_tools)
|
|
||||||
self._render_agent_tool_descriptions()
|
self._render_agent_tool_descriptions()
|
||||||
self._rebuild_tool_search()
|
self._rebuild_tool_search()
|
||||||
|
|
||||||
def _render_agent_tool_descriptions(self) -> None:
|
def _render_agent_tool_descriptions(self) -> None:
|
||||||
"""Inject the live alias list into the ``model`` parameter description
|
"""Inject the live alias list into the ``model`` parameter description
|
||||||
on plan_agent / task_agent tools.
|
on the task_agent tool.
|
||||||
|
|
||||||
Lets the calling LLM see which aliases are valid right now.
|
Lets the calling LLM see which aliases are valid right now.
|
||||||
Called on session init and on registry reload (via
|
Called on session init and on registry reload (via
|
||||||
@@ -1860,19 +1852,18 @@ class ChatSession:
|
|||||||
Replaces affected tool dicts with deep copies so the module-level
|
Replaces affected tool dicts with deep copies so the module-level
|
||||||
tool-list constants stay untouched across sessions.
|
tool-list constants stay untouched across sessions.
|
||||||
|
|
||||||
plan_agent and task_agent live in ``self._tools`` (the main session's
|
task_agent lives in ``self._tools`` (the main session's tool set) —
|
||||||
tool set) — not in ``self._agent_tools`` / ``self._task_tools``,
|
not in ``self._task_tools``, which is what *sub-agents* see
|
||||||
which are what *sub-agents* see (sub-agents don't get delegation
|
(sub-agents don't get delegation tools to avoid infinite recursion).
|
||||||
tools to avoid infinite recursion).
|
|
||||||
"""
|
"""
|
||||||
if self._registry is None:
|
if self._registry is None:
|
||||||
return
|
return
|
||||||
# Hide ``default`` from the alias list — the LLM reads the English
|
# Hide ``default`` from the alias list — the LLM reads the English
|
||||||
# word and picks it explicitly, which routes to whichever model
|
# word and picks it explicitly, which routes to whichever model
|
||||||
# carries that alias rather than the operator-configured per-role
|
# carries that alias rather than the operator-configured per-role
|
||||||
# default (plan_alias / task_alias). Omitting ``model=`` already
|
# default (task_alias). Omitting ``model=`` already selects the
|
||||||
# selects the per-role default; offering the literal name as an
|
# per-role default; offering the literal name as an alternative
|
||||||
# alternative invites the bypass.
|
# invites the bypass.
|
||||||
aliases = sorted(a for a in self._registry.list_aliases() if a != "default")
|
aliases = sorted(a for a in self._registry.list_aliases() if a != "default")
|
||||||
aliases_str = ", ".join(f"`{a}`" for a in aliases)
|
aliases_str = ", ".join(f"`{a}`" for a in aliases)
|
||||||
|
|
||||||
@@ -1880,10 +1871,9 @@ class ChatSession:
|
|||||||
for tool in self._tools:
|
for tool in self._tools:
|
||||||
fn = tool.get("function") or {}
|
fn = tool.get("function") or {}
|
||||||
name = fn.get("name", "")
|
name = fn.get("name", "")
|
||||||
if name not in ("plan_agent", "task_agent"):
|
if name != "task_agent":
|
||||||
new_tools.append(tool)
|
new_tools.append(tool)
|
||||||
continue
|
continue
|
||||||
kind = "plan model" if name == "plan_agent" else "task model"
|
|
||||||
new_tool = copy.deepcopy(tool)
|
new_tool = copy.deepcopy(tool)
|
||||||
props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
|
props = new_tool.get("function", {}).get("parameters", {}).get("properties", {})
|
||||||
if "model" in props:
|
if "model" in props:
|
||||||
@@ -1893,13 +1883,13 @@ class ChatSession:
|
|||||||
# render, not return early and leave them in place.
|
# render, not return early and leave them in place.
|
||||||
if aliases:
|
if aliases:
|
||||||
props["model"]["description"] = (
|
props["model"]["description"] = (
|
||||||
f"Optional model alias to run this {name} on. "
|
"Optional model alias to run this task_agent on. "
|
||||||
f"Omit to use the operator-configured {kind}. "
|
"Omit to use the operator-configured task model. "
|
||||||
f"Available aliases: {aliases_str}."
|
f"Available aliases: {aliases_str}."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
props["model"]["description"] = (
|
props["model"]["description"] = (
|
||||||
f"Optional model alias to run this {name} on. "
|
"Optional model alias to run this task_agent on. "
|
||||||
"Omit to use the current session model. "
|
"Omit to use the current session model. "
|
||||||
"(No alternative aliases configured in this session.)"
|
"(No alternative aliases configured in this session.)"
|
||||||
)
|
)
|
||||||
@@ -1907,8 +1897,8 @@ class ChatSession:
|
|||||||
self._tools = new_tools
|
self._tools = new_tools
|
||||||
|
|
||||||
def refresh_agent_tool_schemas(self) -> None:
|
def refresh_agent_tool_schemas(self) -> None:
|
||||||
"""Public entry point: re-render plan_agent / task_agent tool
|
"""Public entry point: re-render the task_agent tool
|
||||||
descriptions to reflect the current ModelRegistry state, and
|
description to reflect the current ModelRegistry state, and
|
||||||
rebuild the BM25 tool-search index so its text matches.
|
rebuild the BM25 tool-search index so its text matches.
|
||||||
|
|
||||||
Called by the server after a registry reload (sync-to-nodes /
|
Called by the server after a registry reload (sync-to-nodes /
|
||||||
@@ -3156,7 +3146,7 @@ class ChatSession:
|
|||||||
"""Persist token usage for a non-streaming auxiliary completion.
|
"""Persist token usage for a non-streaming auxiliary completion.
|
||||||
|
|
||||||
Title generation, compaction, web-fetch summarisation, and
|
Title generation, compaction, web-fetch summarisation, and
|
||||||
plan/task sub-agents all run via ``create_completion`` and bypass
|
task sub-agents all run via ``create_completion`` and bypass
|
||||||
the streaming ``on_status`` accounting path; without this their
|
the streaming ``on_status`` accounting path; without this their
|
||||||
spend never reaches the usage dashboard. Delegates to the UI's
|
spend never reaches the usage dashboard. Delegates to the UI's
|
||||||
``on_aux_usage`` hook (which owns the storage write + any node
|
``on_aux_usage`` hook (which owns the storage write + any node
|
||||||
@@ -5147,8 +5137,6 @@ class ChatSession:
|
|||||||
"prompt": (it.get("prompt") or "")[:200],
|
"prompt": (it.get("prompt") or "")[:200],
|
||||||
"skill": skill_dict.get("name", "") if isinstance(skill_dict, dict) else "",
|
"skill": skill_dict.get("name", "") if isinstance(skill_dict, dict) else "",
|
||||||
}
|
}
|
||||||
elif name == "plan_agent":
|
|
||||||
it["func_args"] = {"goal": (it.get("prompt") or "")[:200]}
|
|
||||||
# Coordinator tool args — only the ``needs_approval=True`` set
|
# Coordinator tool args — only the ``needs_approval=True`` set
|
||||||
# reaches this point (read-only inspect / list_* / wait
|
# reaches this point (read-only inspect / list_* / wait
|
||||||
# tools are filtered above), so this matches the auditable
|
# tools are filtered above), so this matches the auditable
|
||||||
@@ -5993,78 +5981,6 @@ class ChatSession:
|
|||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
results = list(pool.map(run_one, items))
|
results = list(pool.map(run_one, items))
|
||||||
|
|
||||||
# Post-plan gate: iterative review loop. When the user gives
|
|
||||||
# feedback the plan agent re-runs and the revised plan is shown
|
|
||||||
# again, up to _MAX_PLAN_REFINEMENTS rounds.
|
|
||||||
for i, item in enumerate(items):
|
|
||||||
if item.get("func_name") != "plan_agent" or item.get("error") or item.get("denied"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cid, output = results[i]
|
|
||||||
if not isinstance(output, str):
|
|
||||||
raise TypeError(f"plan_agent must return str, got {type(output).__name__}")
|
|
||||||
plan_path = f".plan-{self._ws_id}.md"
|
|
||||||
|
|
||||||
if not self.auto_approve:
|
|
||||||
original_goal = item.get("prompt", "")
|
|
||||||
|
|
||||||
refinement_round = 0
|
|
||||||
while True:
|
|
||||||
self._emit_state("attention")
|
|
||||||
resp = self.ui.on_plan_review(output)
|
|
||||||
self._emit_state("running")
|
|
||||||
|
|
||||||
if resp.lower() in ("n", "no", "reject"):
|
|
||||||
output += (
|
|
||||||
"\n\n---\nUser REJECTED this plan. Do not "
|
|
||||||
"proceed with implementation. Ask the user "
|
|
||||||
"what they want instead."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
elif not resp:
|
|
||||||
break # empty response = approve
|
|
||||||
elif refinement_round >= self._MAX_PLAN_REFINEMENTS:
|
|
||||||
self.ui.on_info("[plan] max refinement rounds reached")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Re-run plan agent with user feedback.
|
|
||||||
# Strip any internal warning prefix so the
|
|
||||||
# agent sees the raw plan content.
|
|
||||||
raw = output
|
|
||||||
_warn = "[Warning: plan may be incomplete or poorly structured]\n\n"
|
|
||||||
if raw.startswith(_warn):
|
|
||||||
raw = raw[len(_warn) :]
|
|
||||||
try:
|
|
||||||
output = self._refine_plan(
|
|
||||||
raw,
|
|
||||||
original_goal,
|
|
||||||
resp,
|
|
||||||
)
|
|
||||||
refinement_round += 1
|
|
||||||
except (KeyboardInterrupt, GenerationCancelled):
|
|
||||||
output += "\n\n---\n(plan refinement interrupted)"
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
self.ui.on_info(f"[plan refinement error] {e}")
|
|
||||||
output += f"\n\n---\nUser feedback: {resp}"
|
|
||||||
break
|
|
||||||
# Loop continues → show revised plan to user
|
|
||||||
|
|
||||||
# Write final version to disk (overwrites initial write)
|
|
||||||
try:
|
|
||||||
with open(plan_path, "w") as f:
|
|
||||||
f.write(output)
|
|
||||||
except OSError:
|
|
||||||
log.warning("Failed to write plan to %s", plan_path, exc_info=True)
|
|
||||||
output += "\n\n---\nPlan could not be saved to disk."
|
|
||||||
results[i] = (cid, output)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Always include file path in the tool result so the
|
|
||||||
# outer model knows where the plan lives on disk.
|
|
||||||
output += f"\n\n---\nPlan saved to `{plan_path}`"
|
|
||||||
results[i] = (cid, output)
|
|
||||||
|
|
||||||
return results, user_feedback
|
return results, user_feedback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -6258,13 +6174,10 @@ class ChatSession:
|
|||||||
"diff_file": self._prepare_diff,
|
"diff_file": self._prepare_diff,
|
||||||
"write_file": self._prepare_write_file,
|
"write_file": self._prepare_write_file,
|
||||||
"edit_file": self._prepare_edit_file,
|
"edit_file": self._prepare_edit_file,
|
||||||
"math": self._prepare_math,
|
|
||||||
"man": self._prepare_man,
|
|
||||||
"web_fetch": self._prepare_web_fetch,
|
"web_fetch": self._prepare_web_fetch,
|
||||||
"web_search": self._prepare_web_search,
|
"web_search": self._prepare_web_search,
|
||||||
"tool_search": self._prepare_tool_search,
|
"tool_search": self._prepare_tool_search,
|
||||||
"task_agent": self._prepare_task,
|
"task_agent": self._prepare_task,
|
||||||
"plan_agent": self._prepare_plan,
|
|
||||||
"memory": self._prepare_memory,
|
"memory": self._prepare_memory,
|
||||||
"recall": self._prepare_recall,
|
"recall": self._prepare_recall,
|
||||||
"notify": self._prepare_notify,
|
"notify": self._prepare_notify,
|
||||||
@@ -6820,79 +6733,6 @@ class ChatSession:
|
|||||||
"replace_all": replace_all,
|
"replace_all": replace_all,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _prepare_math(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
code = args.get("code", "")
|
|
||||||
if isinstance(code, list):
|
|
||||||
code = "\n".join(code)
|
|
||||||
if not code:
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "math",
|
|
||||||
"header": "\u2717 math: empty code",
|
|
||||||
"preview": "",
|
|
||||||
"needs_approval": False,
|
|
||||||
"error": "Error: no code provided",
|
|
||||||
}
|
|
||||||
# Show code preview
|
|
||||||
preview = f"{DIM}{textwrap.indent(code, ' ')}{RESET}"
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "math",
|
|
||||||
"header": f"\u2699 math: ({len(code)} chars)",
|
|
||||||
"preview": preview,
|
|
||||||
"needs_approval": True,
|
|
||||||
"approval_label": "math",
|
|
||||||
"execute": self._exec_math,
|
|
||||||
"code": code,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _prepare_man(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Prepare a man/info page lookup."""
|
|
||||||
page = (args.get("page") or "").strip()
|
|
||||||
if not page:
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "man",
|
|
||||||
"header": "\u2717 man: empty page",
|
|
||||||
"preview": "",
|
|
||||||
"needs_approval": False,
|
|
||||||
"error": "Error: no page name provided",
|
|
||||||
}
|
|
||||||
section = (args.get("section") or "").strip()
|
|
||||||
# Accept the canonical "name(section)" notation that the model often
|
|
||||||
# emits (e.g. printf(3), open(2), perlfunc(3pm)) \u2014 the parens are
|
|
||||||
# otherwise rejected by the page-name sanitizer below. An explicit
|
|
||||||
# ``section`` arg, if provided, takes precedence over the parsed one.
|
|
||||||
m = re.match(r"^([a-zA-Z0-9._-]+)\(([1-9][a-z]*)\)$", page)
|
|
||||||
if m:
|
|
||||||
page = m.group(1)
|
|
||||||
if not section:
|
|
||||||
section = m.group(2)
|
|
||||||
# Sanitize: only allow alphanumeric, dash, underscore, dot
|
|
||||||
if not re.match(r"^[a-zA-Z0-9._-]+$", page):
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "man",
|
|
||||||
"header": "\u2717 man: invalid page name",
|
|
||||||
"preview": f" {page}",
|
|
||||||
"needs_approval": False,
|
|
||||||
"error": f"Error: invalid page name {page!r}",
|
|
||||||
}
|
|
||||||
if section and not re.match(r"^[1-9][a-z]*$", section):
|
|
||||||
section = ""
|
|
||||||
label = f"{page}({section})" if section else page
|
|
||||||
preview = f" {DIM}{label}{RESET}"
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "man",
|
|
||||||
"header": f"\u2699 man: {label}",
|
|
||||||
"preview": preview,
|
|
||||||
"needs_approval": False,
|
|
||||||
"execute": self._exec_man,
|
|
||||||
"page": page,
|
|
||||||
"section": section,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _prepare_web_fetch(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
def _prepare_web_fetch(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
url = args.get("url", "").strip()
|
url = args.get("url", "").strip()
|
||||||
question = args.get("question", "").strip()
|
question = args.get("question", "").strip()
|
||||||
@@ -7040,7 +6880,7 @@ class ChatSession:
|
|||||||
def _validate_agent_model_override(
|
def _validate_agent_model_override(
|
||||||
self, call_id: str, func_name: str, args: dict[str, Any]
|
self, call_id: str, func_name: str, args: dict[str, Any]
|
||||||
) -> tuple[str | None, dict[str, Any] | None]:
|
) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
"""Pull and validate the optional `model` arg for plan/task agents.
|
"""Pull and validate the optional `model` arg for the task agent.
|
||||||
|
|
||||||
Returns (alias, error_item). When the caller passed a `model` and
|
Returns (alias, error_item). When the caller passed a `model` and
|
||||||
it isn't in the registry, returns an error_item shaped like the
|
it isn't in the registry, returns an error_item shaped like the
|
||||||
@@ -7056,7 +6896,7 @@ class ChatSession:
|
|||||||
# ``default`` is operator-only — the alias either back-compat-shims
|
# ``default`` is operator-only — the alias either back-compat-shims
|
||||||
# a single-CLI-model registry or aliases a hand-named DB row, and
|
# a single-CLI-model registry or aliases a hand-named DB row, and
|
||||||
# in both cases an LLM that explicitly routes here bypasses the
|
# in both cases an LLM that explicitly routes here bypasses the
|
||||||
# operator-configured ``plan_alias`` / ``task_alias`` per-role
|
# operator-configured ``task_alias`` per-role
|
||||||
# default. Symmetric with the description filter at
|
# default. Symmetric with the description filter at
|
||||||
# ``_render_agent_tool_descriptions`` — closes the loophole where
|
# ``_render_agent_tool_descriptions`` — closes the loophole where
|
||||||
# an LLM that learned the alias name out-of-band (training data,
|
# an LLM that learned the alias name out-of-band (training data,
|
||||||
@@ -7184,34 +7024,6 @@ class ChatSession:
|
|||||||
"skill": skill_data,
|
"skill": skill_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _prepare_plan(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Prepare a planning agent for approval."""
|
|
||||||
goal = args.get("goal", "").strip()
|
|
||||||
if not goal:
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "plan_agent",
|
|
||||||
"header": "\u2717 plan_agent: empty goal",
|
|
||||||
"preview": "",
|
|
||||||
"needs_approval": False,
|
|
||||||
"error": "Error: empty goal",
|
|
||||||
}
|
|
||||||
model_override, err = self._validate_agent_model_override(call_id, "plan_agent", args)
|
|
||||||
if err is not None:
|
|
||||||
return err
|
|
||||||
preview_text = goal[:300] + ("..." if len(goal) > 300 else "")
|
|
||||||
return {
|
|
||||||
"call_id": call_id,
|
|
||||||
"func_name": "plan_agent",
|
|
||||||
"header": "\u2699 plan_agent (planning agent)",
|
|
||||||
"preview": f" {preview_text}",
|
|
||||||
"needs_approval": True,
|
|
||||||
"approval_label": "plan_agent",
|
|
||||||
"execute": self._exec_plan,
|
|
||||||
"prompt": goal,
|
|
||||||
"model_override": model_override,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _resolve_scope_id(self, scope: str) -> str:
|
def _resolve_scope_id(self, scope: str) -> str:
|
||||||
"""Map a scope name to its scope_id.
|
"""Map a scope name to its scope_id.
|
||||||
|
|
||||||
@@ -11069,12 +10881,14 @@ class ChatSession:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_messages: Pre-built message list (system + developer + user).
|
agent_messages: Pre-built message list (system + developer + user).
|
||||||
label: Display prefix for progress lines ("agent" or "plan").
|
label: Display prefix for progress lines (e.g. "task").
|
||||||
tools: Tool definitions to send to the API. Defaults to AGENT_TOOLS (read-only).
|
tools: Tool definitions to send to the API. Defaults to the
|
||||||
auto_tools: Set of tool names the agent may execute. Defaults to AGENT_AUTO_TOOLS.
|
session's task tool set.
|
||||||
|
auto_tools: Set of tool names the agent may execute. Defaults to
|
||||||
|
TASK_AUTO_TOOLS.
|
||||||
reasoning_effort: Override reasoning effort for this agent.
|
reasoning_effort: Override reasoning effort for this agent.
|
||||||
agent_alias: Per-call model alias override (the LLM passed
|
agent_alias: Per-call model alias override (the LLM passed
|
||||||
``model="<alias>"`` to plan_agent/task_agent). Wins over
|
``model="<alias>"`` to task_agent). Wins over
|
||||||
the registry's per-kind resolution when set. Caller is
|
the registry's per-kind resolution when set. Caller is
|
||||||
expected to have validated the alias against the registry;
|
expected to have validated the alias against the registry;
|
||||||
an unknown alias here raises ``ValueError``.
|
an unknown alias here raises ``ValueError``.
|
||||||
@@ -11083,13 +10897,13 @@ class ChatSession:
|
|||||||
Final content string from the agent.
|
Final content string from the agent.
|
||||||
"""
|
"""
|
||||||
if tools is None:
|
if tools is None:
|
||||||
tools = self._agent_tools
|
tools = self._task_tools
|
||||||
if auto_tools is None:
|
if auto_tools is None:
|
||||||
auto_tools = AGENT_AUTO_TOOLS
|
auto_tools = TASK_AUTO_TOOLS
|
||||||
max_tool_turns = self.agent_max_turns
|
max_tool_turns = self.agent_max_turns
|
||||||
|
|
||||||
# Resolve agent model: explicit per-call override wins, then per-kind
|
# Resolve agent model: explicit per-call override wins, then the
|
||||||
# registry override (plan_model/task_model), then the legacy single-
|
# per-kind registry override (task_model), then the legacy single-
|
||||||
# knob agent_model, then the session's primary model.
|
# knob agent_model, then the session's primary model.
|
||||||
if agent_alias is not None:
|
if agent_alias is not None:
|
||||||
if self._registry is None or not self._registry.has_alias(agent_alias):
|
if self._registry is None or not self._registry.has_alias(agent_alias):
|
||||||
@@ -11109,17 +10923,10 @@ class ChatSession:
|
|||||||
agent_alias = self._model_alias
|
agent_alias = self._model_alias
|
||||||
|
|
||||||
# Per-kind reasoning effort. Explicit caller arg wins; otherwise
|
# Per-kind reasoning effort. Explicit caller arg wins; otherwise
|
||||||
# delegate to the registry which knows the per-kind default (plan
|
# delegate to the registry which knows the per-kind default (task
|
||||||
# gets the back-compat "high", task returns None to inherit the
|
# returns None to inherit the session).
|
||||||
# session). When no registry exists, apply the plan back-compat
|
if reasoning_effort is None and self._registry:
|
||||||
# default directly so single-process callers keep prior behaviour.
|
|
||||||
if reasoning_effort is None:
|
|
||||||
if self._registry:
|
|
||||||
reasoning_effort = self._registry.resolve_agent_effort(label)
|
reasoning_effort = self._registry.resolve_agent_effort(label)
|
||||||
elif label == "plan":
|
|
||||||
from turnstone.core.model_registry import ModelRegistry
|
|
||||||
|
|
||||||
reasoning_effort = ModelRegistry.PLAN_DEFAULT_EFFORT
|
|
||||||
|
|
||||||
# Gate web_search: remove when no backend exists for the agent model
|
# Gate web_search: remove when no backend exists for the agent model
|
||||||
agent_caps = self._resolve_capabilities(agent_provider, agent_model, agent_alias)
|
agent_caps = self._resolve_capabilities(agent_provider, agent_model, agent_alias)
|
||||||
@@ -11141,7 +10948,7 @@ class ChatSession:
|
|||||||
# NOT wired here. Agent assistant messages are built from
|
# NOT wired here. Agent assistant messages are built from
|
||||||
# ``CompletionResult.content + tool_calls`` only (no
|
# ``CompletionResult.content + tool_calls`` only (no
|
||||||
# ``_provider_content`` carried), so the helper would no-op
|
# ``_provider_content`` carried), so the helper would no-op
|
||||||
# every turn anyway. Plan/task agents are excluded from the
|
# every turn anyway. Task agents are excluded from the
|
||||||
# persistence/replay contract — their conversation history
|
# persistence/replay contract — their conversation history
|
||||||
# is in-memory and rebuilt per ``_run_agent`` invocation.
|
# is in-memory and rebuilt per ``_run_agent`` invocation.
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
@@ -11162,7 +10969,7 @@ class ChatSession:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Sub-agent turns bypass on_status — record per-turn so
|
# Sub-agent turns bypass on_status — record per-turn so
|
||||||
# plan/task spend is visible in the dashboard, attributed
|
# task-agent spend is visible in the dashboard, attributed
|
||||||
# to the agent's own model.
|
# to the agent's own model.
|
||||||
self._record_aux_usage(agent_result, model=agent_model)
|
self._record_aux_usage(agent_result, model=agent_model)
|
||||||
return agent_result
|
return agent_result
|
||||||
@@ -11229,7 +11036,7 @@ class ChatSession:
|
|||||||
tool_name = tc_dict["function"]["name"].strip()
|
tool_name = tc_dict["function"]["name"].strip()
|
||||||
|
|
||||||
# Guard 1: block recursive agent calls.
|
# Guard 1: block recursive agent calls.
|
||||||
if tool_name in ("task_agent", "plan_agent"):
|
if tool_name == "task_agent":
|
||||||
output = "Error: agents cannot spawn further agents"
|
output = "Error: agents cannot spawn further agents"
|
||||||
# Guard 2: tool not in this agent's API tool list.
|
# Guard 2: tool not in this agent's API tool list.
|
||||||
elif tool_name not in tool_names:
|
elif tool_name not in tool_names:
|
||||||
@@ -11309,7 +11116,7 @@ class ChatSession:
|
|||||||
"# Task Agent\n\n"
|
"# Task Agent\n\n"
|
||||||
"You are an autonomous task agent with full tool access. "
|
"You are an autonomous task agent with full tool access. "
|
||||||
"You can use bash, read_file, write_file, edit_file, search, "
|
"You can use bash, read_file, write_file, edit_file, search, "
|
||||||
"math, web_fetch, and web_search."
|
"web_fetch, and web_search."
|
||||||
)
|
)
|
||||||
# Operating guidance always applies — these are sub-agent semantics
|
# Operating guidance always applies — these are sub-agent semantics
|
||||||
# (one-shot, tool-use over narration, no follow-up questions) that a
|
# (one-shot, tool-use over narration, no follow-up questions) that a
|
||||||
@@ -11383,224 +11190,6 @@ class ChatSession:
|
|||||||
self.ui.on_info(f"[task error] {e}")
|
self.ui.on_info(f"[task error] {e}")
|
||||||
return call_id, f"Task error: {e}"
|
return call_id, f"Task error: {e}"
|
||||||
|
|
||||||
_PLAN_IDENTITY = (
|
|
||||||
"You are a planning agent. Explore the codebase with read_file and search, "
|
|
||||||
"then write a plan with these sections: "
|
|
||||||
"## Goal (1-2 sentences), "
|
|
||||||
"## Current State (files/line numbers found), "
|
|
||||||
"## Plan (numbered steps naming exact files and functions), "
|
|
||||||
"## Risks (edge cases and unknowns). "
|
|
||||||
"Never guess at structure — verify first. Be specific: name files, line numbers, "
|
|
||||||
"and functions in every step."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _plan_system_content(self) -> str:
|
|
||||||
"""Plan agent system message: skill guardrails + plan identity."""
|
|
||||||
if not self._skill_content:
|
|
||||||
return self._PLAN_IDENTITY
|
|
||||||
tpl = self._skill_content
|
|
||||||
if len(tpl) > _MAX_SKILL_CONTENT:
|
|
||||||
log.warning("skill_content.truncated", length=len(tpl), agent="plan")
|
|
||||||
tpl = tpl[:_MAX_SKILL_CONTENT]
|
|
||||||
return tpl + "\n\n" + self._PLAN_IDENTITY
|
|
||||||
|
|
||||||
_MIN_PLAN_LENGTH = 100
|
|
||||||
_PLAN_REQUIRED_SECTIONS = ("## goal", "## current state", "## plan", "## risks")
|
|
||||||
_MIN_PLAN_SECTIONS = 2
|
|
||||||
_MAX_PLAN_REFINEMENTS = 5
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_plan(content: str, goal: str) -> tuple[bool, list[str]]:
|
|
||||||
"""Check if plan output meets minimum quality bar.
|
|
||||||
|
|
||||||
Returns ``(valid, issues)`` where *issues* is a list of
|
|
||||||
human-readable problem descriptions (empty when valid).
|
|
||||||
"""
|
|
||||||
issues: list[str] = []
|
|
||||||
stripped = content.strip()
|
|
||||||
stripped_lower = stripped.lower()
|
|
||||||
|
|
||||||
# 1. Minimum length
|
|
||||||
if len(stripped) < ChatSession._MIN_PLAN_LENGTH:
|
|
||||||
issues.append(
|
|
||||||
f"too short ({len(stripped)} chars, minimum {ChatSession._MIN_PLAN_LENGTH})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Section structure
|
|
||||||
found_sections = sum(
|
|
||||||
1 for section in ChatSession._PLAN_REQUIRED_SECTIONS if section in stripped_lower
|
|
||||||
)
|
|
||||||
if found_sections < ChatSession._MIN_PLAN_SECTIONS:
|
|
||||||
issues.append(
|
|
||||||
f"missing plan sections (found {found_sections}/"
|
|
||||||
f"{len(ChatSession._PLAN_REQUIRED_SECTIONS)}, "
|
|
||||||
f"need at least {ChatSession._MIN_PLAN_SECTIONS})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Echo detection: plan is basically just the goal repeated
|
|
||||||
goal_stripped = goal.strip().lower()
|
|
||||||
if (
|
|
||||||
goal_stripped
|
|
||||||
and len(stripped) < len(goal_stripped) * 2
|
|
||||||
and goal_stripped in stripped_lower
|
|
||||||
):
|
|
||||||
issues.append("plan appears to echo the goal without elaboration")
|
|
||||||
|
|
||||||
# 4. Refusal detection
|
|
||||||
refusal_starts = (
|
|
||||||
"i cannot",
|
|
||||||
"i'm sorry",
|
|
||||||
"i am sorry",
|
|
||||||
"error:",
|
|
||||||
"i can't",
|
|
||||||
)
|
|
||||||
if any(stripped_lower.startswith(r) for r in refusal_starts):
|
|
||||||
issues.append("plan appears to be a refusal or error")
|
|
||||||
|
|
||||||
return (len(issues) == 0, issues)
|
|
||||||
|
|
||||||
def _exec_plan(self, item: dict[str, Any]) -> tuple[str, str]:
|
|
||||||
"""Run a planning agent and write the result to .plan-<ws_id>.md."""
|
|
||||||
call_id, prompt = item["call_id"], item["prompt"]
|
|
||||||
plan_path = f".plan-{self._ws_id}.md"
|
|
||||||
|
|
||||||
# If plan was called before in this session, the previous assistant
|
|
||||||
# tool_call + tool result are already in self.messages — pass them
|
|
||||||
# directly to the inner agent so it refines rather than restarts.
|
|
||||||
prior_plan_msgs: list[dict[str, Any]] = []
|
|
||||||
for i, msg in enumerate(self.messages):
|
|
||||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
||||||
for tc in msg["tool_calls"]:
|
|
||||||
if tc.get("function", {}).get("name") == "plan_agent":
|
|
||||||
tc_id = tc["id"]
|
|
||||||
for j in range(i + 1, len(self.messages)):
|
|
||||||
if (
|
|
||||||
self.messages[j].get("role") == "tool"
|
|
||||||
and self.messages[j].get("tool_call_id") == tc_id
|
|
||||||
):
|
|
||||||
prior_plan_msgs = [msg, self.messages[j]]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Plan agent gets template guardrails + its own identity — no tool
|
|
||||||
# patterns, MCP resources, or general conversation history (only
|
|
||||||
# prior plan tool_call/result pairs are forwarded for refinement).
|
|
||||||
agent_messages: list[dict[str, Any]] = [
|
|
||||||
{"role": "system", "content": self._plan_system_content()},
|
|
||||||
]
|
|
||||||
agent_messages.extend(prior_plan_msgs)
|
|
||||||
agent_messages.append({"role": "user", "content": prompt})
|
|
||||||
|
|
||||||
plan_alias = item.get("model_override")
|
|
||||||
try:
|
|
||||||
content = self._run_agent(
|
|
||||||
agent_messages,
|
|
||||||
label="plan",
|
|
||||||
agent_alias=plan_alias,
|
|
||||||
)
|
|
||||||
except (KeyboardInterrupt, GenerationCancelled):
|
|
||||||
return call_id, "(plan interrupted by user)"
|
|
||||||
except Exception as e:
|
|
||||||
self.ui.on_info(f"[plan error] {e}")
|
|
||||||
return call_id, f"Plan error: {e}"
|
|
||||||
|
|
||||||
# Validate plan quality — retry once with coaching on failure
|
|
||||||
valid, issues = self._validate_plan(content, prompt)
|
|
||||||
if not valid:
|
|
||||||
self.ui.on_info(f"[plan] quality issues: {', '.join(issues)}")
|
|
||||||
preview = content[:200] + ("..." if len(content) > 200 else "")
|
|
||||||
coaching = (
|
|
||||||
"Your previous response did not follow the required plan "
|
|
||||||
"format. A valid plan should include at least two of "
|
|
||||||
"these markdown sections:\n"
|
|
||||||
"## Goal (1-2 sentences)\n"
|
|
||||||
"## Current State (files/line numbers found)\n"
|
|
||||||
"## Plan (numbered steps with file names and functions)\n"
|
|
||||||
"## Risks (edge cases and unknowns)\n\n"
|
|
||||||
f'Your previous response was: "{preview}"\n\n'
|
|
||||||
"Please try again. Explore the codebase first, then write "
|
|
||||||
"the plan."
|
|
||||||
)
|
|
||||||
agent_messages.append({"role": "user", "content": coaching})
|
|
||||||
try:
|
|
||||||
content = self._run_agent(
|
|
||||||
agent_messages,
|
|
||||||
label="plan",
|
|
||||||
agent_alias=plan_alias,
|
|
||||||
)
|
|
||||||
except (KeyboardInterrupt, GenerationCancelled):
|
|
||||||
return call_id, "(plan interrupted by user)"
|
|
||||||
except Exception as e:
|
|
||||||
self.ui.on_info(f"[plan retry error] {e}")
|
|
||||||
return call_id, f"Plan error: {e}"
|
|
||||||
|
|
||||||
valid2, issues2 = self._validate_plan(content, prompt)
|
|
||||||
if not valid2:
|
|
||||||
self.ui.on_info(f"[plan] still has issues after retry: {', '.join(issues2)}")
|
|
||||||
content = "[Warning: plan may be incomplete or poorly structured]\n\n" + content
|
|
||||||
|
|
||||||
# Write to file separately — always return content even if write fails
|
|
||||||
try:
|
|
||||||
with open(plan_path, "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
self.ui.on_info(f"Plan written to {plan_path}")
|
|
||||||
except OSError as e:
|
|
||||||
self.ui.on_info(f"[plan] could not write {plan_path}: {e}")
|
|
||||||
|
|
||||||
return call_id, content
|
|
||||||
|
|
||||||
def _refine_plan(
|
|
||||||
self,
|
|
||||||
original_content: str,
|
|
||||||
original_goal: str,
|
|
||||||
feedback: str,
|
|
||||||
) -> str:
|
|
||||||
"""Re-run the plan agent incorporating user feedback."""
|
|
||||||
tc_id = f"plan_refine_{uuid.uuid4().hex[:8]}"
|
|
||||||
agent_messages: list[dict[str, Any]] = [
|
|
||||||
{"role": "system", "content": self._plan_system_content()},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": tc_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "plan_agent",
|
|
||||||
"arguments": json.dumps({"goal": original_goal}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc_id,
|
|
||||||
"content": original_content,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
"The user reviewed this plan and provided feedback:\n\n"
|
|
||||||
f"{feedback}\n\n"
|
|
||||||
"Please revise the plan accordingly. Keep the same "
|
|
||||||
"format (## Goal, ## Current State, ## Plan, ## Risks) "
|
|
||||||
"and address the feedback."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
self.ui.on_info("[plan] revising based on feedback...")
|
|
||||||
content = self._run_agent(
|
|
||||||
agent_messages,
|
|
||||||
label="plan",
|
|
||||||
)
|
|
||||||
|
|
||||||
valid, issues = self._validate_plan(content, original_goal)
|
|
||||||
if not valid:
|
|
||||||
self.ui.on_info(f"[plan] revised plan has issues: {', '.join(issues)}")
|
|
||||||
|
|
||||||
return content
|
|
||||||
|
|
||||||
def _audit_memory_event(
|
def _audit_memory_event(
|
||||||
self,
|
self,
|
||||||
action: str,
|
action: str,
|
||||||
@@ -12428,74 +12017,6 @@ class ChatSession:
|
|||||||
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
|
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
|
||||||
return call_id, msg
|
return call_id, msg
|
||||||
|
|
||||||
def _exec_math(self, item: dict[str, Any]) -> tuple[str, str]:
|
|
||||||
"""Execute Python code in sandboxed subprocess."""
|
|
||||||
call_id, code = item["call_id"], item["code"]
|
|
||||||
output, is_error = execute_math_sandboxed(code, timeout=self.tool_timeout)
|
|
||||||
output = self._truncate_output(output)
|
|
||||||
|
|
||||||
result_msg = f"Error:\n{output}" if is_error else output if output else "(no output)"
|
|
||||||
self._report_tool_result(call_id, "math", result_msg, is_error=is_error)
|
|
||||||
return call_id, result_msg
|
|
||||||
|
|
||||||
def _exec_man(self, item: dict[str, Any]) -> tuple[str, str]:
|
|
||||||
"""Look up a man or info page."""
|
|
||||||
self._check_cancelled()
|
|
||||||
call_id = item["call_id"]
|
|
||||||
page = item["page"]
|
|
||||||
section = item.get("section", "")
|
|
||||||
|
|
||||||
# Try man first, fall back to info
|
|
||||||
cmd = ["man"]
|
|
||||||
if section:
|
|
||||||
cmd.append(section)
|
|
||||||
cmd.append(page)
|
|
||||||
|
|
||||||
text = ""
|
|
||||||
try:
|
|
||||||
from turnstone.core.env import scrubbed_env
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
env=scrubbed_env(extra={"MANWIDTH": "80", "MAN_KEEP_FORMATTING": "0"}),
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
# Strip formatting: backspace overstrikes and ANSI escapes
|
|
||||||
text = re.sub(r".\x08", "", result.stdout)
|
|
||||||
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
||||||
else:
|
|
||||||
# Fall back to info
|
|
||||||
result = subprocess.run(
|
|
||||||
["info", page],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10,
|
|
||||||
env=scrubbed_env(),
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
text = result.stdout
|
|
||||||
else:
|
|
||||||
msg = f"No man or info page found for '{page}'"
|
|
||||||
self._report_tool_result(call_id, "man", msg)
|
|
||||||
return call_id, msg
|
|
||||||
except FileNotFoundError:
|
|
||||||
msg = "Error: man command not available"
|
|
||||||
self._report_tool_result(call_id, "man", msg, is_error=True)
|
|
||||||
return call_id, msg
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
msg = "Error: man page lookup timed out"
|
|
||||||
self._report_tool_result(call_id, "man", msg, is_error=True)
|
|
||||||
return call_id, msg
|
|
||||||
|
|
||||||
text = self._truncate_output(text)
|
|
||||||
|
|
||||||
self._report_tool_result(call_id, "man", f"{len(text)} chars")
|
|
||||||
|
|
||||||
return call_id, text
|
|
||||||
|
|
||||||
def _exec_web_fetch(self, item: dict[str, Any]) -> tuple[str, str]:
|
def _exec_web_fetch(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||||
"""Fetch a URL, then summarize/extract using an API call."""
|
"""Fetch a URL, then summarize/extract using an API call."""
|
||||||
self._check_cancelled()
|
self._check_cancelled()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class SessionKindAdapter(Protocol):
|
|||||||
|
|
||||||
- **Session construction**: what UI class wraps the workstream,
|
- **Session construction**: what UI class wraps the workstream,
|
||||||
what ``ChatSession`` factory signature applies.
|
what ``ChatSession`` factory signature applies.
|
||||||
- **UI cleanup**: unblocking pending approval / plan / foreground
|
- **UI cleanup**: unblocking pending approval / foreground
|
||||||
events when a workstream closes.
|
events when a workstream closes.
|
||||||
|
|
||||||
Lifecycle event fan-out (``ws_created`` / ``ws_state`` /
|
Lifecycle event fan-out (``ws_created`` / ``ws_state`` /
|
||||||
@@ -906,7 +906,7 @@ class SessionManager:
|
|||||||
self._state_subscribers.remove(callback)
|
self._state_subscribers.remove(callback)
|
||||||
|
|
||||||
def cancel(self, ws_id: str) -> bool:
|
def cancel(self, ws_id: str) -> bool:
|
||||||
"""Cancel in-flight generation and unblock any pending approval / plan.
|
"""Cancel in-flight generation and unblock any pending approval.
|
||||||
|
|
||||||
Does NOT unload the workstream — use ``close`` for that. The
|
Does NOT unload the workstream — use ``close`` for that. The
|
||||||
session stays live and can receive further messages. Returns
|
session stays live and can receive further messages. Returns
|
||||||
@@ -920,13 +920,9 @@ class SessionManager:
|
|||||||
ws.session.cancel()
|
ws.session.cancel()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("session_mgr.cancel_failed ws=%s", ws_id[:8], exc_info=True)
|
log.debug("session_mgr.cancel_failed ws=%s", ws_id[:8], exc_info=True)
|
||||||
if ws.ui is not None:
|
if ws.ui is not None and hasattr(ws.ui, "resolve_approval"):
|
||||||
if hasattr(ws.ui, "resolve_approval"):
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
ws.ui.resolve_approval(False, "cancelled")
|
ws.ui.resolve_approval(False, "cancelled")
|
||||||
if hasattr(ws.ui, "resolve_plan"):
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
ws.ui.resolve_plan("reject")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def close_idle(self, max_age_seconds: float) -> list[str]:
|
def close_idle(self, max_age_seconds: float) -> list[str]:
|
||||||
|
|||||||
@@ -153,15 +153,14 @@ class EventsReplay(Protocol):
|
|||||||
live event loop starts. Each yielded dict gets JSON-serialised
|
live event loop starts. Each yielded dict gets JSON-serialised
|
||||||
and sent as a single ``data:`` line to the client.
|
and sent as a single ``data:`` line to the client.
|
||||||
|
|
||||||
Interactive yields five things on connect: ``connected`` (model +
|
Interactive yields four things on connect: ``connected`` (model +
|
||||||
skip_permissions), ``status`` (token usage + context %, only when
|
skip_permissions), ``status`` (token usage + context %, only when
|
||||||
``session._last_usage`` exists), ``history`` (replayed conversation),
|
``session._last_usage`` exists), ``history`` (replayed conversation),
|
||||||
``pending_approval`` + cached intent verdicts, and
|
and ``pending_approval`` + cached intent verdicts. Coord yields
|
||||||
``pending_plan_review``. Coord yields just two: ``pending_approval``
|
just one: ``pending_approval`` (the rest aren't needed because
|
||||||
and ``pending_plan_review`` (the rest aren't needed because coord's
|
coord's dashboard fetches history via a separate ``/history``
|
||||||
dashboard fetches history via a separate ``/history`` endpoint
|
endpoint and doesn't render the per-tab status bar). Kinds that
|
||||||
and doesn't render the per-tab status bar). Kinds that don't
|
don't need any pre-replay wire ``None`` and the live loop starts
|
||||||
need any pre-replay wire ``None`` and the live loop starts
|
|
||||||
immediately.
|
immediately.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -389,9 +388,8 @@ class SessionEndpointConfig:
|
|||||||
# SSE replay payload the lifted ``events`` body yields after
|
# SSE replay payload the lifted ``events`` body yields after
|
||||||
# registering the per-UI listener queue but before the live
|
# registering the per-UI listener queue but before the live
|
||||||
# event loop. Interactive replays connected + status + history
|
# event loop. Interactive replays connected + status + history
|
||||||
# + pending_approval (with cached intent verdicts) +
|
# + pending_approval (with cached intent verdicts). Coord replays
|
||||||
# pending_plan_review. Coord replays just pending_approval +
|
# just pending_approval (its dashboard fetches history via a
|
||||||
# pending_plan_review (its dashboard fetches history via a
|
|
||||||
# separate ``/history`` endpoint and doesn't render the per-tab
|
# separate ``/history`` endpoint and doesn't render the per-tab
|
||||||
# status bar). Kinds that don't need pre-replay wire ``None``.
|
# status bar). Kinds that don't need pre-replay wire ``None``.
|
||||||
events_replay: EventsReplay | None = None
|
events_replay: EventsReplay | None = None
|
||||||
@@ -978,12 +976,12 @@ def make_cancel_handler(
|
|||||||
"""Lifted body for ``POST {prefix}/{ws_id}/cancel``.
|
"""Lifted body for ``POST {prefix}/{ws_id}/cancel``.
|
||||||
|
|
||||||
Cancels in-flight generation on a workstream. Sets the cooperative
|
Cancels in-flight generation on a workstream. Sets the cooperative
|
||||||
cancel flag on the session, unblocks any pending approval / plan
|
cancel flag on the session, unblocks any pending approval, and
|
||||||
waits, and (when the request body asks for it) force-abandons a
|
(when the request body asks for it) force-abandons a stuck worker
|
||||||
stuck worker thread so the UI recovers immediately.
|
thread so the UI recovers immediately.
|
||||||
|
|
||||||
Both kinds share the cancel sequence (``session.cancel`` →
|
Both kinds share the cancel sequence (``session.cancel`` →
|
||||||
``ui.resolve_approval(False)`` → ``ui.resolve_plan("reject")``).
|
``ui.resolve_approval(False)``).
|
||||||
Per-kind divergence captured via the cfg + ``audit_emit``:
|
Per-kind divergence captured via the cfg + ``audit_emit``:
|
||||||
|
|
||||||
- ``cancel_forensics`` (cfg) — when set, the lifted body calls
|
- ``cancel_forensics`` (cfg) — when set, the lifted body calls
|
||||||
@@ -1020,14 +1018,7 @@ def make_cancel_handler(
|
|||||||
``coord_mgr.cancel`` which silently no-op'd on a placeholder;
|
``coord_mgr.cancel`` which silently no-op'd on a placeholder;
|
||||||
the lifted body 400s for parity with interactive's existing
|
the lifted body 400s for parity with interactive's existing
|
||||||
"No session" branch.
|
"No session" branch.
|
||||||
- **Interactive ``resolve_plan`` now runs on every cancel** (was
|
- ``resolve_approval`` is **gated on ``ui._pending_approval is not None``**
|
||||||
gated on ``was_running``). Lifts coord's always-resolve
|
|
||||||
behaviour onto interactive — a stuck plan-pending state from
|
|
||||||
a crashed worker thread can now be cleared via ``cancel``,
|
|
||||||
matching coord's pre-lift recovery path. ``resolve_plan`` has
|
|
||||||
its own internal ``_pending_plan_review is None`` guard, so
|
|
||||||
the call is genuinely no-op when nothing is blocked.
|
|
||||||
``resolve_approval`` is **gated on ``ui._pending_approval is not None``**
|
|
||||||
because :meth:`SessionUIBase.resolve_approval` is *not*
|
because :meth:`SessionUIBase.resolve_approval` is *not*
|
||||||
idempotent — it always broadcasts ``approval_resolved`` and
|
idempotent — it always broadcasts ``approval_resolved`` and
|
||||||
overwrites ``_approval_result``. Without the gate, every idle
|
overwrites ``_approval_result``. Without the gate, every idle
|
||||||
@@ -1091,18 +1082,16 @@ def make_cancel_handler(
|
|||||||
dropped = {}
|
dropped = {}
|
||||||
|
|
||||||
# Always set the cooperative cancel flag — cheap, no harm if
|
# Always set the cooperative cancel flag — cheap, no harm if
|
||||||
# nothing's running. resolve_approval / resolve_plan are
|
# nothing's running. resolve_approval is gated on its
|
||||||
# gated by their respective ``_pending_*`` slots: pre-lift
|
# ``_pending_approval`` slot: pre-lift coord called it
|
||||||
# coord called them unconditionally via ``mgr.cancel`` (which
|
# unconditionally via ``mgr.cancel`` (which is
|
||||||
# is recovery-friendly: a stuck approval-pending state from a
|
# recovery-friendly: a stuck approval-pending state from a
|
||||||
# crashed worker can still be cleared), but ``resolve_approval``
|
# crashed worker can still be cleared), but ``resolve_approval``
|
||||||
# is NOT idempotent — calling it with no pending approval
|
# is NOT idempotent — calling it with no pending approval
|
||||||
# broadcasts a stale ``approval_resolved`` SSE event and
|
# broadcasts a stale ``approval_resolved`` SSE event and
|
||||||
# overwrites ``_approval_result``. Gating on the pending slot
|
# overwrites ``_approval_result``. Gating on the pending slot
|
||||||
# preserves the recovery semantics for the actual stuck case
|
# preserves the recovery semantics for the actual stuck case
|
||||||
# while skipping the broadcast on idle cancels. ``resolve_plan``
|
# while skipping the broadcast on idle cancels.
|
||||||
# has its own internal no-pending guard, so the call is
|
|
||||||
# already safe to make unconditionally.
|
|
||||||
try:
|
try:
|
||||||
session.cancel()
|
session.cancel()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1116,15 +1105,6 @@ def make_cancel_handler(
|
|||||||
ws_id[:8],
|
ws_id[:8],
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
if hasattr(ui, "resolve_plan"):
|
|
||||||
try:
|
|
||||||
ui.resolve_plan("reject")
|
|
||||||
except Exception:
|
|
||||||
log.debug(
|
|
||||||
"ws.cancel.resolve_plan_failed ws=%s",
|
|
||||||
ws_id[:8],
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The remaining steps only matter when a worker is actually
|
# The remaining steps only matter when a worker is actually
|
||||||
# running: force-recovery has nothing to recover otherwise,
|
# running: force-recovery has nothing to recover otherwise,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ Both :class:`turnstone.server.WebUI` (interactive node UI) and
|
|||||||
:class:`turnstone.console.coordinator_ui.ConsoleCoordinatorUI` wrap a
|
:class:`turnstone.console.coordinator_ui.ConsoleCoordinatorUI` wrap a
|
||||||
:class:`~turnstone.core.session.ChatSession` and fan events out over
|
:class:`~turnstone.core.session.ChatSession` and fan events out over
|
||||||
SSE to one or more connected browser tabs. They also block the worker
|
SSE to one or more connected browser tabs. They also block the worker
|
||||||
thread on two pending-input gates (tool approval, plan review) that
|
thread on a pending-input gate (tool approval) that HTTP handlers
|
||||||
HTTP handlers resolve.
|
resolve.
|
||||||
|
|
||||||
That skeleton — plus per-workstream metrics tracking, intent-verdict
|
That skeleton — plus per-workstream metrics tracking, intent-verdict
|
||||||
bookkeeping, output-warning persistence, and the canonical
|
bookkeeping, output-warning persistence, and the canonical
|
||||||
@@ -195,14 +195,14 @@ class AutoApproveReason:
|
|||||||
|
|
||||||
|
|
||||||
class SessionUIBase:
|
class SessionUIBase:
|
||||||
"""SSE listener fan-out + approval/plan event machinery.
|
"""SSE listener fan-out + approval event machinery.
|
||||||
|
|
||||||
Thread-safety: the ChatSession worker thread calls the ``on_*``
|
Thread-safety: the ChatSession worker thread calls the ``on_*``
|
||||||
methods (and the approval/plan blocking helpers that live on
|
methods (and the approval blocking helpers that live on
|
||||||
subclasses); HTTP handlers drive ``_register_listener`` /
|
subclasses); HTTP handlers drive ``_register_listener`` /
|
||||||
``_unregister_listener`` / ``resolve_approval`` / ``resolve_plan``
|
``_unregister_listener`` / ``resolve_approval`` from the event
|
||||||
from the event loop. All shared state is guarded by
|
loop. All shared state is guarded by ``_listeners_lock`` or
|
||||||
``_listeners_lock`` or ``threading.Event`` primitives.
|
``threading.Event`` primitives.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
def __init__(self, ws_id: str = "", user_id: str = "") -> None:
|
||||||
@@ -249,9 +249,6 @@ class SessionUIBase:
|
|||||||
# Pending approval shape — re-sent on SSE reconnect so a user
|
# Pending approval shape — re-sent on SSE reconnect so a user
|
||||||
# switching tabs still sees the prompt.
|
# switching tabs still sees the prompt.
|
||||||
self._pending_approval: dict[str, Any] | None = None
|
self._pending_approval: dict[str, Any] | None = None
|
||||||
self._plan_event = threading.Event()
|
|
||||||
self._plan_result: str = ""
|
|
||||||
self._pending_plan_review: dict[str, Any] | None = None
|
|
||||||
self.auto_approve = False
|
self.auto_approve = False
|
||||||
self.auto_approve_tools: set[str] = set()
|
self.auto_approve_tools: set[str] = set()
|
||||||
# Smart Approvals (``judge.smart_approvals``): when enabled, a
|
# Smart Approvals (``judge.smart_approvals``): when enabled, a
|
||||||
@@ -658,7 +655,7 @@ class SessionUIBase:
|
|||||||
return latest_id > cursor
|
return latest_id > cursor
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Approval / plan blocking gates
|
# Approval blocking gates
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _reset_approval_cycle(self) -> None:
|
def _reset_approval_cycle(self) -> None:
|
||||||
@@ -756,26 +753,6 @@ class SessionUIBase:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.debug("Failed to update verdict user_decision", exc_info=True)
|
log.debug("Failed to update verdict user_decision", exc_info=True)
|
||||||
|
|
||||||
def resolve_plan(self, feedback: str) -> None:
|
|
||||||
"""Unblock a pending plan review with the caller's verdict.
|
|
||||||
|
|
||||||
``cancel_generation`` calls this unconditionally to unblock
|
|
||||||
any wait, so the path has to be safe when no plan is pending
|
|
||||||
(just signal and skip the broadcast).
|
|
||||||
"""
|
|
||||||
self._plan_result = feedback
|
|
||||||
if self._pending_plan_review is None:
|
|
||||||
self._plan_event.set()
|
|
||||||
return
|
|
||||||
# Clear pending BEFORE broadcasting so a client reconnecting
|
|
||||||
# in the window between enqueue and clear cannot receive both
|
|
||||||
# the replayed plan_review (SSE re-injection at the connect
|
|
||||||
# handler) AND the live plan_resolved. Mirrors the
|
|
||||||
# approval_resolved pattern above.
|
|
||||||
self._pending_plan_review = None
|
|
||||||
self._enqueue({"type": "plan_resolved", "feedback": feedback})
|
|
||||||
self._plan_event.set()
|
|
||||||
|
|
||||||
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
|
||||||
"""Two-phase approval gate for a batch of tool calls.
|
"""Two-phase approval gate for a batch of tool calls.
|
||||||
|
|
||||||
@@ -1308,10 +1285,10 @@ class SessionUIBase:
|
|||||||
_LLM_VERDICT_CACHE_MAX = 50
|
_LLM_VERDICT_CACHE_MAX = 50
|
||||||
|
|
||||||
# Hard cap on how long a worker thread blocks waiting for an
|
# Hard cap on how long a worker thread blocks waiting for an
|
||||||
# approval / plan-review decision. Subclasses' ``approve_tools`` and
|
# approval decision. Subclasses' ``approve_tools`` references this
|
||||||
# ``on_plan_review`` reference this rather than the literal so a
|
# rather than the literal so a future
|
||||||
# future ``settings.approval_timeout_seconds`` knob can swap it in
|
# ``settings.approval_timeout_seconds`` knob can swap it in one
|
||||||
# one place.
|
# place.
|
||||||
_APPROVAL_WAIT_TIMEOUT = 3600
|
_APPROVAL_WAIT_TIMEOUT = 3600
|
||||||
|
|
||||||
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
|
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
|
||||||
@@ -2313,7 +2290,7 @@ class SessionUIBase:
|
|||||||
"""Persist a ``usage_event`` for an auxiliary (non-main-loop) LLM call.
|
"""Persist a ``usage_event`` for an auxiliary (non-main-loop) LLM call.
|
||||||
|
|
||||||
Title generation, conversation compaction, web-fetch
|
Title generation, conversation compaction, web-fetch
|
||||||
summarisation, and plan/task sub-agents all run through the
|
summarisation, and task sub-agents all run through the
|
||||||
provider's non-streaming ``create_completion`` and never reach
|
provider's non-streaming ``create_completion`` and never reach
|
||||||
:meth:`on_status` — so without this their tokens are invisible to
|
:meth:`on_status` — so without this their tokens are invisible to
|
||||||
the governance usage dashboard, undercounting real consumption
|
the governance usage dashboard, undercounting real consumption
|
||||||
|
|||||||
@@ -83,17 +83,6 @@ def _build_registry() -> dict[str, SettingDef]:
|
|||||||
"Higher effort improves quality on complex tasks but is slower and uses more "
|
"Higher effort improves quality on complex tasks but is slower and uses more "
|
||||||
"tokens. Per-model overrides can be set in the Models tab.",
|
"tokens. Per-model overrides can be set in the Models tab.",
|
||||||
),
|
),
|
||||||
SettingDef(
|
|
||||||
"model.plan_alias",
|
|
||||||
"str",
|
|
||||||
"",
|
|
||||||
"Model alias for plan_agent (empty = inherit from config / session)",
|
|
||||||
"model",
|
|
||||||
help="Which model the plan_agent sub-agent uses. When empty, falls back to "
|
|
||||||
"[model].plan_model in config.toml, then [model].agent_model, then the session "
|
|
||||||
"model. Plan_agent runs rarely but benefits from a stronger model for "
|
|
||||||
"high-quality plans \u2014 point this at your strongest reasoner.",
|
|
||||||
),
|
|
||||||
SettingDef(
|
SettingDef(
|
||||||
"model.task_alias",
|
"model.task_alias",
|
||||||
"str",
|
"str",
|
||||||
@@ -103,20 +92,7 @@ def _build_registry() -> dict[str, SettingDef]:
|
|||||||
help="Which model the task_agent sub-agent uses. When empty, falls back to "
|
help="Which model the task_agent sub-agent uses. When empty, falls back to "
|
||||||
"[model].task_model in config.toml, then [model].agent_model, then the session "
|
"[model].task_model in config.toml, then [model].agent_model, then the session "
|
||||||
"model. Task_agent fires frequently for autonomous subtasks \u2014 point this "
|
"model. Task_agent fires frequently for autonomous subtasks \u2014 point this "
|
||||||
"at a cheaper/faster model than your plan_agent.",
|
"at a cheaper/faster model than your conversation model.",
|
||||||
),
|
|
||||||
SettingDef(
|
|
||||||
"model.plan_effort",
|
|
||||||
"str",
|
|
||||||
"",
|
|
||||||
"Reasoning effort for plan_agent (empty = inherit from config; default \u2018high\u2019)",
|
|
||||||
"model",
|
|
||||||
choices=["", "none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
||||||
help="Reasoning effort for plan_agent specifically. When empty, falls back to "
|
|
||||||
"[model].plan_effort in config.toml, then to the built-in default \u2018high\u2019. "
|
|
||||||
"Use \u2018xhigh\u2019 or \u2018max\u2019 with models that support deeper reasoning "
|
|
||||||
"for higher-quality plans. (Empty here means \u201cinherit\u201d \u2014 use "
|
|
||||||
"\u2018none\u2019 to actually disable reasoning.)",
|
|
||||||
),
|
),
|
||||||
SettingDef(
|
SettingDef(
|
||||||
"model.task_effort",
|
"model.task_effort",
|
||||||
@@ -195,12 +171,12 @@ def _build_registry() -> dict[str, SettingDef]:
|
|||||||
"tools.agent_max_turns",
|
"tools.agent_max_turns",
|
||||||
"int",
|
"int",
|
||||||
-1,
|
-1,
|
||||||
"Max turns for plan/task agents (-1 = unlimited)",
|
"Max turns for the task agent (-1 = unlimited)",
|
||||||
"tools",
|
"tools",
|
||||||
min_value=-1,
|
min_value=-1,
|
||||||
max_value=200,
|
max_value=200,
|
||||||
help="Limits how many back-and-forth steps a sub-agent can take when executing a plan "
|
help="Limits how many back-and-forth steps the task sub-agent can take when executing "
|
||||||
"or task. Prevents runaway agents from consuming excessive tokens.",
|
"a task. Prevents runaway agents from consuming excessive tokens.",
|
||||||
),
|
),
|
||||||
SettingDef(
|
SettingDef(
|
||||||
"tools.skip_permissions",
|
"tools.skip_permissions",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import Any
|
|||||||
|
|
||||||
_TOOLS_DIR = Path(__file__).resolve().parent.parent / "tools"
|
_TOOLS_DIR = Path(__file__).resolve().parent.parent / "tools"
|
||||||
_META_KEYS = {
|
_META_KEYS = {
|
||||||
"agent",
|
|
||||||
"task_agent",
|
"task_agent",
|
||||||
"coordinator",
|
"coordinator",
|
||||||
"interactive",
|
"interactive",
|
||||||
@@ -38,7 +37,7 @@ def _load_tools() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|||||||
|
|
||||||
Returns (tool_defs, metadata) where:
|
Returns (tool_defs, metadata) where:
|
||||||
- tool_defs: list of OpenAI function-calling dicts
|
- tool_defs: list of OpenAI function-calling dicts
|
||||||
- metadata: dict mapping tool_name -> {agent, task_agent, coordinator,
|
- metadata: dict mapping tool_name -> {task_agent, coordinator,
|
||||||
interactive, auto_approve, primary_key, kind_variants}
|
interactive, auto_approve, primary_key, kind_variants}
|
||||||
"""
|
"""
|
||||||
tools = []
|
tools = []
|
||||||
@@ -89,7 +88,6 @@ def _apply_kind_variant(tool: dict[str, Any], kind: str, meta: dict[str, Any]) -
|
|||||||
|
|
||||||
TOOLS, _META = _load_tools()
|
TOOLS, _META = _load_tools()
|
||||||
|
|
||||||
AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("agent")]
|
|
||||||
TASK_AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("task_agent")]
|
TASK_AGENT_TOOLS = [t for t in TOOLS if _META[t["function"]["name"]].get("task_agent")]
|
||||||
# COORDINATOR_TOOLS apply the ``coordinator`` kind variant (if any) so a
|
# COORDINATOR_TOOLS apply the ``coordinator`` kind variant (if any) so a
|
||||||
# coord session sees the coord-tailored description + parameter schema.
|
# coord session sees the coord-tailored description + parameter schema.
|
||||||
@@ -121,7 +119,6 @@ INTERACTIVE_TOOLS = [
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
INTERACTIVE_TOOL_NAMES = frozenset(t["function"]["name"] for t in INTERACTIVE_TOOLS)
|
INTERACTIVE_TOOL_NAMES = frozenset(t["function"]["name"] for t in INTERACTIVE_TOOLS)
|
||||||
AGENT_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")}
|
|
||||||
TASK_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")}
|
TASK_AUTO_TOOLS = {n for n, m in _META.items() if m.get("auto_approve")}
|
||||||
PRIMARY_KEY_MAP = {n: m["primary_key"] for n, m in _META.items() if "primary_key" in m}
|
PRIMARY_KEY_MAP = {n: m["primary_key"] for n, m in _META.items() if "primary_key" in m}
|
||||||
BUILTIN_TOOL_NAMES = frozenset(_META)
|
BUILTIN_TOOL_NAMES = frozenset(_META)
|
||||||
|
|||||||
+8
-35
@@ -136,9 +136,6 @@ class NullUI:
|
|||||||
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
|
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def on_info(self, message: str) -> None:
|
def on_info(self, message: str) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1208,10 +1205,9 @@ against expected tool call sequences.
|
|||||||
Your job: identify SEMANTIC PATTERNS across failures and successes — \
|
Your job: identify SEMANTIC PATTERNS across failures and successes — \
|
||||||
not just what failed, but WHY, and what the failures have in common.
|
not just what failed, but WHY, and what the failures have in common.
|
||||||
|
|
||||||
You have access to `math` (Python with numpy/scipy/collections) and \
|
You have access to a `bash` tool. Use it to compute statistics, build \
|
||||||
`bash` tools. Use them to compute statistics, build confusion matrices, \
|
confusion matrices, analyze tool co-occurrence, or quantify patterns — \
|
||||||
analyze tool co-occurrence, or quantify patterns — don't just eyeball \
|
don't just eyeball the data.
|
||||||
the data.
|
|
||||||
|
|
||||||
## Analysis Framework
|
## Analysis Framework
|
||||||
|
|
||||||
@@ -1278,8 +1274,8 @@ right and wrong.
|
|||||||
Your job: edit the prompt so more tests pass.
|
Your job: edit the prompt so more tests pass.
|
||||||
|
|
||||||
Style — the prompt teaches through patterns and examples, not rules:
|
Style — the prompt teaches through patterns and examples, not rules:
|
||||||
- Good: "Plan a refactor → plan_agent:\\n plan_agent(goal='...')"
|
- Good: "Delegate a subtask → task_agent:\\n task_agent(prompt='...')"
|
||||||
- Bad: "You MUST call plan_agent. NEVER skip it. ALWAYS use it."
|
- Bad: "You MUST call task_agent. NEVER skip it. ALWAYS use it."
|
||||||
- If the current prompt contains imperative rules (MUST, NEVER, \
|
- If the current prompt contains imperative rules (MUST, NEVER, \
|
||||||
ALWAYS, ABSOLUTE RULE, etc.), replace them with a pattern that \
|
ALWAYS, ABSOLUTE RULE, etc.), replace them with a pattern that \
|
||||||
demonstrates the right behavior. Rules are noise — examples teach.
|
demonstrates the right behavior. Rules are noise — examples teach.
|
||||||
@@ -1727,24 +1723,6 @@ def _build_failure_analysis(
|
|||||||
|
|
||||||
|
|
||||||
_ANALYST_TOOLS = [
|
_ANALYST_TOOLS = [
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "math",
|
|
||||||
"description": (
|
|
||||||
"Execute Python code for analysis. Available: numpy, scipy, "
|
|
||||||
"collections, itertools, math, json, re. Use print() for output. "
|
|
||||||
"Example: print(numpy.mean([0.8, 0.6, 1.0]))"
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"code": {"type": "string", "description": "Python code to execute."},
|
|
||||||
},
|
|
||||||
"required": ["code"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1769,12 +1747,7 @@ def _exec_analyst_tool(name: str, arguments: str) -> str:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return f"Invalid JSON arguments: {arguments[:200]}"
|
return f"Invalid JSON arguments: {arguments[:200]}"
|
||||||
|
|
||||||
if name == "math":
|
if name == "bash":
|
||||||
from turnstone.core.sandbox import execute_math_sandboxed
|
|
||||||
|
|
||||||
output, is_error = execute_math_sandboxed(args.get("code", ""), timeout=15.0)
|
|
||||||
return output[:4000]
|
|
||||||
elif name == "bash":
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1802,7 +1775,7 @@ def _run_analyst(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Run the analyst agent to produce a semantic failure analysis.
|
"""Run the analyst agent to produce a semantic failure analysis.
|
||||||
|
|
||||||
Multi-turn agent with math and bash tools for computing statistics.
|
Multi-turn agent with a bash tool for computing statistics.
|
||||||
Phase 1 of the two-phase optimization: analyst diagnoses patterns,
|
Phase 1 of the two-phase optimization: analyst diagnoses patterns,
|
||||||
then optimizer uses the diagnosis to modify the prompt.
|
then optimizer uses the diagnosis to modify the prompt.
|
||||||
"""
|
"""
|
||||||
@@ -1878,7 +1851,7 @@ def _run_analyst(
|
|||||||
user_content += (
|
user_content += (
|
||||||
"\n\nAnalyze the semantic patterns across these results. "
|
"\n\nAnalyze the semantic patterns across these results. "
|
||||||
"Focus on WHY failures happen and what passing cases have in common. "
|
"Focus on WHY failures happen and what passing cases have in common. "
|
||||||
"Use the math or bash tools if you need to compute statistics, "
|
"Use the bash tool if you need to compute statistics, "
|
||||||
"build confusion matrices, or analyze distributions."
|
"build confusion matrices, or analyze distributions."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ Find something across files → search:
|
|||||||
Find and modify → search then read_file then edit_file:
|
Find and modify → search then read_file then edit_file:
|
||||||
search(query='MAX_RETRIES') → read_file(path='found.py') → edit_file(path='found.py')
|
search(query='MAX_RETRIES') → read_file(path='found.py') → edit_file(path='found.py')
|
||||||
|
|
||||||
Plan, design, or architect something → explore codebase then plan_agent:
|
|
||||||
bash(command='ls') → read_file(path='app.py') → plan_agent(goal='add caching to the application')
|
|
||||||
plan_agent(goal='refactor database layer from monolith to service')
|
|
||||||
plan_agent(goal='restructure auth module')
|
|
||||||
|
|
||||||
Run a command, git, or tests → bash:
|
Run a command, git, or tests → bash:
|
||||||
bash(command='git log -5')
|
bash(command='git log -5')
|
||||||
bash(command='pytest')
|
bash(command='pytest')
|
||||||
@@ -34,6 +29,3 @@ Retrieve a URL → web_fetch:
|
|||||||
Search the web for information → web_search:
|
Search the web for information → web_search:
|
||||||
web_search(query='current population of Tokyo')
|
web_search(query='current population of Tokyo')
|
||||||
|
|
||||||
Look up command flags or documentation → man:
|
|
||||||
man(page='tar')
|
|
||||||
man(page='grep')
|
|
||||||
@@ -30,7 +30,6 @@ from turnstone.sdk.events import (
|
|||||||
InfoEvent,
|
InfoEvent,
|
||||||
NodeJoinedEvent,
|
NodeJoinedEvent,
|
||||||
NodeLostEvent,
|
NodeLostEvent,
|
||||||
PlanReviewEvent,
|
|
||||||
ReasoningEvent,
|
ReasoningEvent,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
StatusEvent,
|
StatusEvent,
|
||||||
@@ -73,7 +72,6 @@ __all__ = [
|
|||||||
"ToolResultEvent",
|
"ToolResultEvent",
|
||||||
"ToolOutputChunkEvent",
|
"ToolOutputChunkEvent",
|
||||||
"StatusEvent",
|
"StatusEvent",
|
||||||
"PlanReviewEvent",
|
|
||||||
"InfoEvent",
|
"InfoEvent",
|
||||||
"ErrorEvent",
|
"ErrorEvent",
|
||||||
"BusyErrorEvent",
|
"BusyErrorEvent",
|
||||||
|
|||||||
@@ -426,12 +426,6 @@ class AsyncTurnstoneConsole(_BaseClient):
|
|||||||
"POST", f"/v1/api/route/workstreams/{ws_id}/approve", json_body=body
|
"POST", f"/v1/api/route/workstreams/{ws_id}/approve", json_body=body
|
||||||
)
|
)
|
||||||
|
|
||||||
async def route_plan_feedback(self, *, ws_id: str, feedback: str) -> dict[str, Any]:
|
|
||||||
"""Send plan feedback via the routing proxy."""
|
|
||||||
return await self._request(
|
|
||||||
"POST", "/v1/api/route/plan", json_body={"ws_id": ws_id, "feedback": feedback}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def route_close(self, ws_id: str) -> dict[str, Any]:
|
async def route_close(self, ws_id: str) -> dict[str, Any]:
|
||||||
"""Close a workstream via the routing proxy."""
|
"""Close a workstream via the routing proxy."""
|
||||||
return await self._request("POST", f"/v1/api/route/workstreams/{ws_id}/close", json_body={})
|
return await self._request("POST", f"/v1/api/route/workstreams/{ws_id}/close", json_body={})
|
||||||
@@ -1360,9 +1354,6 @@ class TurnstoneConsole:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def route_plan_feedback(self, *, ws_id: str, feedback: str) -> dict[str, Any]:
|
|
||||||
return self._runner.run(self._async.route_plan_feedback(ws_id=ws_id, feedback=feedback))
|
|
||||||
|
|
||||||
def route_close(self, ws_id: str) -> dict[str, Any]:
|
def route_close(self, ws_id: str) -> dict[str, Any]:
|
||||||
return self._runner.run(self._async.route_close(ws_id))
|
return self._runner.run(self._async.route_close(ws_id))
|
||||||
|
|
||||||
|
|||||||
@@ -193,18 +193,6 @@ class StatusEvent(ServerEvent):
|
|||||||
turn_count: int = 0
|
turn_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlanReviewEvent(ServerEvent):
|
|
||||||
type: str = "plan_review"
|
|
||||||
content: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlanResolvedEvent(ServerEvent):
|
|
||||||
type: str = "plan_resolved"
|
|
||||||
feedback: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InfoEvent(ServerEvent):
|
class InfoEvent(ServerEvent):
|
||||||
type: str = "info"
|
type: str = "info"
|
||||||
@@ -452,8 +440,6 @@ _SERVER_REGISTRY: dict[str, type[ServerEvent]] = {
|
|||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
ToolOutputChunkEvent,
|
ToolOutputChunkEvent,
|
||||||
StatusEvent,
|
StatusEvent,
|
||||||
PlanReviewEvent,
|
|
||||||
PlanResolvedEvent,
|
|
||||||
InfoEvent,
|
InfoEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
BusyErrorEvent,
|
BusyErrorEvent,
|
||||||
|
|||||||
@@ -283,14 +283,6 @@ class AsyncTurnstoneServer(_BaseClient):
|
|||||||
response_model=StatusResponse,
|
response_model=StatusResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def plan_feedback(self, *, ws_id: str, feedback: str = "") -> StatusResponse:
|
|
||||||
return await self._request(
|
|
||||||
"POST",
|
|
||||||
"/v1/api/plan",
|
|
||||||
json_body={"ws_id": ws_id, "feedback": feedback},
|
|
||||||
response_model=StatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
async def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||||
return await self._request(
|
return await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -689,9 +681,6 @@ class TurnstoneServer:
|
|||||||
self._async.approve(ws_id=ws_id, approved=approved, feedback=feedback, always=always)
|
self._async.approve(ws_id=ws_id, approved=approved, feedback=feedback, always=always)
|
||||||
)
|
)
|
||||||
|
|
||||||
def plan_feedback(self, *, ws_id: str, feedback: str = "") -> StatusResponse:
|
|
||||||
return self._runner.run(self._async.plan_feedback(ws_id=ws_id, feedback=feedback))
|
|
||||||
|
|
||||||
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
def command(self, *, ws_id: str, command: str) -> StatusResponse:
|
||||||
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
return self._runner.run(self._async.command(ws_id=ws_id, command=command))
|
||||||
|
|
||||||
|
|||||||
+19
-81
@@ -371,16 +371,6 @@ class WebUI(SessionUIBase):
|
|||||||
)
|
)
|
||||||
super().on_aux_usage(usage)
|
super().on_aux_usage(usage)
|
||||||
|
|
||||||
def on_plan_review(self, content: str) -> str:
|
|
||||||
self._plan_event.clear()
|
|
||||||
self._pending_plan_review = {"type": "plan_review", "content": content}
|
|
||||||
self._enqueue(self._pending_plan_review)
|
|
||||||
if not self._plan_event.wait(timeout=self._APPROVAL_WAIT_TIMEOUT):
|
|
||||||
log.warning("Plan review timed out for ws_id=%s", self.ws_id)
|
|
||||||
self._plan_result = ""
|
|
||||||
self._pending_plan_review = None
|
|
||||||
return self._plan_result
|
|
||||||
|
|
||||||
def on_error(self, message: str) -> None:
|
def on_error(self, message: str) -> None:
|
||||||
"""Layer node-only Prometheus error counter on top of the shared body."""
|
"""Layer node-only Prometheus error counter on top of the shared body."""
|
||||||
_metrics.record_error()
|
_metrics.record_error()
|
||||||
@@ -417,10 +407,9 @@ class WebUI(SessionUIBase):
|
|||||||
|
|
||||||
# ``on_output_warning`` inherited from :class:`SessionUIBase`.
|
# ``on_output_warning`` inherited from :class:`SessionUIBase`.
|
||||||
|
|
||||||
# ``resolve_approval`` / ``resolve_plan`` inherited from
|
# ``resolve_approval`` inherited from :class:`SessionUIBase`.
|
||||||
# :class:`SessionUIBase`. Intent-verdict decision propagation lives
|
# Intent-verdict decision propagation lives in the base now — both
|
||||||
# in the base now — both interactive and coord share the same
|
# interactive and coord share the same bookkeeping.
|
||||||
# bookkeeping.
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -727,9 +716,8 @@ def _interactive_events_replay(
|
|||||||
|
|
||||||
Yields a ``connected`` event (model + skip_permissions), a
|
Yields a ``connected`` event (model + skip_permissions), a
|
||||||
``status`` event with the workstream's last token usage + context %
|
``status`` event with the workstream's last token usage + context %
|
||||||
(when a turn has completed), the pending approval prompt + cached
|
(when a turn has completed), and the pending approval prompt + cached
|
||||||
intent verdicts (if a prompt is pending), and the pending
|
intent verdicts (if a prompt is pending). The lifted
|
||||||
plan-review (if a review is pending). The lifted
|
|
||||||
``make_events_handler`` body delegates that yield sequence to this
|
``make_events_handler`` body delegates that yield sequence to this
|
||||||
callback so the kind-specific shape stays in this module.
|
callback so the kind-specific shape stays in this module.
|
||||||
|
|
||||||
@@ -762,11 +750,6 @@ def _interactive_events_replay(
|
|||||||
for v in cached_verdicts:
|
for v in cached_verdicts:
|
||||||
yield {"type": "intent_verdict", **v}
|
yield {"type": "intent_verdict", **v}
|
||||||
|
|
||||||
# Pending plan-review re-injection.
|
|
||||||
pending_plan = getattr(ui, "_pending_plan_review", None)
|
|
||||||
if pending_plan is not None:
|
|
||||||
yield pending_plan
|
|
||||||
|
|
||||||
|
|
||||||
def _interactive_open_post_load(request: Request, ws: Workstream) -> None:
|
def _interactive_open_post_load(request: Request, ws: Workstream) -> None:
|
||||||
"""Post-load hook for the lifted interactive ``open`` body.
|
"""Post-load hook for the lifted interactive ``open`` body.
|
||||||
@@ -1465,26 +1448,6 @@ async def metrics_endpoint(request: Request) -> Response:
|
|||||||
return Response(content, media_type="text/plain; version=0.0.4; charset=utf-8")
|
return Response(content, media_type="text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
async def plan_feedback(request: Request) -> JSONResponse:
|
|
||||||
"""POST /v1/api/plan — respond to a plan review."""
|
|
||||||
from turnstone.core.web_helpers import read_json_or_400
|
|
||||||
|
|
||||||
body = await read_json_or_400(request)
|
|
||||||
if isinstance(body, JSONResponse):
|
|
||||||
return body
|
|
||||||
feedback = body.get("feedback", "")
|
|
||||||
ws_id = body.get("ws_id")
|
|
||||||
mgr = request.app.state.workstreams
|
|
||||||
_owner, err = _require_ws_access(request, str(ws_id or ""), mgr=mgr)
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
ws, ui = _get_ws(mgr, ws_id)
|
|
||||||
if not ws or not ui:
|
|
||||||
return JSONResponse({"error": "Unknown workstream"}, status_code=404)
|
|
||||||
ui.resolve_plan(feedback)
|
|
||||||
return JSONResponse({"status": "ok"})
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_cancel_forensics(session: Any, ui: Any, *, was_running: bool) -> dict[str, Any]:
|
def _capture_cancel_forensics(session: Any, ui: Any, *, was_running: bool) -> dict[str, Any]:
|
||||||
"""Snapshot in-flight session state for the cancel response.
|
"""Snapshot in-flight session state for the cancel response.
|
||||||
|
|
||||||
@@ -2822,7 +2785,7 @@ def config_reload(request: Request) -> JSONResponse:
|
|||||||
return JSONResponse({"status": "noop"})
|
return JSONResponse({"status": "noop"})
|
||||||
cs.reload()
|
cs.reload()
|
||||||
# Apply routing overrides to the live registry — admin settings updates
|
# Apply routing overrides to the live registry — admin settings updates
|
||||||
# fan out via this endpoint and would otherwise not affect plan/task
|
# fan out via this endpoint and would otherwise not affect task
|
||||||
# routing until a model-reload or restart.
|
# routing until a model-reload or restart.
|
||||||
registry = getattr(request.app.state, "registry", None)
|
registry = getattr(request.app.state, "registry", None)
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
@@ -3006,13 +2969,11 @@ def _effective_routing(
|
|||||||
cs: Any,
|
cs: Any,
|
||||||
base_models: dict[str, Any],
|
base_models: dict[str, Any],
|
||||||
base_default: str,
|
base_default: str,
|
||||||
base_plan_model: str | None,
|
|
||||||
base_task_model: str | None,
|
base_task_model: str | None,
|
||||||
base_plan_effort: str | None,
|
|
||||||
base_task_effort: str | None,
|
base_task_effort: str | None,
|
||||||
) -> tuple[str, str | None, str | None, str | None, str | None]:
|
) -> tuple[str, str | None, str | None]:
|
||||||
"""Compute (default, plan_model, task_model, plan_effort, task_effort)
|
"""Compute (default, task_model, task_effort) after layering ConfigStore
|
||||||
after layering ConfigStore overrides on top of the supplied base values.
|
overrides on top of the supplied base values.
|
||||||
|
|
||||||
Aliases require existence in *base_models* (silently dropped otherwise);
|
Aliases require existence in *base_models* (silently dropped otherwise);
|
||||||
effort values were validated against SettingDef choices on write, so a
|
effort values were validated against SettingDef choices on write, so a
|
||||||
@@ -3021,32 +2982,24 @@ def _effective_routing(
|
|||||||
Returns the base values unchanged when *cs* is None.
|
Returns the base values unchanged when *cs* is None.
|
||||||
"""
|
"""
|
||||||
eff_default = base_default
|
eff_default = base_default
|
||||||
eff_plan_model = base_plan_model
|
|
||||||
eff_task_model = base_task_model
|
eff_task_model = base_task_model
|
||||||
eff_plan_effort = base_plan_effort
|
|
||||||
eff_task_effort = base_task_effort
|
eff_task_effort = base_task_effort
|
||||||
if cs is not None:
|
if cs is not None:
|
||||||
cs_default = cs.get("model.default_alias")
|
cs_default = cs.get("model.default_alias")
|
||||||
if cs_default and cs_default in base_models:
|
if cs_default and cs_default in base_models:
|
||||||
eff_default = cs_default
|
eff_default = cs_default
|
||||||
cs_plan_alias = cs.get("model.plan_alias")
|
|
||||||
if cs_plan_alias and cs_plan_alias in base_models:
|
|
||||||
eff_plan_model = cs_plan_alias
|
|
||||||
cs_task_alias = cs.get("model.task_alias")
|
cs_task_alias = cs.get("model.task_alias")
|
||||||
if cs_task_alias and cs_task_alias in base_models:
|
if cs_task_alias and cs_task_alias in base_models:
|
||||||
eff_task_model = cs_task_alias
|
eff_task_model = cs_task_alias
|
||||||
cs_plan_effort = cs.get("model.plan_effort")
|
|
||||||
if cs_plan_effort:
|
|
||||||
eff_plan_effort = cs_plan_effort
|
|
||||||
cs_task_effort = cs.get("model.task_effort")
|
cs_task_effort = cs.get("model.task_effort")
|
||||||
if cs_task_effort:
|
if cs_task_effort:
|
||||||
eff_task_effort = cs_task_effort
|
eff_task_effort = cs_task_effort
|
||||||
return eff_default, eff_plan_model, eff_task_model, eff_plan_effort, eff_task_effort
|
return eff_default, eff_task_model, eff_task_effort
|
||||||
|
|
||||||
|
|
||||||
def _broadcast_agent_tool_schema_refresh(app_state: Any) -> None:
|
def _broadcast_agent_tool_schema_refresh(app_state: Any) -> None:
|
||||||
"""Tell every active session on this node to re-render its plan_agent /
|
"""Tell every active session on this node to re-render its task_agent
|
||||||
task_agent tool descriptions. Best-effort: a session that lacks the
|
tool description. Best-effort: a session that lacks the
|
||||||
method (older code path or test stub) is skipped silently.
|
method (older code path or test stub) is skipped silently.
|
||||||
|
|
||||||
Called after a registry reload that may have added/removed model
|
Called after a registry reload that may have added/removed model
|
||||||
@@ -3084,27 +3037,21 @@ def _apply_routing_overrides(registry: Any, cs: Any) -> bool:
|
|||||||
cs,
|
cs,
|
||||||
registry.models,
|
registry.models,
|
||||||
registry.default,
|
registry.default,
|
||||||
registry.plan_model,
|
|
||||||
registry.task_model,
|
registry.task_model,
|
||||||
registry.plan_effort,
|
|
||||||
registry.task_effort,
|
registry.task_effort,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
eff[0] != registry.default
|
eff[0] != registry.default
|
||||||
or eff[1] != registry.plan_model
|
or eff[1] != registry.task_model
|
||||||
or eff[2] != registry.task_model
|
or eff[2] != registry.task_effort
|
||||||
or eff[3] != registry.plan_effort
|
|
||||||
or eff[4] != registry.task_effort
|
|
||||||
):
|
):
|
||||||
registry.reload(
|
registry.reload(
|
||||||
registry.models,
|
registry.models,
|
||||||
eff[0],
|
eff[0],
|
||||||
registry.fallback,
|
registry.fallback,
|
||||||
registry.agent_model,
|
registry.agent_model,
|
||||||
plan_model=eff[1],
|
task_model=eff[1],
|
||||||
task_model=eff[2],
|
task_effort=eff[2],
|
||||||
plan_effort=eff[3],
|
|
||||||
task_effort=eff[4],
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -3132,17 +3079,13 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
|||||||
cs = getattr(request.app.state, "config_store", None)
|
cs = getattr(request.app.state, "config_store", None)
|
||||||
if cs is not None:
|
if cs is not None:
|
||||||
cs.reload() # Ensure latest settings from DB
|
cs.reload() # Ensure latest settings from DB
|
||||||
eff_default, eff_plan_model, eff_task_model, eff_plan_effort, eff_task_effort = (
|
eff_default, eff_task_model, eff_task_effort = _effective_routing(
|
||||||
_effective_routing(
|
|
||||||
cs,
|
cs,
|
||||||
new_registry.models,
|
new_registry.models,
|
||||||
new_registry.default,
|
new_registry.default,
|
||||||
new_registry.plan_model,
|
|
||||||
new_registry.task_model,
|
new_registry.task_model,
|
||||||
new_registry.plan_effort,
|
|
||||||
new_registry.task_effort,
|
new_registry.task_effort,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if eff_default != new_registry.default:
|
if eff_default != new_registry.default:
|
||||||
log.info(
|
log.info(
|
||||||
"ConfigStore override: using '%s' as default model (registry had '%s')",
|
"ConfigStore override: using '%s' as default model (registry had '%s')",
|
||||||
@@ -3157,9 +3100,7 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
|||||||
and new_registry.fallback == registry.fallback
|
and new_registry.fallback == registry.fallback
|
||||||
and new_registry.agent_model == registry.agent_model
|
and new_registry.agent_model == registry.agent_model
|
||||||
and eff_default == registry.default
|
and eff_default == registry.default
|
||||||
and eff_plan_model == registry.plan_model
|
|
||||||
and eff_task_model == registry.task_model
|
and eff_task_model == registry.task_model
|
||||||
and eff_plan_effort == registry.plan_effort
|
|
||||||
and eff_task_effort == registry.task_effort
|
and eff_task_effort == registry.task_effort
|
||||||
)
|
)
|
||||||
if unchanged:
|
if unchanged:
|
||||||
@@ -3172,9 +3113,7 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
|||||||
eff_default,
|
eff_default,
|
||||||
new_registry.fallback,
|
new_registry.fallback,
|
||||||
new_registry.agent_model,
|
new_registry.agent_model,
|
||||||
plan_model=eff_plan_model,
|
|
||||||
task_model=eff_task_model,
|
task_model=eff_task_model,
|
||||||
plan_effort=eff_plan_effort,
|
|
||||||
task_effort=eff_task_effort,
|
task_effort=eff_task_effort,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -3189,8 +3128,8 @@ def internal_model_reload(request: Request) -> JSONResponse:
|
|||||||
cfg = registry.get_config(alias)
|
cfg = registry.get_config(alias)
|
||||||
health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
|
health_reg.get_tracker(provider=cfg.provider, base_url=cfg.base_url)
|
||||||
|
|
||||||
# Push the new alias list into active sessions so plan_agent/task_agent
|
# Push the new alias list into active sessions so the task_agent
|
||||||
# `model` parameter descriptions reflect the current registry.
|
# `model` parameter description reflects the current registry.
|
||||||
_broadcast_agent_tool_schema_refresh(request.app.state)
|
_broadcast_agent_tool_schema_refresh(request.app.state)
|
||||||
|
|
||||||
# Refresh the per-node ``models`` metadata entry the coord reads on
|
# Refresh the per-node ``models`` metadata entry the coord reads on
|
||||||
@@ -4002,7 +3941,6 @@ def create_app(
|
|||||||
*v1_routes,
|
*v1_routes,
|
||||||
Route("/api/skills", list_skills_summary),
|
Route("/api/skills", list_skills_summary),
|
||||||
Route("/api/models", list_available_models),
|
Route("/api/models", list_available_models),
|
||||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
|
||||||
Route("/api/command", command, methods=["POST"]),
|
Route("/api/command", command, methods=["POST"]),
|
||||||
Route("/api/watches", list_watches),
|
Route("/api/watches", list_watches),
|
||||||
Route("/api/watches/{watch_id}/cancel", cancel_watch, methods=["POST"]),
|
Route("/api/watches/{watch_id}/cancel", cancel_watch, methods=["POST"]),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["path_a"]
|
"required": ["path_a"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
"primary_key": "path_a"
|
"primary_key": "path_a"
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "man",
|
|
||||||
"description": "Read a man page. Questions about command flags, options, or usage are tool-use tasks — use man, not memory. Use this to look up, check, or explain a command's flags, options, or usage — e.g. 'What does --xattrs do in tar?' → man(page='tar'). Use this instead of bash('man ...') or web_search for command documentation.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"page": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The man page name (e.g. 'grep', 'socket', 'printf')."
|
|
||||||
},
|
|
||||||
"section": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Manual section (e.g. '1' commands, '2' syscalls, '3' library). Optional."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["page"]
|
|
||||||
},
|
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
|
||||||
"auto_approve": true,
|
|
||||||
"primary_key": "page"
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "math",
|
|
||||||
"description": "Execute Python code for math/computation. Use this for any arithmetic, algebra, or numerical task — e.g. math(code='print(2**64 - 1)'). Code must use print() to produce output. Available: sympy, numpy, scipy (with scipy.special, scipy.optimize, scipy.integrate, scipy.linalg), math, fractions, itertools, functools, collections, decimal, operator, random, re, string. Common sympy names (symbols, solve, simplify, expand, factor, sqrt, Rational, Matrix, integrate, diff, etc.) are pre-imported. Example: x = symbols('x'); print(solve(x**2 - 4, x))",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Python code to execute. Must use print() for output."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["code"]
|
|
||||||
},
|
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
|
||||||
"auto_approve": true,
|
|
||||||
"primary_key": "code"
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["message"]
|
"required": ["message"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"coordinator": true,
|
"coordinator": true,
|
||||||
"interactive": true,
|
"interactive": true,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "plan_agent",
|
|
||||||
"description": "Delegate planning to a sub-agent. The agent autonomously explores the codebase, gathers context, and writes a step-by-step plan — just pass the goal directly. Use when asked to plan, design, think through, or strategize. Not for direct code changes like 'add a docstring' or 'fix a bug' — use read_file+edit_file for those. The plan agent has read-only tools (read_file, search, web_fetch, web_search, man) but cannot run bash, save memories, set watches, or delegate further.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"goal": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The goal and scope of the plan, including any constraints."
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional model alias to run this plan agent on. Omit to use the current session model. (No alternative aliases configured in this session.)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["goal"]
|
|
||||||
},
|
|
||||||
"primary_key": "goal"
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
"primary_key": "path"
|
"primary_key": "path"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["uri"]
|
"required": ["uri"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": false,
|
"auto_approve": false,
|
||||||
"primary_key": "uri"
|
"primary_key": "uri"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["query"]
|
"required": ["query"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": true,
|
"auto_approve": true,
|
||||||
"primary_key": "query"
|
"primary_key": "query"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "task_agent",
|
"name": "task_agent",
|
||||||
"description": "Delegate a general-purpose task to an autonomous sub-agent. The agent can read, write, edit, search, and run commands but does NOT have access to memory, recall, watch, skill, plan_agent, or task_agent — it cannot save memories, search conversation history, set up watches, switch skills mid-task, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description. Optionally pass `skill=<name>` to run the sub-agent under a specific persona (the skill is fixed at invocation and cannot be changed mid-task); use `skill(action='search', query='...')` to find an appropriate name first. An empty `skill` value is acceptable — the sub-agent runs as a competent general-purpose task helper.",
|
"description": "Delegate a general-purpose task to an autonomous sub-agent. The agent can read, write, edit, search, and run commands but does NOT have access to memory, recall, watch, skill, or task_agent — it cannot save memories, search conversation history, set up watches, switch skills mid-task, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description. Optionally pass `skill=<name>` to run the sub-agent under a specific persona (the skill is fixed at invocation and cannot be changed mid-task); use `skill(action='search', query='...')` to find an appropriate name first. An empty `skill` value is acceptable — the sub-agent runs as a competent general-purpose task helper.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["name"]
|
"required": ["name"]
|
||||||
},
|
},
|
||||||
"agent": true,
|
|
||||||
"task_agent": true,
|
"task_agent": true,
|
||||||
"auto_approve": false,
|
"auto_approve": false,
|
||||||
"primary_key": "name"
|
"primary_key": "name"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user