mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6adc577d30 | |||
| 02c50b81c1 | |||
| 753cd04b4e | |||
| c3217748dc | |||
| 8cbff49694 | |||
| 4f26d63c14 | |||
| 74347fb29f | |||
| 2ace8cccc8 | |||
| e95b8f5ca1 | |||
| 7a32c51a1c | |||
| dce663105b | |||
| cfef3616e6 | |||
| c22d39a798 | |||
| 63921450b1 | |||
| 929fad63be | |||
| 976e9df3b6 | |||
| 7cb21b84f1 | |||
| 7263edd48d | |||
| 6742c7e405 | |||
| 1aa6982868 | |||
| 42d1abbd04 | |||
| f543ed714a | |||
| 2c6abb0fde | |||
| 491fc6748a | |||
| 9a996f0067 | |||
| a465ac6383 | |||
| a4539923e4 | |||
| 42e99d6990 |
@@ -0,0 +1,4 @@
|
||||
# libexpat integer overflow — no fix available in Debian repos yet
|
||||
# https://avd.aquasec.com/nvd/cve-2026-25210
|
||||
# Review: remove this entry once a patched libexpat1 is published
|
||||
CVE-2026-25210
|
||||
+6
-2
@@ -10,8 +10,12 @@ LABEL org.opencontainers.image.title="turnstone" \
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.2 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
|
||||
# System dependencies: psycopg (libpq5), developer tooling for agent workflows
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq5 git curl jq man-db manpages \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user
|
||||
|
||||
@@ -415,6 +415,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded by `[server].max_workstre
|
||||
- Redis (for message queue bridge — `pip install turnstone[mq]`)
|
||||
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
|
||||
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
|
||||
- Math sandbox packages (optional — `pip install turnstone[sandbox]` for sympy, numpy, scipy, pytest)
|
||||
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
|
||||
|
||||
## License
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
Anthropic's native Messages API via pluggable provider adapters, and gives the
|
||||
model 17 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
model 19 built-in tools plus external tools via MCP (Model Context Protocol) for
|
||||
reading, writing, searching, planning, and executing code.
|
||||
|
||||
The core design principle is a **UI-agnostic engine with pluggable frontends**.
|
||||
@@ -259,7 +259,7 @@ class SessionUI(Protocol):
|
||||
| Class | Module | Notes |
|
||||
|-------|--------|-------|
|
||||
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
|
||||
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
|
||||
| `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). |
|
||||
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
|
||||
|
||||
### WorkstreamTerminalUI
|
||||
|
||||
@@ -97,7 +97,7 @@ package "turnstone/sdk/" <<Rectangle>> {
|
||||
|
||||
' Tool schemas
|
||||
package "turnstone/tools/" <<Rectangle>> {
|
||||
component [*.json\n18 tool schemas] as schemas <<artifact>>
|
||||
component [*.json\n19 tool schemas] as schemas <<artifact>>
|
||||
}
|
||||
|
||||
' Entry point dependencies
|
||||
|
||||
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
:Dispatch to _prepare_{func_name}();
|
||||
|
||||
note right
|
||||
**Dispatch table (17 tools):**
|
||||
**Dispatch table (19 built-in + tool_search):**
|
||||
┌───────────────┬──────────────────┐
|
||||
│ Tool │ Needs Approval? │
|
||||
├───────────────┼──────────────────┤
|
||||
@@ -33,16 +33,19 @@ partition "Phase 1: Prepare" #E8F5E9 {
|
||||
│ write_file │ ✓ Yes │
|
||||
│ edit_file │ ✓ Yes │
|
||||
│ search │ ✗ Auto-approve │
|
||||
│ diff_file │ ✗ Auto-approve │
|
||||
│ math │ ✗ Auto-approve │
|
||||
│ man │ ✗ Auto-approve │
|
||||
│ web_fetch │ ✗ Auto-approve │
|
||||
│ web_search │ ✗ Auto-approve │
|
||||
│ tool_search │ ✗ Auto-approve │
|
||||
│ task │ ✓ Yes │
|
||||
│ plan │ ✓ Yes │
|
||||
│ task_agent │ ✓ Yes │
|
||||
│ plan_agent │ ✓ Yes │
|
||||
│ memory │ ✗ Auto-approve │
|
||||
│ recall │ ✗ Auto-approve │
|
||||
│ notify │ ✗ Auto-approve │
|
||||
│ watch │ ✓ create only │
|
||||
│ skill │ ✓ load only │
|
||||
│ read_resource │ ✓ Yes │
|
||||
│ use_prompt │ ✓ Yes │
|
||||
├───────────────┼──────────────────┤
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
|
||||
size 310079
|
||||
oid sha256:2b3ea69f852e93dc1bc7943db0c71d0cdd1afcbcf470a8737189ae56e85b3206
|
||||
size 310075
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72b3932ce99a860f5069544cd8423d3cdae3a51f6566db262b19ced19c780eb0
|
||||
size 274374
|
||||
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
|
||||
size 281519
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ from the output before it enters the conversation.
|
||||
| Priority | Category | Risk | Examples |
|
||||
|----------|----------|------|----------|
|
||||
| 1 | Prompt injection | high | Override phrases, role injection (`{"role":"system"}`), instruction override markers |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets |
|
||||
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets, JSON secrets (`"api_key": "..."`, `"password": "..."`, etc.) |
|
||||
| 3 | Encoded payloads | medium | Script data URIs, hex shellcode sequences |
|
||||
| 4 | Adversarial URLs | medium | Cloud metadata endpoints, credential-bearing query parameters |
|
||||
| 5 | System info disclosure | low | Private IP addresses, sensitive file paths |
|
||||
|
||||
+42
-14
@@ -1,6 +1,6 @@
|
||||
# Tools Reference
|
||||
|
||||
turnstone exposes 18 built-in tools plus any number of external MCP tools to the
|
||||
turnstone exposes 19 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
|
||||
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
|
||||
MCP tools are discovered from configured MCP servers at startup by
|
||||
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------|-------------|
|
||||
| `TOOLS` | All 17 tool definitions (sent to the model). |
|
||||
| `TOOLS` | All 19 tool definitions (sent to the model). |
|
||||
| `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. |
|
||||
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
|
||||
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
|
||||
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. 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. |
|
||||
|
||||
---
|
||||
@@ -69,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
|
||||
- Parses the JSON arguments (with fallback for malformed JSON).
|
||||
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
|
||||
to the correct parameter.
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
|
||||
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 19
|
||||
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
|
||||
the generic `_prepare_mcp_tool()` handler for MCP tools.
|
||||
- Validates arguments and builds a preview dict containing:
|
||||
@@ -188,8 +188,11 @@ Execute a bash command and return stdout + stderr.
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `command` | string | yes | The bash command to execute. |
|
||||
| `timeout` | integer | no | Timeout in seconds (1-600). Omit to use the global `tools.timeout` setting (typically 120s). |
|
||||
| `stop_on_error` | boolean | no | Enable `set -e` so the script exits on the first command failure. Default false. |
|
||||
|
||||
- **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 /`).
|
||||
- **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).
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
|
||||
|
||||
@@ -221,8 +224,9 @@ Write content to a file, creating it if needed.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `content` | string | yes | The full file content to write. |
|
||||
| `mode` | string | no | `"overwrite"` (default) replaces the file. `"append"` adds content to the end. |
|
||||
|
||||
- **What it does**: Creates or overwrites the file at the given path. Parent directories are created as needed.
|
||||
- **What it does**: Creates or overwrites (or appends to) the file at the given path. Parent directories are created as needed.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
@@ -230,21 +234,44 @@ Write content to a file, creating it if needed.
|
||||
|
||||
### edit_file
|
||||
|
||||
Replace an exact string in a file with new content.
|
||||
Replace exact strings in a file, or apply multiple replacements atomically.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------|---------|----------|-------------|
|
||||
| `path` | string | yes | Absolute or relative file path. |
|
||||
| `old_string` | string | yes | The exact text to find and replace. |
|
||||
| `new_string` | string | yes | The replacement text. |
|
||||
| `old_string` | string | no* | The exact text to find and replace. |
|
||||
| `new_string` | string | no* | The replacement text. |
|
||||
| `near_line` | integer | no | Disambiguate when `old_string` matches multiple locations. |
|
||||
| `edits` | array | no* | Multiple replacements to apply atomically (see below). |
|
||||
| `replace_all` | boolean | no | Replace ALL occurrences of `old_string`. Cannot combine with `near_line` or `edits`. |
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` is provided to pick the nearest match). Requires a prior `read_file` call on the same path.
|
||||
\* Provide either `old_string`+`new_string` (single edit) or `edits` array (batch), not both.
|
||||
|
||||
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` or `replace_all` is provided). Requires a prior `read_file` or `diff_file` call on the same path.
|
||||
- **Batch mode**: The `edits` array accepts multiple `{old_string, new_string, near_line?}` entries applied atomically. All edits are validated before any are applied. Overlapping edits (two entries targeting the same text region) are rejected. Edits are applied in reverse file-position order so character offsets stay stable.
|
||||
- **Replace-all mode**: When `replace_all` is true, all occurrences are replaced via `str.replace()`. The approval preview shows the occurrence count.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **Agent availability**: `task_agent` only.
|
||||
|
||||
---
|
||||
|
||||
### diff_file
|
||||
|
||||
Show a unified diff between two files, or between a file and a provided string.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------------|---------|----------|-------------|
|
||||
| `path_a` | string | yes | Path to the first file. |
|
||||
| `path_b` | string | no | Path to the second file. Mutually exclusive with `content_b`. |
|
||||
| `content_b` | string | no | String content to compare against `path_a`. Mutually exclusive with `path_b`. |
|
||||
| `context_lines` | integer | no | Number of context lines around changes (default 3, max 20). |
|
||||
|
||||
- **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).
|
||||
- **Agent availability**: `agent` and `task_agent`.
|
||||
|
||||
---
|
||||
|
||||
### search
|
||||
|
||||
Search file contents for a regex pattern.
|
||||
@@ -270,8 +297,9 @@ Execute Python code for math and computation in a sandbox.
|
||||
|-----------|--------|----------|-------------|
|
||||
| `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.
|
||||
- **Auto-approve**: No -- requires user confirmation.
|
||||
- **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`.
|
||||
|
||||
---
|
||||
@@ -601,7 +629,7 @@ CLI flags override the config file:
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
- **Always-on** -- the 19 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
These are always visible to the model.
|
||||
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
|
||||
the model searches for them.
|
||||
@@ -644,7 +672,7 @@ MCP-compatible service.
|
||||
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
|
||||
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
|
||||
|
||||
4. **Merging**: MCP tools are appended after the 17 built-in tools via
|
||||
4. **Merging**: MCP tools are appended after the 19 built-in tools via
|
||||
`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
|
||||
visible -- the model discovers them via search as needed (see
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.9.2"
|
||||
version = "0.9.4"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -8583,4 +8583,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -2043,4 +2043,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ export interface StreamEndEvent {
|
||||
type: "stream_end";
|
||||
}
|
||||
|
||||
export interface StateChangeEvent {
|
||||
type: "state_change";
|
||||
state: "idle" | "thinking" | "running" | "attention" | "error";
|
||||
}
|
||||
|
||||
export interface ToolInfoEvent {
|
||||
type: "tool_info";
|
||||
items: Array<Record<string, unknown>>;
|
||||
@@ -150,6 +155,7 @@ export type ServerEvent =
|
||||
| ContentEvent
|
||||
| ReasoningEvent
|
||||
| StreamEndEvent
|
||||
| StateChangeEvent
|
||||
| ToolInfoEvent
|
||||
| ApproveRequestEvent
|
||||
| ApprovalResolvedEvent
|
||||
@@ -247,6 +253,10 @@ export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
|
||||
return e.type === "stream_end";
|
||||
}
|
||||
|
||||
export function isStateChangeEvent(e: ServerEvent): e is StateChangeEvent {
|
||||
return e.type === "state_change";
|
||||
}
|
||||
|
||||
export function isToolResultEvent(e: ServerEvent): e is ToolResultEvent {
|
||||
return e.type === "tool_result";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type {
|
||||
ContentEvent,
|
||||
ReasoningEvent,
|
||||
StreamEndEvent,
|
||||
StateChangeEvent,
|
||||
ToolInfoEvent,
|
||||
ApproveRequestEvent,
|
||||
ApprovalResolvedEvent,
|
||||
@@ -65,6 +66,7 @@ export {
|
||||
isReasoningEvent,
|
||||
isErrorEvent,
|
||||
isStreamEndEvent,
|
||||
isStateChangeEvent,
|
||||
isToolResultEvent,
|
||||
isWsStateEvent,
|
||||
isApproveRequestEvent,
|
||||
|
||||
+10
-8
@@ -180,7 +180,7 @@ class TestCancelDuringToolExecution:
|
||||
"""Cancel while tools are being executed."""
|
||||
|
||||
def test_rollback_incomplete_tool_results(self, tmp_db):
|
||||
"""When cancelled during tool execution, incomplete results are rolled back."""
|
||||
"""When cancelled during tool execution, synthesized results replace missing tool outputs."""
|
||||
ui = NullUI()
|
||||
session = _make_session(ui=ui)
|
||||
|
||||
@@ -236,13 +236,15 @@ class TestCancelDuringToolExecution:
|
||||
|
||||
# Session should be idle
|
||||
assert ui.states[-1] == "idle"
|
||||
# No tool result messages should remain (rolled back)
|
||||
roles = [m["role"] for m in session.messages]
|
||||
assert "tool" not in roles
|
||||
# The assistant message with tool_calls should also be rolled back
|
||||
for m in session.messages:
|
||||
if m["role"] == "assistant":
|
||||
assert "tool_calls" not in m or not m["tool_calls"]
|
||||
# Cancelled tool calls should have synthesized results
|
||||
tool_msgs = [m for m in session.messages if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0]["tool_call_id"] == "tc_1"
|
||||
assert "Cancelled by user" in tool_msgs[0]["content"]
|
||||
assert tool_msgs[0].get("is_error") is True
|
||||
# The assistant message with tool_calls should still be present
|
||||
assistant_msgs = [m for m in session.messages if m.get("tool_calls")]
|
||||
assert len(assistant_msgs) == 1
|
||||
|
||||
|
||||
class TestCancelWhenIdle:
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestProbeModelEndpoint:
|
||||
)
|
||||
assert result["reachable"] is True
|
||||
assert result["server_type"] == "anthropic"
|
||||
assert result["context_window"] == 200000
|
||||
assert result["context_window"] == 1000000
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_connection_failure(self, mock_cc: MagicMock) -> None:
|
||||
@@ -184,7 +184,7 @@ class TestLookupModelCapabilities:
|
||||
def test_known_anthropic_model(self) -> None:
|
||||
caps = lookup_model_capabilities("anthropic", "claude-opus-4-6")
|
||||
assert caps is not None
|
||||
assert caps["context_window"] == 200000
|
||||
assert caps["context_window"] == 1000000
|
||||
assert caps["thinking_mode"] == "adaptive"
|
||||
|
||||
def test_unknown_model_returns_none(self) -> None:
|
||||
|
||||
+56
-13
@@ -957,7 +957,7 @@ class TestAnthropicHelpers:
|
||||
|
||||
provider = AnthropicProvider()
|
||||
caps = provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.context_window == 200000
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.max_output_tokens == 128000
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
assert caps.supports_effort is True
|
||||
@@ -966,11 +966,11 @@ class TestAnthropicHelpers:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
provider = AnthropicProvider()
|
||||
# Prefix match: "claude-sonnet-4" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-20260101")
|
||||
assert caps.context_window == 200000
|
||||
# Prefix match: "claude-sonnet-4-6" matches dated variants
|
||||
caps = provider.get_capabilities("claude-sonnet-4-6-20260101")
|
||||
assert caps.context_window == 1000000
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "manual"
|
||||
assert caps.thinking_mode == "adaptive"
|
||||
|
||||
def test_capabilities_lookup_unknown(self) -> None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
@@ -1273,11 +1273,13 @@ class TestAnthropicOrphanedToolUse:
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
result_map = {r["tool_use_id"]: r for r in tool_results}
|
||||
assert "c1" in result_map
|
||||
assert result_map["c1"]["content"] == "file1.txt" # real result
|
||||
assert "c2" in result_map
|
||||
assert result_map["c2"]["is_error"] is True # synthetic
|
||||
# Real result should come before synthetic (ordering matters for Anthropic)
|
||||
assert len(tool_results) == 2
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["content"] == "file1.txt" # real result
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
assert tool_results[1]["tool_use_id"] == "c2"
|
||||
assert tool_results[1]["is_error"] is True # synthetic
|
||||
|
||||
def test_complete_results_no_synthesis(self) -> None:
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
@@ -1294,12 +1296,16 @@ class TestAnthropicOrphanedToolUse:
|
||||
{"role": "user", "content": "thanks"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# No synthetic results — only the real one
|
||||
# No synthetic results — only the real one (no is_error flag)
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
assert "cancelled" not in block.get("content", "").lower()
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan(self) -> None:
|
||||
"""Orphaned tool_use at end of conversation (no following messages)."""
|
||||
@@ -1324,6 +1330,43 @@ class TestAnthropicOrphanedToolUse:
|
||||
assert tool_results[0]["tool_use_id"] == "c1"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
def test_provider_content_orphan(self) -> None:
|
||||
"""Orphaned tool_use inside _provider_content (Anthropic raw blocks)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "run something"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Running...",
|
||||
"_provider_content": [
|
||||
{"type": "text", "text": "Running..."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_abc",
|
||||
"name": "bash",
|
||||
"input": {"command": "sleep 30"},
|
||||
},
|
||||
],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "toolu_abc",
|
||||
"function": {"name": "bash", "arguments": '{"command": "sleep 30"}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "never mind"},
|
||||
]
|
||||
_, converted = self.provider._convert_messages(messages)
|
||||
# Should synthesize a tool_result for the orphaned tool_use in provider_content
|
||||
tool_results = []
|
||||
for msg in converted:
|
||||
if msg["role"] == "user" and isinstance(msg["content"], list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
tool_results.append(block)
|
||||
assert len(tool_results) == 1
|
||||
assert tool_results[0]["tool_use_id"] == "toolu_abc"
|
||||
assert tool_results[0]["is_error"] is True
|
||||
|
||||
|
||||
class TestAnthropicReasoningNone:
|
||||
"""Verify 'none' effort disables thinking for manual-thinking models."""
|
||||
@@ -1364,7 +1407,7 @@ class TestAnthropicWebSearch:
|
||||
"""All Anthropic models should support native web search."""
|
||||
caps = self.provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4")
|
||||
caps = self.provider.get_capabilities("claude-sonnet-4-6")
|
||||
assert caps.supports_web_search is True
|
||||
# Unknown models use default which also has web search
|
||||
caps = self.provider.get_capabilities("claude-unknown-99")
|
||||
|
||||
@@ -226,3 +226,91 @@ class TestEdgeCases:
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["role"] == "assistant"
|
||||
|
||||
|
||||
class TestMidConversationOrphanRepair:
|
||||
"""Mid-conversation orphaned tool_calls get synthetic tool results."""
|
||||
|
||||
def test_all_orphaned_mid_conversation(self):
|
||||
"""Assistant has 2 tool_calls, no tool results, then user message."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
_row("user", "never mind"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1), tool(c2), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["is_error"] is True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_partial_results_mid_conversation(self):
|
||||
"""2 tool_calls, 1 result present, 1 missing — synthesize only the missing one."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do stuff"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "file1.txt", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "skip the write"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Should have: user, assistant, tool(c1 real), tool(c2 synthetic), user
|
||||
assert len(msgs) == 5
|
||||
assert msgs[2]["role"] == "tool"
|
||||
assert msgs[2]["tool_call_id"] == "c1"
|
||||
assert msgs[2]["content"] == "file1.txt"
|
||||
assert msgs[2].get("is_error") is not True
|
||||
assert msgs[3]["role"] == "tool"
|
||||
assert msgs[3]["tool_call_id"] == "c2"
|
||||
assert msgs[3]["is_error"] is True
|
||||
assert msgs[4]["role"] == "user"
|
||||
|
||||
def test_complete_results_no_synthesis(self):
|
||||
"""All tool_calls have results — no synthesis needed."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "", tool_calls=tc),
|
||||
_row("tool", "done", tool_name="bash", tc_id="c1"),
|
||||
_row("user", "thanks"),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
assert len(msgs) == 4
|
||||
tool_msgs = [m for m in msgs if m["role"] == "tool"]
|
||||
assert len(tool_msgs) == 1
|
||||
assert tool_msgs[0].get("is_error") is not True
|
||||
|
||||
def test_trailing_orphan_stripped_not_synthesized(self):
|
||||
"""Trailing orphan is handled by the existing strip repair, not synthesis."""
|
||||
tc = json.dumps(
|
||||
[
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
]
|
||||
)
|
||||
rows = [
|
||||
_row("user", "do it"),
|
||||
_row("assistant", "Running...", tool_calls=tc),
|
||||
]
|
||||
msgs = reconstruct_messages(rows, "ws1")
|
||||
# Trailing strip removes the assistant message entirely
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["role"] == "user"
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for conversation rewind and retry functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NullUI:
|
||||
"""UI adapter that discards all output."""
|
||||
|
||||
def on_thinking_start(self):
|
||||
pass
|
||||
|
||||
def on_thinking_stop(self):
|
||||
pass
|
||||
|
||||
def on_reasoning_token(self, text):
|
||||
pass
|
||||
|
||||
def on_content_token(self, text):
|
||||
pass
|
||||
|
||||
def on_stream_end(self):
|
||||
pass
|
||||
|
||||
def approve_tools(self, items):
|
||||
return True, None
|
||||
|
||||
def on_tool_result(self, call_id, name, output, **kwargs):
|
||||
pass
|
||||
|
||||
def on_tool_output_chunk(self, call_id, chunk):
|
||||
pass
|
||||
|
||||
def on_status(self, usage, context_window, effort):
|
||||
pass
|
||||
|
||||
def on_plan_review(self, content):
|
||||
return ""
|
||||
|
||||
def on_info(self, message):
|
||||
pass
|
||||
|
||||
def on_error(self, message):
|
||||
pass
|
||||
|
||||
def on_state_change(self, state):
|
||||
pass
|
||||
|
||||
def on_rename(self, name):
|
||||
pass
|
||||
|
||||
def on_output_warning(self, call_id, assessment):
|
||||
pass
|
||||
|
||||
|
||||
def _make_session(tmp_db) -> ChatSession:
|
||||
return ChatSession(
|
||||
client=MagicMock(),
|
||||
model="test-model",
|
||||
ui=NullUI(),
|
||||
instructions="",
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
tool_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _populate_simple(session: ChatSession) -> None:
|
||||
"""Populate with 2 simple turns (no tool calls)."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
{"role": "assistant", "content": "I'm fine."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20]
|
||||
|
||||
|
||||
def _populate_with_tools(session: ChatSession) -> None:
|
||||
"""Populate with 2 turns, first has tool calls."""
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Write a test"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
{"role": "user", "content": "Fix the import"},
|
||||
{"role": "assistant", "content": "Fixed."},
|
||||
]
|
||||
session._msg_tokens = [10, 20, 10, 20, 10, 20]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_turn_boundaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindTurnBoundaries:
|
||||
def test_empty_messages(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session._find_turn_boundaries() == []
|
||||
|
||||
def test_single_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
]
|
||||
assert session._find_turn_boundaries() == [0]
|
||||
|
||||
def test_multi_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session._find_turn_boundaries() == [0, 2]
|
||||
|
||||
def test_with_tool_calls(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
assert session._find_turn_boundaries() == [0, 4]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewind:
|
||||
def test_rewind_zero(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
assert session.rewind(0) == 0
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_rewind_one_turn(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user + assistant
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0]["content"] == "Hello"
|
||||
assert session.messages[1]["content"] == "Hi there!"
|
||||
assert len(session._msg_tokens) == 2
|
||||
|
||||
def test_rewind_all_turns(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(2)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
assert len(session._msg_tokens) == 0
|
||||
|
||||
def test_rewind_clamped(self, tmp_db):
|
||||
"""Rewinding more turns than exist should clamp to available."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
removed = session.rewind(999)
|
||||
assert removed == 4
|
||||
assert len(session.messages) == 0
|
||||
|
||||
def test_rewind_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.rewind(1) == 0
|
||||
|
||||
def test_rewind_with_tools(self, tmp_db):
|
||||
"""Rewinding 1 turn on a multi-sub-turn conversation."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
removed = session.rewind(1)
|
||||
assert removed == 2 # user "Fix the import" + assistant "Fixed."
|
||||
assert len(session.messages) == 4
|
||||
assert session.messages[-1]["content"] == "Done."
|
||||
|
||||
def test_rewind_tokens_sync(self, tmp_db):
|
||||
"""_msg_tokens stays in sync with messages."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.rewind(1)
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_retry_returns_user_message(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
msg = session.retry()
|
||||
assert msg == "How are you?"
|
||||
# Only Turn 1 remains, without the second user message
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[-1]["content"] == "Hi there!"
|
||||
|
||||
def test_retry_empty(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
assert session.retry() is None
|
||||
|
||||
def test_retry_with_tools(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_with_tools(session)
|
||||
msg = session.retry()
|
||||
assert msg == "Fix the import"
|
||||
# Only Turn 1 remains (user + assistant w/tools + tool result + assistant)
|
||||
assert len(session.messages) == 4
|
||||
|
||||
def test_retry_sets_pending(self, tmp_db):
|
||||
"""handle_command for /retry should set _pending_retry."""
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/retry")
|
||||
assert session._pending_retry == "How are you?"
|
||||
|
||||
def test_retry_tokens_sync(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.retry()
|
||||
assert len(session._msg_tokens) == len(session.messages)
|
||||
|
||||
def test_retry_multipart_content_returns_none(self, tmp_db):
|
||||
"""retry() should refuse multipart (vision/image) messages."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": [{"type": "text", "text": "describe this"}]},
|
||||
{"role": "assistant", "content": "It's an image."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
# Messages should be unchanged
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_retry_none_content_returns_none(self, tmp_db):
|
||||
"""retry() should handle content=None gracefully."""
|
||||
session = _make_session(tmp_db)
|
||||
session.messages = [
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "Ok."},
|
||||
]
|
||||
session._msg_tokens = [10, 20]
|
||||
assert session.retry() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_command integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
def test_rewind_command(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
_populate_simple(session)
|
||||
session.handle_command("/rewind 1")
|
||||
assert len(session.messages) == 2
|
||||
|
||||
def test_rewind_no_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Usage" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_rewind_invalid_arg(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/rewind abc")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "integer" in ui.on_info.call_args[0][0]
|
||||
|
||||
def test_retry_nothing_to_retry(self, tmp_db):
|
||||
session = _make_session(tmp_db)
|
||||
ui = session.ui
|
||||
ui.on_info = MagicMock()
|
||||
session.handle_command("/retry")
|
||||
ui.on_info.assert_called_once()
|
||||
assert "Nothing" in ui.on_info.call_args[0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage integration — delete_messages_after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteMessagesAfter:
|
||||
def test_delete_truncates_db(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-delete"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 2)
|
||||
assert deleted == 2
|
||||
|
||||
msgs = load_messages(ws_id)
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["content"] == "Hello"
|
||||
assert msgs[1]["content"] == "Hi!"
|
||||
|
||||
def test_delete_nothing(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-noop"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 10)
|
||||
assert deleted == 0
|
||||
|
||||
def test_delete_all(self, tmp_db):
|
||||
from turnstone.core.memory import (
|
||||
delete_messages_after,
|
||||
load_messages,
|
||||
register_workstream,
|
||||
save_message,
|
||||
)
|
||||
|
||||
ws_id = "test-ws-all"
|
||||
register_workstream(ws_id)
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
|
||||
deleted = delete_messages_after(ws_id, 0)
|
||||
assert deleted == 2
|
||||
assert load_messages(ws_id) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: rewind + DB sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRewindDBSync:
|
||||
def test_rewind_persists_to_db(self, tmp_db):
|
||||
from turnstone.core.memory import load_messages, register_workstream, save_message
|
||||
|
||||
session = _make_session(tmp_db)
|
||||
ws_id = session.ws_id
|
||||
register_workstream(ws_id)
|
||||
|
||||
# Persist messages to DB and set in-memory state
|
||||
save_message(ws_id, "user", "Hello")
|
||||
save_message(ws_id, "assistant", "Hi!")
|
||||
save_message(ws_id, "user", "Bye")
|
||||
save_message(ws_id, "assistant", "Goodbye!")
|
||||
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
{"role": "assistant", "content": "Goodbye!"},
|
||||
]
|
||||
session._msg_tokens = [5, 5, 5, 5]
|
||||
|
||||
session.rewind(1)
|
||||
|
||||
# Verify DB matches in-memory state
|
||||
db_msgs = load_messages(ws_id)
|
||||
assert len(db_msgs) == 2
|
||||
assert db_msgs[0]["content"] == "Hello"
|
||||
assert db_msgs[1]["content"] == "Hi!"
|
||||
@@ -72,18 +72,19 @@ class TestToolsMetadata:
|
||||
"""Validate the metadata extracted from JSON files."""
|
||||
|
||||
def test_tool_count(self):
|
||||
assert len(TOOLS) == 18
|
||||
assert len(TOOLS) == 19
|
||||
|
||||
def test_agent_tools_count(self):
|
||||
assert len(AGENT_TOOLS) == 9
|
||||
assert len(AGENT_TOOLS) == 10
|
||||
|
||||
def test_task_agent_tools_count(self):
|
||||
assert len(TASK_AGENT_TOOLS) == 12
|
||||
assert len(TASK_AGENT_TOOLS) == 13
|
||||
|
||||
def test_auto_approve_sets_match(self):
|
||||
expected = {
|
||||
"read_file",
|
||||
"search",
|
||||
"diff_file",
|
||||
"math",
|
||||
"man",
|
||||
"web_fetch",
|
||||
@@ -113,6 +114,7 @@ class TestToolsMetadata:
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"skill": "name",
|
||||
"diff_file": "path_a",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "0.9.4"
|
||||
|
||||
@@ -63,6 +63,8 @@ SLASH_COMMANDS = [
|
||||
"/creative",
|
||||
"/debug",
|
||||
"/mcp",
|
||||
"/retry",
|
||||
"/rewind",
|
||||
"/help",
|
||||
"/exit",
|
||||
"/quit",
|
||||
@@ -1254,6 +1256,16 @@ def main() -> None:
|
||||
should_exit = active.session.handle_command(user_input)
|
||||
if should_exit:
|
||||
break
|
||||
# Dispatch deferred retry (handle_command sets _pending_retry)
|
||||
retry_msg = active.session._pending_retry
|
||||
if retry_msg:
|
||||
active.session._pending_retry = None
|
||||
try:
|
||||
active.session.send(retry_msg)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{yellow('Interrupted.')}")
|
||||
except Exception as e:
|
||||
print(f"\n{red(f'Error: {e}')}")
|
||||
else:
|
||||
try:
|
||||
active.session.send(user_input)
|
||||
|
||||
@@ -1771,6 +1771,7 @@ _VALID_PERMISSIONS = frozenset(
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
"conversation.modify",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+33
-5
@@ -189,6 +189,20 @@ _CRITICAL_RULES: list[_HeuristicRule] = [
|
||||
"This is a two-step variant of pipe-to-shell."
|
||||
),
|
||||
),
|
||||
_HeuristicRule(
|
||||
name="proc-environ-exfil",
|
||||
risk_level="critical",
|
||||
confidence=0.95,
|
||||
recommendation="deny",
|
||||
tool_pattern="bash",
|
||||
arg_patterns=[r"/proc/\d+/environ", r"/proc/self/environ"],
|
||||
intent_template="Process environment exfiltration: {arg_snippet}",
|
||||
reasoning_template=(
|
||||
"Reading /proc/*/environ exposes all environment variables of the "
|
||||
"target process, which may include database credentials, API keys, "
|
||||
"and JWT secrets. This is a credential exfiltration vector."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
# -- High (confidence 0.80, review) ----------------------------------------
|
||||
@@ -606,7 +620,10 @@ def _get_arg_text(func_name: str, func_args: dict[str, object]) -> str:
|
||||
if func_name == "bash":
|
||||
return str(func_args.get("command", ""))
|
||||
if func_name in ("write_file", "edit_file"):
|
||||
return str(func_args.get("path", ""))
|
||||
path = str(func_args.get("path", ""))
|
||||
expanded = os.path.expanduser(path) if path else ""
|
||||
resolved = os.path.realpath(expanded) if expanded else ""
|
||||
return f"{path} {resolved}" if resolved != os.path.abspath(expanded) else path
|
||||
try:
|
||||
return json.dumps(func_args, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
@@ -1031,10 +1048,11 @@ class IntentJudge:
|
||||
# Prepare context
|
||||
judge_messages = self._prepare_context(item, messages)
|
||||
|
||||
# Prepare tools (only if read_only_tools enabled)
|
||||
# Prepare tools (only if read_only_tools enabled).
|
||||
# Pass raw OpenAI-format schemas — create_completion handles conversion.
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if self._config.read_only_tools:
|
||||
tools = self._provider.convert_tools(_JUDGE_TOOL_SCHEMAS)
|
||||
tools = _JUDGE_TOOL_SCHEMAS
|
||||
|
||||
# Multi-turn judge loop
|
||||
timeout_budget = self._config.timeout
|
||||
@@ -1212,10 +1230,20 @@ class IntentJudge:
|
||||
f"{json.dumps(func_args, indent=2, ensure_ascii=False)}\n```"
|
||||
)
|
||||
|
||||
# FIFO truncation of conversation history (keep most recent)
|
||||
# Trim to messages from the last user message onward — the judge
|
||||
# only needs the immediate request context, not the full history.
|
||||
# This keeps latency bounded as conversations grow.
|
||||
last_user_idx = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
recent = messages[last_user_idx:] if last_user_idx is not None else messages
|
||||
|
||||
# Apply FIFO budget cap on the trimmed context
|
||||
truncated: list[dict[str, Any]] = []
|
||||
total_chars = 0
|
||||
for msg in reversed(messages):
|
||||
for msg in reversed(recent):
|
||||
content = msg.get("content", "") or ""
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
|
||||
@@ -65,6 +65,23 @@ def load_messages(ws_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def delete_messages_after(ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows.
|
||||
|
||||
Returns the number of rows deleted, or 0 on error.
|
||||
"""
|
||||
try:
|
||||
return get_storage().delete_messages_after(ws_id, keep_count)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Failed to delete messages after count=%d for ws=%s",
|
||||
keep_count,
|
||||
ws_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# -- Workstream management ----------------------------------------------------
|
||||
|
||||
|
||||
@@ -250,10 +267,10 @@ def update_workstream_title(ws_id: str, title: str) -> None:
|
||||
# -- Conversation search -------------------------------------------------------
|
||||
|
||||
|
||||
def search_history(query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history."""
|
||||
try:
|
||||
return get_storage().search_history(query, limit)
|
||||
return get_storage().search_history(query, limit, offset)
|
||||
except Exception:
|
||||
log.warning("Failed to search history", exc_info=True)
|
||||
return []
|
||||
|
||||
@@ -51,11 +51,13 @@ _RE_PRIVATE_KEY_BLOCK = re.compile(
|
||||
r"-----END\s+(?:RSA\s+|EC\s+|OPENSSH\s+|PGP\s+)?PRIVATE\s+KEY-----",
|
||||
)
|
||||
_RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
|
||||
r"(?:postgresql\+?(?:psycopg)?|mysql|mongodb|redis|amqp|sqlite)://[^:@\s]+:[^@\s]+@",
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
_RE_ENV_SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|DSN)(?:_|$)"
|
||||
r"|(?:^|_)KEY(?:_|$)"
|
||||
r"|^(?:DATABASE_URL|TURNSTONE_DB_URL|DB_URL)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_JSON_SECRET = re.compile(
|
||||
|
||||
@@ -84,7 +84,7 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
|
||||
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
"claude-opus-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=128000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -95,7 +95,7 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4-6": ModelCapabilities(
|
||||
context_window=200000,
|
||||
context_window=1000000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="adaptive",
|
||||
@@ -131,24 +131,6 @@ _ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
supports_web_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-opus-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=32000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
"claude-sonnet-4": ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="manual",
|
||||
supports_web_search=True,
|
||||
supports_tool_search=True,
|
||||
supports_vision=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +272,7 @@ class AnthropicProvider:
|
||||
"""
|
||||
system_parts: list[str] = []
|
||||
converted: list[dict[str, Any]] = []
|
||||
pending_orphan_results: list[dict[str, Any]] = []
|
||||
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
@@ -303,11 +286,50 @@ class AnthropicProvider:
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Safety: flush any unconsumed synthetic results from a prior
|
||||
# assistant message (should not happen with well-formed data).
|
||||
if pending_orphan_results:
|
||||
converted.append({"role": "user", "content": pending_orphan_results})
|
||||
pending_orphan_results = []
|
||||
# If raw provider content was preserved, pass it through verbatim
|
||||
# so encrypted_content/encrypted_index from web search are retained
|
||||
provider_content = msg.get("_provider_content")
|
||||
if provider_content:
|
||||
converted.append({"role": "assistant", "content": provider_content})
|
||||
# Check for orphaned tool_use in provider content too
|
||||
if isinstance(provider_content, list):
|
||||
pc_tool_ids = [
|
||||
b["id"]
|
||||
for b in provider_content
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if pc_tool_ids:
|
||||
j = i + 1
|
||||
result_ids_pc: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids_pc.add(tc_id)
|
||||
j += 1
|
||||
orphaned_pc = [uid for uid in pc_tool_ids if uid not in result_ids_pc]
|
||||
if orphaned_pc:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned provider_content tool_use IDs",
|
||||
len(orphaned_pc),
|
||||
)
|
||||
synthetic_pc = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned_pc
|
||||
]
|
||||
if j == i + 1:
|
||||
converted.append({"role": "user", "content": synthetic_pc})
|
||||
else:
|
||||
pending_orphan_results = synthetic_pc
|
||||
i += 1
|
||||
continue
|
||||
|
||||
@@ -339,20 +361,28 @@ class AnthropicProvider:
|
||||
# happens when a cancel interrupts tool execution — the
|
||||
# assistant message is saved to DB before tools run, but
|
||||
# GenerationCancelled prevents tool results from being created.
|
||||
tool_use_ids = {b["id"] for b in content_blocks if b.get("type") == "tool_use"}
|
||||
# Collect IDs in order, skip empty IDs (from malformed tool calls).
|
||||
tool_use_ids = [
|
||||
b["id"] for b in content_blocks if b.get("type") == "tool_use" and b.get("id")
|
||||
]
|
||||
if tool_use_ids:
|
||||
# Peek ahead to collect tool_result IDs
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
result_ids.add(messages[j].get("tool_call_id", ""))
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
orphaned = tool_use_ids - result_ids
|
||||
orphaned = [uid for uid in tool_use_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
log.debug(
|
||||
"Synthesizing %d tool_result(s) for orphaned tool_use IDs",
|
||||
len(orphaned),
|
||||
)
|
||||
# Store for deferred injection — synthetic results are
|
||||
# appended after any real tool results so
|
||||
# _merge_consecutive produces them in tool_use order.
|
||||
synthetic = [
|
||||
{
|
||||
"type": "tool_result",
|
||||
@@ -362,29 +392,67 @@ class AnthropicProvider:
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
converted.append({"role": "user", "content": synthetic})
|
||||
if j == i + 1:
|
||||
# No real tool messages follow — inject immediately
|
||||
converted.append({"role": "user", "content": synthetic})
|
||||
else:
|
||||
# Real tool messages follow — they'll be converted
|
||||
# next iteration. Stash synthetic results to append
|
||||
# after them.
|
||||
pending_orphan_results = synthetic
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Anthropic: tool results are content blocks in a user message
|
||||
# Anthropic: tool results are content blocks in a user message.
|
||||
# Collect valid tool_use IDs from the preceding assistant message
|
||||
# so we can drop orphaned tool_results that have no matching
|
||||
# tool_use (e.g. from compaction boundary, old cancel stripping).
|
||||
prev_tool_use_ids: set[str] = set()
|
||||
if converted and converted[-1].get("role") == "assistant":
|
||||
prev_content = converted[-1].get("content", [])
|
||||
if isinstance(prev_content, list):
|
||||
for block in prev_content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
bid = block.get("id", "")
|
||||
if bid:
|
||||
prev_tool_use_ids.add(bid)
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
while i < len(messages) and messages[i]["role"] == "tool":
|
||||
tool_msg = messages[i]
|
||||
tc_id = tool_msg.get("tool_call_id", "")
|
||||
# Drop orphaned tool_results with no matching tool_use.
|
||||
# When prev_tool_use_ids is empty (no preceding assistant
|
||||
# tool_use), let all results through — avoids false drops
|
||||
# from unexpected message ordering.
|
||||
if prev_tool_use_ids and tc_id not in prev_tool_use_ids:
|
||||
log.debug(
|
||||
"Dropping orphaned tool_result (no matching tool_use): %s",
|
||||
tc_id,
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
content = tool_msg.get("content", "")
|
||||
# Convert image_url parts to Anthropic image format
|
||||
if isinstance(content, list):
|
||||
content = self._convert_content_parts(content)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_msg.get("tool_call_id", ""),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
result_block: dict[str, Any] = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc_id,
|
||||
"content": content,
|
||||
}
|
||||
if tool_msg.get("is_error"):
|
||||
result_block["is_error"] = True
|
||||
tool_results.append(result_block)
|
||||
i += 1
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
# Append any deferred synthetic results after real ones
|
||||
if pending_orphan_results:
|
||||
tool_results.extend(pending_orphan_results)
|
||||
pending_orphan_results = []
|
||||
if tool_results:
|
||||
converted.append({"role": "user", "content": tool_results})
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
|
||||
@@ -11,12 +11,30 @@ BLOCKED_PATTERNS = [
|
||||
"reboot",
|
||||
"halt",
|
||||
"poweroff",
|
||||
"dd if=",
|
||||
"of=/dev/sd",
|
||||
"of=/dev/nvme",
|
||||
"of=/dev/vd",
|
||||
"of=/dev/xvd",
|
||||
"of=/dev/hd",
|
||||
"of=/dev/dm-",
|
||||
"of=/dev/md",
|
||||
"of=/dev/loop",
|
||||
"of=/dev/disk/",
|
||||
":(){ :|:& };:", # fork bomb
|
||||
"> /dev/sda",
|
||||
"> /dev/sd",
|
||||
"> /dev/nvme",
|
||||
"> /dev/vd",
|
||||
"> /dev/xvd",
|
||||
"> /dev/hd",
|
||||
"> /dev/dm-",
|
||||
"> /dev/md",
|
||||
"> /dev/disk/",
|
||||
"mv / ",
|
||||
"chmod -R 777 /",
|
||||
"chown -R ",
|
||||
# Credential exfiltration via procfs
|
||||
"/proc/1/environ",
|
||||
"/proc/self/environ",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ _MATH_BLOCKED_BUILTINS = {
|
||||
"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 = {
|
||||
@@ -85,6 +94,9 @@ class _ASTValidator(ast.NodeVisitor):
|
||||
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)
|
||||
|
||||
|
||||
@@ -109,6 +121,7 @@ def validate_math_code(code: str) -> list[str]:
|
||||
|
||||
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
|
||||
@@ -124,7 +137,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
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")
|
||||
return original_import(name, *args, **kwargs)
|
||||
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__"]
|
||||
@@ -242,7 +262,14 @@ def _math_exec_in_process(code: str, result_queue: multiprocessing.Queue[tuple[s
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
exec(code, ns)
|
||||
# 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()
|
||||
|
||||
+513
-95
@@ -12,6 +12,7 @@ import base64
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
@@ -35,6 +36,7 @@ from turnstone.core.edit import find_occurrences, pick_nearest
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.memory import (
|
||||
count_structured_memories,
|
||||
delete_messages_after,
|
||||
delete_structured_memory,
|
||||
delete_workstream,
|
||||
get_skill_by_name,
|
||||
@@ -361,6 +363,7 @@ class ChatSession:
|
||||
self._active_procs: set[subprocess.Popen[str]] = set() # for force-kill
|
||||
self._procs_lock = threading.Lock()
|
||||
self._cancelled_partial_msg: dict[str, Any] | None = None
|
||||
self._pending_retry: str | None = None
|
||||
# Intent validation judge (lazy-initialized)
|
||||
self._judge_config: JudgeConfig | None = judge_config
|
||||
self._judge: IntentJudge | None = None
|
||||
@@ -1458,7 +1461,27 @@ class ChatSession:
|
||||
# text would corrupt the payload.
|
||||
_tc_by_id = {c["id"]: c for c in tool_calls}
|
||||
_repeat_detected = False
|
||||
_error_prefixes = ("Error", "JSON parse error", "Unknown tool", "Command timed out")
|
||||
_error_prefixes = (
|
||||
"Error",
|
||||
"JSON parse error",
|
||||
"Unknown tool",
|
||||
"Command timed out",
|
||||
"Blocked:",
|
||||
"Denied",
|
||||
)
|
||||
|
||||
# Clear dedup sigs when a write tool executed successfully —
|
||||
# the state has changed so re-running a read tool is valid.
|
||||
_write_tools = frozenset({"write_file", "edit_file", "bash"})
|
||||
if any(
|
||||
tc["function"]["name"] in _write_tools
|
||||
and not any(
|
||||
cid == tc["id"] and isinstance(out, str) and out.startswith(_error_prefixes)
|
||||
for cid, out in results
|
||||
)
|
||||
for tc in tool_calls
|
||||
):
|
||||
self._recent_tool_sigs.clear()
|
||||
for i, (tc_id, output) in enumerate(results):
|
||||
tc = _tc_by_id.get(tc_id)
|
||||
if tc and isinstance(output, str) and not output.startswith(_error_prefixes):
|
||||
@@ -1602,19 +1625,11 @@ class ChatSession:
|
||||
if content:
|
||||
save_message(self._ws_id, "assistant", content)
|
||||
else:
|
||||
# Cancelled during tool execution — roll back incomplete results
|
||||
while self.messages and self.messages[-1]["role"] == "tool":
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
while (
|
||||
self.messages
|
||||
and self.messages[-1]["role"] == "assistant"
|
||||
and self.messages[-1].get("tool_calls")
|
||||
):
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
# Cancelled during tool execution — synthesize cancelled
|
||||
# tool_result for any tool_calls that lack a matching result.
|
||||
# This keeps the conversation valid for both providers while
|
||||
# preserving the full tool call structure in history.
|
||||
self._synthesize_cancelled_results("Cancelled by user.")
|
||||
# No need to clear _cancel_event — it's replaced per-generation
|
||||
# in send(), so this generation's event is simply discarded.
|
||||
self.ui.on_info("[Generation cancelled]")
|
||||
@@ -1622,26 +1637,102 @@ class ChatSession:
|
||||
# Do NOT re-raise — return normally so server worker thread
|
||||
# completes cleanly.
|
||||
except KeyboardInterrupt:
|
||||
# Remove any partial tool results, then the originating assistant
|
||||
# message with unanswered tool_calls — keep _msg_tokens in sync
|
||||
while self.messages and self.messages[-1]["role"] == "tool":
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
while (
|
||||
self.messages
|
||||
and self.messages[-1]["role"] == "assistant"
|
||||
and self.messages[-1].get("tool_calls")
|
||||
):
|
||||
self.messages.pop()
|
||||
if self._msg_tokens:
|
||||
self._msg_tokens.pop()
|
||||
self._synthesize_cancelled_results("Interrupted by user.")
|
||||
self._emit_state("error")
|
||||
raise
|
||||
except Exception:
|
||||
self._emit_state("error")
|
||||
raise
|
||||
|
||||
def _synthesize_cancelled_results(self, reason: str) -> None:
|
||||
"""Synthesize tool_result messages for orphaned tool_calls after cancel.
|
||||
|
||||
Finds the last assistant message with tool_calls, collects the IDs of
|
||||
tool_calls that already have matching tool results, and synthesizes
|
||||
cancelled results for any that don't. This keeps the conversation
|
||||
valid (both providers require matching tool_results) while preserving
|
||||
the full tool call structure so the model knows what was attempted.
|
||||
"""
|
||||
# Find the last assistant message with tool_calls
|
||||
assistant_idx = None
|
||||
for i in range(len(self.messages) - 1, -1, -1):
|
||||
msg = self.messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
assistant_idx = i
|
||||
break
|
||||
if assistant_idx is None:
|
||||
return
|
||||
|
||||
# Collect tool_call IDs that already have results
|
||||
answered_ids: set[str] = set()
|
||||
for msg in self.messages[assistant_idx + 1 :]:
|
||||
if msg.get("role") == "tool":
|
||||
answered_ids.add(msg.get("tool_call_id", ""))
|
||||
|
||||
# Synthesize results for unanswered tool_calls
|
||||
for tc in self.messages[assistant_idx].get("tool_calls", []):
|
||||
tc_id = tc.get("id", "")
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
if tc_id and tc_id not in answered_ids:
|
||||
self.messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": reason,
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
self._msg_tokens.append(1)
|
||||
save_message(self._ws_id, "tool", reason, func_name, tool_call_id=tc_id)
|
||||
|
||||
# -- Rewind / retry -------------------------------------------------------
|
||||
|
||||
def _find_turn_boundaries(self) -> list[int]:
|
||||
"""Return indices of user messages in self.messages (turn start positions)."""
|
||||
return [i for i, m in enumerate(self.messages) if m["role"] == "user"]
|
||||
|
||||
def rewind(self, n: int) -> int:
|
||||
"""Drop the last *n* complete turns from the conversation.
|
||||
|
||||
A turn = user message + all assistant/tool messages until the next
|
||||
user message. Returns the number of messages removed. Updates
|
||||
both in-memory state and the persistent database.
|
||||
"""
|
||||
if n < 1:
|
||||
return 0
|
||||
boundaries = self._find_turn_boundaries()
|
||||
if not boundaries:
|
||||
return 0
|
||||
n = min(n, len(boundaries))
|
||||
cut_index = boundaries[-n]
|
||||
removed_count = len(self.messages) - cut_index
|
||||
del self.messages[cut_index:]
|
||||
del self._msg_tokens[cut_index:]
|
||||
delete_messages_after(self._ws_id, len(self.messages))
|
||||
return removed_count
|
||||
|
||||
def retry(self) -> str | None:
|
||||
"""Drop the last assistant response and return the user message to re-send.
|
||||
|
||||
The caller is responsible for calling ``send()`` with the returned
|
||||
message. Returns ``None`` if there is nothing to retry.
|
||||
"""
|
||||
boundaries = self._find_turn_boundaries()
|
||||
if not boundaries:
|
||||
return None
|
||||
last_user_idx = boundaries[-1]
|
||||
content = self.messages[last_user_idx].get("content")
|
||||
# Multipart messages (vision/images) have list-type content;
|
||||
# retry only supports plain text.
|
||||
if not isinstance(content, str) or not content:
|
||||
return None
|
||||
# Drop everything from (and including) the user message onward;
|
||||
# send() will re-append the user message.
|
||||
del self.messages[last_user_idx:]
|
||||
del self._msg_tokens[last_user_idx:]
|
||||
delete_messages_after(self._ws_id, len(self.messages))
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _strip_reasoning(text: str) -> str:
|
||||
"""Remove <think>/<reasoning> tags and their content."""
|
||||
@@ -2664,6 +2755,7 @@ class ChatSession:
|
||||
"bash": self._prepare_bash,
|
||||
"read_file": self._prepare_read_file,
|
||||
"search": self._prepare_search,
|
||||
"diff_file": self._prepare_diff,
|
||||
"write_file": self._prepare_write_file,
|
||||
"edit_file": self._prepare_edit_file,
|
||||
"math": self._prepare_math,
|
||||
@@ -2729,17 +2821,38 @@ class ChatSession:
|
||||
"error": blocked,
|
||||
}
|
||||
display_cmd = command.split("\n")[0]
|
||||
if "\n" in command:
|
||||
display_cmd += f" ... ({command.count(chr(10))} more lines)"
|
||||
is_multiline = "\n" in command
|
||||
if is_multiline:
|
||||
extra = command.count(chr(10))
|
||||
display_cmd += f" ... ({extra} more {'line' if extra == 1 else 'lines'})"
|
||||
timeout = args.get("timeout")
|
||||
try:
|
||||
timeout = int(timeout) if timeout is not None else None
|
||||
except (ValueError, TypeError):
|
||||
timeout = None
|
||||
if timeout is not None:
|
||||
timeout = max(1, min(timeout, 600)) # clamp 1-600s
|
||||
|
||||
# Show full command in preview for multi-line scripts
|
||||
preview = ""
|
||||
if is_multiline:
|
||||
preview = f"{DIM}{textwrap.indent(command, ' ')}{RESET}"
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "bash",
|
||||
"header": f"\u2699 bash: {display_cmd}",
|
||||
"preview": "",
|
||||
"header": (
|
||||
f"\u2699 bash ({timeout}s): {display_cmd}"
|
||||
if timeout is not None
|
||||
else f"\u2699 bash: {display_cmd}"
|
||||
),
|
||||
"preview": preview,
|
||||
"needs_approval": True,
|
||||
"approval_label": "bash",
|
||||
"execute": self._exec_bash,
|
||||
"command": command,
|
||||
"timeout": timeout,
|
||||
"stop_on_error": args.get("stop_on_error") is True,
|
||||
}
|
||||
|
||||
def _prepare_read_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -2840,6 +2953,62 @@ class ChatSession:
|
||||
"path": path,
|
||||
}
|
||||
|
||||
def _prepare_diff(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path_a = args.get("path_a", "")
|
||||
path_b = args.get("path_b", "")
|
||||
content_b = args.get("content_b")
|
||||
if not path_a:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: missing path_a",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: path_a is required",
|
||||
}
|
||||
if path_b and content_b is not None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: ambiguous params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: provide path_b or content_b, not both",
|
||||
}
|
||||
if not path_b and content_b is None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": "\u2717 diff_file: missing comparison target",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: provide path_b (another file) or content_b (string to compare against)",
|
||||
}
|
||||
ctx = args.get("context_lines")
|
||||
try:
|
||||
ctx = int(ctx) if ctx is not None else 3
|
||||
except (ValueError, TypeError):
|
||||
ctx = 3
|
||||
ctx = max(0, min(ctx, 20))
|
||||
path_a = os.path.expanduser(path_a)
|
||||
path_b = os.path.expanduser(path_b) if path_b else ""
|
||||
if path_b:
|
||||
header = f"\u2699 diff_file: {path_a} vs {path_b}"
|
||||
else:
|
||||
header = f"\u2699 diff_file: {path_a} vs provided content"
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "diff_file",
|
||||
"header": header,
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_diff,
|
||||
"path_a": path_a,
|
||||
"path_b": path_b,
|
||||
"content_b": content_b,
|
||||
"context_lines": ctx,
|
||||
}
|
||||
|
||||
def _prepare_write_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args.get("path", "")
|
||||
content = args.get("content", "")
|
||||
@@ -2854,28 +3023,46 @@ class ChatSession:
|
||||
}
|
||||
path = os.path.expanduser(path)
|
||||
resolved = os.path.realpath(path)
|
||||
is_symlink = os.path.abspath(path) != resolved
|
||||
exists = os.path.exists(resolved)
|
||||
is_overwrite = exists and resolved not in self._read_files
|
||||
raw_mode = args.get("mode")
|
||||
mode = str(raw_mode).strip().lower() if raw_mode else "overwrite"
|
||||
if mode not in ("overwrite", "append"):
|
||||
mode = "overwrite"
|
||||
is_append = mode == "append"
|
||||
is_overwrite = exists and resolved not in self._read_files and not is_append
|
||||
|
||||
# Build preview
|
||||
preview_parts = []
|
||||
if is_symlink:
|
||||
preview_parts.append(f" {YELLOW}Warning: symlink — actual target: {resolved}{RESET}")
|
||||
if is_overwrite:
|
||||
preview_parts.append(
|
||||
f" {YELLOW}Warning: overwriting existing file not previously read{RESET}"
|
||||
)
|
||||
if is_append:
|
||||
preview_parts.append(f" {YELLOW}(append mode){RESET}")
|
||||
preview_parts.append(f"{DIM}{textwrap.indent(content, ' ')}{RESET}")
|
||||
|
||||
verb = "append" if is_append else "write"
|
||||
header = f"\u2699 write_file ({verb}): {path} ({len(content)} chars)"
|
||||
if is_symlink:
|
||||
header = f"\u2699 write_file ({verb}): {path} \u2192 {resolved} ({len(content)} chars)"
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "write_file",
|
||||
"header": f"\u2699 write_file: {path} ({len(content)} chars)",
|
||||
"header": header,
|
||||
"preview": "\n".join(preview_parts),
|
||||
"needs_approval": True,
|
||||
"approval_label": "overwrite_file" if is_overwrite else "write_file",
|
||||
"approval_label": "append_file"
|
||||
if is_append
|
||||
else ("overwrite_file" if is_overwrite else "write_file"),
|
||||
"execute": self._exec_write_file,
|
||||
"path": path,
|
||||
"resolved": resolved,
|
||||
"content": content,
|
||||
"append": is_append,
|
||||
}
|
||||
|
||||
def _validate_edit_entry(self, e: dict[str, Any], idx: int | None) -> dict[str, Any] | None:
|
||||
@@ -2968,8 +3155,29 @@ class ChatSession:
|
||||
return err
|
||||
edits = [self._normalize_edit_entry(args)]
|
||||
|
||||
replace_all = bool(args.get("replace_all"))
|
||||
if replace_all and has_batch:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
"header": "\u2717 edit_file: invalid params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: replace_all cannot be used with edits array",
|
||||
}
|
||||
if replace_all and edits[0].get("near_line") is not None:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
"header": "\u2717 edit_file: invalid params",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: replace_all cannot be used with near_line",
|
||||
}
|
||||
|
||||
path = os.path.expanduser(path)
|
||||
resolved = os.path.realpath(path)
|
||||
is_symlink = os.path.abspath(path) != resolved
|
||||
|
||||
if resolved not in self._read_files:
|
||||
return {
|
||||
@@ -2983,7 +3191,7 @@ class ChatSession:
|
||||
|
||||
# Pre-read to validate all edits and build diff preview
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(resolved) as f:
|
||||
content = f.read()
|
||||
|
||||
for i, edit in enumerate(edits):
|
||||
@@ -3003,7 +3211,7 @@ class ChatSession:
|
||||
"The file may have changed — re-read it before retrying."
|
||||
),
|
||||
}
|
||||
if len(occurrences) > 1 and nl is None:
|
||||
if len(occurrences) > 1 and nl is None and not replace_all:
|
||||
line_list = ", ".join(str(ln) for ln in occurrences)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
@@ -3013,7 +3221,7 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": (
|
||||
f"Error: {label}old_string found {len(occurrences)} times "
|
||||
f"at lines {line_list} — use near_line to pick one"
|
||||
f"at lines {line_list} — use near_line or replace_all"
|
||||
),
|
||||
}
|
||||
except FileNotFoundError:
|
||||
@@ -3037,6 +3245,11 @@ class ChatSession:
|
||||
|
||||
# Build diff preview
|
||||
preview_parts = []
|
||||
if is_symlink:
|
||||
preview_parts.append(f" {YELLOW}Warning: symlink — actual target: {resolved}{RESET}")
|
||||
if replace_all:
|
||||
occ = content.count(edits[0]["old_string"])
|
||||
preview_parts.append(f" {YELLOW}(replace_all: {occ} occurrences){RESET}")
|
||||
for i, edit in enumerate(edits):
|
||||
if len(edits) > 1:
|
||||
preview_parts.append(f" {YELLOW}--- edit {i + 1}/{len(edits)} ---{RESET}")
|
||||
@@ -3050,11 +3263,18 @@ class ChatSession:
|
||||
preview_parts.append(f" {YELLOW}(deletion — {n} chars removed){RESET}")
|
||||
|
||||
count = len(edits)
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path}"
|
||||
)
|
||||
if is_symlink:
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} \u2192 {resolved} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path} \u2192 {resolved}"
|
||||
)
|
||||
else:
|
||||
header = (
|
||||
f"\u2699 edit_file: {path} ({count} edits)"
|
||||
if count > 1
|
||||
else f"\u2699 edit_file: {path}"
|
||||
)
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "edit_file",
|
||||
@@ -3066,6 +3286,7 @@ class ChatSession:
|
||||
"path": path,
|
||||
"resolved": resolved,
|
||||
"edits": edits,
|
||||
"replace_all": replace_all,
|
||||
}
|
||||
|
||||
def _prepare_math(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -3412,14 +3633,23 @@ class ChatSession:
|
||||
name = (args.get("name") or args.get("key") or "").strip()
|
||||
content = (args.get("content") or args.get("value") or "").strip()
|
||||
name = normalize_key(name)
|
||||
if not name or not content:
|
||||
if not name:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory save: requires name and content",
|
||||
"header": "\u2717 memory save: missing name",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: both 'name' and 'content' are required for save",
|
||||
"error": "Error: 'name' is required for save",
|
||||
}
|
||||
if not content:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory save: missing content",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: 'content' must be non-empty for save",
|
||||
}
|
||||
if len(content) > self._mem_cfg.max_content:
|
||||
return {
|
||||
@@ -3468,13 +3698,33 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": "Error: name is required for delete",
|
||||
}
|
||||
scope = (args.get("scope") or "global").strip().lower()
|
||||
if scope not in ("global", "workstream", "user"):
|
||||
scope = "global"
|
||||
scope_err = self._validate_scope(scope, call_id)
|
||||
if scope_err:
|
||||
return scope_err
|
||||
scope_id = self._resolve_scope_id(scope)
|
||||
explicit_scope = (args.get("scope") or "").strip().lower()
|
||||
valid_scopes = ("global", "workstream", "user")
|
||||
if explicit_scope and explicit_scope not in valid_scopes:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
"header": "\u2717 memory delete: invalid scope",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": (
|
||||
f"Error: invalid scope '{explicit_scope}'. "
|
||||
f"Valid scopes: {', '.join(valid_scopes)}"
|
||||
),
|
||||
}
|
||||
if explicit_scope:
|
||||
scope_err = self._validate_scope(explicit_scope, call_id)
|
||||
if scope_err:
|
||||
return scope_err
|
||||
scope_id = self._resolve_scope_id(explicit_scope)
|
||||
scopes_to_try = [(explicit_scope, scope_id)]
|
||||
else:
|
||||
# No scope specified — try narrowest first: workstream → user → global
|
||||
scopes_to_try = []
|
||||
for s in ("workstream", "user", "global"):
|
||||
sid = self._resolve_scope_id(s)
|
||||
if sid or s == "global":
|
||||
scopes_to_try.append((s, sid))
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "memory",
|
||||
@@ -3484,8 +3734,7 @@ class ChatSession:
|
||||
"execute": self._exec_memory,
|
||||
"action": "delete",
|
||||
"name": name,
|
||||
"scope": scope,
|
||||
"scope_id": scope_id,
|
||||
"scopes_to_try": scopes_to_try,
|
||||
}
|
||||
|
||||
if action == "search":
|
||||
@@ -3567,12 +3816,14 @@ class ChatSession:
|
||||
"needs_approval": False,
|
||||
"error": "Error: query is required",
|
||||
}
|
||||
limit = args.get("limit", 20)
|
||||
if isinstance(limit, str):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except ValueError:
|
||||
limit = 20
|
||||
try:
|
||||
limit = int(args.get("limit", 20))
|
||||
except (TypeError, ValueError):
|
||||
limit = 20
|
||||
try:
|
||||
offset = int(args.get("offset", 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "recall",
|
||||
@@ -3582,6 +3833,7 @@ class ChatSession:
|
||||
"execute": self._exec_recall,
|
||||
"query": query,
|
||||
"limit": max(1, min(limit, 50)),
|
||||
"offset": max(0, offset),
|
||||
}
|
||||
|
||||
# -- skill prepare/execute -------------------------------------------------
|
||||
@@ -3967,9 +4219,13 @@ class ChatSession:
|
||||
# _cancel_event with a fresh instance) doesn't disarm this check.
|
||||
cancel = self._cancel_event
|
||||
call_id, command = item["call_id"], item["command"]
|
||||
timeout = item.get("timeout") or self.tool_timeout
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
||||
f.write("set -o pipefail\n" + command)
|
||||
preamble = "set -o pipefail\n"
|
||||
if item.get("stop_on_error"):
|
||||
preamble += "set -e\n"
|
||||
f.write(preamble + command)
|
||||
script_path = f.name
|
||||
try:
|
||||
from turnstone.core.env import scrubbed_env
|
||||
@@ -4010,7 +4266,7 @@ class ChatSession:
|
||||
with contextlib.suppress(OSError, ProcessLookupError):
|
||||
proc.kill()
|
||||
|
||||
timer = threading.Timer(self.tool_timeout, _on_timeout)
|
||||
timer = threading.Timer(timeout, _on_timeout)
|
||||
timer.start()
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
@@ -4043,7 +4299,14 @@ class ChatSession:
|
||||
os.unlink(script_path)
|
||||
|
||||
if timed_out.is_set():
|
||||
raise subprocess.TimeoutExpired(cmd="bash", timeout=self.tool_timeout)
|
||||
raise subprocess.TimeoutExpired(cmd="bash", timeout=timeout)
|
||||
|
||||
# Distinguish user cancel from unexpected SIGKILL.
|
||||
# Popen.returncode is negative of the signal number when killed.
|
||||
if cancel.is_set() and proc.returncode == -signal.SIGKILL:
|
||||
msg = "Cancelled by user."
|
||||
self._report_tool_result(call_id, "bash", msg)
|
||||
return call_id, msg
|
||||
|
||||
output = "".join(stdout_parts)
|
||||
if stderr_lines:
|
||||
@@ -4052,7 +4315,13 @@ class ChatSession:
|
||||
output = output.strip()
|
||||
output = self._truncate_output(output)
|
||||
|
||||
bash_error = proc.returncode not in (0, 1)
|
||||
# With stop_on_error, any non-zero exit is a real failure (set -e
|
||||
# killed the script). Without it, exit code 1 is often benign
|
||||
# (e.g. grep no-match).
|
||||
if item.get("stop_on_error"):
|
||||
bash_error = proc.returncode != 0
|
||||
else:
|
||||
bash_error = proc.returncode not in (0, 1)
|
||||
if proc.returncode != 0:
|
||||
output += f"\n[exit code: {proc.returncode}]"
|
||||
|
||||
@@ -4061,7 +4330,7 @@ class ChatSession:
|
||||
return call_id, output if output else "(no output)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"Command timed out after {self.tool_timeout}s"
|
||||
msg = f"Command timed out after {timeout}s"
|
||||
self._report_tool_result(call_id, "bash", msg, is_error=True)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
@@ -4069,6 +4338,33 @@ class ChatSession:
|
||||
self._report_tool_result(call_id, "bash", msg, is_error=True)
|
||||
return call_id, msg
|
||||
|
||||
@staticmethod
|
||||
def _read_text_lines(path: str) -> tuple[list[str], str, str | None]:
|
||||
"""Read a text file with binary detection and symlink resolution.
|
||||
|
||||
Returns (lines, resolved_path, error_msg). On success error_msg is
|
||||
None. On failure lines is empty and error_msg describes the problem.
|
||||
"""
|
||||
resolved = os.path.realpath(os.path.expanduser(path))
|
||||
try:
|
||||
with open(resolved, "rb") as fb:
|
||||
sample = fb.read(8192)
|
||||
if b"\x00" in sample:
|
||||
return (
|
||||
[],
|
||||
resolved,
|
||||
(
|
||||
f"Error: {path} appears to be a binary file "
|
||||
"(contains null bytes). Use bash to inspect binary files."
|
||||
),
|
||||
)
|
||||
with open(resolved) as f:
|
||||
return f.readlines(), resolved, None
|
||||
except FileNotFoundError:
|
||||
return [], resolved, f"Error: {path} not found"
|
||||
except Exception as e:
|
||||
return [], resolved, f"Error reading {path}: {e}"
|
||||
|
||||
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str | list[dict[str, Any]]]:
|
||||
"""Read a file and return numbered lines, or image content parts."""
|
||||
call_id, path = item["call_id"], item["path"]
|
||||
@@ -4081,19 +4377,11 @@ class ChatSession:
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return self._exec_read_image(call_id, path, resolved)
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
all_lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
all_lines, _, err = self._read_text_lines(path)
|
||||
if err:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error: {path} not found"
|
||||
self._report_tool_result(call_id, "read_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error reading {path}: {e}"
|
||||
self._report_tool_result(call_id, "read_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
self._report_tool_result(call_id, "read_file", err, is_error=True)
|
||||
return call_id, err
|
||||
|
||||
self._read_files.add(resolved)
|
||||
total_lines = len(all_lines)
|
||||
@@ -4126,7 +4414,7 @@ class ChatSession:
|
||||
caps = self._get_capabilities()
|
||||
if not caps.supports_vision:
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
size = os.path.getsize(resolved)
|
||||
except OSError as e:
|
||||
self._read_files.discard(resolved)
|
||||
msg = f"Error: {path}: {e}"
|
||||
@@ -4141,7 +4429,7 @@ class ChatSession:
|
||||
)
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
with open(resolved, "rb") as f:
|
||||
raw = f.read()
|
||||
except FileNotFoundError:
|
||||
self._read_files.discard(resolved)
|
||||
@@ -4198,6 +4486,21 @@ class ChatSession:
|
||||
"-m",
|
||||
"200", # max matches per file
|
||||
"--color=never", # no ANSI codes in output
|
||||
# Skip common build/vendor/VCS directories
|
||||
"--exclude-dir=.git",
|
||||
"--exclude-dir=node_modules",
|
||||
"--exclude-dir=target",
|
||||
"--exclude-dir=__pycache__",
|
||||
"--exclude-dir=.mypy_cache",
|
||||
"--exclude-dir=.ruff_cache",
|
||||
"--exclude-dir=.pytest_cache",
|
||||
"--exclude-dir=dist",
|
||||
"--exclude-dir=build",
|
||||
"--exclude-dir=*.egg-info",
|
||||
"--exclude-dir=.tox",
|
||||
"--exclude-dir=.venv",
|
||||
"--exclude-dir=venv",
|
||||
"--exclude-dir=vendor",
|
||||
"--",
|
||||
pattern,
|
||||
path, # -- prevents pattern as flag
|
||||
@@ -4213,10 +4516,18 @@ class ChatSession:
|
||||
elif result.returncode > 1:
|
||||
output = result.stderr.strip() or f"grep error (exit {result.returncode})"
|
||||
|
||||
# Count matches BEFORE truncation
|
||||
# Count matches and files BEFORE truncation
|
||||
match_count = output.count("\n") + 1 if result.returncode == 0 and output else 0
|
||||
if match_count:
|
||||
files = {line.split(":", 1)[0] for line in output.splitlines() if ":" in line}
|
||||
file_count = len(files)
|
||||
else:
|
||||
file_count = 0
|
||||
|
||||
# Append summary footer before truncation so it counts toward the limit
|
||||
original_len = len(output)
|
||||
if match_count:
|
||||
output += f"\n\n({match_count} matches across {file_count} files)"
|
||||
output = self._truncate_output(output)
|
||||
|
||||
desc = f"{match_count} matches" if match_count else "no matches"
|
||||
@@ -4235,6 +4546,47 @@ class ChatSession:
|
||||
self._report_tool_result(call_id, "search", msg, is_error=True)
|
||||
return call_id, msg
|
||||
|
||||
def _exec_diff(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Show unified diff between two files or a file and provided content."""
|
||||
call_id = item["call_id"]
|
||||
path_a = item["path_a"]
|
||||
path_b = item.get("path_b", "")
|
||||
content_b = item.get("content_b")
|
||||
ctx = item.get("context_lines", 3)
|
||||
|
||||
lines_a, resolved_a, err = self._read_text_lines(path_a)
|
||||
if err:
|
||||
self._report_tool_result(call_id, "diff_file", err, is_error=True)
|
||||
return call_id, err
|
||||
self._read_files.add(resolved_a)
|
||||
|
||||
if path_b:
|
||||
label_b = path_b
|
||||
lines_b, resolved_b, err = self._read_text_lines(path_b)
|
||||
if err:
|
||||
self._report_tool_result(call_id, "diff_file", err, is_error=True)
|
||||
return call_id, err
|
||||
self._read_files.add(resolved_b)
|
||||
else:
|
||||
label_b = "(provided content)"
|
||||
lines_b = (content_b or "").splitlines(keepends=True)
|
||||
|
||||
# Stream diff with early cutoff to avoid large allocations
|
||||
max_chars = self.tool_truncation or 262_144
|
||||
chunks: list[str] = []
|
||||
total_chars = 0
|
||||
line_count = 0
|
||||
for line in difflib.unified_diff(lines_a, lines_b, fromfile=path_a, tofile=label_b, n=ctx):
|
||||
line_count += 1
|
||||
if total_chars < max_chars:
|
||||
chunks.append(line)
|
||||
total_chars += len(line)
|
||||
output = "".join(chunks) if chunks else "(no differences)"
|
||||
output = self._truncate_output(output)
|
||||
desc = f"{line_count} diff lines" if line_count else "identical"
|
||||
self._report_tool_result(call_id, "diff_file", desc)
|
||||
return call_id, output
|
||||
|
||||
def _run_agent(
|
||||
self,
|
||||
agent_messages: list[dict[str, Any]],
|
||||
@@ -4724,13 +5076,21 @@ class ChatSession:
|
||||
return call_id, msg
|
||||
|
||||
if action == "delete":
|
||||
deleted = delete_structured_memory(item["name"], item["scope"], item["scope_id"])
|
||||
scopes = item["scopes_to_try"]
|
||||
deleted = False
|
||||
deleted_scope = ""
|
||||
for scope, scope_id in scopes:
|
||||
if delete_structured_memory(item["name"], scope, scope_id):
|
||||
deleted = True
|
||||
deleted_scope = scope
|
||||
break
|
||||
if not deleted:
|
||||
msg = f"Error: memory '{item['name']}' not found (scope={item['scope']})"
|
||||
tried = ", ".join(s for s, _ in scopes)
|
||||
msg = f"Error: memory '{item['name']}' not found (searched scopes: {tried})"
|
||||
self._report_tool_result(call_id, "memory", msg, is_error=True)
|
||||
else:
|
||||
self._init_system_messages()
|
||||
msg = f"Deleted memory '{item['name']}'"
|
||||
msg = f"Deleted memory '{item['name']}' (scope={deleted_scope})"
|
||||
self._report_tool_result(call_id, "memory", msg)
|
||||
return call_id, msg
|
||||
|
||||
@@ -4793,21 +5153,26 @@ class ChatSession:
|
||||
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Search conversation history."""
|
||||
call_id = item["call_id"]
|
||||
query, limit = item["query"], item["limit"]
|
||||
query, limit, offset = item["query"], item["limit"], item.get("offset", 0)
|
||||
|
||||
conv_rows = search_history(query, limit)
|
||||
conv_rows = search_history(query, limit, offset)
|
||||
if conv_rows:
|
||||
lines = []
|
||||
for ts, sid, role, content, tool_name in conv_rows:
|
||||
label = f"{role}({tool_name})" if tool_name else role
|
||||
text = (content or "")[:500]
|
||||
if content and len(content) > 500:
|
||||
text += "..."
|
||||
text = (content or "")[:2000]
|
||||
if content and len(content) > 2000:
|
||||
text += f"... ({len(content)} chars total)"
|
||||
lines.append(f"[{ts} {sid}] {label}: {text}")
|
||||
output = f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines)
|
||||
header = f"Conversations ({len(conv_rows)} matches"
|
||||
if offset:
|
||||
header += f", offset {offset}"
|
||||
header += "):"
|
||||
output = header + "\n" + "\n".join(lines)
|
||||
else:
|
||||
output = f"No conversation history found for '{query}'."
|
||||
|
||||
output = self._truncate_output(output)
|
||||
self._report_tool_result(call_id, "recall", output)
|
||||
return call_id, output
|
||||
|
||||
@@ -5290,12 +5655,14 @@ class ChatSession:
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path, content, resolved = item["path"], item["content"], item["resolved"]
|
||||
is_append = item.get("append", False)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
|
||||
with open(resolved, "a" if is_append else "w") as f:
|
||||
f.write(content)
|
||||
self._read_files.add(resolved)
|
||||
msg = f"Wrote {len(content)} chars to {path}"
|
||||
verb = "Appended" if is_append else "Wrote"
|
||||
msg = f"{verb} {len(content)} chars to {path}"
|
||||
self._report_tool_result(call_id, "write_file", msg)
|
||||
return call_id, msg
|
||||
except Exception as e:
|
||||
@@ -5312,11 +5679,29 @@ class ChatSession:
|
||||
self._check_cancelled()
|
||||
call_id = item["call_id"]
|
||||
path = item["path"]
|
||||
resolved = item.get("resolved", os.path.realpath(os.path.expanduser(path)))
|
||||
edits: list[dict[str, Any]] = item["edits"]
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(resolved) as f:
|
||||
content = f.read()
|
||||
|
||||
# replace_all mode: simple str.replace, skip offset logic
|
||||
do_replace_all = item.get("replace_all", False)
|
||||
if do_replace_all and len(edits) == 1:
|
||||
old = edits[0]["old_string"]
|
||||
new = edits[0]["new_string"]
|
||||
count = content.count(old)
|
||||
if count == 0:
|
||||
msg = f"Error: old_string not found in {path}"
|
||||
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
|
||||
return call_id, msg
|
||||
content = content.replace(old, new)
|
||||
with open(resolved, "w") as f:
|
||||
f.write(content)
|
||||
msg = f"Edited {path}: replaced {count} occurrences"
|
||||
self._report_tool_result(call_id, "edit_file", msg)
|
||||
return call_id, msg
|
||||
|
||||
# Resolve each edit to a (start_idx, end_idx, new_string) replacement
|
||||
replacements: list[tuple[int, int, str]] = []
|
||||
for i, edit in enumerate(edits):
|
||||
@@ -5356,7 +5741,7 @@ class ChatSession:
|
||||
for start, end, new in reversed(replacements):
|
||||
content = content[:start] + new + content[end:]
|
||||
|
||||
with open(path, "w") as f:
|
||||
with open(resolved, "w") as f:
|
||||
f.write(content)
|
||||
count = len(replacements)
|
||||
noun = "edit" if count == 1 else "edits"
|
||||
@@ -5820,6 +6205,37 @@ class ChatSession:
|
||||
else:
|
||||
self.ui.on_info("\n".join(mcp_lines))
|
||||
|
||||
elif cmd == "/retry":
|
||||
user_msg = self.retry()
|
||||
if user_msg is None:
|
||||
self.ui.on_info("Nothing to retry.")
|
||||
else:
|
||||
self._pending_retry = user_msg
|
||||
self.ui.on_info(f"Retrying: {user_msg[:80]}...")
|
||||
|
||||
elif cmd == "/rewind":
|
||||
if not arg:
|
||||
self.ui.on_info("Usage: /rewind <N> — drop the last N turns")
|
||||
else:
|
||||
try:
|
||||
n = int(arg)
|
||||
except ValueError:
|
||||
self.ui.on_info("Usage: /rewind <N> — N must be a positive integer")
|
||||
else:
|
||||
if n < 1:
|
||||
self.ui.on_info("N must be at least 1.")
|
||||
else:
|
||||
turns_available = len(self._find_turn_boundaries())
|
||||
actual_n = min(n, turns_available)
|
||||
removed = self.rewind(n)
|
||||
if removed == 0:
|
||||
self.ui.on_info("No turns to rewind.")
|
||||
else:
|
||||
self.ui.on_info(
|
||||
f"Rewound {actual_n} turn(s) ({removed} messages removed). "
|
||||
f"{len(self.messages)} messages remain."
|
||||
)
|
||||
|
||||
elif cmd == "/help":
|
||||
self.ui.on_info(
|
||||
"\n".join(
|
||||
@@ -5837,6 +6253,8 @@ class ChatSession:
|
||||
"",
|
||||
" /history [query] Search conversation history (or show recent)",
|
||||
" /compact Compact conversation (summarize old messages)",
|
||||
" /retry Re-send the last user message for a new response",
|
||||
" /rewind <N> Drop the last N turns (user + response)",
|
||||
"",
|
||||
" /model [alias] Show/switch model (alias from config)",
|
||||
" /raw Toggle reasoning content display",
|
||||
|
||||
@@ -147,6 +147,29 @@ class PostgreSQLBackend:
|
||||
).fetchall()
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0
|
||||
cutoff_id = cutoff_row[0]
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -383,10 +406,11 @@ class PostgreSQLBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
# Use PostgreSQL full-text search if search_vector column exists
|
||||
try:
|
||||
@@ -399,9 +423,9 @@ class PostgreSQLBackend:
|
||||
" @@ plainto_tsquery('english', :query) "
|
||||
"ORDER BY ts_rank(to_tsvector('english', COALESCE(c.content, '')), "
|
||||
" plainto_tsquery('english', :query)) DESC "
|
||||
"LIMIT :limit"
|
||||
"LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": query, "limit": capped},
|
||||
{"query": query, "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
except Exception:
|
||||
@@ -411,9 +435,9 @@ class PostgreSQLBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content ILIKE :pattern "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{query}%", "limit": capped},
|
||||
{"pattern": f"%{query}%", "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ class StorageBackend(Protocol):
|
||||
"""Load messages for a workstream and reconstruct OpenAI message format."""
|
||||
...
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
"""Delete conversation rows beyond the first *keep_count* rows for a workstream.
|
||||
|
||||
Rows are ordered by auto-increment ``id``. If the workstream has
|
||||
N rows total and ``keep_count`` < N, the last N - keep_count rows
|
||||
are deleted. Returns the number of rows deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -180,7 +189,7 @@ class StorageBackend(Protocol):
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
"""Search conversation history. Returns (timestamp, ws_id, role, content, tool_name)."""
|
||||
...
|
||||
|
||||
|
||||
@@ -212,6 +212,43 @@ class SQLiteBackend:
|
||||
|
||||
return _reconstruct_messages(list(rows), ws_id)
|
||||
|
||||
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
|
||||
with self._engine.connect() as conn:
|
||||
# Find the id of the first row to delete (the row at offset keep_count)
|
||||
cutoff_row = conn.execute(
|
||||
sa.select(conversations.c.id)
|
||||
.where(conversations.c.ws_id == ws_id)
|
||||
.order_by(conversations.c.id)
|
||||
.limit(1)
|
||||
.offset(keep_count)
|
||||
).fetchone()
|
||||
if cutoff_row is None:
|
||||
return 0 # nothing to delete
|
||||
cutoff_id = cutoff_row[0]
|
||||
# Remove FTS5 entries first (external content table doesn't auto-sync)
|
||||
if self._fts5_available:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"DELETE FROM conversations_fts WHERE rowid IN "
|
||||
"(SELECT id FROM conversations "
|
||||
" WHERE ws_id = :ws_id AND id >= :cutoff_id)"
|
||||
),
|
||||
{"ws_id": ws_id, "cutoff_id": cutoff_id},
|
||||
)
|
||||
except Exception:
|
||||
self._fts5_available = False
|
||||
result = conn.execute(
|
||||
sa.delete(conversations).where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id == ws_id,
|
||||
conversations.c.id >= cutoff_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- Workstream management -------------------------------------------------
|
||||
|
||||
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
|
||||
@@ -457,10 +494,11 @@ class SQLiteBackend:
|
||||
|
||||
# -- Conversation search ---------------------------------------------------
|
||||
|
||||
def search_history(self, query: str, limit: int = 20) -> list[Any]:
|
||||
def search_history(self, query: str, limit: int = 20, offset: int = 0) -> list[Any]:
|
||||
if not query or not query.strip():
|
||||
return []
|
||||
capped = min(limit, 100)
|
||||
capped = min(int(limit), 100)
|
||||
capped_offset = max(0, int(offset))
|
||||
with self._engine.connect() as conn:
|
||||
if self._fts5_available:
|
||||
return list(
|
||||
@@ -470,9 +508,9 @@ class SQLiteBackend:
|
||||
"FROM conversations_fts f "
|
||||
"JOIN conversations c ON c.id = f.rowid "
|
||||
"WHERE conversations_fts MATCH :query "
|
||||
"ORDER BY f.rank ASC LIMIT :limit"
|
||||
"ORDER BY f.rank ASC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"query": _fts5_query(query), "limit": capped},
|
||||
{"query": _fts5_query(query), "limit": capped, "offset": capped_offset},
|
||||
).fetchall()
|
||||
)
|
||||
return list(
|
||||
@@ -480,9 +518,13 @@ class SQLiteBackend:
|
||||
sa.text(
|
||||
"SELECT timestamp, ws_id, role, content, tool_name "
|
||||
"FROM conversations WHERE content LIKE :pattern ESCAPE '\\' "
|
||||
"ORDER BY timestamp DESC LIMIT :limit"
|
||||
"ORDER BY timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
),
|
||||
{"pattern": f"%{_escape_like(query)}%", "limit": capped},
|
||||
{
|
||||
"pattern": f"%{_escape_like(query)}%",
|
||||
"limit": capped,
|
||||
"offset": capped_offset,
|
||||
},
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
@@ -200,4 +200,45 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
||||
break
|
||||
del messages[asst_idx:]
|
||||
|
||||
# Repair: synthesize tool results for mid-conversation orphaned tool calls.
|
||||
# This happens when a cancel interrupts tool execution — the assistant
|
||||
# message with tool_calls is saved to DB but GenerationCancelled prevents
|
||||
# tool results from being created. Both Anthropic (strict) and OpenAI
|
||||
# (lenient today, may tighten) benefit from well-formed histories.
|
||||
i = 0
|
||||
while i < len(messages):
|
||||
msg = messages[i]
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
expected_ids = [tc.get("id", "") for tc in msg["tool_calls"] if tc.get("id")]
|
||||
# Collect tool result IDs that follow
|
||||
j = i + 1
|
||||
result_ids: set[str] = set()
|
||||
while j < len(messages) and messages[j].get("role") == "tool":
|
||||
tc_id = messages[j].get("tool_call_id", "")
|
||||
if tc_id:
|
||||
result_ids.add(tc_id)
|
||||
j += 1
|
||||
# Synthesize results for any missing IDs
|
||||
orphaned = [uid for uid in expected_ids if uid not in result_ids]
|
||||
if orphaned:
|
||||
synthetic = [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": uid,
|
||||
"content": "Tool execution was cancelled.",
|
||||
"is_error": True,
|
||||
}
|
||||
for uid in orphaned
|
||||
]
|
||||
# Insert after the last existing tool result (or after assistant)
|
||||
messages[j:j] = synthetic
|
||||
if orphaned:
|
||||
i = j + len(orphaned) # skip past spliced synthetics
|
||||
elif j > i + 1:
|
||||
i = j # skip past existing tool block
|
||||
else:
|
||||
i += 1 # no tools followed; just advance
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return messages
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Grant conversation.modify permission to admin and operator roles.
|
||||
|
||||
Revision ID: 029
|
||||
Revises: 028
|
||||
Create Date: 2026-03-29
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "029"
|
||||
down_revision = "028"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Grant to admin role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-admin' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
# Grant to operator role
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
|
||||
"WHERE role_id = 'builtin-operator' "
|
||||
"AND permissions NOT LIKE '%conversation.modify%'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE roles SET permissions = REPLACE(permissions, ',conversation.modify', '') "
|
||||
"WHERE role_id IN ('builtin-admin', 'builtin-operator')"
|
||||
)
|
||||
)
|
||||
+23
-7
@@ -21,16 +21,32 @@ def strip_html(html: str) -> str:
|
||||
|
||||
|
||||
def check_ssrf(url: str) -> str | None:
|
||||
"""Return error string if URL resolves to a private/link-local address, else None."""
|
||||
"""Return error string if URL resolves to a private/link-local address, else None.
|
||||
|
||||
Checks both IPv4 and IPv6 addresses via getaddrinfo to prevent bypasses
|
||||
using IPv6 loopback (``::1``), link-local (``fe80::``), or unique-local
|
||||
(``fd00::``/``fc00::``) addresses.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return "Invalid URL: no hostname"
|
||||
addr = socket.gethostbyname(hostname)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, ValueError):
|
||||
pass # DNS failure or invalid IP — let the actual fetch handle it
|
||||
# Resolve all address families (IPv4 + IPv6)
|
||||
results = socket.getaddrinfo(hostname, parsed.port or 80, proto=socket.IPPROTO_TCP)
|
||||
for _family, _type, _proto, _canonname, sockaddr in results:
|
||||
addr = str(sockaddr[0])
|
||||
# Strip IPv6 zone/scope identifier (e.g. "fe80::1%lo0")
|
||||
addr_clean = addr.split("%", 1)[0] if "%" in addr else addr
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr_clean)
|
||||
except ValueError:
|
||||
return f"Blocked: unable to parse resolved address ({addr})"
|
||||
# Normalize IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return f"Blocked: URL resolves to private/internal address ({addr})"
|
||||
except (socket.gaierror, OSError):
|
||||
pass # DNS failure — let the actual fetch handle it
|
||||
return None
|
||||
|
||||
+84
-1
@@ -805,6 +805,22 @@ def _get_ws(
|
||||
return None, None
|
||||
|
||||
|
||||
def _audit_context(request: Request) -> tuple[str, str]:
|
||||
"""Extract (user_id, ip_address) from request for audit logging."""
|
||||
auth = getattr(getattr(request, "state", None), "auth_result", None)
|
||||
uid: str = auth.user_id if auth else ""
|
||||
ip = ""
|
||||
if request.client:
|
||||
ip = request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
from turnstone.core.auth import is_secure_request
|
||||
|
||||
if is_secure_request(dict(request.headers), request.url.scheme):
|
||||
ip = forwarded.split(",")[0].strip()
|
||||
return uid, ip
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers — all async
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1331,11 +1347,30 @@ async def command(request: Request) -> JSONResponse:
|
||||
assert ws.session is not None
|
||||
|
||||
try:
|
||||
# Permission gate for conversation-modifying commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/rewind", "/retry"):
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "conversation.modify")
|
||||
if err:
|
||||
ui.on_error("Permission denied: conversation.modify required")
|
||||
return err
|
||||
# Prevent rewind/retry while a generation is in progress
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui._enqueue(
|
||||
{
|
||||
"type": "busy_error",
|
||||
"message": "Cannot rewind/retry while processing.",
|
||||
}
|
||||
)
|
||||
return JSONResponse({"status": "busy"})
|
||||
|
||||
should_exit = ws.session.handle_command(cmd)
|
||||
if should_exit:
|
||||
ui.on_info("Session ended. You can close this tab.")
|
||||
# Handle UI updates for workstream-changing commands
|
||||
cmd_word = cmd.strip().split(None, 1)[0].lower()
|
||||
if cmd_word in ("/clear", "/new"):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
elif cmd_word == "/resume":
|
||||
@@ -1343,6 +1378,54 @@ async def command(request: Request) -> JSONResponse:
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
elif cmd_word in ("/rewind", "/retry"):
|
||||
# Refresh frontend with truncated history
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
history = _build_history(ws.session)
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
# Audit trail
|
||||
storage = getattr(request.app.state, "auth_storage", None)
|
||||
if storage:
|
||||
from turnstone.core.audit import record_audit
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
f"conversation.{cmd_word[1:]}",
|
||||
"workstream",
|
||||
ws.id,
|
||||
{"command": cmd, "ws_id": ws.id},
|
||||
ip,
|
||||
)
|
||||
# Dispatch deferred retry in background thread
|
||||
retry_msg = ws.session._pending_retry
|
||||
if retry_msg:
|
||||
ws.session._pending_retry = None
|
||||
session = ws.session
|
||||
|
||||
def run_retry() -> None:
|
||||
me = threading.current_thread()
|
||||
try:
|
||||
session.send(retry_msg)
|
||||
except GenerationCancelled:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("idle")
|
||||
except Exception as exc:
|
||||
if ws.worker_thread is me:
|
||||
ui.on_error(f"Error: {exc}")
|
||||
ui.on_stream_end()
|
||||
ui.on_state_change("error")
|
||||
|
||||
with ws._lock:
|
||||
if ws.worker_thread and ws.worker_thread.is_alive():
|
||||
ui.on_error("Cannot retry: workstream is busy")
|
||||
else:
|
||||
t = threading.Thread(target=run_retry, daemon=True)
|
||||
ws.worker_thread = t
|
||||
t.start()
|
||||
# Sync in-memory workstream name after any command that can change it.
|
||||
# This ensures /api/workstreams and future page loads see the right name.
|
||||
if cmd_word in ("/name", "/resume"):
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead; for man pages use man instead.",
|
||||
"description": "Execute a bash command and return stdout + stderr. Use for running programs, git, tests, system commands, installing packages, etc. Environment questions ('What Python version?', 'Is X installed?') are tool-use tasks — e.g. bash(command='python --version'). For file creation use write_file instead; for man pages use man instead. Long output is truncated (head+tail preserved, middle elided). Stderr lines prefixed with [stderr].",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (1-600). Omit to use the global tools.timeout setting (typically 120s). Use higher values for long-running commands like test suites or builds."
|
||||
},
|
||||
"stop_on_error": {
|
||||
"type": "boolean",
|
||||
"description": "If true, enables 'set -e' so the script exits on the first command failure. Default false. Use for multi-step scripts where intermediate failures should halt execution."
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "diff_file",
|
||||
"description": "Show a unified diff between two files, or between a file and a provided string. Use after edit_file to verify changes, or to compare two files. Returns unified diff output with context lines.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path_a": {
|
||||
"type": "string",
|
||||
"description": "Path to the first file (or the file to diff against path_b or content_b)."
|
||||
},
|
||||
"path_b": {
|
||||
"type": "string",
|
||||
"description": "Path to the second file. Mutually exclusive with content_b."
|
||||
},
|
||||
"content_b": {
|
||||
"type": "string",
|
||||
"description": "String content to compare against path_a. Mutually exclusive with path_b. Useful for comparing current file state against a known previous version."
|
||||
},
|
||||
"context_lines": {
|
||||
"type": "integer",
|
||||
"description": "Number of context lines around changes (default 3)."
|
||||
}
|
||||
},
|
||||
"required": ["path_a"]
|
||||
},
|
||||
"agent": true,
|
||||
"task_agent": true,
|
||||
"auto_approve": true,
|
||||
"primary_key": "path_a"
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
"type": "integer",
|
||||
"description": "When old_string matches multiple locations, pick the one nearest this line number."
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace ALL occurrences of old_string instead of requiring a unique match. Cannot be used with near_line or edits array."
|
||||
},
|
||||
"edits": {
|
||||
"type": "array",
|
||||
"description": "Multiple replacements to apply atomically. Each entry has old_string, new_string, and optional near_line. All edits are validated before any are applied. Preferred over multiple edit_file calls when making several changes to the same file.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"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": {
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default 20)."
|
||||
"description": "Max results to return (default 20, max 50)."
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Skip this many results for pagination. Default 0."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "task_agent",
|
||||
"description": "Delegate a general-purpose task to an autonomous sub-agent. The agent inherits all tools and can read, write, edit, search, and run commands. Use for work that requires file modifications or command execution. Provide a clear, self-contained prompt.",
|
||||
"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, or delegate further. All context must be in the prompt. Provide a clear, self-contained task description.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The full file content to write."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["overwrite", "append"],
|
||||
"description": "Write mode. 'overwrite' (default) replaces the file. 'append' adds content to the end."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
|
||||
@@ -32,6 +32,7 @@ function Pane(wsId) {
|
||||
this.statusText = "";
|
||||
this._cancelTimeout = null;
|
||||
this._forceTimeout = null;
|
||||
this._pendingEditSend = null;
|
||||
this._createDOM();
|
||||
}
|
||||
|
||||
@@ -181,6 +182,7 @@ Pane.prototype.reset = function () {
|
||||
this.setBusy(false);
|
||||
this.pendingApproval = false;
|
||||
this.approvalBlockEl = null;
|
||||
this._pendingEditSend = null;
|
||||
this.inputEl.disabled = false;
|
||||
};
|
||||
|
||||
@@ -211,6 +213,7 @@ Pane.prototype.disconnectSSE = function () {
|
||||
|
||||
Pane.prototype.setBusy = function (b) {
|
||||
this.busy = b;
|
||||
this.messagesEl.dataset.busy = b ? "true" : "false";
|
||||
this.sendBtn.disabled = b;
|
||||
this.sendBtn.style.display = b ? "none" : "";
|
||||
this.stopBtn.style.display = b ? "" : "none";
|
||||
@@ -394,6 +397,7 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
case "state_change":
|
||||
if (evt.state === "idle" || evt.state === "error") {
|
||||
this.setBusy(false);
|
||||
this._attachRetryToLastAssistant();
|
||||
// Only steal focus if this is the active pane and no approval pending.
|
||||
if (this.id === focusedPaneId && !this.pendingApproval) {
|
||||
this.inputEl.focus();
|
||||
@@ -527,6 +531,21 @@ Pane.prototype.handleEvent = function (evt) {
|
||||
|
||||
case "history":
|
||||
this.replayHistory(evt.messages);
|
||||
// Dispatch pending edit-and-resend after rewind history arrives
|
||||
if (this._pendingEditSend) {
|
||||
var editText = this._pendingEditSend;
|
||||
this._pendingEditSend = null;
|
||||
this.setBusy(true);
|
||||
this.addUserMessage(editText);
|
||||
authFetch("/v1/api/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: editText, ws_id: self.wsId }),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Connection error: " + err.message);
|
||||
self.setBusy(false);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "clear_ui":
|
||||
@@ -554,10 +573,208 @@ Pane.prototype.addUserMessage = function (text) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg msg-user";
|
||||
el.textContent = text;
|
||||
this._addUserMsgActions(el, text);
|
||||
this.messagesEl.appendChild(el);
|
||||
this.scrollToBottom(true);
|
||||
};
|
||||
|
||||
Pane.prototype._addUserMsgActions = function (el, text) {
|
||||
var self = this;
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
// Edit button
|
||||
var editBtn = document.createElement("button");
|
||||
editBtn.className = "msg-action-btn";
|
||||
editBtn.title = "Edit & resend";
|
||||
editBtn.setAttribute("aria-label", "Edit and resend this message");
|
||||
var editIcon = document.createElement("span");
|
||||
editIcon.className = "icon-edit";
|
||||
editIcon.setAttribute("aria-hidden", "true");
|
||||
editBtn.appendChild(editIcon);
|
||||
editBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._startEdit(el, text);
|
||||
});
|
||||
bar.appendChild(editBtn);
|
||||
// Rewind-to-here button
|
||||
var rewindBtn = document.createElement("button");
|
||||
rewindBtn.className = "msg-action-btn";
|
||||
rewindBtn.title = "Rewind to before this message";
|
||||
rewindBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Rewind conversation to before this message",
|
||||
);
|
||||
var rewindIcon = document.createElement("span");
|
||||
rewindIcon.className = "icon-rewind";
|
||||
rewindIcon.setAttribute("aria-hidden", "true");
|
||||
rewindBtn.appendChild(rewindIcon);
|
||||
rewindBtn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._rewindToMessage(el);
|
||||
});
|
||||
bar.appendChild(rewindBtn);
|
||||
el.appendChild(bar);
|
||||
};
|
||||
|
||||
Pane.prototype._addRetryAction = function (el) {
|
||||
var self = this;
|
||||
var bar = el.querySelector(".msg-actions");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
bar.className = "msg-actions";
|
||||
bar.setAttribute("role", "toolbar");
|
||||
bar.setAttribute("aria-label", "Message actions");
|
||||
el.appendChild(bar);
|
||||
}
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "msg-action-btn";
|
||||
btn.title = "Retry (regenerate response)";
|
||||
btn.setAttribute("aria-label", "Retry last response");
|
||||
var icon = document.createElement("span");
|
||||
icon.className = "icon-retry";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._retryLast();
|
||||
});
|
||||
bar.insertBefore(btn, bar.firstChild);
|
||||
};
|
||||
|
||||
Pane.prototype._retryLast = function () {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: "/retry", ws_id: this.wsId }),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Retry failed: " + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype._rewindToMessage = function (msgEl) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Count how many user messages come at or after this one
|
||||
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
|
||||
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
|
||||
if (idx < 0) return;
|
||||
var turnsToRewind = userMsgs.length - idx;
|
||||
if (turnsToRewind < 1) return;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command: "/rewind " + turnsToRewind,
|
||||
ws_id: this.wsId,
|
||||
}),
|
||||
}).catch(function (err) {
|
||||
self.addErrorMessage("Rewind failed: " + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype._startEdit = function (msgEl, originalText) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Save current child nodes for cancel restoration
|
||||
var savedNodes = [];
|
||||
while (msgEl.firstChild) {
|
||||
savedNodes.push(msgEl.removeChild(msgEl.firstChild));
|
||||
}
|
||||
msgEl.classList.add("msg-editing");
|
||||
|
||||
var form = document.createElement("div");
|
||||
form.className = "msg-edit-form";
|
||||
|
||||
var textarea = document.createElement("textarea");
|
||||
textarea.className = "msg-edit-textarea";
|
||||
textarea.setAttribute("aria-label", "Edit message text");
|
||||
textarea.value = originalText;
|
||||
textarea.rows = Math.min(originalText.split("\n").length + 1, 8);
|
||||
form.appendChild(textarea);
|
||||
|
||||
var actions = document.createElement("div");
|
||||
actions.className = "msg-edit-actions";
|
||||
|
||||
var cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "msg-edit-btn";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.addEventListener("click", function () {
|
||||
// Restore original nodes
|
||||
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
|
||||
savedNodes.forEach(function (n) {
|
||||
msgEl.appendChild(n);
|
||||
});
|
||||
msgEl.classList.remove("msg-editing");
|
||||
});
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
var sendBtn = document.createElement("button");
|
||||
sendBtn.className = "msg-edit-btn msg-edit-btn-send";
|
||||
sendBtn.textContent = "Send";
|
||||
sendBtn.addEventListener("click", function () {
|
||||
var newText = textarea.value.trim();
|
||||
if (!newText) return;
|
||||
self._editAndResend(msgEl, newText);
|
||||
});
|
||||
actions.appendChild(sendBtn);
|
||||
|
||||
// Ctrl+Enter to send, Escape to cancel
|
||||
textarea.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
sendBtn.click();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
form.appendChild(actions);
|
||||
msgEl.appendChild(form);
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
};
|
||||
|
||||
Pane.prototype._editAndResend = function (msgEl, newText) {
|
||||
if (this.busy) return;
|
||||
var self = this;
|
||||
// Count turns to rewind (from this message onward)
|
||||
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
|
||||
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
|
||||
if (idx < 0) return;
|
||||
var turnsToRewind = userMsgs.length - idx;
|
||||
|
||||
this.setBusy(true);
|
||||
// Store pending send — dispatched when the rewind history event arrives
|
||||
this._pendingEditSend = newText;
|
||||
authFetch("/v1/api/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command: "/rewind " + turnsToRewind,
|
||||
ws_id: self.wsId,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r && !r.ok) {
|
||||
self._pendingEditSend = null;
|
||||
self.setBusy(false);
|
||||
self.addErrorMessage(
|
||||
"Rewind failed (HTTP " + r.status + " " + r.statusText + ")",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
self._pendingEditSend = null;
|
||||
self.addErrorMessage("Rewind failed: " + err.message);
|
||||
self.setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
Pane.prototype.replayHistory = function (messages) {
|
||||
var self = this;
|
||||
this.messagesEl.innerHTML = "";
|
||||
@@ -660,9 +877,25 @@ Pane.prototype.replayHistory = function (messages) {
|
||||
}
|
||||
}
|
||||
}
|
||||
this._attachRetryToLastAssistant();
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
Pane.prototype._attachRetryToLastAssistant = function () {
|
||||
// Remove any previous retry buttons
|
||||
var old = this.messagesEl.querySelectorAll(".msg-assistant .msg-actions");
|
||||
for (var i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
|
||||
// Find the last assistant message with content and add retry
|
||||
var assistants = this.messagesEl.querySelectorAll(".msg-assistant");
|
||||
if (assistants.length) {
|
||||
var last = assistants[assistants.length - 1];
|
||||
// Only add if it's not a reasoning block
|
||||
if (!last.classList.contains("reasoning")) {
|
||||
this._addRetryAction(last);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Pane.prototype.showInlineToolBlock = function (
|
||||
items,
|
||||
autoApproved,
|
||||
|
||||
@@ -625,6 +625,184 @@ body { position: static; }
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Message action toolbar (hover controls for retry / edit / rewind)
|
||||
========================================================================== */
|
||||
.msg-user, .msg-assistant { position: relative; }
|
||||
.msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
.msg-user:hover .msg-actions,
|
||||
.msg-assistant:hover .msg-actions,
|
||||
.msg-user:focus-within .msg-actions,
|
||||
.msg-assistant:focus-within .msg-actions,
|
||||
.msg-actions:hover { opacity: 1; pointer-events: auto; }
|
||||
.msg-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg-dim);
|
||||
transition: color 0.1s ease, background 0.1s ease;
|
||||
padding: 0;
|
||||
}
|
||||
.msg-action-btn:hover { color: var(--accent); background: var(--bg-highlight); box-shadow: 0 0 6px var(--accent-glow); }
|
||||
.msg-action-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.msg-action-btn + .msg-action-btn { border-left: 1px solid var(--border); }
|
||||
|
||||
/* Icon: retry (circular arrow) */
|
||||
.icon-retry {
|
||||
width: 13px; height: 13px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 50%;
|
||||
border-bottom-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
.icon-retry::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1px; right: -1px;
|
||||
width: 0; height: 0;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
border-top: 3px solid currentColor;
|
||||
transform: rotate(-30deg);
|
||||
}
|
||||
|
||||
/* Icon: edit (pencil) */
|
||||
.icon-edit {
|
||||
width: 12px; height: 12px;
|
||||
position: relative;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.icon-edit::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 3px;
|
||||
width: 6px; height: 8px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 1px 1px 0 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.icon-edit::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0; left: 3px;
|
||||
width: 0; height: 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-right: 3px solid transparent;
|
||||
border-top: 3px solid currentColor;
|
||||
}
|
||||
|
||||
/* Icon: rewind (chevrons pointing left) */
|
||||
.icon-rewind {
|
||||
width: 14px; height: 12px;
|
||||
position: relative;
|
||||
}
|
||||
.icon-rewind::before, .icon-rewind::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
width: 6px; height: 6px;
|
||||
border-left: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.icon-rewind::before { left: 1px; }
|
||||
.icon-rewind::after { left: 6px; }
|
||||
|
||||
/* Edit-in-place form */
|
||||
.msg-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.msg-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
background: var(--bg);
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--accent-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.msg-edit-textarea:focus { border-color: var(--accent); }
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-edit-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
|
||||
}
|
||||
.msg-edit-btn:hover { background: var(--bg-highlight); }
|
||||
.msg-edit-btn-send {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.msg-edit-btn-send:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
/* Edit-in-place active state */
|
||||
.msg-editing { background: var(--bg-surface); border-color: var(--accent-dim); }
|
||||
.msg-editing .msg-actions { display: none; }
|
||||
|
||||
/* Busy-state disables action buttons */
|
||||
[data-busy="true"] .msg-action-btn { opacity: 0.3; pointer-events: none; cursor: not-allowed; }
|
||||
|
||||
/* Touch devices: always show action buttons inline */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.msg-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
position: static;
|
||||
margin-top: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-action-btn { width: 36px; height: 36px; }
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg-actions, .msg-action-btn, .msg-edit-textarea, .msg-edit-btn { transition: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Input area
|
||||
========================================================================== */
|
||||
|
||||
Reference in New Issue
Block a user