Compare commits

...

43 Commits

Author SHA1 Message Date
Patrick Buckley 6adc577d30 release: v0.9.4 2026-03-29 18:32:44 -07:00
Patrick Buckley 02c50b81c1 docs: update tool counts, add diff_file docs, new params (#244)
* docs: update tool counts, add diff_file docs, new params

- Tool count 17/18 → 19 across tools.md, architecture.md, and
  PlantUML diagrams (02-package-structure, 05-tool-pipeline)
- Add diff_file tool documentation section
- Document new params: bash timeout + stop_on_error, write_file
  mode (append), edit_file replace_all
- Add diff_file, watch, skill to tool pipeline dispatch table
- Regenerate diagram PNGs

* fix: remove slim dpkg exclusion so man pages are actually installed

The python:3.14-slim image excludes /usr/share/man/* via dpkg config.
man-db was installed but had no pages to serve.  Remove the exclusion
before installing packages, and add manpages package for coreutils
documentation.  Dropped info (rarely used, man covers the same).

* fix: redact DB connection strings and URL-based secrets in output guard

The output redactor missed TURNSTONE_DB_URL and DATABASE_URL because
the env secret key pattern only matched SECRET/TOKEN/PASSWORD/KEY,
not URL-based credential keys.  Also the connection string regex
didn't cover the postgresql+psycopg:// scheme used by psycopg3.

- Add DATABASE_URL, TURNSTONE_DB_URL, DB_URL to explicit env key matches
- Add psycopg and sqlite to connection string scheme pattern

* fix: address Copilot review on docs — tool names, counts, approval

- Fix remaining 17→19 count in tools.md execution pipeline section
- Dispatch table: task→task_agent, plan→plan_agent (match actual names)
- Dispatch table: header clarifies "19 built-in + tool_search"
- watch/skill: show conditional approval (create only / load only)
- Regenerate pipeline diagram PNG
2026-03-29 18:28:21 -07:00
Patrick Buckley 753cd04b4e Fix/orphaned tool results (#243)
* fix: drop orphaned tool_results with no matching tool_use in _convert_messages

The context window increase from 200K to 1M for Claude 4.6 means
conversations that previously triggered auto-compaction now send their
full history.  Older messages with orphaned tool_results (from
pre-fix cancels or compaction boundaries) are now visible to the API,
causing "unexpected tool_use_id in tool_result blocks" errors.

The existing repair code handles orphaned tool_use (synthesizes
missing results), but not the reverse.  Now validates each
tool_result against the preceding assistant message's tool_use IDs
and silently drops results with no match.

* fix: filter empty IDs from prev_tool_use_ids, document pass-through

Code review: empty-ID tool_use blocks were added to the filter set,
and the intentional pass-through when prev_tool_use_ids is empty
needed documentation.
2026-03-29 18:26:25 -07:00
Patrick Buckley c3217748dc fix: block math sandbox escape via getattr/setattr/type reflection (#239)
* fix: block math sandbox escape via getattr/setattr/type reflection

getattr() with runtime-constructed strings bypassed the AST validator,
allowing full os/subprocess access from the sandboxed math tool via
module.__builtins__['__import__']('os').

Three-layer fix:
- Block getattr, setattr, delattr, type, __import__ in
  _MATH_BLOCKED_BUILTINS (prevents direct calls)
- Add AST validation for getattr/setattr/delattr call nodes
  (catches them even if builtins dict is bypassed)
- Strip __builtins__ from all pre-imported modules in the execution
  namespace (runtime defense — even if AST is somehow bypassed,
  module.__builtins__ returns empty dict)

Normal math, sympy, numpy, scipy operations unaffected.

* fix: harden _safe_import to strip __builtins__ from runtime imports

Copilot review: modules imported at runtime via _safe_import still
had their original __builtins__ dict, accessible via
operator.attrgetter('__builtins__').  Now _safe_import strips
__builtins__ from every module it returns.  Also blocks
operator.attrgetter/itemgetter at the AST level, and removes the
redundant duplicate getattr check in visit_Call.

* fix: add type ignore for module __builtins__ assignment
2026-03-29 17:37:59 -07:00
Patrick Buckley 8cbff49694 fix: block /proc/*/environ access in bash filter and judge heuristic (#240)
* fix: block /proc/*/environ access in bash filter and judge heuristic

/proc/1/environ leaks the full server environment including DB
credentials, API keys, and JWT secrets.  Env scrubbing in env.py
only affects subprocess calls, not procfs reads.

- Add /proc/1/environ and /proc/self/environ to BLOCKED_PATTERNS
  in safety.py (hard block)
- Add proc-environ-exfil heuristic rule at critical severity with
  deny recommendation (catches /proc/<pid>/environ patterns)

* fix: move proc-environ-exfil rule to _CRITICAL_RULES list

Copilot review: rule had risk_level=critical but was placed in
_HIGH_RULES.  Move to _CRITICAL_RULES for consistency with the
first-match-wins severity ordering.
2026-03-29 17:37:46 -07:00
Patrick Buckley 4f26d63c14 perf: trim judge context to messages from last user turn onward (#241)
The intent judge was receiving up to 50% of the context window in
conversation history (FIFO from end), which grows linearly with
conversation length and causes increasing latency.  The judge only
needs the immediate request context to evaluate a tool call's safety.

Now trims to messages from the last user message onward before
applying the FIFO budget cap.  Keeps the user's request, the
assistant's response with tool calls, and any recent tool results
while discarding earlier conversation that isn't relevant to the
current intent evaluation.
2026-03-29 17:34:41 -07:00
Patrick Buckley 74347fb29f fix: update Claude 4.6 context windows to 1M, remove EOL 4.0 models (#242)
Claude 4.6 (Opus + Sonnet) unified on 1M token context windows.
Update capabilities table from 200K to 1M for both models.  Remove
claude-opus-4 and claude-sonnet-4 entries (end of life).  4.5 models
remain at 200K.  Default fallback stays at 200K for unknown models.
2026-03-29 17:28:45 -07:00
Patrick Buckley 2ace8cccc8 fix: distinguish user cancel from crash in bash tool results (#235)
* fix: distinguish user cancel from crash in bash tool results

When a user cancels a running bash command, the process is killed
with SIGKILL (exit code -9).  Previously this showed as an error,
causing the model to retry.  Now checks cancel.is_set() after proc
exit and returns "Cancelled by user." as a non-error result so the
model knows to stop rather than retry.

* fix: use -signal.SIGKILL instead of magic -9

Copilot review: replace hard-coded -9 with -signal.SIGKILL for
clarity.  Popen.returncode is negative of signal number when killed.
2026-03-29 17:09:44 -07:00
Patrick Buckley e95b8f5ca1 feat: add stop_on_error param to bash tool for set -e behavior (#236)
* feat: add stop_on_error param to bash tool for set -e behavior

New boolean parameter enables 'set -e' in the bash preamble so
multi-step scripts exit on the first command failure instead of
silently continuing.  Default false (existing behavior preserved).
pipefail remains always-on.

* fix: strict bool parsing for stop_on_error, treat exit 1 as error with set -e

Copilot review: bool("false") is True — use `is True` for strict
JSON boolean parsing.  Also, with stop_on_error enabled, any non-zero
exit code is now treated as an error (set -e means the script halted
on failure), whereas without it exit code 1 remains benign.
2026-03-29 17:09:29 -07:00
Patrick Buckley 7a32c51a1c fix: synthesize cancelled tool results instead of stripping turns (#237)
* fix: synthesize cancelled tool results instead of stripping turns

When a user cancels during tool execution, the model previously lost
all context about what was attempted (assistant message + tool_calls
stripped entirely).  Now synthesizes tool_result messages with
is_error=true and "Cancelled by user." content for any tool_calls
that lack matching results.  This keeps the conversation valid for
both providers while preserving the full tool call structure so the
model knows what was tried.

Also applies to KeyboardInterrupt with "Interrupted by user." text.

* fix: persist synthesized cancel results to DB, assert is_error in test

Copilot review: synthesized tool messages were in-memory only,
creating a mismatch with DB that could break rewind/retry.  Now
calls save_message() for each synthesized result.  Also adds
is_error=True assertion to the cancel test.
2026-03-29 17:09:17 -07:00
Patrick Buckley dce663105b feat: add pagination and longer content to recall tool (#238)
* feat: add pagination and longer content to recall tool

- New offset parameter for paginating through recall results
- Content preview increased from 500 to 2000 chars per match with
  total length indicator when truncated
- Output passed through _truncate_output for consistency
- OFFSET clause added to SQLite (FTS5 + LIKE) and PostgreSQL
  (tsvector + ILIKE) search queries

* fix: defensive int coercion for recall offset/limit

Copilot review: offset/limit could arrive as null, float, or other
non-int types from JSON.  Coerce with int() + try/except in prepare,
and int() at the storage layer before binding into SQL OFFSET/LIMIT.
2026-03-29 17:09:05 -07:00
Patrick Buckley cfef3616e6 feat: add diff_file tool for comparing files and content (#234)
* feat: add diff_file tool for comparing files and content

New read-only tool that shows unified diffs between two files or
between a file and provided content.  Useful for verifying edit_file
changes and comparing file versions.  Auto-approved (no side effects).
Configurable context lines (default 3).  Available to task agents.

* refactor: extract _read_text_lines helper, share across read_file and diff_file

Copilot review: diff_file duplicated file-loading and lacked binary
detection.  Extract _read_text_lines() that handles realpath
resolution, null-byte binary detection, and error handling.  Used by
both _exec_read_file and _exec_diff for consistent behavior.

* fix: address code review — agent flag, resolved shadowing, read_files

- Add agent: true to diff_file schema so plan agents can use it
- Fix resolved variable shadowing in _exec_read_file (use _ for
  unused return from _read_text_lines)
- Register diffed files in _read_files so edit_file read guard
  is satisfied after diff_file
- Move difflib import to module level (stdlib, no lazy-load needed)
- Fix description wording ("provided string" not "previous version")

* fix: stream diff with early cutoff, expand paths before header

- Stream difflib output and stop collecting after tool_truncation
  chars to avoid large intermediate allocations on big diffs
- Expand paths with expanduser before building the approval header
  so display matches actual execution paths
2026-03-29 16:17:48 -07:00
Patrick Buckley c22d39a798 docs: tool descriptions, bash timeout param, multi-line preview (#233)
* docs: tool descriptions, bash timeout param, multi-line preview

- task_agent/plan_agent: document the tool subset limitation (no
  memory, recall, watch, skill, or further delegation)
- bash: add per-call timeout parameter (1-600s, defaults to 120s),
  shown in approval header when specified
- bash: show full command in preview for multi-line scripts so the
  approval flow displays the complete command, not just the first line
- bash: document 256KB output cap and stderr prefix in description

* fix: address Copilot review on tool descriptions

- bash: say "truncated" not "256KB" (limit is configurable), document
  timeout clamping range (1-600) and global fallback
- bash preview: fix "1 more lines" → "1 more line" singular
- plan_agent: remove bash from listed tools (not in AGENT_TOOLS)
2026-03-29 16:17:35 -07:00
Patrick Buckley 63921450b1 fix: improve memory save error message, narrow dd command filter (#232)
* fix: improve memory save error message, narrow dd command filter

Two minor fixes from harness shakedown:

- memory save: split "both name and content required" into separate
  errors for missing name vs empty content
- bash safety: replace blanket "dd if=" block with targeted patterns
  for writes to block devices (of=/dev/sd*, /dev/nvme*, /dev/disk/,
  etc.) and redirects to the same.  Legitimate dd use like generating
  test data or benchmarking reads is no longer blocked.

* fix: generalize > /dev/sda redirect pattern to > /dev/sd

Copilot review: only /dev/sda was blocked for redirects while
/dev/sdb, /dev/sdc etc were not.  Generalize to match any /dev/sd*
device, consistent with the of= patterns.
2026-03-29 16:17:22 -07:00
Patrick Buckley 929fad63be feat: edit_file replace_all, write_file append mode, search match count (#231)
* feat: edit_file replace_all, write_file append mode, search match count

Three tool enhancements from harness shakedown feedback:

- edit_file: new replace_all parameter replaces all occurrences of
  old_string instead of requiring a unique match.  Cannot combine with
  near_line or edits array.
- write_file: new mode parameter with "append" option.  Appends
  content to end of file instead of truncating.
- search: output now includes a summary footer showing total match
  count and file count (e.g. "47 matches across 12 files").

* fix: address Copilot review on tool enhancements

- replace_all: skip multi-occurrence rejection in pre-validation so
  the feature actually works; show occurrence count in preview
- write_file mode: coerce non-string types safely via str()
- search footer: append before truncation to respect output limits
- edit_file error: mention replace_all as alternative to near_line
2026-03-29 16:17:11 -07:00
Patrick Buckley 976e9df3b6 ci: suppress CVE-2026-25210 (libexpat1, no fix available) (#230)
Integer overflow in libexpat1 2.7.1-2 with no patched version in
Debian repos yet.  Suppress in Trivy until a fix is published.
2026-03-29 15:35:20 -07:00
Patrick Buckley 7cb21b84f1 fix: detect binary files in read_file instead of silent corruption (#227)
read_file silently converted null bytes to spaces, showing corrupted
content with no warning.  Now samples the first 8KB for null bytes and
returns a clear error directing the user to bash for binary inspection.
2026-03-29 15:32:04 -07:00
Patrick Buckley 7263edd48d fix: memory delete searches all scopes when scope not specified (#228)
* fix: memory delete searches all scopes when scope not specified

Previously delete defaulted to scope=global, so deleting a
workstream-scoped memory without explicitly passing scope=workstream
silently failed.  Now tries narrowest scope first (workstream → user
→ global) and deletes the first match.  Explicit scope still honored
when provided.

* fix: reject invalid scope on memory delete instead of silent fallback

Copilot review: invalid scope values were silently treated as
unspecified, which could cause accidental deletion from the wrong
scope.  Now returns a clear error listing valid scopes.
2026-03-29 15:29:14 -07:00
Patrick Buckley 6742c7e405 fix: exclude build/vendor/VCS directories from search tool (#226)
* fix: exclude build/vendor/VCS directories from search tool

grep -rn recursed into .git, node_modules, target, __pycache__, etc.
producing hundreds of noise hits from generated content.  Add
--exclude-dir flags for common directories that should never appear
in search results.

* fix: glob egg-info pattern and add vendor exclude

Copilot review: .egg-info misses turnstone.egg-info (named dirs),
use *.egg-info glob.  Also add vendor to the exclude list.
2026-03-29 15:28:52 -07:00
Patrick Buckley 1aa6982868 fix: add git, curl, jq, man-db, info to Docker image (#225)
Agent workflows need git for version control, curl for raw HTTP
requests, jq for JSON processing, and man/info for documentation
lookup.  All were missing from the slim base image, leaving the man
tool non-functional and standard dev workflows broken.
2026-03-29 15:28:38 -07:00
Patrick Buckley 42d1abbd04 fix: block IPv6 loopback/link-local/private in SSRF filter (#224)
* fix: block IPv6 loopback/link-local/private in SSRF filter

check_ssrf used gethostbyname which only resolves IPv4.  IPv6 addresses
like ::1, fe80::, fd00:: bypassed the filter entirely.  Switch to
getaddrinfo which resolves both address families and check all results.

* fix: handle IPv4-mapped IPv6 and zone IDs in SSRF filter

Copilot review caught two bypasses: ::ffff:127.0.0.1 (IPv4-mapped
IPv6) wasn't normalized before private/loopback checks, and fe80::1%lo0
(zone ID suffix) caused a ValueError that was silently swallowed.
Now normalizes IPv4-mapped addresses and strips zone IDs before parsing.
2026-03-29 15:28:27 -07:00
Patrick Buckley f543ed714a fix: resolve symlinks before file I/O to prevent path-based bypass (#223)
* fix: resolve symlinks before file I/O to prevent path-based bypass

write_file and edit_file followed symlinks silently — a symlink at
/data/link → /etc/passwd would show the /data path in the approval
header while writing to the real target.  Three changes:

- open() calls in _exec_write_file, _exec_edit_file, _exec_read_file
  now use the resolved (realpath) path instead of the raw symlink
- Approval headers show both paths when a symlink is detected
  (e.g. "⚙ write_file: /data/link → /etc/passwd")
- Judge _get_arg_text includes the resolved path so heuristic rules
  like write-system-path fire even through symlinks

* fix: address Copilot review — expanduser in fallback, pre-read, image paths

- edit_file exec fallback: add expanduser before realpath (tilde bypass)
- judge _get_arg_text: compare resolved against abspath(expanduser(path))
  so ~/ paths don't false-positive as symlinks
- edit_file pre-read: use resolved path instead of raw symlink path
- _exec_read_image: use resolved path for getsize and binary open
2026-03-29 15:28:13 -07:00
Patrick Buckley 2c6abb0fde fix: clear dedup sigs after write tools to avoid false repeat warnings (#229)
* fix: clear dedup sigs after write tools to avoid false repeat warnings

The read→edit→read workflow triggered "identical repeat" warnings
because the dedup tracker compared (tool_name, args) without
considering intervening state changes.  Now clears the signature set
when write_file, edit_file, or bash executes successfully, so
subsequent reads of the same file are not flagged.

* fix: use shared error prefixes for write-success detection in dedup

Copilot review: the error detection for write tools only checked
"Error" prefix, missing "Command timed out", "Blocked:", "Denied",
etc.  Now shares the same _error_prefixes tuple used by the repeat
detection below, ensuring consistent classification.
2026-03-29 15:27:59 -07:00
Patrick Buckley 491fc6748a fix: judge double tool conversion on Anthropic (#222)
The judge pre-converted tool schemas via convert_tools() before
passing them to create_completion(), which internally calls
convert_tools() again. The second conversion tried to extract
function.name from already-converted Anthropic-format tools,
producing empty tool names that the API rejected with
"tools.0.custom.name: String should have at least 1 character".

Fix: pass raw OpenAI-format schemas directly — create_completion
handles the provider-specific conversion.
2026-03-29 14:51:11 -07:00
Patrick Buckley 9a996f0067 release: v0.9.3
- fix: orphaned tool_use followup — ordering, empty IDs, provider_content bypass, universal repair (#220)
- feat: /retry and /rewind commands, message action controls in web UI (#221)
- docs: update tools, architecture, SDK for v0.9.2 changes
2026-03-29 14:19:02 -07:00
Patrick Buckley a465ac6383 Feat/rewind retry (#221)
* feat: add /retry and /rewind commands for conversation history navigation

Allow users to re-send the last message for a new response (/retry) or
drop the last N turns to restore an earlier conversation state (/rewind N).
Both operations sync in-memory state with the persistent database.

Server path includes conversation.modify permission gate, audit trail
(conversation.rewind / conversation.retry events), and thread-safe retry
dispatch. Migration 029 grants the permission to admin and operator roles.

* feat: add message action controls for retry, edit, and rewind in web UI

Hover toolbar on messages with CSS-only icons matching instrument panel
aesthetic. User messages get edit (pencil) and rewind (chevrons) buttons;
last assistant message gets retry (circular arrow). Edit flow uses
event-driven coordination — rewind completes via SSE history event before
send fires. Includes ARIA labels, keyboard nav, touch device support,
reduced motion, and busy-state gating.
2026-03-29 14:18:39 -07:00
Patrick Buckley a4539923e4 fix: orphaned tool_use followup — ordering, empty IDs, universal repair (#220)
Addresses Copilot review feedback on #219:

1. Anthropic _convert_messages: collect tool_use IDs in order (list
   not set), filter empty IDs, defer synthetic results until after
   real tool results so _merge_consecutive produces correct ordering.

2. Universal repair in reconstruct_messages: synthesize tool results
   for mid-conversation orphaned tool calls on DB load. Benefits all
   providers (OpenAI is lenient today but may tighten).

3. Test improvements: assert on is_error flag instead of "cancelled"
   substring, verify real-before-synthetic ordering in partial results.
2026-03-29 13:33:58 -07:00
Patrick Buckley 42e99d6990 docs: update tools, architecture, SDK for v0.9.2 changes
- docs/tools.md: batch edit_file (edits array), bash stderr prefix,
  math sandbox extras, output truncation
- docs/judge.md: JSON secret detection in output guard
- docs/architecture.md: state_change now sent to per-workstream SSE
- README.md: [sandbox] extras group in requirements
- TypeScript SDK: StateChangeEvent type, type guard, exports
- OpenAPI specs regenerated
2026-03-29 06:06:30 -07:00
Patrick Buckley a0ff22e137 release: v0.9.2
- fix: UI busy state during multi-tool-call turns (#216)
- feat: batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
- fix: Anthropic sub-agent streaming timeout (#218)
- fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
2026-03-29 05:56:40 -07:00
Patrick Buckley de4b3b3909 fix: synthesize tool_results for orphaned tool_use blocks on Anthropic (#219)
When a cancel interrupts tool execution, the assistant message with
tool_use blocks is saved to DB before tools run, but
GenerationCancelled prevents tool results from being created. The
in-memory rollback removes the orphaned message, but the DB row
persists. On resume, Anthropic rejects the conversation with
"tool_use ids were found without tool_result blocks".

Fix: _convert_messages now peeks ahead after each assistant message
with tool_use blocks. If any tool_use IDs lack matching tool_result
messages, synthetic error results are injected (is_error: true,
"Tool execution was cancelled."). Transparent to all callers,
provider-specific (OpenAI is lenient about this).

5 new tests covering: single orphan, multiple orphans, partial
results, complete results (no synthesis), and trailing orphan.
2026-03-29 05:54:19 -07:00
Patrick Buckley 120d229b5f fix: Anthropic sub-agent streaming timeout (#218)
plan_agent and task_agent fail on Anthropic models with "Streaming is
required for operations that may take longer than 10 minutes" from
the SDK. The non-streaming create_completion path used
client.messages.create() which the SDK rejects for thinking-enabled
models.

Fix: use client.messages.stream() internally and call
get_final_message() to get the same Message object. Transparent to
all callers — fixes sub-agents, title generation, summarization,
web fetch, and judge create_completion calls.
2026-03-29 05:35:27 -07:00
Patrick Buckley c6f4c11870 feat: harness quick wins — batch edit_file, sandbox packages, stderr labels, JSON secret redaction, model resume (#217)
Five improvements from Opus self-evaluation of the turnstone harness:

1. Batch edit_file: edits array parameter for atomic multi-edit in a
   single tool call. Overlap detection, reverse-order application,
   mutual exclusivity with single-edit params.

2. Sandbox packages: new [sandbox] extras group with sympy, numpy,
   scipy, pytest — the sandbox already had graceful ImportError
   fallbacks, now the packages are actually installed.

3. Stderr labeling: bash tool output prefixes stderr lines with
   [stderr] so the model can distinguish errors from stdout.

4. JSON secret redaction: output guard now detects and redacts secrets
   in JSON format ("api_key": "...", "password": "...", etc.) with
   18 key patterns and 8-char minimum value length.

5. Model persisted on resume: workstream config now saves model and
   model_alias. Resume restores the original model via registry
   (same path as /model command), falling back to raw model name
   if the alias is no longer available.

24 new tests (23 in test_edit_file.py, 1 in test_sessions.py).
2026-03-29 05:21:08 -07:00
Patrick Buckley 979fab37a9 fix: UI busy state during multi-tool-call turns (#216)
stream_end fires per-segment (between tool calls), not per-turn.
The UI was using stream_end to transition to idle, causing a window
where the Send button appeared but the server worker thread was still
alive. User messages submitted during this window were silently
dropped. No Stop button was visible, so the user had no cancel path.

Root cause: state_change events (idle/thinking/running/error) were
only broadcast to the global SSE stream (console dashboard), never
to the per-workstream SSE that the browser UI listens to.

Fix: (1) server.py: on_state_change now also enqueues to the
per-workstream SSE listeners. (2) app.js: stream_end no longer
calls setBusy(false) — it only finalizes markdown rendering.
New state_change handler manages busy transitions: idle/error
set busy=false, thinking/running set busy=true.
2026-03-29 05:20:47 -07:00
Patrick Buckley da5bf90a4b feat: model detect button, capabilities API, and model dropdowns (#215)
* feat: model detect button, capabilities API, and model dropdowns

Admin Models tab: add Detect button that probes a model endpoint to
verify reachability, list available models, detect context_window, and
identify server type (llama.cpp/vLLM/SGLang/OpenAI/Anthropic). Add
static capability lookup endpoint for auto-filling form fields when
a known model name is entered. Add known-models endpoint for datalist
autocomplete suggestions.

Add "openai-compatible" as a third provider option for local servers,
keeping the OpenAI SDK under the hood but suppressing capability
auto-fill and known-model suggestions.

Replace free-text model input with a select dropdown in both console
and server new-workstream modals, populated from a new lightweight
GET /v1/api/models endpoint.

New endpoints:
- POST /v1/api/admin/model-definitions/detect
- GET /v1/api/admin/model-capabilities
- GET /v1/api/admin/model-capabilities/known
- GET /v1/api/models (both console and server)

* fix: accumulate signature_delta for Anthropic thinking blocks (#214)

The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.

* fix: address PR review — empty base_url, capability leak, response schemas

- Don't pass empty base_url to OpenAI client (falls back to SDK default)
- Return None from lookup_model_capabilities for openai-compatible provider
- Only use static capability table for known models in _detect_openai_compat,
  avoiding misleading 200k default for unknown local models
- Add AvailableModelInfo + ListAvailableModelsResponse schemas to both
  console_spec and server_spec
- Regenerate TypeScript SDK OpenAPI snapshots

* fix: apply same known-model guard to Anthropic context_window detection

Only report context_window from the static capability table when the
Anthropic model is actually known, matching the OpenAI path fix.

* ui: add autocomplete hint to Model ID label in admin modal

* fix: use explicit kwargs for OpenAI() to satisfy strict mypy
2026-03-29 03:50:21 -07:00
Patrick Buckley 70c18467cb fix: accumulate signature_delta for Anthropic thinking blocks (#214)
The streaming path captured thinking_delta events but not
signature_delta, leaving the signature empty on round-trip and
causing 400 errors on multi-turn conversations with thinking enabled.
2026-03-29 03:10:49 -07:00
Patrick Buckley 801774bc4a fix: add diagnostic logging for silent tool call drops (#213)
When a local model generates tool calls that are silently dropped
(truncation, missing tool-call-parser), there was zero server-side
logging — making it impossible to diagnose from docker compose logs.

- OpenAI provider: log request params and response summary (debug)
- Session: log stream completion, tool call presence (info), and
  tool call discard with names when truncated (warning)

CLI unaffected — log level is WARNING there.
2026-03-29 02:25:43 -07:00
Patrick Buckley 497984b452 feat: database-backed model definitions with admin UI (#212)
Add model_definitions table (migration 028) enabling model management
via the admin console without SSH access or server restarts. Models
defined in the database coexist with config.toml models through a
per-node merge strategy — config.toml overrides DB for the same alias,
DB-only models coexist alongside, no cross-node contamination.

Storage layer:
- model_definitions table with CRUD (SQLite + PostgreSQL)
- MODEL_DEFINITION_MUTABLE allowlist, admin.models permission

ModelRegistry integration:
- load_model_registry() merges DB + config.toml + CLI models
- context_window=0 auto-detects from provider capability table
or inherits CLI-detected value (same as config.toml behavior)
- ModelRegistry.reload() with validation, TOCTOU-safe accessors
- internal_model_reload + internal_model_status server endpoints

Admin API + UI:
- 6 console endpoints (list, create, get, update, delete, reload)
with admin.models permission, audit trail, provider validation
- Models tab in System group with sky blue (--blue) accent color
- Provider badges (openai/anthropic), source badges (config/db)
- Write-only API keys (never readable, "***" sentinel on update)
- Sync-pending indicator, mobile responsive, focus-trapped modal

Also changes is_secret settings from write-blocked (403) to write-only
across all settings, making judge.api_key configurable via admin UI.
2026-03-29 01:58:08 -07:00
Patrick Buckley bdc1eba34c cleanup: drop vestigial tool_args column from conversations (migration 027) 2026-03-28 23:36:49 -07:00
Patrick Buckley 028c77cae5 fix: display tool errors inline in CLI (#210)
* fix: display tool errors inline in CLI

* fix: thread-safe stderr write with _print_lock and flush
2026-03-28 23:08:24 -07:00
Patrick Buckley 76d007d83f fix: TypeScript SDK DeleteSettingResponse type drift (#209)
* fix: TypeScript SDK DeleteSettingResponse type drift

* fix: export DeleteSettingResponse from SDK index
2026-03-28 23:08:09 -07:00
Patrick Buckley 3f432b8a42 fix: watch dispatch error handler missing stream_end and state cleanup (#208)
* fix: watch dispatch error handler missing stream_end and state cleanup

The watch dispatch run() closure was missing GenerationCancelled
handling, stream_end emission, on_state_change calls, and the
worker_thread identity guard that the send_message path has. This
left the web UI in a stale state when watch-dispatched sends failed.

* fix: address review feedback - on_stream_end, put_nowait, ws._lock, tests

* fix: ruff lint (unused pytest import)

* fix: send_message() use on_stream_end() instead of raw _enqueue
2026-03-28 23:07:47 -07:00
Patrick Buckley f74aa2264e refactor: add is_error to on_tool_result protocol, remove text heuris… (#207)
* refactor: add is_error to on_tool_result protocol, remove text heuristics

Add is_error keyword arg to SessionUI.on_tool_result() so tools
report errors structurally. Server and JS client no longer guess
from output text prefixes — each tool sets the flag at the source.

Bash tool: exit code >= 2 is error, exit code 1 is ambiguous (grep
no-match). History reconstruction keeps text heuristic as fallback
for pre-migration data.

Update SDKs (Python + TypeScript), test mocks, docs, and diagrams.

* fix: infinite recursion in _report_tool_result, signal exits, stale docs

* fix: add _tool_error_flags to test_load_skill ChatSession stubs
2026-03-28 22:09:52 -07:00
Patrick Buckley d00aae2429 fix: tool UX improvements (bash exit codes, previews, edit guard) (#206)
* fix: tool UX improvements (bash exit codes, previews, edit guard)

- Enable pipefail in bash tool so piped commands surface real exit codes
- Move exit code append before UI callback so web UI shows failures
- Remove preview truncation from edit_file, write_file, and math tools
- Add no-op guard to edit_file when old_string == new_string
- Fix collapsed tool output scroll — "click to expand" stays anchored

* fix: correct stale comment on edit_file preview

* fix: suggest re-reading file when edit_file old_string not found
2026-03-28 21:24:23 -07:00
89 changed files with 7676 additions and 523 deletions
+4
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+2 -2
View File
@@ -386,10 +386,10 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
{"type": "tool_output_chunk", "call_id": "call_abc123", "chunk": "Building project...\n"}
```
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr.
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr. The `is_error` field is `true` when the tool execution failed (e.g. bash exit code >= 2 or signal, file not found, timeout). Exit code 1 is ambiguous (e.g. `grep` no-match) and is not flagged. User denials are tracked separately via a `denied` flag. Clients should use `is_error` instead of text-prefix heuristics.
```json
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n"}
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false}
```
**`status`** -- token usage statistics, sent after each model turn.
+3 -3
View File
@@ -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**.
@@ -242,7 +242,7 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
+ on_content_token(text: str)
+ on_stream_end()
+ approve_tools(items: list) → (bool, str|None)
+ on_tool_result(call_id: str, name: str, output: str)
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
+ on_tool_output_chunk(call_id: str, chunk: str)
+ on_status(usage: dict, ctx_window: int, effort: str)
+ on_plan_review(content: str) → str
+2 -1
View File
@@ -133,7 +133,8 @@ group loop [while tool_calls present]
note right of TP
bash: on_tool_output_chunk(call_id, line)
called per stdout line,
then on_tool_result(call_id, name, output).
then on_tool_result(call_id, name, output, is_error).
is_error=True when execution failed.
call_id routes chunks/results to correct
tool div during parallel execution.
Other tools: on_tool_result() only.
+7 -4
View File
@@ -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 │
├───────────────┼──────────────────┤
@@ -127,7 +130,7 @@ partition "Phase 3: Execute" #E3F2FD {
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
:ui.on_tool_result(call_id, name, output) for each;
:ui.on_tool_result(call_id, name, output, is_error) for each;
if (plan tool was executed?) then (yes)
:ui.on_plan_review(output);
+1
View File
@@ -147,6 +147,7 @@ package "Outbound Events (Bridge → Client)" as OutPkg #E3F2FD {
+ call_id: str
+ name: str
+ output: str
+ is_error: bool
}
class PlanReviewEvent {
type = "plan_review"
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
size 310079
oid sha256:2b3ea69f852e93dc1bc7943db0c71d0cdd1afcbcf470a8737189ae56e85b3206
size 310075
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e605963c649574c7bf987b2d338257c78bc1fc68b524ac8276bb25365035e06
size 594096
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
size 594676
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
size 319702
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
size 325245
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43844b07d36beb04db871f6795a3f3be17852a6a484fdc0ea207403bd7f512a6
size 274286
oid sha256:674712a0563f51837383184652efeb28b7bec13378be636e89d2959bfba39d1e
size 281519
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2636e2d4d2f84f26f93de6e984b780f6ad5bf8d3618a0202ea0a2ee80859c5b5
size 312409
oid sha256:d831c5e10266f0232262b6a29b0ea8e45b3cc63df75f716a80f18462b4f85e66
size 319125
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -127,7 +127,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `reasoning` | `ReasoningEvent` | `text` |
| `tool_info` | `ToolInfoEvent` | `items` |
| `approve_request` | `ApproveRequestEvent` | `items` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
| `plan_review` | `PlanReviewEvent` | `content` |
+49 -16
View File
@@ -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:
@@ -102,10 +102,15 @@ Each item's `execute` callable is invoked:
- Errored or denied items return their error/denial message without executing.
- The `bash` tool streams stdout incrementally: each line calls
`ui.on_tool_output_chunk(call_id, line)` as it is produced, then the final
combined output (stdout + stderr) is delivered via `ui.on_tool_result(call_id, name, output)`.
combined output (stdout + stderr) is delivered via
`ui.on_tool_result(call_id, name, output, is_error=...)`.
The `call_id` links `tool_info`/`approve_request` items to their streaming chunks and
final result, enabling correct routing when multiple bash tools run in parallel.
Other tools deliver results atomically via `ui.on_tool_result(call_id, name, output)` only.
The `is_error` flag is `True` when the tool execution failed (e.g. bash exit code >= 2
or signal, file not found, timeout). Exit code 1 is ambiguous and not flagged; user
denials are tracked separately. This removes the need for text-prefix heuristics.
Other tools deliver results atomically via
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
- Special post-execution gate for `plan`: the plan output is shown to the user
for review, and the user can reject or annotate it.
@@ -183,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).
@@ -216,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.
@@ -225,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.
@@ -265,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`.
---
@@ -596,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.
@@ -639,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
+3 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.9.1"
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"
@@ -54,7 +54,8 @@ postgres = ["psycopg[binary]>=3.2"]
ddg = ["ddgs>=9.0"]
discord = ["discord.py>=2.4", "redis>=7.2"]
tls = ["lacme>=1.0.4"]
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls]"]
sandbox = ["sympy>=1.13", "numpy>=2.0", "scipy>=1.14", "pytest>=9.0"]
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls,sandbox]"]
[project.scripts]
turnstone = "turnstone.cli:main"
+807 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "0.9.0",
"version": "0.9.2",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
@@ -2103,6 +2103,27 @@
}
}
},
"/v1/api/models": {
"get": {
"summary": "List enabled model aliases for workstream creation",
"operationId": "v1_api_models_get",
"tags": [
"Models"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListAvailableModelsResponse"
}
}
}
}
}
}
},
"/v1/api/skills": {
"get": {
"summary": "List available skills (summary)",
@@ -3453,6 +3474,353 @@
}
}
},
"/v1/api/admin/model-definitions": {
"get": {
"summary": "List model definitions with live status from cluster nodes",
"operationId": "v1_api_admin_model-definitions_get",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListModelDefinitionsResponse"
}
}
}
}
}
},
"post": {
"summary": "Create a model definition",
"operationId": "v1_api_admin_model-definitions_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateModelDefinitionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/reload": {
"post": {
"summary": "Tell all nodes to re-read model definitions from DB and rebuild registry",
"operationId": "v1_api_admin_model-definitions_reload_post",
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelReloadResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/{definition_id}": {
"get": {
"summary": "Get a single model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"put": {
"summary": "Update a model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_put",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateModelDefinitionRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"delete": {
"summary": "Delete a model definition",
"operationId": "v1_api_admin_model-definitions_{definition_id}_delete",
"tags": [
"Admin"
],
"parameters": [
{
"name": "definition_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-definitions/detect": {
"post": {
"summary": "Probe a model endpoint: verify reachability, list models, detect context window and server type",
"operationId": "v1_api_admin_model-definitions_detect_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DetectModelRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DetectModelResponse"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-capabilities": {
"get": {
"summary": "Look up static capabilities for a known model",
"operationId": "v1_api_admin_model-capabilities_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "provider",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider name"
},
{
"name": "model",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Model ID to look up"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelCapabilitiesResponse"
}
}
}
}
}
}
},
"/v1/api/admin/model-capabilities/known": {
"get": {
"summary": "List known model name prefixes for a provider",
"operationId": "v1_api_admin_model-capabilities_known_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "provider",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Provider name"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KnownModelsResponse"
}
}
}
}
}
}
},
"/v1/api/admin/tls/ca": {
"get": {
"summary": "CA status: initialization state, CN, cert count, cert inventory",
@@ -6508,6 +6876,443 @@
"title": "McpReloadResponse",
"type": "object"
},
"ModelDefinitionInfo": {
"properties": {
"definition_id": {
"title": "Definition Id",
"type": "string"
},
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"context_window": {
"default": 32768,
"title": "Context Window",
"type": "integer"
},
"capabilities": {
"default": "{}",
"title": "Capabilities",
"type": "string"
},
"enabled": {
"default": true,
"title": "Enabled",
"type": "boolean"
},
"source": {
"default": "",
"title": "Source",
"type": "string"
},
"created_by": {
"default": "",
"title": "Created By",
"type": "string"
},
"created": {
"default": "",
"title": "Created",
"type": "string"
},
"updated": {
"default": "",
"title": "Updated",
"type": "string"
}
},
"required": [
"definition_id",
"alias",
"model"
],
"title": "ModelDefinitionInfo",
"type": "object"
},
"CreateModelDefinitionRequest": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"context_window": {
"default": 32768,
"title": "Context Window",
"type": "integer"
},
"capabilities": {
"additionalProperties": true,
"title": "Capabilities",
"type": "object"
},
"enabled": {
"default": true,
"title": "Enabled",
"type": "boolean"
}
},
"required": [
"alias",
"model"
],
"title": "CreateModelDefinitionRequest",
"type": "object"
},
"UpdateModelDefinitionRequest": {
"properties": {
"alias": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Alias"
},
"model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Model"
},
"provider": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Provider"
},
"base_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Base Url"
},
"api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Api Key"
},
"context_window": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Context Window"
},
"capabilities": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"default": null,
"title": "Capabilities"
},
"enabled": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Enabled"
}
},
"title": "UpdateModelDefinitionRequest",
"type": "object"
},
"ListModelDefinitionsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/ModelDefinitionInfo"
},
"title": "Models",
"type": "array"
}
},
"required": [
"models"
],
"title": "ListModelDefinitionsResponse",
"type": "object"
},
"ModelReloadResponse": {
"properties": {
"status": {
"default": "ok",
"title": "Status",
"type": "string"
},
"results": {
"additionalProperties": true,
"title": "Results",
"type": "object"
}
},
"title": "ModelReloadResponse",
"type": "object"
},
"DetectModelRequest": {
"properties": {
"provider": {
"default": "openai",
"title": "Provider",
"type": "string"
},
"base_url": {
"default": "",
"title": "Base Url",
"type": "string"
},
"api_key": {
"default": "",
"title": "Api Key",
"type": "string"
},
"model": {
"default": "",
"title": "Model",
"type": "string"
},
"definition_id": {
"default": "",
"title": "Definition Id",
"type": "string"
}
},
"title": "DetectModelRequest",
"type": "object"
},
"DetectModelResponse": {
"properties": {
"reachable": {
"default": false,
"title": "Reachable",
"type": "boolean"
},
"model_found": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null,
"title": "Model Found"
},
"available_models": {
"items": {
"type": "string"
},
"title": "Available Models",
"type": "array"
},
"context_window": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Context Window"
},
"server_type": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Server Type"
},
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Error"
}
},
"title": "DetectModelResponse",
"type": "object"
},
"ModelCapabilitiesResponse": {
"properties": {
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
},
"known": {
"default": false,
"title": "Known",
"type": "boolean"
},
"capabilities": {
"additionalProperties": true,
"title": "Capabilities",
"type": "object"
}
},
"required": [
"model",
"provider"
],
"title": "ModelCapabilitiesResponse",
"type": "object"
},
"KnownModelsResponse": {
"properties": {
"provider": {
"title": "Provider",
"type": "string"
},
"models": {
"items": {
"type": "string"
},
"title": "Models",
"type": "array"
}
},
"required": [
"provider"
],
"title": "KnownModelsResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
}
},
"required": [
"alias",
"model",
"provider"
],
"title": "AvailableModelInfo",
"type": "object"
},
"ListAvailableModelsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/AvailableModelInfo"
},
"title": "Models",
"type": "array"
}
},
"title": "ListAvailableModelsResponse",
"type": "object"
},
"RegistrySearchResponse": {
"properties": {
"servers": {
@@ -7778,4 +8583,4 @@
}
}
}
}
}
+59 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.9.0",
"version": "0.9.2",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -458,6 +458,27 @@
}
}
},
"/v1/api/models": {
"get": {
"summary": "List available model aliases",
"operationId": "v1_api_models_get",
"tags": [
"Models"
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListAvailableModelsResponse"
}
}
}
}
}
}
},
"/v1/api/auth/login": {
"post": {
"summary": "Authenticate with a token",
@@ -1983,7 +2004,43 @@
],
"title": "ListSkillSummaryResponse",
"type": "object"
},
"AvailableModelInfo": {
"properties": {
"alias": {
"title": "Alias",
"type": "string"
},
"model": {
"title": "Model",
"type": "string"
},
"provider": {
"title": "Provider",
"type": "string"
}
},
"required": [
"alias",
"model",
"provider"
],
"title": "AvailableModelInfo",
"type": "object"
},
"ListAvailableModelsResponse": {
"properties": {
"models": {
"items": {
"$ref": "#/components/schemas/AvailableModelInfo"
},
"title": "Models",
"type": "array"
}
},
"title": "ListAvailableModelsResponse",
"type": "object"
}
}
}
}
}
+5 -1
View File
@@ -44,6 +44,7 @@ import type {
OrgInfo,
RoleInfo,
ScheduleInfo,
DeleteSettingResponse,
SettingInfo,
StatusResponse,
ToolPolicyInfo,
@@ -394,7 +395,10 @@ export class TurnstoneConsole extends BaseClient {
});
}
async deleteSetting(key: string, nodeId?: string): Promise<StatusResponse> {
async deleteSetting(
key: string,
nodeId?: string,
): Promise<DeleteSettingResponse> {
const params: Record<string, string> = {};
if (nodeId) params.node_id = nodeId;
return this.request("DELETE", `/v1/api/admin/settings/${key}`, {
+11
View File
@@ -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>>;
@@ -59,6 +64,7 @@ export interface ToolResultEvent {
call_id: string;
name: string;
output: string;
is_error?: boolean;
}
export interface ToolOutputChunkEvent {
@@ -149,6 +155,7 @@ export type ServerEvent =
| ContentEvent
| ReasoningEvent
| StreamEndEvent
| StateChangeEvent
| ToolInfoEvent
| ApproveRequestEvent
| ApprovalResolvedEvent
@@ -246,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";
}
+3
View File
@@ -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,
@@ -98,6 +100,7 @@ export type {
AuthLoginResponse,
AuthStatusResponse,
AuthSetupResponse,
DeleteSettingResponse,
StatusResponse,
ErrorResponse,
ClusterOverviewResponse,
+6
View File
@@ -10,6 +10,12 @@ export interface StatusResponse {
status: string;
}
export interface DeleteSettingResponse {
status: string;
key: string;
default: unknown;
}
export interface AuthLoginRequest {
token: string;
}
+11 -9
View File
@@ -37,7 +37,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
@@ -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:
+460
View File
@@ -0,0 +1,460 @@
"""Tests for edit_file tool — single edit and batch edit modes."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
from turnstone.core.session import ChatSession
@pytest.fixture
def session(tmp_db, mock_openai_client):
"""Create a ChatSession wired to a temp database."""
return ChatSession(
client=mock_openai_client,
model="test-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
@pytest.fixture
def sample_file(tmp_path):
"""Create a sample file and return its path."""
p = tmp_path / "test.py"
p.write_text("line1\nline2\nline3\nline4\nline5\n")
return str(p)
def _mark_read(session: ChatSession, path: str) -> None:
"""Simulate a prior read_file so the edit guard passes."""
resolved = os.path.realpath(os.path.expanduser(path))
session._read_files.add(resolved)
# ── Single edit (backward compat) ────────────────────────────────────
class TestSingleEdit:
def test_basic_replace(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
assert result["func_name"] == "edit_file"
call_id, msg = session._exec_edit_file(result)
assert call_id == "c1"
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nreplaced\nline3\nline4\nline5\n"
def test_missing_path(self, session):
result = session._prepare_edit_file(
"c1",
{
"old_string": "a",
"new_string": "b",
},
)
assert result.get("error")
assert "missing path" in result["error"]
def test_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"new_string": "b",
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_identical_strings(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "line1",
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_old_string_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "nonexistent",
"new_string": "replaced",
},
)
assert result.get("error")
assert "not found" in result["error"]
def test_must_read_first(self, session, sample_file):
# Don't call _mark_read — should fail
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
},
)
assert result.get("error")
assert "must read_file" in result["error"]
def test_multiple_occurrences_without_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
},
)
assert result.get("error")
assert "found 2 times" in result["error"]
def test_near_line_disambiguates(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"old_string": "foo",
"new_string": "baz",
"near_line": 3,
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(path) as f:
assert f.read() == "foo\nbar\nbaz\n"
def test_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line3\n",
"new_string": "",
},
)
assert result["needs_approval"]
assert "deletion" in result["preview"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline2\nline4\nline5\n"
# ── Batch edits ──────────────────────────────────────────────────────
class TestBatchEdit:
def test_two_edits_applied_atomically(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
assert "2 edits" in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(sample_file) as f:
assert f.read() == "first\nline2\nline3\nline4\nlast\n"
def test_three_edits_middle_of_file(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2", "new_string": "second"},
{"old_string": "line3", "new_string": "third"},
{"old_string": "line4", "new_string": "fourth"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 3 edits" in msg
with open(sample_file) as f:
assert f.read() == "line1\nsecond\nthird\nfourth\nline5\n"
def test_overlapping_edits_rejected(self, session, tmp_path):
p = tmp_path / "overlap.txt"
p.write_text("abcdefgh\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "abcdef", "new_string": "XXX"},
{"old_string": "defgh", "new_string": "YYY"},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "overlap" in msg.lower()
# File should be untouched
with open(path) as f:
assert f.read() == "abcdefgh\n"
def test_batch_edit_not_found(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "nonexistent", "new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "not found" in result["error"]
def test_batch_edit_missing_old_string(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"new_string": "oops"},
],
},
)
assert result.get("error")
assert "edits[1]" in result["error"]
assert "old_string" in result["error"]
def test_batch_edit_identical_strings(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "line1"},
],
},
)
assert result.get("error")
assert "identical" in result["error"]
def test_batch_with_near_line(self, session, tmp_path):
p = tmp_path / "dup.txt"
p.write_text("foo\nbar\nfoo\nbaz\n")
path = str(p)
_mark_read(session, path)
result = session._prepare_edit_file(
"c1",
{
"path": path,
"edits": [
{"old_string": "foo", "new_string": "first_foo", "near_line": 1},
{"old_string": "foo", "new_string": "second_foo", "near_line": 3},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
assert "applied 2 edits" in msg
with open(path) as f:
assert f.read() == "first_foo\nbar\nsecond_foo\nbaz\n"
def test_batch_with_deletion(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line2\n", "new_string": ""},
{"old_string": "line4\n", "new_string": ""},
],
},
)
assert result["needs_approval"]
call_id, msg = session._exec_edit_file(result)
with open(sample_file) as f:
assert f.read() == "line1\nline3\nline5\n"
def test_single_item_edits_array(self, session, sample_file):
"""An edits array with one item should work like a single edit."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line3", "new_string": "middle"},
],
},
)
assert result["needs_approval"]
# Single edit — no "(N edits)" count in header
assert "edits)" not in result["header"]
call_id, msg = session._exec_edit_file(result)
assert "applied 1 edit" in msg
with open(sample_file) as f:
assert f.read() == "line1\nline2\nmiddle\nline4\nline5\n"
# ── Mutual exclusivity ──────────────────────────────────────────────
class TestMutualExclusivity:
def test_both_single_and_batch_rejected(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line1",
"new_string": "replaced",
"edits": [
{"old_string": "line2", "new_string": "also_replaced"},
],
},
)
assert result.get("error")
assert "not both" in result["error"]
def test_neither_single_nor_batch(self, session, sample_file):
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
},
)
assert result.get("error")
assert "old_string" in result["error"]
def test_empty_edits_array_falls_through_to_single(self, session, sample_file):
"""An empty edits array should be treated as no batch."""
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [],
},
)
# Falls through to single-edit path, which requires old_string
assert result.get("error")
assert "old_string" in result["error"]
# ── TOCTOU edge cases ───────────────────────────────────────────────
class TestExecEdgeCases:
def test_file_changed_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
# Modify the file after prepare
with open(sample_file, "w") as f:
f.write("completely different content\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
def test_file_deleted_between_prepare_and_exec(self, session, sample_file):
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"old_string": "line2",
"new_string": "replaced",
},
)
assert result["needs_approval"]
os.unlink(sample_file)
call_id, msg = session._exec_edit_file(result)
assert "Error" in msg
def test_batch_file_changed_partial_match(self, session, sample_file):
"""If file changes so one edit fails, none should be applied."""
_mark_read(session, sample_file)
result = session._prepare_edit_file(
"c1",
{
"path": sample_file,
"edits": [
{"old_string": "line1", "new_string": "first"},
{"old_string": "line5", "new_string": "last"},
],
},
)
assert result["needs_approval"]
# Remove line5 between prepare and exec
with open(sample_file, "w") as f:
f.write("line1\nline2\nline3\nline4\n")
call_id, msg = session._exec_edit_file(result)
assert "no longer found" in msg
# line1 should NOT have been edited (atomic failure)
with open(sample_file) as f:
assert "line1" in f.read()
+2
View File
@@ -54,6 +54,7 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
session._notify_on_complete = "{}"
session.messages = []
session._config = {}
session._tool_error_flags = {}
# Stub set_skill to just record the call
session._set_skill_called: list[str | None] = []
@@ -423,6 +424,7 @@ class TestSkillCatalogDisclosure:
session._tool_search = None
session._mcp_client = None
session._notify_on_complete = "{}"
session._tool_error_flags = {}
# Memory stubs
session._memory_config = MagicMock()
+163
View File
@@ -0,0 +1,163 @@
"""Tests for model definition storage CRUD operations."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from turnstone.core.storage._sqlite import SQLiteBackend
def _make_id() -> str:
return uuid.uuid4().hex
class TestModelDefinitionStorage:
def test_create_and_get(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did,
alias="test-model",
model="gpt-5",
provider="openai",
base_url="https://api.openai.com/v1",
api_key="sk-test",
context_window=128000,
)
m = db.get_model_definition(did)
assert m is not None
assert m["alias"] == "test-model"
assert m["model"] == "gpt-5"
assert m["provider"] == "openai"
assert m["base_url"] == "https://api.openai.com/v1"
assert m["api_key"] == "sk-test"
assert m["context_window"] == 128000
assert m["capabilities"] == "{}"
assert m["enabled"] is True
def test_get_by_alias(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="by-alias", model="gpt-5")
m = db.get_model_definition_by_alias("by-alias")
assert m is not None
assert m["definition_id"] == did
def test_get_by_alias_not_found(self, db: SQLiteBackend) -> None:
assert db.get_model_definition_by_alias("nope") is None
def test_get_not_found(self, db: SQLiteBackend) -> None:
assert db.get_model_definition("nonexistent") is None
def test_list_empty(self, db: SQLiteBackend) -> None:
assert db.list_model_definitions() == []
def test_list_all(self, db: SQLiteBackend) -> None:
db.create_model_definition(definition_id=_make_id(), alias="alpha", model="gpt-5")
db.create_model_definition(
definition_id=_make_id(), alias="beta", model="claude-opus-4-6", provider="anthropic"
)
models = db.list_model_definitions()
assert len(models) == 2
assert models[0]["alias"] == "alpha" # ordered by alias
assert models[1]["alias"] == "beta"
def test_list_enabled_only(self, db: SQLiteBackend) -> None:
db.create_model_definition(
definition_id=_make_id(), alias="enabled-model", model="gpt-5", enabled=True
)
db.create_model_definition(
definition_id=_make_id(), alias="disabled-model", model="gpt-5", enabled=False
)
enabled = db.list_model_definitions(enabled_only=True)
assert len(enabled) == 1
assert enabled[0]["alias"] == "enabled-model"
def test_update_basic_fields(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did, alias="orig", model="gpt-5", base_url="http://old"
)
ok = db.update_model_definition(did, alias="renamed", base_url="http://new")
assert ok is True
m = db.get_model_definition(did)
assert m is not None
assert m["alias"] == "renamed"
assert m["base_url"] == "http://new"
def test_update_boolean_conversion(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="booltest", model="gpt-5")
db.update_model_definition(did, enabled=False)
m = db.get_model_definition(did)
assert m is not None
assert m["enabled"] is False
def test_update_not_found(self, db: SQLiteBackend) -> None:
ok = db.update_model_definition("nonexistent", alias="x")
assert ok is False
def test_update_ignores_disallowed_fields(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(
definition_id=did, alias="guard", model="gpt-5", created_by="admin"
)
original = db.get_model_definition(did)
assert original is not None
original_created = original["created"]
# created_by and created are not in the mutable allowlist
db.update_model_definition(did, created_by="evil", created="2000-01-01T00:00:00")
m = db.get_model_definition(did)
assert m is not None
assert m["created_by"] == "admin" # unchanged
assert m["created"] == original_created # unchanged
def test_delete(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="delme", model="gpt-5")
ok = db.delete_model_definition(did)
assert ok is True
assert db.get_model_definition(did) is None
def test_delete_not_found(self, db: SQLiteBackend) -> None:
ok = db.delete_model_definition("nonexistent")
assert ok is False
def test_create_duplicate_alias(self, db: SQLiteBackend) -> None:
db.create_model_definition(definition_id=_make_id(), alias="unique", model="gpt-5")
# Second create with same alias but different ID should be no-op (OR IGNORE)
did2 = _make_id()
db.create_model_definition(definition_id=did2, alias="unique", model="gpt-5")
assert db.get_model_definition(did2) is None
def test_create_idempotent_same_id(self, db: SQLiteBackend) -> None:
did = _make_id()
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5")
db.create_model_definition(definition_id=did, alias="idem", model="gpt-5-mini")
m = db.get_model_definition(did)
assert m is not None
assert m["model"] == "gpt-5" # original preserved
def test_capabilities_json(self, db: SQLiteBackend) -> None:
did = _make_id()
caps = '{"supports_vision": true, "supports_web_search": false}'
db.create_model_definition(
definition_id=did, alias="caps-test", model="gpt-5", capabilities=caps
)
m = db.get_model_definition(did)
assert m is not None
assert m["capabilities"] == caps
def test_defaults(self, db: SQLiteBackend) -> None:
"""Verify default values for optional fields."""
did = _make_id()
db.create_model_definition(definition_id=did, alias="defaults", model="gpt-5")
m = db.get_model_definition(did)
assert m is not None
assert m["provider"] == "openai"
assert m["base_url"] == ""
assert m["api_key"] == ""
assert m["context_window"] == 32768
assert m["capabilities"] == "{}"
assert m["enabled"] is True
assert m["created_by"] == ""
+235
View File
@@ -0,0 +1,235 @@
"""Tests for probe_model_endpoint() and lookup_model_capabilities()."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.model_registry import probe_model_endpoint
from turnstone.core.providers import list_known_models, lookup_model_capabilities
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_model(
model_id: str,
*,
owned_by: str = "test",
meta: dict[str, Any] | None = None,
) -> MagicMock:
m = MagicMock()
m.id = model_id
dumped: dict[str, Any] = {"owned_by": owned_by}
if meta is not None:
dumped["meta"] = meta
m.model_dump.return_value = dumped
return m
def _mock_client(*models: MagicMock) -> MagicMock:
fast = MagicMock()
fast.models.list.return_value = MagicMock(data=list(models))
client = MagicMock()
client.with_options.return_value = fast
return client
# ---------------------------------------------------------------------------
# probe_model_endpoint
# ---------------------------------------------------------------------------
class TestProbeModelEndpoint:
@patch("turnstone.core.providers.create_client")
def test_probe_success(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
m2 = _mock_model("model-b")
mock_cc.return_value = _mock_client(m1, m2)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["reachable"] is True
assert result["available_models"] == ["model-a", "model-b"]
assert result["error"] is None
@patch("turnstone.core.providers.create_client")
def test_target_found(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint(
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
)
assert result["model_found"] is True
@patch("turnstone.core.providers.create_client")
def test_target_not_found(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint(
"openai", "http://localhost:8000/v1", "key", target_model="gpt-5"
)
assert result["model_found"] is False
assert result["available_models"] == ["model-a"]
@patch("turnstone.core.providers.create_client")
def test_no_target_model_found_is_none(self, mock_cc: MagicMock) -> None:
m1 = _mock_model("model-a")
mock_cc.return_value = _mock_client(m1)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["model_found"] is None
@patch("turnstone.core.providers.create_client")
def test_context_window_llama_cpp(self, mock_cc: MagicMock) -> None:
m = _mock_model("qwen-32b", meta={"n_ctx_train": 131072})
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["context_window"] == 131072
assert result["server_type"] == "llama.cpp"
@patch("turnstone.core.providers.create_client")
def test_server_type_openai(self, mock_cc: MagicMock) -> None:
m = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "https://api.openai.com/v1", "sk-test")
assert result["server_type"] == "openai"
@patch("turnstone.core.providers.create_client")
def test_server_type_sglang(self, mock_cc: MagicMock) -> None:
m = _mock_model("meta-llama/Llama-3", owned_by="sglang")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:30000/v1", "key")
assert result["server_type"] == "sglang"
@patch("turnstone.core.providers.create_client")
def test_server_type_vllm(self, mock_cc: MagicMock) -> None:
m = _mock_model("org/model-name")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["server_type"] == "vllm"
@patch("turnstone.core.providers.create_client")
def test_server_type_generic(self, mock_cc: MagicMock) -> None:
m = _mock_model("my-model")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["server_type"] == "openai-compatible"
@patch("turnstone.core.providers.create_client")
def test_anthropic_provider(self, mock_cc: MagicMock) -> None:
m = _mock_model("claude-sonnet-4-6")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint(
"anthropic",
"https://api.anthropic.com",
"sk-ant-test",
target_model="claude-sonnet-4-6",
)
assert result["reachable"] is True
assert result["server_type"] == "anthropic"
assert result["context_window"] == 1000000
@patch("turnstone.core.providers.create_client")
def test_connection_failure(self, mock_cc: MagicMock) -> None:
mock_cc.side_effect = OSError("Connection refused")
result = probe_model_endpoint("openai", "http://bad:1234/v1", "key")
assert result["reachable"] is False
assert "Connection refused" in (result["error"] or "")
@patch("turnstone.core.providers.create_client")
def test_empty_model_list(self, mock_cc: MagicMock) -> None:
mock_cc.return_value = _mock_client() # no models
result = probe_model_endpoint("openai", "http://localhost:8000/v1", "key")
assert result["reachable"] is True
assert result["available_models"] == []
assert "No models found" in (result["error"] or "")
@patch("turnstone.core.providers.create_client")
def test_context_window_openai_static_table(self, mock_cc: MagicMock) -> None:
"""When base_url is api.openai.com and model is known, use static table."""
m = _mock_model("gpt-5")
mock_cc.return_value = _mock_client(m)
result = probe_model_endpoint(
"openai", "https://api.openai.com/v1", "sk-test", target_model="gpt-5"
)
assert result["context_window"] == 400000
# ---------------------------------------------------------------------------
# lookup_model_capabilities
# ---------------------------------------------------------------------------
class TestLookupModelCapabilities:
def test_known_openai_model(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
assert caps["context_window"] == 400000
assert caps["supports_temperature"] is False
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"] == 1000000
assert caps["thinking_mode"] == "adaptive"
def test_unknown_model_returns_none(self) -> None:
caps = lookup_model_capabilities("openai", "totally-unknown-model")
assert caps is None
def test_tuples_converted_to_lists(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
for val in caps.values():
assert not isinstance(val, tuple), f"Found tuple: {val}"
def test_reasoning_effort_values_are_list(self) -> None:
caps = lookup_model_capabilities("openai", "gpt-5")
assert caps is not None
assert isinstance(caps["reasoning_effort_values"], list)
assert "medium" in caps["reasoning_effort_values"]
def test_openai_compatible_returns_none(self) -> None:
caps = lookup_model_capabilities("openai-compatible", "my-local-model")
assert caps is None
def test_invalid_provider_raises(self) -> None:
with pytest.raises(ValueError, match="Unknown provider"):
lookup_model_capabilities("bad-provider", "gpt-5")
# ---------------------------------------------------------------------------
# list_known_models
# ---------------------------------------------------------------------------
class TestListKnownModels:
def test_openai_models(self) -> None:
models = list_known_models("openai")
assert "gpt-5" in models
assert isinstance(models, list)
assert models == sorted(models)
def test_anthropic_models(self) -> None:
models = list_known_models("anthropic")
assert "claude-opus-4-6" in models
def test_openai_compatible_returns_empty(self) -> None:
assert list_known_models("openai-compatible") == []
def test_unknown_provider_returns_empty(self) -> None:
assert list_known_models("bad-provider") == []
+276 -1
View File
@@ -10,6 +10,7 @@ import pytest
from turnstone.core.model_registry import (
ModelConfig,
ModelRegistry,
_resolve_env_vars,
detect_model,
load_model_registry,
)
@@ -319,6 +320,280 @@ class TestLoadModelRegistry:
assert alt_cfg.api_key == "my-key"
# ---------------------------------------------------------------------------
# load_model_registry with DB storage
# ---------------------------------------------------------------------------
class _MockStorage:
"""Minimal storage mock returning canned model definitions."""
def __init__(self, rows: list[dict[str, Any]] | None = None) -> None:
self._rows = rows or []
self.calls: list[str] = []
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
self.calls.append("list_model_definitions")
if enabled_only:
return [r for r in self._rows if r.get("enabled", True)]
return list(self._rows)
class TestLoadModelRegistryWithDB:
def test_db_models_loaded(self) -> None:
"""DB model definitions are loaded into the registry."""
storage = _MockStorage(
[
{
"alias": "cloud-gpt",
"model": "gpt-5",
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-db",
"context_window": 128000,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("cloud-gpt")
cfg = reg.get_config("cloud-gpt")
assert cfg.model == "gpt-5"
assert cfg.source == "db"
def test_config_overrides_db(self) -> None:
"""Config.toml entry overrides DB entry with same alias."""
storage = _MockStorage(
[
{
"alias": "shared",
"model": "db-model",
"provider": "openai",
"base_url": "http://db/v1",
"api_key": "sk-db",
"context_window": 32768,
"capabilities": "{}",
"enabled": True,
}
]
)
fake_cfg: dict[str, Any] = {
"models": {
"shared": {
"model": "config-model",
"base_url": "http://config/v1",
},
},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
cfg = reg.get_config("shared")
assert cfg.model == "config-model"
assert cfg.source == "config"
def test_db_only_models_coexist(self) -> None:
"""DB models coexist alongside config.toml models."""
storage = _MockStorage(
[
{
"alias": "db-only",
"model": "db-model",
"provider": "anthropic",
"base_url": "",
"api_key": "sk-db",
"context_window": 200000,
"capabilities": "{}",
"enabled": True,
}
]
)
fake_cfg: dict[str, Any] = {
"models": {
"config-only": {"model": "config-model"},
},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("db-only")
assert reg.has_alias("config-only")
assert reg.has_alias("default")
assert reg.get_config("db-only").source == "db"
assert reg.get_config("config-only").source == "config"
def test_source_field_set(self) -> None:
"""Source field correctly distinguishes origin."""
storage = _MockStorage(
[
{
"alias": "from-db",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.get_config("from-db").source == "db"
assert reg.get_config("default").source == ""
def test_disabled_db_models_excluded(self) -> None:
"""Disabled DB models are not loaded."""
storage = _MockStorage(
[
{
"alias": "disabled",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": "{}",
"enabled": False,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert not reg.has_alias("disabled")
def test_db_capabilities_parsed(self) -> None:
"""JSON capabilities from DB are parsed into dict."""
storage = _MockStorage(
[
{
"alias": "caps-model",
"model": "m",
"provider": "openai",
"base_url": "",
"api_key": "",
"context_window": 32768,
"capabilities": '{"supports_vision": true}',
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.get_config("caps-model").capabilities == {"supports_vision": True}
def test_db_default_alias_not_clobbered(self) -> None:
"""DB model with alias='default' is not overwritten by CLI args."""
storage = _MockStorage(
[
{
"alias": "default",
"model": "db-default-model",
"provider": "openai",
"base_url": "http://db/v1",
"api_key": "sk-db",
"context_window": 128000,
"capabilities": "{}",
"enabled": True,
}
]
)
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://cli/v1", "cli-key", "cli-model", storage=storage)
cfg = reg.get_config("default")
assert cfg.model == "db-default-model"
assert cfg.source == "db"
def test_no_db_writes(self) -> None:
"""Config.toml models are NOT written to storage."""
storage = _MockStorage()
fake_cfg: dict[str, Any] = {
"models": {"local": {"model": "llama"}},
}
with patch("turnstone.core.model_registry.load_config", return_value=fake_cfg):
load_model_registry("http://x/v1", "x", "x", storage=storage)
# Only list_model_definitions should be called, no create
assert storage.calls == ["list_model_definitions"]
def test_storage_failure_graceful(self) -> None:
"""Storage errors don't prevent registry creation."""
storage = MagicMock()
storage.list_model_definitions.side_effect = RuntimeError("db down")
with patch("turnstone.core.model_registry.load_config", return_value={}):
reg = load_model_registry("http://x/v1", "x", "x", storage=storage)
assert reg.has_alias("default")
# ---------------------------------------------------------------------------
# _resolve_env_vars
# ---------------------------------------------------------------------------
class TestResolveEnvVars:
def test_expand_single(self) -> None:
with patch.dict("os.environ", {"MY_KEY": "secret123"}):
assert _resolve_env_vars("sk-${MY_KEY}") == "sk-secret123"
def test_expand_multiple(self) -> None:
with patch.dict("os.environ", {"A": "1", "B": "2"}):
assert _resolve_env_vars("${A}-${B}") == "1-2"
def test_missing_var_empty(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert _resolve_env_vars("${MISSING}") == ""
def test_no_vars(self) -> None:
assert _resolve_env_vars("plain-key") == "plain-key"
def test_empty_string(self) -> None:
assert _resolve_env_vars("") == ""
# ---------------------------------------------------------------------------
# ModelRegistry.reload
# ---------------------------------------------------------------------------
class TestRegistryReload:
def test_reload_replaces_models(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m1")}
reg = ModelRegistry(models=models_a, default="a")
assert reg.has_alias("a")
models_b = {"b": ModelConfig("b", "y", "y", "m2")}
reg.reload(models_b, "b")
assert not reg.has_alias("a")
assert reg.has_alias("b")
assert reg.default == "b"
def test_reload_clears_clients(self) -> None:
models = {"a": ModelConfig("a", "http://x/v1", "key", "m")}
reg = ModelRegistry(models=models, default="a")
# Force client creation
reg.get_client("a")
assert "a" in reg._clients
# Reload with same models — clients should be cleared
reg.reload(dict(models), "a")
assert "a" not in reg._clients
def test_reload_validates_default(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m")}
reg = ModelRegistry(models=models_a, default="a")
with pytest.raises(ValueError, match="Default model"):
reg.reload(models_a, "nonexistent")
# Registry should be unchanged after failed reload
assert reg.has_alias("a")
assert reg.default == "a"
def test_reload_validates_empty(self) -> None:
models_a = {"a": ModelConfig("a", "x", "x", "m")}
reg = ModelRegistry(models=models_a, default="a")
with pytest.raises(ValueError, match="at least one"):
reg.reload({}, "a")
# ---------------------------------------------------------------------------
# Session integration
# ---------------------------------------------------------------------------
@@ -339,7 +614,7 @@ class _FakeUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
return True, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str:
+1 -1
View File
@@ -29,7 +29,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+318 -13
View File
@@ -94,6 +94,7 @@ def _anthropic_event(
delta.type = kwargs.get("delta_type", "text_delta")
delta.text = kwargs.get("text", "")
delta.thinking = kwargs.get("thinking", "")
delta.signature = kwargs.get("signature", "")
delta.partial_json = kwargs.get("partial_json", "")
event.delta = delta
event.index = kwargs.get("index", 0)
@@ -507,10 +508,11 @@ class TestAnthropicProvider:
},
}
],
}
},
{"role": "tool", "tool_call_id": "call_1", "content": "file contents"},
]
_, converted = self.provider._convert_messages(messages)
assert len(converted) == 1
assert len(converted) == 2
blocks = converted[0]["content"]
assert len(blocks) == 2
assert blocks[0] == {"type": "text", "text": "Let me check that."}
@@ -518,6 +520,8 @@ class TestAnthropicProvider:
assert blocks[1]["id"] == "call_1"
assert blocks[1]["name"] == "read_file"
assert blocks[1]["input"] == {"path": "foo.py"}
# Tool result in user message
assert converted[1]["role"] == "user"
def test_message_conversion_tool_results(self) -> None:
messages = [
@@ -626,7 +630,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 5
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -658,7 +666,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 20
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -689,7 +701,11 @@ class TestAnthropicProvider:
response.usage.output_tokens = 50
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
@@ -941,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
@@ -950,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
@@ -1169,6 +1185,189 @@ class TestOpenAIParameterGating:
assert kwargs["reasoning_effort"] == "medium" # fell back from unsupported "low"
class TestAnthropicOrphanedToolUse:
"""Verify _convert_messages synthesizes tool_results for orphaned tool_use."""
def setup_method(self) -> None:
from turnstone.core.providers._anthropic import AnthropicProvider
self.provider = AnthropicProvider()
def test_orphaned_tool_use_gets_synthetic_result(self) -> None:
"""Assistant has tool_calls but next message is user (no tool results)."""
messages = [
{"role": "user", "content": "do something"},
{
"role": "assistant",
"content": "I'll run that.",
"tool_calls": [
{
"id": "call_abc",
"function": {"name": "bash", "arguments": '{"command": "ls"}'},
}
],
},
{"role": "user", "content": "never mind, do something else"},
]
_, converted = self.provider._convert_messages(messages)
# Should have: user, assistant(tool_use), user(synthetic tool_result), user
# After _merge_consecutive, the two user messages may merge.
# Find the synthetic tool_result
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"] == "call_abc"
assert tool_results[0]["is_error"] is True
assert "cancelled" in tool_results[0]["content"].lower()
def test_multiple_orphaned_tool_calls(self) -> None:
"""Assistant has 3 tool_calls, none have results."""
messages = [
{"role": "user", "content": "do three things"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "read_file", "arguments": "{}"}},
{"id": "c3", "function": {"name": "write_file", "arguments": "{}"}},
],
},
{"role": "user", "content": "skip all that"},
]
_, converted = self.provider._convert_messages(messages)
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) == 3
result_ids = {r["tool_use_id"] for r in tool_results}
assert result_ids == {"c1", "c2", "c3"}
def test_partial_results_only_orphans_synthesized(self) -> None:
"""2 tool_calls, only 1 has a result — synthesize for the missing one."""
messages = [
{"role": "user", "content": "do two things"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "file1.txt"},
{"role": "user", "content": "skip the write"},
]
_, converted = self.provider._convert_messages(messages)
# c1 should have a real result, c2 should have a synthetic one
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)
# 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."""
messages = [
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "done"},
{"role": "user", "content": "thanks"},
]
_, converted = self.provider._convert_messages(messages)
# 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":
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)."""
messages = [
{"role": "user", "content": "do it"},
{
"role": "assistant",
"content": "Running...",
"tool_calls": [
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
],
},
]
_, converted = self.provider._convert_messages(messages)
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"] == "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."""
@@ -1208,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")
@@ -1411,7 +1610,11 @@ class TestAnthropicWebSearch:
response.usage.output_tokens = 50
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
with patch("turnstone.core.providers._anthropic._ensure_anthropic"):
result = self.provider.create_completion(
@@ -1962,6 +2165,104 @@ class TestAnthropicProviderBlocks:
assert blocks[2]["type"] == "web_search_tool_result"
assert blocks[2]["encrypted_content"] == "enc_data"
def test_streaming_thinking_block_captures_signature(self) -> None:
"""Streaming thinking block accumulates signature from signature_delta events."""
thinking_block = MagicMock()
thinking_block.type = "thinking"
thinking_block.model_dump.return_value = {
"type": "thinking",
"thinking": "",
"signature": "",
}
text_block = MagicMock()
text_block.type = "text"
text_block.model_dump.return_value = {"type": "text", "text": ""}
events = [
MagicMock(type="content_block_start", index=0, content_block=thinking_block),
_anthropic_event(
"content_block_delta", delta_type="thinking_delta", thinking="step 1", index=0
),
_anthropic_event(
"content_block_delta", delta_type="thinking_delta", thinking=" step 2", index=0
),
_anthropic_event(
"content_block_delta",
delta_type="signature_delta",
signature="sig_part1",
index=0,
),
_anthropic_event(
"content_block_delta",
delta_type="signature_delta",
signature="sig_part2",
index=0,
),
_anthropic_event("content_block_stop", index=0),
MagicMock(type="content_block_start", index=1, content_block=text_block),
_anthropic_event("content_block_delta", delta_type="text_delta", text="Hello", index=1),
_anthropic_event("content_block_stop", index=1),
_anthropic_event("message_delta", stop_reason="end_turn", usage_output_tokens=50),
]
chunks = list(self.provider._iter_anthropic_stream(iter(events)))
final_chunks = [c for c in chunks if c.provider_blocks]
assert len(final_chunks) == 1
blocks = final_chunks[0].provider_blocks
assert blocks[0]["type"] == "thinking"
assert blocks[0]["thinking"] == "step 1 step 2"
assert blocks[0]["signature"] == "sig_part1sig_part2"
def test_thinking_block_multiturn_roundtrip(self) -> None:
"""Thinking block with signature survives _convert_messages round-trip."""
provider_content = [
{
"type": "thinking",
"thinking": "Let me reason...",
"signature": "ErUBCkYIAxgCIkD_valid_sig",
},
{"type": "text", "text": "Here is my answer."},
]
messages = [
{"role": "user", "content": "Question"},
{
"role": "assistant",
"content": "Here is my answer.",
"_provider_content": provider_content,
},
{"role": "user", "content": "Follow up"},
]
_, converted = self.provider._convert_messages(messages)
assistant_msg = converted[1]
assert assistant_msg["content"] is provider_content
assert assistant_msg["content"][0]["signature"] == "ErUBCkYIAxgCIkD_valid_sig"
assert assistant_msg["content"][0]["type"] == "thinking"
def test_block_to_dict_preserves_thinking_signature(self) -> None:
"""_block_to_dict preserves signature on thinking blocks."""
from turnstone.core.providers._anthropic import _block_to_dict
class FakeThinkingBlock:
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
return {
"type": "thinking",
"thinking": "reasoning...",
"signature": "abc123sig",
}
result = _block_to_dict(FakeThinkingBlock())
assert result["signature"] == "abc123sig"
# Also test fallback path (no model_dump)
class FallbackBlock:
type = "thinking"
thinking = "reasoning..."
signature = "abc123sig"
result2 = _block_to_dict(FallbackBlock())
assert result2["signature"] == "abc123sig"
# ---------------------------------------------------------------------------
# Tool search tests
@@ -2335,7 +2636,11 @@ class TestAnthropicPromptCaching:
response.usage = usage
client = MagicMock()
client.messages.create.return_value = response
stream_ctx = MagicMock()
stream_ctx.__enter__ = MagicMock(return_value=stream_ctx)
stream_ctx.__exit__ = MagicMock(return_value=False)
stream_ctx.get_final_message.return_value = response
client.messages.stream.return_value = stream_ctx
result = self.provider.create_completion(
client=client,
+90 -3
View File
@@ -9,13 +9,12 @@ def _row(
role,
content=None,
tool_name=None,
tool_args=None,
tc_id=None,
pdata=None,
tool_calls=None,
):
"""Build a 7-element conversation row tuple (post-migration 013 format)."""
return (role, content, tool_name, tool_args, tc_id, pdata, tool_calls)
"""Build a 6-element conversation row tuple (post-migration 027 format)."""
return (role, content, tool_name, tc_id, pdata, tool_calls)
class TestAssistantWithToolCalls:
@@ -227,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"
+387
View File
@@ -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!"
+1 -1
View File
@@ -88,7 +88,7 @@ class RecordingUI:
def approve_tools(self, items):
return True, None # auto-approve everything
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
self.tool_results.append((call_id, name, output))
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -28,7 +28,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+31
View File
@@ -525,6 +525,37 @@ class TestWorkstreamConfig:
assert session.instructions == "be concise"
assert session.creative_mode is True
def test_resume_restores_model(self, tmp_db):
"""ChatSession.resume() should restore the model from workstream config."""
client = MagicMock()
client.models.list.return_value.data = [MagicMock(id="test-model")]
ui = MagicMock()
ui.on_info = MagicMock()
ui.on_error = MagicMock()
ui.on_state_change = MagicMock()
ui.on_rename = MagicMock()
# Create a workstream that was using a specific model
register_workstream("model_ws")
save_message("model_ws", "user", "hello")
save_message("model_ws", "assistant", "hi")
save_workstream_config("model_ws", {"model": "gpt-5", "model_alias": ""})
# Resume into a session that was created with a different model
session = ChatSession(
client=client,
model="gpt-5-nano",
ui=ui,
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
assert session.model == "gpt-5-nano"
result = session.resume("model_ws")
assert result is True
assert session.model == "gpt-5"
# ── Prune workstreams ─────────────────────────────────────────────────
+33 -7
View File
@@ -254,20 +254,46 @@ class TestSecretMasking:
by_key = {s["key"]: s for s in r.json()["settings"]}
assert by_key["judge.api_key"]["value"] == "***"
def test_secret_write_blocked(self, client):
"""Secret settings cannot be modified via API."""
def test_secret_writable_via_api(self, client):
"""Secret settings can be written via API (write-only pattern)."""
r = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-secret-123"},
)
assert r.status_code == 403
assert "config.toml" in r.json()["error"]
assert r.status_code == 200
# Response value is masked even for the write confirmation
assert r.json()["value"] == "***"
def test_secret_shows_managed_label(self, client):
"""Secret settings show a label instead of a value."""
def test_secret_sentinel_preserves_existing(self, client):
"""Submitting '***' for a secret setting is a no-op (preserve existing)."""
# First write a real value
r1 = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-real-key"},
)
assert r1.status_code == 200
# Now submit the sentinel — should return unchanged with full response shape
r2 = client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "***"},
)
assert r2.status_code == 200
data = r2.json()
assert data.get("unchanged") is True
assert data["key"] == "judge.api_key"
assert data["value"] == "***"
assert data["type"] == "str"
assert data["is_secret"] is True
def test_secret_still_masked_in_list(self, client):
"""After writing a secret, list still shows '***'."""
client.put(
"/v1/api/admin/settings/judge.api_key",
json={"value": "sk-written-via-api"},
)
r = client.get("/v1/api/admin/settings")
by_key = {s["key"]: s for s in r.json()["settings"]}
assert "managed via" in by_key["judge.api_key"]["value"]
assert by_key["judge.api_key"]["value"] == "***"
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -52,7 +52,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+5 -3
View File
@@ -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
+217
View File
@@ -0,0 +1,217 @@
"""Tests for _make_watch_dispatch error/cancel handling and concurrency guards."""
import queue
import threading
import time
from turnstone.core.session import GenerationCancelled
from turnstone.core.workstream import Workstream
from turnstone.server import _make_watch_dispatch
class _StubSession:
"""Minimal ChatSession stand-in with controllable send() behaviour."""
def __init__(self, *, side_effect=None):
self._watch_pending: queue.Queue = queue.Queue(maxsize=20)
self._side_effect = side_effect
def send(self, msg: str) -> None:
if self._side_effect is not None:
raise self._side_effect
class _RecordingUI:
"""Track calls made by the dispatch error handlers."""
def __init__(self):
self.errors: list[str] = []
self.state_changes: list[str] = []
self.stream_end_calls: int = 0
# -- SessionUI protocol stubs used by the dispatch code --
def on_error(self, message: str) -> None:
self.errors.append(message)
def on_state_change(self, state: str) -> None:
self.state_changes.append(state)
def on_stream_end(self) -> None:
self.stream_end_calls += 1
# ── helpers ──────────────────────────────────────────────────────────────────
def _wait_for_worker(ws: Workstream, timeout: float = 2.0) -> None:
"""Block until the worker thread started by dispatch() finishes."""
t = ws.worker_thread
if t is not None:
t.join(timeout)
assert not t.is_alive(), "worker thread did not finish in time"
# ── GenerationCancelled path ────────────────────────────────────────────────
def test_cancelled_emits_stream_end_and_idle():
session = _StubSession(side_effect=GenerationCancelled())
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 1
assert ui.state_changes == ["idle"]
assert ui.errors == []
# ── Generic exception path ──────────────────────────────────────────────────
def test_exception_emits_stream_end_and_error():
session = _StubSession(side_effect=RuntimeError("boom"))
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 1
assert ui.state_changes == ["error"]
assert len(ui.errors) == 1
assert "boom" in ui.errors[0]
# ── Worker-thread identity guard ────────────────────────────────────────────
def test_abandoned_thread_emits_no_events():
"""After force-cancel sets worker_thread=None, the old thread must not
emit stream_end or state changes."""
barrier = threading.Event()
class _BlockingSession(_StubSession):
def send(self, msg: str) -> None:
barrier.wait(timeout=5)
raise RuntimeError("late error")
session = _BlockingSession()
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
# Simulate force-cancel: clear the worker_thread reference.
ws.worker_thread = None
barrier.set()
# Wait for the thread to actually complete (it's still running).
time.sleep(0.3)
assert ui.stream_end_calls == 0
assert ui.state_changes == []
assert ui.errors == []
# ── Path A: busy workstream enqueue ─────────────────────────────────────────
def test_busy_workstream_enqueues_message():
"""When the workstream already has a live worker, dispatch enqueues."""
session = _StubSession()
ws = Workstream()
ui = _RecordingUI()
# Simulate a live worker thread.
blocker = threading.Event()
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
ws.worker_thread.start()
try:
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("queued msg")
item = session._watch_pending.get_nowait()
assert item == {"message": "queued msg"}
finally:
blocker.set()
ws.worker_thread.join(2)
def test_busy_workstream_drops_on_full_queue():
"""When the pending queue is full, dispatch drops the message."""
session = _StubSession()
# Fill the queue to capacity.
for i in range(20):
session._watch_pending.put_nowait({"message": f"msg{i}"})
ws = Workstream()
ui = _RecordingUI()
blocker = threading.Event()
ws.worker_thread = threading.Thread(target=blocker.wait, args=(5,), daemon=True)
ws.worker_thread.start()
try:
dispatch = _make_watch_dispatch(ws, session, ui)
# Should not block or raise — just log a warning and drop.
dispatch("overflow msg")
assert session._watch_pending.full()
finally:
blocker.set()
ws.worker_thread.join(2)
# ── Lock guard ───────────────────────────────────────────────────────────────
def test_dispatch_holds_lock_during_thread_start():
"""Dispatch acquires ws._lock before checking/starting the worker."""
session = _StubSession()
ws = Workstream()
ui = _RecordingUI()
acquire_count = 0
inner = ws._lock
class _CountingLock:
def __enter__(self):
nonlocal acquire_count
acquire_count += 1
return inner.__enter__()
def __exit__(self, *args):
return inner.__exit__(*args)
ws._lock = _CountingLock() # type: ignore[assignment]
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert acquire_count >= 1
# ── Happy path ───────────────────────────────────────────────────────────────
def test_successful_send_no_error_events():
"""Normal send() completion should not trigger error/cancel events."""
session = _StubSession() # send() does nothing (success)
ws = Workstream()
ui = _RecordingUI()
dispatch = _make_watch_dispatch(ws, session, ui)
dispatch("hello")
_wait_for_worker(ws)
assert ui.stream_end_calls == 0
assert ui.state_changes == []
assert ui.errors == []
+1 -1
View File
@@ -54,7 +54,7 @@ class FakeUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.9.1"
__version__ = "0.9.4"
+91
View File
@@ -785,3 +785,94 @@ class RegistryInstallRequest(BaseModel):
variables: dict[str, str] = Field(default_factory=dict)
env: dict[str, str] = Field(default_factory=dict)
headers: dict[str, str] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Admin: Model Definitions
# ---------------------------------------------------------------------------
class ModelDefinitionInfo(BaseModel):
definition_id: str
alias: str
model: str
provider: str = "openai"
base_url: str = ""
api_key: str = ""
context_window: int = 32768
capabilities: str = "{}"
enabled: bool = True
source: str = ""
created_by: str = ""
created: str = ""
updated: str = ""
class CreateModelDefinitionRequest(BaseModel):
alias: str
model: str
provider: str = "openai"
base_url: str = ""
api_key: str = ""
context_window: int = 32768
capabilities: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class UpdateModelDefinitionRequest(BaseModel):
alias: str | None = None
model: str | None = None
provider: str | None = None
base_url: str | None = None
api_key: str | None = None
context_window: int | None = None
capabilities: dict[str, Any] | None = None
enabled: bool | None = None
class ListModelDefinitionsResponse(BaseModel):
models: list[ModelDefinitionInfo]
class ModelReloadResponse(BaseModel):
status: str = "ok"
results: dict[str, Any] = Field(default_factory=dict)
class DetectModelRequest(BaseModel):
provider: str = "openai"
base_url: str = ""
api_key: str = ""
model: str = ""
definition_id: str = ""
class DetectModelResponse(BaseModel):
reachable: bool = False
model_found: bool | None = None
available_models: list[str] = Field(default_factory=list)
context_window: int | None = None
server_type: str | None = None
error: str | None = None
class ModelCapabilitiesResponse(BaseModel):
model: str
provider: str
known: bool = False
capabilities: dict[str, Any] = Field(default_factory=dict)
class KnownModelsResponse(BaseModel):
provider: str
models: list[str] = Field(default_factory=list)
class AvailableModelInfo(BaseModel):
alias: str
model: str
provider: str
class ListAvailableModelsResponse(BaseModel):
models: list[AvailableModelInfo] = Field(default_factory=list)
+108
View File
@@ -11,6 +11,7 @@ from turnstone.api.console_schemas import (
AdminMemoryInfo,
AssignRoleRequest,
AuditEventInfo,
AvailableModelInfo,
ChannelUserInfo,
ClusterNodesResponse,
ClusterOverviewResponse,
@@ -21,16 +22,22 @@ from turnstone.api.console_schemas import (
ConsoleHealthResponse,
CreateChannelUserRequest,
CreateMcpServerRequest,
CreateModelDefinitionRequest,
CreateRoleRequest,
CreateSkillRequest,
CreateSkillResourceRequest,
CreateToolPolicyRequest,
DetectModelRequest,
DetectModelResponse,
ImportMcpConfigRequest,
ImportMcpConfigResponse,
KnownModelsResponse,
ListAdminMemoriesResponse,
ListAuditEventsResponse,
ListAvailableModelsResponse,
ListChannelUsersResponse,
ListMcpServersResponse,
ListModelDefinitionsResponse,
ListOrgsResponse,
ListOutputAssessmentsResponse,
ListRolesResponse,
@@ -44,6 +51,9 @@ from turnstone.api.console_schemas import (
ListVerdictsResponse,
McpReloadResponse,
McpServerDetail,
ModelCapabilitiesResponse,
ModelDefinitionInfo,
ModelReloadResponse,
NodeDetailResponse,
OrgInfo,
OutputAssessmentInfo,
@@ -60,6 +70,7 @@ from turnstone.api.console_schemas import (
SkillVersionInfo,
ToolPolicyInfo,
UpdateMcpServerRequest,
UpdateModelDefinitionRequest,
UpdateOrgRequest,
UpdateRoleRequest,
UpdateSettingRequest,
@@ -560,6 +571,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
response_model=ListSkillVersionsResponse,
tags=["Admin"],
),
# --- Models ---
EndpointSpec(
"/v1/api/models",
"GET",
"List enabled model aliases for workstream creation",
response_model=ListAvailableModelsResponse,
tags=["Models"],
),
# --- Skills ---
EndpointSpec(
"/v1/api/skills",
@@ -843,6 +862,84 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400],
tags=["Admin"],
),
# --- Admin: Model Definitions ---
EndpointSpec(
"/v1/api/admin/model-definitions",
"GET",
"List model definitions with live status from cluster nodes",
response_model=ListModelDefinitionsResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions",
"POST",
"Create a model definition",
request_model=CreateModelDefinitionRequest,
response_model=ModelDefinitionInfo,
error_codes=[400, 409],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/reload",
"POST",
"Tell all nodes to re-read model definitions from DB and rebuild registry",
response_model=ModelReloadResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"GET",
"Get a single model definition",
response_model=ModelDefinitionInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"PUT",
"Update a model definition",
request_model=UpdateModelDefinitionRequest,
response_model=ModelDefinitionInfo,
error_codes=[400, 404, 409],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/{definition_id}",
"DELETE",
"Delete a model definition",
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-definitions/detect",
"POST",
"Probe a model endpoint: verify reachability, list models, detect context window and server type",
request_model=DetectModelRequest,
response_model=DetectModelResponse,
error_codes=[400],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-capabilities",
"GET",
"Look up static capabilities for a known model",
response_model=ModelCapabilitiesResponse,
query_params=[
QueryParam(name="provider", description="Provider name", required=True),
QueryParam(name="model", description="Model ID to look up", required=True),
],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/model-capabilities/known",
"GET",
"List known model name prefixes for a provider",
response_model=KnownModelsResponse,
query_params=[
QueryParam(name="provider", description="Provider name", required=True),
],
tags=["Admin"],
),
# --- Admin: TLS / ACME ---
EndpointSpec(
"/v1/api/admin/tls/ca",
@@ -953,6 +1050,17 @@ _ALL_MODELS: list[type[BaseModel]] = [
ImportMcpConfigRequest,
ImportMcpConfigResponse,
McpReloadResponse,
ModelDefinitionInfo,
CreateModelDefinitionRequest,
UpdateModelDefinitionRequest,
ListModelDefinitionsResponse,
ModelReloadResponse,
DetectModelRequest,
DetectModelResponse,
ModelCapabilitiesResponse,
KnownModelsResponse,
AvailableModelInfo,
ListAvailableModelsResponse,
RegistrySearchResponse,
RegistryInstallRequest,
SkillDiscoverResponse,
+10
View File
@@ -257,3 +257,13 @@ class SkillSummary(BaseModel):
class ListSkillSummaryResponse(BaseModel):
skills: list[SkillSummary]
class AvailableModelInfo(BaseModel):
alias: str
model: str
provider: str
class ListAvailableModelsResponse(BaseModel):
models: list[AvailableModelInfo] = Field(default_factory=list)
+12
View File
@@ -20,6 +20,7 @@ from turnstone.api.schemas import (
)
from turnstone.api.server_schemas import (
ApproveRequest,
AvailableModelInfo,
CancelRequest,
CloseWorkstreamRequest,
CommandRequest,
@@ -27,6 +28,7 @@ from turnstone.api.server_schemas import (
CreateWorkstreamResponse,
DashboardResponse,
HealthResponse,
ListAvailableModelsResponse,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListSkillSummaryResponse,
@@ -155,6 +157,14 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
response_model=ListSkillSummaryResponse,
tags=["Skills"],
),
# --- Models ---
EndpointSpec(
"/v1/api/models",
"GET",
"List available model aliases",
response_model=ListAvailableModelsResponse,
tags=["Models"],
),
# --- Auth ---
EndpointSpec(
"/v1/api/auth/login",
@@ -293,6 +303,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
SearchMemoriesRequest,
SkillSummary,
ListSkillSummaryResponse,
AvailableModelInfo,
ListAvailableModelsResponse,
]
+33 -4
View File
@@ -63,6 +63,8 @@ SLASH_COMMANDS = [
"/creative",
"/debug",
"/mcp",
"/retry",
"/rewind",
"/help",
"/exit",
"/quit",
@@ -251,8 +253,18 @@ class TerminalUI(SessionUI):
item["denial_msg"] = denial_msg
return False, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
pass # Optional: display summary
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if is_error:
with self._print_lock:
sys.stderr.write(f"{RED}\u2717 {name}: {output}{RESET}\n")
sys.stderr.flush()
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
pass # Terminal shows spinner during tool execution
@@ -424,9 +436,16 @@ class WorkstreamTerminalUI(TerminalUI):
else:
self._buffer("error", message)
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if self.is_foreground:
super().on_tool_result(call_id, name, output)
super().on_tool_result(call_id, name, output, is_error=is_error)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
if self.is_foreground:
@@ -1237,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)
+559 -11
View File
@@ -365,6 +365,25 @@ async def oidc_callback(request: Request) -> Response:
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
# ---------------------------------------------------------------------------
# Route handlers — available models (lightweight, no admin permission)
# ---------------------------------------------------------------------------
async def list_available_models(request: Request) -> JSONResponse:
"""GET /v1/api/models — enabled model aliases for workstream creation."""
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
rows = storage.list_model_definitions(enabled_only=True)
# Only expose alias/model/provider — rows also contain api_key, base_url, etc.
models = [{"alias": r["alias"], "model": r["model"], "provider": r["provider"]} for r in rows]
return JSONResponse({"models": models})
# ---------------------------------------------------------------------------
# Route handlers — workstream creation
# ---------------------------------------------------------------------------
@@ -1748,9 +1767,11 @@ _VALID_PERMISSIONS = frozenset(
"admin.memories",
"admin.settings",
"admin.mcp",
"admin.models",
"tools.approve",
"workstreams.create",
"workstreams.close",
"conversation.modify",
}
)
@@ -3656,7 +3677,6 @@ async def admin_list_settings(request: Request) -> JSONResponse:
if err:
return err
reveal = request.query_params.get("reveal") == "true"
stored = {r["key"]: r for r in storage.list_system_settings() if r.get("node_id", "") == ""}
settings: list[dict[str, Any]] = []
@@ -3669,7 +3689,7 @@ async def admin_list_settings(request: Request) -> JSONResponse:
val = row["value"]
info = {
"key": key,
"value": "***" if defn.is_secret and not reveal else val,
"value": "***" if defn.is_secret else val,
"source": "storage",
"type": defn.type,
"description": defn.description,
@@ -3683,7 +3703,7 @@ async def admin_list_settings(request: Request) -> JSONResponse:
else:
info = {
"key": key,
"value": "(managed via config file / env)" if defn.is_secret else defn.default,
"value": "***" if defn.is_secret else defn.default,
"source": "default",
"type": defn.type,
"description": defn.description,
@@ -3758,18 +3778,31 @@ async def admin_update_setting(request: Request) -> JSONResponse:
except ValueError:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
if defn.is_secret:
return JSONResponse(
{
"error": "Secret settings cannot be modified via API — use config.toml or environment variables"
},
status_code=403,
)
if "value" not in body:
return JSONResponse({"error": "value is required"}, status_code=400)
raw_value = body.get("value")
# Secret sentinel: "***" means "keep existing value"
if defn.is_secret and raw_value == "***":
existing = storage.get_system_setting(key)
return JSONResponse(
{
"key": key,
"value": "***",
"source": "storage" if existing else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": True,
"node_id": existing.get("node_id", "") if existing else "",
"changed_by": existing.get("changed_by", "") if existing else "",
"updated": existing.get("updated", "") if existing else "",
"restart_required": defn.restart_required,
"unchanged": True,
}
)
try:
typed_value = validate_value(key, raw_value)
except ValueError as e:
@@ -4663,6 +4696,484 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
return JSONResponse({"imported": imported, "skipped": skipped, "errors": errors})
# ---------------------------------------------------------------------------
# Admin: Model Definitions
# ---------------------------------------------------------------------------
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible"})
def _mask_model_secrets(model: dict[str, Any]) -> dict[str, Any]:
"""Replace api_key with '***' (unconditional, write-only)."""
m = dict(model)
if m.get("api_key"):
m["api_key"] = "***"
return m
async def _collect_model_status(
request: Request,
) -> dict[str, dict[str, dict[str, Any]]]:
"""Query all nodes for model status. Returns {node_id: {alias: info}}."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.get(
f"{url.rstrip('/')}/v1/api/_internal/model-status",
headers=headers,
timeout=10,
)
if resp.status_code == 200:
return node_id, resp.json().get("models", {})
except Exception:
log.debug("Failed to fetch model status from node %s", node_id, exc_info=True)
return node_id, None
results = await asyncio.gather(*[_fetch(n) for n in nodes])
return {nid: models for nid, models in results if models is not None}
async def _notify_nodes_model_reload(request: Request) -> dict[str, Any]:
"""Tell all nodes to re-read model definitions from DB and rebuild registry."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.post(
f"{url.rstrip('/')}/v1/api/_internal/model-reload",
headers=headers,
timeout=30,
)
return node_id, resp.json()
except Exception as exc:
log.debug("Failed to notify node %s for model reload", node_id, exc_info=True)
return node_id, {"error": str(exc)}
results = await asyncio.gather(*[_notify(n) for n in nodes])
return {nid: data for nid, data in results if data is not None}
async def admin_list_model_definitions(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-definitions — list all model definitions."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
db_models = storage.list_model_definitions()
# Collect live status from all nodes
node_statuses = await _collect_model_status(request)
db_aliases: set[str] = set()
result = []
for m in db_models:
db_aliases.add(m["alias"])
m["source"] = "db"
result.append(_mask_model_secrets(m))
# Merge config-sourced models visible on nodes but not in DB
config_aliases: set[str] = set()
for node_models in node_statuses.values():
for alias in node_models:
if alias not in db_aliases:
config_aliases.add(alias)
for alias in sorted(config_aliases):
# Build a synthetic read-only entry from node-reported data
model_name = ""
provider = "openai"
context_window = 0
for node_models in node_statuses.values():
nm = node_models.get(alias)
if nm:
model_name = nm.get("model", "")
provider = nm.get("provider", "openai")
context_window = nm.get("context_window", 0)
break
result.append(
{
"definition_id": "",
"alias": alias,
"model": model_name,
"provider": provider,
"base_url": "",
"api_key": "",
"context_window": context_window,
"capabilities": "{}",
"enabled": True,
"source": "config",
"created_by": "",
"created": "",
"updated": "",
}
)
return JSONResponse({"models": result})
async def admin_create_model_definition(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions — create a model definition."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
alias = str(body.get("alias", "")).strip()[:64]
model_name = str(body.get("model", "")).strip()[:128]
if not alias:
return JSONResponse({"error": "alias is required"}, status_code=400)
if not model_name:
return JSONResponse({"error": "model is required"}, status_code=400)
if not _MODEL_ALIAS_RE.match(alias):
return JSONResponse(
{"error": "alias must match [a-zA-Z0-9._-]+"},
status_code=400,
)
# Check alias uniqueness
if storage.get_model_definition_by_alias(alias):
return JSONResponse(
{"error": f"Model alias '{alias}' already exists"},
status_code=409,
)
definition_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
provider = str(body.get("provider", "openai")).strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse(
{"error": f"Unknown provider: {provider!r}"},
status_code=400,
)
base_url = str(body.get("base_url", "")).strip()
api_key = str(body.get("api_key", "")).strip()
ctx_raw = body.get("context_window", 32768)
context_window = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
caps = body.get("capabilities", {})
capabilities = json.dumps(caps) if isinstance(caps, dict) else "{}"
enabled = bool(body.get("enabled", True))
storage.create_model_definition(
definition_id=definition_id,
alias=alias,
model=model_name,
provider=provider,
base_url=base_url,
api_key=api_key,
context_window=context_window,
capabilities=capabilities,
enabled=enabled,
created_by=audit_uid,
)
record_audit(
storage,
audit_uid,
"model_definition.create",
"model_definition",
definition_id,
{"alias": alias},
ip,
)
created = storage.get_model_definition(definition_id)
if created is None:
return JSONResponse(
{"error": f"Model alias '{alias}' already exists (concurrent insert)"},
status_code=409,
)
return JSONResponse(_mask_model_secrets(created))
async def admin_get_model_definition(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
model_def = storage.get_model_definition(definition_id)
if model_def is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
return JSONResponse(_mask_model_secrets(model_def))
async def admin_update_model_definition(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
existing = storage.get_model_definition(definition_id)
if existing is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "alias" in body:
alias = str(body["alias"]).strip()[:64]
if not alias:
return JSONResponse({"error": "alias cannot be empty"}, status_code=400)
if not _MODEL_ALIAS_RE.match(alias):
return JSONResponse(
{"error": "alias must match [a-zA-Z0-9._-]+"},
status_code=400,
)
if alias != existing["alias"] and storage.get_model_definition_by_alias(alias):
return JSONResponse(
{"error": f"Model alias '{alias}' already exists"},
status_code=409,
)
updates["alias"] = alias
if "model" in body:
model_val = str(body["model"]).strip()[:128]
if not model_val:
return JSONResponse({"error": "model cannot be empty"}, status_code=400)
updates["model"] = model_val
if "provider" in body:
prov = str(body["provider"]).strip()
if prov not in _MODEL_PROVIDERS:
return JSONResponse(
{"error": f"Unknown provider: {prov!r}"},
status_code=400,
)
updates["provider"] = prov
if "base_url" in body:
updates["base_url"] = str(body["base_url"]).strip()
if "api_key" in body:
api_key = str(body["api_key"]).strip()
# Sentinel "***" or empty string means "keep existing"
if api_key and api_key != "***":
updates["api_key"] = api_key
if "context_window" in body:
ctx_raw = body["context_window"]
updates["context_window"] = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
if "capabilities" in body:
caps = body["capabilities"]
updates["capabilities"] = json.dumps(caps) if isinstance(caps, dict) else "{}"
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
if updates:
storage.update_model_definition(definition_id, **updates)
audit_uid, ip = _audit_context(request)
audit_detail = dict(updates)
if "api_key" in audit_detail:
audit_detail["api_key"] = "(updated)"
record_audit(
storage,
audit_uid,
"model_definition.update",
"model_definition",
definition_id,
audit_detail,
ip,
)
model_def = storage.get_model_definition(definition_id)
return JSONResponse(_mask_model_secrets(model_def or {}))
async def admin_delete_model_definition(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
existing = storage.get_model_definition(definition_id)
if existing is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
storage.delete_model_definition(definition_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"model_definition.delete",
"model_definition",
definition_id,
{"alias": existing.get("alias", "")},
ip,
)
return JSONResponse({"status": "ok", "definition_id": definition_id})
async def admin_model_reload(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions/reload — tell nodes to re-read DB."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
results = await _notify_nodes_model_reload(request)
return JSONResponse({"status": "ok", "results": results})
async def admin_detect_model(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions/detect — stateless endpoint probe."""
import asyncio
from turnstone.core.auth import require_permission
from turnstone.core.model_registry import probe_model_endpoint
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
provider = str(body.get("provider", "openai")).strip()
base_url = str(body.get("base_url", "")).strip()
api_key = str(body.get("api_key", "")).strip()
model = str(body.get("model", "")).strip()
definition_id = str(body.get("definition_id", "")).strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
# Resolve api_key from DB when the UI sends the masked sentinel
if (not api_key or api_key == "***") and definition_id:
row = storage.get_model_definition(definition_id)
if row:
api_key = row.get("api_key", "")
if not base_url:
base_url = row.get("base_url", "")
# For commercial endpoints an api_key is required
if not api_key and (
not base_url or "api.openai.com" in base_url or "api.anthropic.com" in base_url
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None, probe_model_endpoint, provider, base_url, api_key, model
)
return JSONResponse(result)
async def admin_model_capabilities(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-capabilities — static capability lookup."""
from turnstone.core.auth import require_permission
from turnstone.core.providers import lookup_model_capabilities
err = require_permission(request, "admin.models")
if err:
return err
provider = request.query_params.get("provider", "").strip()
model = request.query_params.get("model", "").strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
if not model:
return JSONResponse({"error": "model is required"}, status_code=400)
caps = lookup_model_capabilities(provider, model)
return JSONResponse(
{
"model": model,
"provider": provider,
"known": caps is not None,
"capabilities": caps or {},
}
)
async def admin_known_models(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-capabilities/known — list known model name prefixes."""
from turnstone.core.auth import require_permission
from turnstone.core.providers import list_known_models
err = require_permission(request, "admin.models")
if err:
return err
provider = request.query_params.get("provider", "").strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
return JSONResponse({"provider": provider, "models": list_known_models(provider)})
# ---------------------------------------------------------------------------
# TLS endpoints
# ---------------------------------------------------------------------------
@@ -4854,6 +5365,7 @@ def create_app(
Route("/api/cluster/node/{node_id}", cluster_node_detail),
Route("/api/cluster/snapshot", cluster_snapshot),
Route("/api/cluster/events", cluster_events_sse),
Route("/api/models", list_available_models),
Route("/api/skills", list_skills_summary),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
@@ -5046,6 +5558,42 @@ def create_app(
admin_delete_mcp_server,
methods=["DELETE"],
),
# System: Model Definitions
Route("/api/admin/model-definitions", admin_list_model_definitions),
Route(
"/api/admin/model-definitions",
admin_create_model_definition,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/reload",
admin_model_reload,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/detect",
admin_detect_model,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_get_model_definition,
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_update_model_definition,
methods=["PUT"],
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_delete_model_definition,
methods=["DELETE"],
),
Route("/api/admin/model-capabilities", admin_model_capabilities),
Route(
"/api/admin/model-capabilities/known",
admin_known_models,
),
# Governance: Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
+565
View File
@@ -66,6 +66,7 @@ function showAdmin() {
settings: "admin.settings",
tls: "admin.settings",
mcp: "admin.mcp",
models: "admin.models",
};
if (perms) {
var permSet = perms.split(",");
@@ -193,6 +194,7 @@ function switchAdminTab(tab) {
"usage",
"audit",
"memories",
"models",
"settings",
"tls",
"mcp",
@@ -216,6 +218,7 @@ function switchAdminTab(tab) {
loadGovAudit();
}
if (tab === "memories") loadAdminMemories();
if (tab === "models") loadAdminModels();
if (tab === "settings") loadSettings();
if (tab === "tls") loadTlsCerts();
if (tab === "mcp") loadAdminMcp();
@@ -1844,6 +1847,7 @@ function _installTrap(overlayId, boxId, trapRef) {
else if (overlayId === "mcp-detail-overlay") hideMcpDetailModal();
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
else if (overlayId === "model-create-overlay") hideCreateModelModal();
}
};
}
@@ -1932,6 +1936,7 @@ document.addEventListener("keydown", function (e) {
["mcp-import-overlay", hideImportMcpModal],
["mcp-create-overlay", hideCreateMcpModal],
["github-import-overlay", hideGitHubImportModal],
["model-create-overlay", hideCreateModelModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
@@ -4025,3 +4030,563 @@ function _pollInstallStatus(serverId, serverName, attempt) {
.catch(function () {});
}, 3000);
}
// ---------------------------------------------------------------------------
// Models tab
// ---------------------------------------------------------------------------
var _modelDefs = [];
var _modelCreateTrap = null;
var _modelCreateTrigger = null;
function loadAdminModels() {
authFetch("/v1/api/admin/model-definitions")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
_modelDefs = data.models || [];
_renderModels(_modelDefs);
})
.catch(function () {
var el = document.getElementById("admin-models-table");
el.textContent = "";
var d = document.createElement("div");
d.className = "dashboard-empty";
d.textContent = "Failed to load models";
el.appendChild(d);
});
}
function _renderModels(items) {
var el = document.getElementById("admin-models-table");
// Clear previous content
el.textContent = "";
if (!items.length) {
var empty = document.createElement("div");
empty.className = "dashboard-empty";
empty.textContent = "No model definitions configured";
el.appendChild(empty);
return;
}
for (var i = 0; i < items.length; i++) {
var m = items[i];
var isConfig = m.source === "config";
// Status
var dotClass = m.enabled
? "model-status-dot enabled"
: "model-status-dot disabled";
var rowClass = m.enabled ? "model-row-enabled" : "model-row-disabled";
var statusText = m.enabled ? "enabled" : "disabled";
// Context window formatting (0 = auto-detect)
var ctxText = m.context_window
? m.context_window >= 1000
? Math.round(m.context_window / 1000) + "k"
: String(m.context_window)
: "auto";
// Provider badge class
var providerCls =
m.provider === "anthropic"
? "model-provider-anthropic"
: "model-provider-openai";
// Build row via DOM
var row = document.createElement("div");
row.className = "admin-row models-grid " + rowClass;
row.setAttribute("role", "listitem");
// Alias + source badge
var colAlias = document.createElement("span");
colAlias.className = "admin-col";
colAlias.textContent = m.alias;
var badge = document.createElement("span");
badge.className = isConfig
? "scope-badge scope-config"
: "scope-badge scope-db";
badge.textContent = isConfig ? "config" : "db";
colAlias.appendChild(document.createTextNode(" "));
colAlias.appendChild(badge);
row.appendChild(colAlias);
// Model ID
var colModel = document.createElement("span");
colModel.className = "admin-col";
var code = document.createElement("code");
code.textContent = m.model;
colModel.appendChild(code);
row.appendChild(colModel);
// Provider
var colProvider = document.createElement("span");
colProvider.className = "admin-col";
var provBadge = document.createElement("span");
provBadge.className = "model-provider-badge " + providerCls;
provBadge.textContent = m.provider;
colProvider.appendChild(provBadge);
row.appendChild(colProvider);
// Context window
var colCtx = document.createElement("span");
colCtx.className = "admin-col";
colCtx.textContent = ctxText;
row.appendChild(colCtx);
// Status
var colStatus = document.createElement("span");
colStatus.className = "admin-col";
var dot = document.createElement("span");
dot.className = dotClass;
dot.setAttribute("aria-hidden", "true");
colStatus.appendChild(dot);
colStatus.appendChild(document.createTextNode(statusText));
row.appendChild(colStatus);
// Actions
var colActions = document.createElement("span");
colActions.className = "admin-col";
if (!isConfig) {
var editBtn = document.createElement("button");
editBtn.className = "admin-btn-action";
editBtn.textContent = "edit";
editBtn.setAttribute("data-model-edit", m.definition_id);
colActions.appendChild(editBtn);
var delBtn = document.createElement("button");
delBtn.className = "admin-btn-danger";
delBtn.textContent = "del";
delBtn.setAttribute("data-model-delete", m.definition_id);
delBtn.setAttribute("data-model-alias", m.alias);
colActions.appendChild(delBtn);
}
row.appendChild(colActions);
el.appendChild(row);
}
// Bind event handlers
el.querySelectorAll("[data-model-edit]").forEach(function (btn) {
btn.addEventListener("click", function () {
showEditModelModal(this.getAttribute("data-model-edit"));
});
});
el.querySelectorAll("[data-model-delete]").forEach(function (btn) {
btn.addEventListener("click", function () {
var did = this.getAttribute("data-model-delete");
var dalias = this.getAttribute("data-model-alias");
showConfirmModal(
"Delete Model",
'Delete model "' + dalias + '"?',
"Delete",
function () {
authFetch(
"/v1/api/admin/model-definitions/" + encodeURIComponent(did),
{
method: "DELETE",
},
)
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Model deleted");
_flagModelSyncPending();
loadAdminModels();
})
.catch(function () {
showToast("Failed to delete model");
});
},
);
});
});
}
function showCreateModelModal() {
_modelCreateTrigger = document.activeElement;
var ov = document.getElementById("model-create-overlay");
ov.style.display = "flex";
document.getElementById("model-edit-id").value = "";
document.getElementById("model-create-title").textContent = "Add Model";
document.getElementById("model-create-submit").textContent = "Create";
document.getElementById("model-create-error").classList.remove("is-visible");
document.getElementById("model-alias").value = "";
document.getElementById("model-name").value = "";
document.getElementById("model-provider").value = "openai";
document.getElementById("model-base-url").value = "";
document.getElementById("model-api-key").value = "";
document.getElementById("model-api-key").placeholder = "sk-...";
document.getElementById("model-ctx-window").value = "0";
document.getElementById("model-capabilities").value = "";
document.getElementById("model-enabled").checked = true;
document.getElementById("model-detect-result").style.display = "none";
document.getElementById("model-detect-btn").disabled = false;
document.getElementById("model-detect-btn").textContent = "Detect";
_refreshModelSuggestions();
document.getElementById("model-alias").focus();
_modelCreateTrap = _installTrap("model-create-overlay", "model-create-box");
}
function showEditModelModal(definitionId) {
authFetch(
"/v1/api/admin/model-definitions/" + encodeURIComponent(definitionId),
)
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (m) {
showCreateModelModal();
document.getElementById("model-edit-id").value = definitionId;
document.getElementById("model-create-title").textContent = "Edit Model";
document.getElementById("model-create-submit").textContent = "Save";
document.getElementById("model-alias").value = m.alias || "";
document.getElementById("model-name").value = m.model || "";
document.getElementById("model-provider").value = m.provider || "openai";
document.getElementById("model-base-url").value = m.base_url || "";
document.getElementById("model-api-key").value = "";
document.getElementById("model-api-key").placeholder =
"\u2022\u2022\u2022 (leave blank to keep existing)";
document.getElementById("model-ctx-window").value =
m.context_window != null ? m.context_window : 0;
// Parse capabilities JSON for display
var caps = m.capabilities || "{}";
try {
caps = JSON.stringify(JSON.parse(caps), null, 2);
} catch (e) {
/* keep raw */
}
if (caps === "{}") caps = "";
document.getElementById("model-capabilities").value = caps;
document.getElementById("model-enabled").checked = m.enabled !== false;
})
.catch(function () {
showToast("Failed to load model details");
});
}
function hideCreateModelModal() {
document.getElementById("model-create-overlay").style.display = "none";
_modelCreateTrap = _removeTrap(_modelCreateTrap);
if (_modelCreateTrigger && _modelCreateTrigger.focus)
_modelCreateTrigger.focus();
_modelCreateTrigger = null;
}
function submitCreateModel() {
var alias = document.getElementById("model-alias").value.trim();
var modelName = document.getElementById("model-name").value.trim();
if (!alias) {
_showModelError("Alias is required");
return;
}
if (!modelName) {
_showModelError("Model ID is required");
return;
}
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
_showModelError("Alias must be alphanumeric (with . _ -)");
return;
}
var capsText = document.getElementById("model-capabilities").value.trim();
var caps = {};
if (capsText) {
try {
caps = JSON.parse(capsText);
} catch (e) {
_showModelError("Invalid JSON in capabilities");
return;
}
}
var form = {
alias: alias,
model: modelName,
provider: document.getElementById("model-provider").value,
base_url: document.getElementById("model-base-url").value.trim(),
context_window:
parseInt(document.getElementById("model-ctx-window").value, 10) || 0,
capabilities: caps,
enabled: document.getElementById("model-enabled").checked,
};
var apiKey = document.getElementById("model-api-key").value;
if (apiKey) form.api_key = apiKey;
var editId = document.getElementById("model-edit-id").value;
var method = editId ? "PUT" : "POST";
var url = editId
? "/v1/api/admin/model-definitions/" + encodeURIComponent(editId)
: "/v1/api/admin/model-definitions";
document.getElementById("model-create-submit").disabled = true;
authFetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
hideCreateModelModal();
showToast(editId ? "Model updated" : "Model created");
_flagModelSyncPending();
loadAdminModels();
})
.catch(function (e) {
_showModelError(e.message);
})
.finally(function () {
document.getElementById("model-create-submit").disabled = false;
});
}
function _showModelError(msg) {
var e = document.getElementById("model-create-error");
e.textContent = msg;
e.classList.add("is-visible");
}
function _detectResultLine(text, color) {
var div = document.createElement("div");
div.style.marginTop = "3px";
if (color) div.style.color = "var(--" + color + ")";
div.textContent = text;
return div;
}
function _clearDetectResult() {
var rd = document.getElementById("model-detect-result");
if (rd) {
rd.style.display = "none";
rd.textContent = "";
rd.style.borderColor = "";
}
}
function detectModel() {
var btn = document.getElementById("model-detect-btn");
var resultDiv = document.getElementById("model-detect-result");
btn.disabled = true;
btn.setAttribute("aria-busy", "true");
btn.textContent = "Detecting\u2026";
resultDiv.style.display = "none";
resultDiv.textContent = "";
var form = {
provider: document.getElementById("model-provider").value,
base_url: document.getElementById("model-base-url").value.trim(),
model: document.getElementById("model-name").value.trim(),
};
var apiKey = document.getElementById("model-api-key").value;
if (apiKey) form.api_key = apiKey;
var editId = document.getElementById("model-edit-id").value;
if (editId) form.definition_id = editId;
authFetch("/v1/api/admin/model-definitions/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Detect failed");
});
return r.json();
})
.then(function (d) {
resultDiv.style.display = "block";
resultDiv.textContent = "";
if (d.error && !d.reachable) {
resultDiv.appendChild(
_detectResultLine("\u2717 Failed: " + d.error, "red"),
);
resultDiv.style.borderColor = "var(--red)";
return;
}
var line1 = "\u2713 Connected";
if (d.available_models && d.available_models.length) {
line1 += " \u2014 " + d.available_models.length + " model(s) available";
}
resultDiv.appendChild(_detectResultLine(line1, "green"));
if (d.model_found === false) {
var models = d.available_models || [];
var msg =
'\u26A0 Model "' +
form.model +
'" not found in ' +
models.length +
" available model(s)";
if (models.length > 0) {
var shown = models.slice(0, 8);
msg += ": " + shown.join(", ");
if (models.length > 8)
msg += ", \u2026 +" + (models.length - 8) + " more";
}
resultDiv.appendChild(_detectResultLine(msg, "yellow"));
}
if (d.context_window) {
resultDiv.appendChild(
_detectResultLine(
"Context window: " + d.context_window.toLocaleString() + " tokens",
),
);
var ctxInput = document.getElementById("model-ctx-window");
if (parseInt(ctxInput.value, 10) === 0) {
ctxInput.value = d.context_window;
}
}
if (d.server_type) {
resultDiv.appendChild(
_detectResultLine("Server type: " + d.server_type),
);
}
resultDiv.style.borderColor = "var(--green)";
})
.catch(function (e) {
if (e.message === "auth") return;
resultDiv.style.display = "block";
resultDiv.textContent = "";
resultDiv.appendChild(_detectResultLine("\u2717 " + e.message, "red"));
resultDiv.style.borderColor = "var(--red)";
})
.finally(function () {
btn.disabled = false;
btn.removeAttribute("aria-busy");
btn.textContent = "Detect";
});
}
/* Capability auto-fill: when the user types a known model name or
changes the provider, look up static capabilities and pre-fill
context_window and the capabilities textarea. */
var _capsTimer = null;
function _onModelFieldChange() {
clearTimeout(_capsTimer);
_capsTimer = setTimeout(function () {
var overlay = document.getElementById("model-create-overlay");
if (!overlay || overlay.style.display === "none") return;
var provider = document.getElementById("model-provider").value;
var modelName = document.getElementById("model-name").value.trim();
if (!modelName) return;
authFetch(
"/v1/api/admin/model-capabilities?provider=" +
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
)
.then(function (r) {
return r.json();
})
.then(function (d) {
if (!d.known || !d.capabilities) return;
var ctxInput = document.getElementById("model-ctx-window");
if (
parseInt(ctxInput.value, 10) === 0 &&
d.capabilities.context_window
) {
ctxInput.value = d.capabilities.context_window;
}
var capsInput = document.getElementById("model-capabilities");
if (!capsInput.value.trim()) {
var caps = Object.assign({}, d.capabilities);
delete caps.context_window;
delete caps.max_output_tokens;
delete caps.token_param;
delete caps.supports_streaming;
delete caps.supports_tools;
var text = JSON.stringify(caps, null, 2);
if (text !== "{}") capsInput.value = text;
}
})
.catch(function () {
/* silent */
});
}, 500);
}
/* Populate the model name datalist with known model prefixes for the
selected provider. Called on page load and provider change. */
function _refreshModelSuggestions() {
var dl = document.getElementById("model-name-suggestions");
if (!dl) return;
var provider = document.getElementById("model-provider").value;
authFetch(
"/v1/api/admin/model-capabilities/known?provider=" +
encodeURIComponent(provider),
)
.then(function (r) {
return r.json();
})
.then(function (d) {
dl.textContent = "";
(d.models || []).forEach(function (m) {
var opt = document.createElement("option");
opt.value = m;
dl.appendChild(opt);
});
})
.catch(function () {
dl.textContent = "";
});
}
/* Register listeners once at page load */
(function () {
var nameEl = document.getElementById("model-name");
var provEl = document.getElementById("model-provider");
if (nameEl) nameEl.addEventListener("input", _onModelFieldChange);
if (provEl) {
provEl.addEventListener("change", _onModelFieldChange);
provEl.addEventListener("change", _refreshModelSuggestions);
provEl.addEventListener("change", _clearDetectResult);
}
/* Clear stale detect results when probe-relevant inputs change */
["model-base-url", "model-api-key"].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener("input", _clearDetectResult);
});
})();
function _flagModelSyncPending() {
var btn = document.getElementById("model-sync-btn");
if (btn) btn.classList.add("model-sync-pending");
}
function _clearModelSyncPending() {
var btn = document.getElementById("model-sync-btn");
if (btn) btn.classList.remove("model-sync-pending");
}
function reloadModelNodes() {
var btn = document.getElementById("model-sync-btn");
btn.disabled = true;
btn.textContent = "Syncing...";
authFetch("/v1/api/admin/model-definitions/reload", { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Model reload dispatched");
_clearModelSyncPending();
loadAdminModels();
})
.catch(function () {
showToast("Failed to sync models");
})
.finally(function () {
btn.disabled = false;
btn.textContent = "Sync to Nodes";
});
}
+24 -1
View File
@@ -1281,8 +1281,31 @@ function showNewWsModal() {
.catch(function () {
/* ignore — defaults still work */
});
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
modelSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default model";
modelSelect.appendChild(defaultOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
})
.then(function (data) {
(data.models || []).forEach(function (m) {
var opt = document.createElement("option");
opt.value = m.alias;
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
});
})
.catch(function () {
/* ignore — default model still works */
});
document.getElementById("new-ws-name").value = "";
document.getElementById("new-ws-model").value = "";
modelSelect.value = "";
var taskEl = document.getElementById("new-ws-task");
taskEl.value = "";
var mod =
+63 -1
View File
@@ -109,6 +109,7 @@
</div>
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
<button id="tab-models" class="admin-nav" data-tab="models" role="tab" aria-selected="false" aria-controls="admin-models" tabindex="-1" onclick="switchAdminTab('models')">Models</button>
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
</div>
@@ -394,6 +395,26 @@
</div>
</div>
<!-- Models Tab -->
<div id="admin-models" class="admin-panel" role="tabpanel" aria-labelledby="tab-models" style="display:none">
<div class="admin-toolbar">
<span class="section-header">Models</span>
<button id="model-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadModelNodes()" title="Push model config to all cluster nodes">Sync to Nodes</button>
<button class="admin-action-btn" onclick="showCreateModelModal()">+ Add Model</button>
</div>
<div class="admin-colheaders models-grid" aria-hidden="true">
<span class="admin-col">ALIAS</span>
<span class="admin-col">MODEL</span>
<span class="admin-col">PROVIDER</span>
<span class="admin-col">CTX WINDOW</span>
<span class="admin-col">STATUS</span>
<span class="admin-col">ACTIONS</span>
</div>
<div id="admin-models-table" role="list" aria-label="Model definitions" aria-live="polite">
<div class="dashboard-empty">Loading...</div>
</div>
</div>
<!-- Settings Tab -->
<div id="admin-settings" class="admin-panel" role="tabpanel" aria-labelledby="tab-settings" style="display:none">
<div class="admin-toolbar">
@@ -523,7 +544,9 @@ window.TURNSTONE_KB_SHORTCUTS = [
<label for="new-ws-name">Name <span class="label-hint">optional</span></label>
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
<label for="new-ws-model">Model <span class="label-hint">optional</span></label>
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
<select id="new-ws-model">
<option value="">Default model</option>
</select>
<label for="new-ws-skill">Skill <span class="label-hint">optional</span></label>
<select id="new-ws-skill">
<option value="">Use defaults</option>
@@ -1189,6 +1212,45 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- Model create/edit modal -->
<div id="model-create-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="model-create-title">
<div id="model-create-box" class="admin-modal">
<h2 id="model-create-title">Add Model</h2>
<div id="model-create-error" role="alert" aria-live="assertive"></div>
<input type="hidden" id="model-edit-id" value="">
<label for="model-alias">Alias</label>
<input type="text" id="model-alias" placeholder="e.g. gpt5-prod" maxlength="64" pattern="[a-zA-Z0-9._-]+">
<label for="model-name">Model ID <span style="font-weight:400;text-transform:none">(type to autocomplete)</span></label>
<input type="text" id="model-name" placeholder="e.g. gpt-5" list="model-name-suggestions">
<datalist id="model-name-suggestions"></datalist>
<label for="model-provider">Provider</label>
<select id="model-provider">
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
<option value="openai-compatible">openai-compatible</option>
</select>
<label for="model-base-url">Base URL <span style="font-weight:400;text-transform:none">(empty = provider default)</span></label>
<input type="text" id="model-base-url" placeholder="https://api.openai.com/v1">
<label for="model-api-key">API Key <span style="font-weight:400;text-transform:none">(write-only, never displayed)</span></label>
<input type="password" id="model-api-key" placeholder="sk-..." autocomplete="off">
<label for="model-ctx-window">Context Window <span style="font-weight:400;text-transform:none">(0 = auto-detect from model)</span></label>
<input type="number" id="model-ctx-window" value="0" min="0">
<label for="model-capabilities">Capabilities <span style="font-weight:400;text-transform:none">(JSON)</span></label>
<textarea id="model-capabilities" rows="3" placeholder='{"supports_vision": true}' style="font-family:var(--font-mono);font-size:11px"></textarea>
<div style="display:flex;gap:20px;margin-top:14px">
<label style="margin:0;font-size:12px;color:var(--fg-dim)"><input type="checkbox" id="model-enabled" checked style="margin-right:5px">Enabled</label>
</div>
<div id="model-detect-area" style="margin-top:14px">
<button type="button" id="model-detect-btn" class="modal-cancel" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<div id="model-detect-result" role="status" aria-live="polite" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);word-break:break-word"></div>
</div>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateModelModal()">Cancel</button>
<button id="model-create-submit" class="modal-submit" onclick="submitCreateModel()">Create</button>
</div>
</div>
</div>
<script src="/static/admin.js"></script>
<script src="/static/governance.js"></script>
<script src="/static/app.js"></script>
+34 -5
View File
@@ -1189,7 +1189,8 @@
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
.admin-modal textarea { resize: vertical; min-height: 40px; }
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
.admin-modal [role="alert"] { display: none; color: var(--red); font-size: 12px; margin-bottom: 8px; }
.admin-modal [role="alert"].is-visible { display: block; }
.admin-details { margin-top: 12px; border: 1px solid var(--border); border-radius: 6px; padding: 0 12px; }
.admin-details[open] { padding-bottom: 12px; }
@@ -1369,6 +1370,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
}
.modal-cancel:hover { background: var(--bg-elevated); }
.modal-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.modal-cancel:disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.modal-submit {
flex: 1;
padding: 9px;
@@ -1394,7 +1396,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
#create-template-overlay, #edit-template-overlay,
#memory-detail-overlay,
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
#github-import-overlay {
#github-import-overlay,
#model-create-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -2116,7 +2119,7 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.admin-action-btn-ghost{background:transparent;color:var(--fg-dim);border:1px solid var(--border-strong)}
.admin-action-btn-ghost:hover{color:var(--fg);background:var(--bg-highlight)}
.mcp-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
.mcp-sync-pending,.model-sync-pending{color:var(--yellow)!important;border-color:var(--yellow)!important;animation:mcp-sync-pulse 2s ease-in-out infinite}
@keyframes mcp-sync-pulse{0%,100%{border-color:var(--yellow)}50%{border-color:rgba(251,191,36,.3)}}
/* -- MCP sub-view toggle -------------------------------------------------- */
@@ -2183,7 +2186,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.mcp-reg-card{grid-template-columns:1fr;gap:8px}
.mcp-reg-card-actions{flex-direction:row;align-items:center}
.mcp-registry-search{flex-direction:column}
#admin-mcp .admin-toolbar{flex-wrap:wrap;gap:8px}
#admin-mcp .admin-toolbar,
#admin-models .admin-toolbar{flex-wrap:wrap;gap:8px}
#mcp-servers-toolbar{display:flex;gap:6px;width:100%}
#admin-skills .admin-toolbar{flex-wrap:wrap;gap:8px}
#skill-installed-toolbar{display:flex;gap:6px;width:100%}
@@ -2287,6 +2291,31 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.oidc-detail-panel { margin-left: 8px; }
}
/* -- Models grid --------------------------------------------------------- */
.models-grid{grid-template-columns:1.2fr 1.2fr 80px 90px 80px 120px;gap:0 6px}
@media(max-width:700px){
.models-grid{grid-template-columns:1fr 80px 120px}
.models-grid .admin-col:nth-child(2),
.models-grid .admin-col:nth-child(3),
.models-grid .admin-col:nth-child(4){display:none}
}
/* Model status indicators */
.model-status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin-right:6px}
.model-status-dot.enabled{background:var(--blue);box-shadow:0 0 6px var(--blue-glow)}
.model-status-dot.disabled{background:var(--fg-dim);opacity:.35}
.model-row-enabled{border-left:3px solid var(--blue)}
.model-row-disabled{border-left:3px solid transparent}
/* Provider badges */
.model-provider-badge{display:inline-block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:2px;background:var(--bg-highlight);border:1px solid var(--border)}
.model-provider-openai{color:var(--blue);border-color:rgba(56,189,248,.2)}
.model-provider-anthropic{color:var(--magenta);border-color:rgba(192,132,252,.25)}
/* Model source badge */
.scope-db{color:var(--blue);border-color:rgba(56,189,248,.2)}
/* ==========================================================================
Reduced motion console-specific
========================================================================== */
@@ -2310,5 +2339,5 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.mcp-view-btn, .mcp-reg-card, .mcp-install-btn, .mcp-install-source-label { transition: none; }
.mcp-registry-search input[type="search"] { transition: none; }
.mcp-reg-card-repo { transition: none; }
.mcp-sync-pending { animation: none; }
.mcp-sync-pending, .model-sync-pending { animation: none; }
}
+6 -1
View File
@@ -173,7 +173,12 @@ WRITE_PATHS: frozenset[str] = frozenset(
)
APPROVE_PATHS: frozenset[str] = frozenset(
{"/api/approve", "/api/_internal/config-reload", "/api/_internal/mcp-reload"}
{
"/api/approve",
"/api/_internal/config-reload",
"/api/_internal/mcp-reload",
"/api/_internal/model-reload",
}
)
ADMIN_PREFIX = "/api/admin/"
+33 -5
View File
@@ -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))
+19 -4
View File
@@ -37,7 +37,6 @@ def save_message(
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -49,7 +48,6 @@ def save_message(
role,
content,
tool_name,
tool_args,
tool_call_id,
provider_data,
tool_calls=tool_calls,
@@ -67,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 ----------------------------------------------------
@@ -252,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 []
+225 -22
View File
@@ -34,6 +34,7 @@ class ModelConfig:
context_window: int = 32768
provider: str = "openai"
capabilities: dict[str, Any] = field(default_factory=dict)
source: str = "" # "config", "db", or "" (CLI default)
# ---------------------------------------------------------------------------
@@ -81,9 +82,9 @@ class ModelRegistry:
def get_client(self, alias: str) -> Any:
"""Get or lazily create an API client for *alias*. Thread-safe."""
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
with self._client_lock:
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
if alias not in self._clients:
cfg = self._models[alias]
self._clients[alias] = create_client(
@@ -93,9 +94,9 @@ class ModelRegistry:
def get_provider(self, alias: str) -> LLMProvider:
"""Get the ``LLMProvider`` for *alias*. Thread-safe, cached."""
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
with self._client_lock:
if alias not in self._models:
raise ValueError(f"Unknown model alias: {alias}")
if alias not in self._providers:
cfg = self._models[alias]
self._providers[alias] = create_provider(cfg.provider)
@@ -129,8 +130,46 @@ class ModelRegistry:
"""Number of registered models."""
return len(self._models)
@property
def models(self) -> dict[str, ModelConfig]:
"""Return a copy of the models dict (public accessor for reload)."""
return dict(self._models)
# -- lifecycle -----------------------------------------------------------
def reload(
self,
models: dict[str, ModelConfig],
default: str,
fallback: list[str] | None = None,
agent_model: str | None = None,
) -> None:
"""Hot-reload all model configs. Thread-safe; clears cached clients.
Validates arguments before mutating state so a bad reload
does not leave the registry in an inconsistent state.
"""
if not models:
raise ValueError("ModelRegistry requires at least one model config")
if default not in models:
raise ValueError(f"Default model '{default}' not found in registry")
if fallback:
for alias in fallback:
if alias not in models:
raise ValueError(f"Fallback model '{alias}' not found in registry")
if agent_model and agent_model not in models:
raise ValueError(f"Agent model '{agent_model}' not found in registry")
with self._client_lock:
self._models = dict(models)
self.default = default
self.fallback = list(fallback) if fallback else []
self.agent_model = agent_model
for client in self._clients.values():
if hasattr(client, "close"):
client.close()
self._clients.clear()
self._providers.clear()
def shutdown(self) -> None:
"""Close all cached client connections."""
with self._client_lock:
@@ -146,32 +185,82 @@ class ModelRegistry:
# ---------------------------------------------------------------------------
def _resolve_env_vars(value: str) -> str:
"""Expand ``${VAR}`` patterns in *value* using environment variables.
Unresolved variables are replaced with empty strings.
"""
import os
import re
def _replace(m: re.Match[str]) -> str:
return os.environ.get(m.group(1), "")
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", _replace, value)
def load_model_registry(
base_url: str,
api_key: str,
model: str,
context_window: int = 32768,
provider: str = "openai",
storage: Any | None = None,
) -> ModelRegistry:
"""Build a ModelRegistry from CLI args and ``config.toml``.
"""Build a ModelRegistry from CLI args, ``config.toml``, and database.
Precedence:
Precedence (highest to lowest):
1. ``[models.*]`` sections in config.toml define named models.
2. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
``"default"`` entry (overrides any ``[models.default]`` section).
3. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
1. ``[models.*]`` sections in config.toml define named models
(``source="config"``). These override DB entries with the same
alias in-memory only the DB rows are never modified.
2. Database model definitions (``source="db"``), loaded when
*storage* is provided.
3. CLI ``--base-url`` / ``--api-key`` / ``--model`` always create a
``"default"`` entry.
4. ``[model].default``, ``[model].fallback``, ``[model].agent_model``
control routing.
4. If no ``[models.*]`` sections exist, a single-entry registry is built
from the CLI args.
"""
import json as _json
cfg = load_config()
models_section: dict[str, Any] = cfg.get("models", {})
model_section: dict[str, Any] = cfg.get("model", {})
configs: dict[str, ModelConfig] = {}
# Build configs from [models.*] sections
# 1. Load DB model definitions (lowest priority, overridden by config.toml)
if storage is not None:
try:
for row in storage.list_model_definitions(enabled_only=True):
alias = row["alias"]
caps: dict[str, Any] = {}
if row.get("capabilities"):
try:
parsed = _json.loads(row["capabilities"])
if isinstance(parsed, dict):
caps = parsed
except (_json.JSONDecodeError, TypeError):
pass
row_provider = row.get("provider", "openai")
row_model = row["model"]
# 0 = auto-detect: inherit CLI-detected context_window,
# same fallback chain as config.toml models
row_ctx = row.get("context_window", 0) or context_window
configs[alias] = ModelConfig(
alias=alias,
base_url=_resolve_env_vars(row.get("base_url", "")),
api_key=_resolve_env_vars(row.get("api_key", "")),
model=row_model,
context_window=row_ctx,
provider=row_provider,
capabilities=caps,
source="db",
)
except Exception:
log.warning("Failed to load model definitions from storage", exc_info=True)
# 2. Build configs from [models.*] sections (overrides DB for same alias)
for alias, entry in models_section.items():
if not isinstance(entry, dict):
continue
@@ -189,17 +278,20 @@ def load_model_registry(
capabilities=entry.get("capabilities", {})
if isinstance(entry.get("capabilities"), dict)
else {},
source="config",
)
# Ensure a "default" entry from CLI args
configs["default"] = ModelConfig(
alias="default",
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider,
)
# 3. Ensure a "default" entry from CLI args (only if not already defined
# by config.toml or DB — those take precedence)
if "default" not in configs:
configs["default"] = ModelConfig(
alias="default",
base_url=base_url,
api_key=api_key,
model=model,
context_window=context_window,
provider=provider,
)
# Determine default alias
default_alias = model_section.get("default", "default")
@@ -342,3 +434,114 @@ def detect_model(
log_fn(f"Warning: Could not connect to LLM backend: {e}")
log_fn("Starting in degraded mode — requests will fail until backend is reachable.")
return None, None
def probe_model_endpoint(
provider: str,
base_url: str,
api_key: str,
target_model: str = "",
) -> dict[str, Any]:
"""Stateless probe of a model endpoint.
Creates a temporary SDK client, calls ``/v1/models``, and returns
reachability status, available model IDs, detected context window,
and server type. Used by the admin *Detect* button never persists
state or stores the API key.
"""
from turnstone.core.providers import create_client
result: dict[str, Any] = {
"reachable": False,
"model_found": None,
"available_models": [],
"context_window": None,
"server_type": None,
"error": None,
}
client = None
try:
client = create_client(provider, base_url=base_url, api_key=api_key)
fast = client.with_options(timeout=10.0, max_retries=0)
models = fast.models.list()
if not models.data:
result["reachable"] = True
result["error"] = "No models found at endpoint"
return result
all_ids = [m.id for m in models.data]
result["reachable"] = True
result["available_models"] = all_ids
# Determine which model to inspect for context_window
if target_model:
result["model_found"] = target_model in all_ids
inspect_id = target_model if result["model_found"] else all_ids[0]
else:
inspect_id = all_ids[0]
inspect_obj = next((m for m in models.data if m.id == inspect_id), None)
# --- context window detection ---
if provider == "anthropic":
from turnstone.core.providers import lookup_model_capabilities
known = lookup_model_capabilities("anthropic", inspect_id)
if known is not None:
result["context_window"] = known["context_window"]
result["server_type"] = "anthropic"
else:
# OpenAI-compatible path
_detect_openai_compat(result, inspect_obj, inspect_id, base_url)
except Exception as exc:
err_msg = str(exc)
if len(err_msg) > 500:
err_msg = err_msg[:500] + "..."
result["error"] = err_msg
finally:
if client is not None and hasattr(client, "close"):
client.close()
return result
def _detect_openai_compat(
result: dict[str, Any],
model_obj: Any,
model_id: str,
base_url: str,
) -> None:
"""Fill context_window and server_type for an OpenAI-compatible endpoint."""
meta: dict[str, Any] | None = None
owned_by: str = ""
if model_obj is not None:
dumped = model_obj.model_dump()
raw_meta = dumped.get("meta")
if isinstance(raw_meta, dict):
meta = raw_meta
owned_by = str(dumped.get("owned_by", ""))
# Context window: prefer backend metadata, fall back to static table
# (only for known models — the default 200k would be misleading for local servers)
if meta is not None:
n_ctx = meta.get("n_ctx_train")
if isinstance(n_ctx, int) and n_ctx > 0:
result["context_window"] = n_ctx
if result["context_window"] is None:
from turnstone.core.providers import lookup_model_capabilities
known = lookup_model_capabilities("openai", model_id)
if known is not None:
result["context_window"] = known["context_window"]
# Server type heuristics
if base_url and "api.openai.com" in base_url:
result["server_type"] = "openai"
elif meta is not None and "n_ctx_train" in meta:
result["server_type"] = "llama.cpp"
elif "sglang" in owned_by.lower():
result["server_type"] = "sglang"
elif "/" in (model_id or ""):
result["server_type"] = "vllm"
else:
result["server_type"] = "openai-compatible"
+28 -2
View File
@@ -51,11 +51,19 @@ _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(
r'"(?:api_key|apikey|api_secret|secret_key|secret|password|passwd|'
r"token|access_token|refresh_token|auth_token|private_key|"
r'client_secret|webhook_secret|signing_key|encryption_key)"\s*:\s*"([^"]{8,})"',
re.IGNORECASE,
)
@@ -228,6 +236,15 @@ def _check_credentials(
found = True
risk = "high"
if _RE_JSON_SECRET.search(text):
_add_flag(flags, "credential_leak")
flags.append("json_secret_leak")
ann.append(
"Output contains JSON with secret-bearing keys (api_key, password, token, etc.)."
)
found = True
risk = "high"
return risk, _redact_credentials(text) if found else None
@@ -247,6 +264,15 @@ def _redact_credentials(text: str) -> str:
return key + "=[REDACTED:secret]" if _RE_ENV_SECRET_KEY.search(key) else m.group()
result = _RE_ENV_SECRET_LINE.sub(_redact_env, result)
def _redact_json_secret(m: re.Match[str]) -> str:
# Positional replacement to avoid corrupting key when value == key name
start = m.start(1) - m.start()
end = m.end(1) - m.start()
full = m.group()
return full[:start] + "[REDACTED:secret]" + full[end:]
result = _RE_JSON_SECRET.sub(_redact_json_secret, result)
return result
+49 -5
View File
@@ -25,6 +25,8 @@ __all__ = [
"UsageInfo",
"create_client",
"create_provider",
"list_known_models",
"lookup_model_capabilities",
]
# Singleton instances (stateless, safe to share)
@@ -36,7 +38,7 @@ _anthropic_provider: LLMProvider | None = None
def create_provider(provider_name: str) -> LLMProvider:
"""Return a provider adapter for the given provider name. Thread-safe."""
global _anthropic_provider # noqa: PLW0603
if provider_name == "openai":
if provider_name in ("openai", "openai-compatible"):
return _openai_provider
if provider_name == "anthropic":
with _provider_lock:
@@ -45,15 +47,19 @@ def create_provider(provider_name: str) -> LLMProvider:
_anthropic_provider = AnthropicProvider()
return _anthropic_provider
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
)
def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
"""Create an SDK client for the given provider."""
if provider_name == "openai":
if provider_name in ("openai", "openai-compatible"):
from openai import OpenAI
return OpenAI(base_url=base_url, api_key=api_key)
if base_url:
return OpenAI(base_url=base_url, api_key=api_key)
return OpenAI(api_key=api_key)
if provider_name == "anthropic":
from turnstone.core.providers._anthropic import _ensure_anthropic
@@ -62,4 +68,42 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
if base_url and base_url != "https://api.anthropic.com":
kwargs["base_url"] = base_url
return anthropic.Anthropic(**kwargs)
raise ValueError(f"Unknown provider: {provider_name!r}. Supported: openai, anthropic")
raise ValueError(
f"Unknown provider: {provider_name!r}. Supported: openai, anthropic, openai-compatible"
)
def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | None:
"""Return static capabilities for a known model, or ``None`` if unknown.
The returned dict has JSON-friendly values (tuples converted to lists).
Returns ``None`` for ``openai-compatible`` (no static table for local models).
"""
import dataclasses
if provider == "openai-compatible":
return None
prov = create_provider(provider)
caps = prov.get_capabilities(model)
default = prov.get_capabilities("")
if caps is default:
return None
result = dataclasses.asdict(caps)
# Convert tuples to lists for JSON serialisation
for key, val in result.items():
if isinstance(val, tuple):
result[key] = list(val)
return result
def list_known_models(provider: str) -> list[str]:
"""Return the model name prefixes in the static capability table."""
if provider == "openai":
from turnstone.core.providers._openai import _OPENAI_CAPABILITIES
return sorted(_OPENAI_CAPABILITIES.keys())
if provider == "anthropic":
from turnstone.core.providers._anthropic import _ANTHROPIC_CAPABILITIES
return sorted(_ANTHROPIC_CAPABILITIES.keys())
return []
+156 -30
View File
@@ -7,6 +7,7 @@ The ``anthropic`` SDK is imported lazily so it remains an optional dependency.
from __future__ import annotations
import json
import logging
import sys
from typing import TYPE_CHECKING, Any
@@ -22,6 +23,8 @@ from turnstone.core.providers._protocol import (
if TYPE_CHECKING:
from collections.abc import Iterator
log = logging.getLogger(__name__)
def _ensure_anthropic() -> Any:
"""Lazy import anthropic SDK, raising helpful error if not installed."""
@@ -81,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",
@@ -92,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",
@@ -128,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,
),
}
@@ -287,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):
@@ -300,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
@@ -329,27 +354,105 @@ class AnthropicProvider:
)
if content_blocks:
converted.append({"role": "assistant", "content": content_blocks})
# Repair orphaned tool_use blocks: if this assistant message
# has tool_use blocks but the next messages don't provide
# matching tool_results, synthesize error results. This
# 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.
# 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":
tc_id = messages[j].get("tool_call_id", "")
if tc_id:
result_ids.add(tc_id)
j += 1
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",
"tool_use_id": uid,
"content": "Tool execution was cancelled.",
"is_error": True,
}
for uid in orphaned
]
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":
@@ -560,6 +663,12 @@ class AnthropicProvider:
raw_blocks[event.index]["thinking"] = (
raw_blocks[event.index].get("thinking", "") + delta.thinking
)
elif delta.type == "signature_delta":
# Accumulate signature into raw block for round-trip
if event.index in raw_blocks:
raw_blocks[event.index]["signature"] = (
raw_blocks[event.index].get("signature", "") + delta.signature
)
elif delta.type == "input_json_delta":
if event.index in server_tool_blocks:
# Accumulate server tool input (search query)
@@ -666,7 +775,24 @@ class AnthropicProvider:
deferred_names,
)
response = client.messages.create(**kwargs)
# Use streaming internally to avoid the Anthropic SDK's 10-minute
# timeout on non-streaming requests. get_final_message() returns the
# same Message object as messages.create() would.
# Mirror create_streaming's defensive __enter__/__exit__ pattern so
# resources are cleaned up even if __enter__ fails.
manager = client.messages.stream(**kwargs)
try:
stream = manager.__enter__()
except BaseException:
manager.__exit__(*sys.exc_info())
raise
try:
response = stream.get_final_message()
except BaseException:
manager.__exit__(*sys.exc_info())
raise
else:
manager.__exit__(None, None, None)
# Extract content and tool_calls from content blocks.
# Skip server-side blocks (server_tool_use, web_search_tool_result)
+47 -1
View File
@@ -11,6 +11,8 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
import structlog
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
@@ -20,6 +22,8 @@ from turnstone.core.providers._protocol import (
_lookup_capabilities,
)
log = structlog.get_logger(__name__)
# -- model capabilities -------------------------------------------------------
_OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
@@ -330,6 +334,14 @@ class OpenAIProvider:
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=True,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
stream = client.chat.completions.create(**kwargs)
if cancel_ref is not None:
cancel_ref.append(stream)
@@ -339,12 +351,17 @@ class OpenAIProvider:
"""Convert OpenAI stream chunks to normalized StreamChunks."""
first = True
annotations: list[Any] = []
content_len = 0
tool_call_count = 0
last_finish_reason: str | None = None
completion_tokens: int | None = None
for chunk in stream:
sc = StreamChunk()
# Finish reason
if chunk.choices and chunk.choices[0].finish_reason:
sc.finish_reason = chunk.choices[0].finish_reason
last_finish_reason = sc.finish_reason
# Usage from final chunk
if hasattr(chunk, "usage") and chunk.usage is not None:
@@ -352,6 +369,7 @@ class OpenAIProvider:
pt = getattr(u, "prompt_tokens", None)
ct = getattr(u, "completion_tokens", None)
tt = getattr(u, "total_tokens", None)
completion_tokens = ct
if pt is not None and ct is not None:
# Extract cached_tokens from prompt_tokens_details.
# OpenAI caching is automatic with no write premium, so
@@ -380,6 +398,7 @@ class OpenAIProvider:
# Content
if delta.content:
sc.content_delta = delta.content
content_len += len(delta.content)
# Tool calls
if delta.tool_calls:
@@ -393,6 +412,7 @@ class OpenAIProvider:
if tc_delta.function.arguments:
tcd.arguments_delta = tc_delta.function.arguments
sc.tool_call_deltas.append(tcd)
tool_call_count += 1
# Accumulate url_citation annotations from search models
delta_anns = getattr(delta, "annotations", None)
@@ -407,6 +427,15 @@ class OpenAIProvider:
if has_content or sc.finish_reason or sc.usage:
yield sc
log.debug(
"openai.response",
stream=True,
finish_reason=last_finish_reason,
content_length=content_len,
tool_call_deltas=tool_call_count,
completion_tokens=completion_tokens,
)
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = self._format_citations("", annotations).strip()
@@ -445,6 +474,14 @@ class OpenAIProvider:
if extra_params:
kwargs["extra_body"] = extra_params
log.debug(
"openai.request",
model=model,
stream=False,
max_tokens=max_tokens,
message_count=len(messages),
tool_count=len(tools) if tools else 0,
)
response = client.chat.completions.create(**kwargs)
choice = response.choices[0]
msg = choice.message
@@ -482,12 +519,21 @@ class OpenAIProvider:
cache_read_tokens=cached or 0,
)
return CompletionResult(
result = CompletionResult(
content=content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
)
log.debug(
"openai.response",
stream=False,
finish_reason=result.finish_reason,
content_length=len(content),
tool_call_count=len(tool_calls) if tool_calls else 0,
completion_tokens=usage.completion_tokens if usage else None,
)
return result
@staticmethod
def _format_citations(content: str, annotations: list[Any]) -> str:
+20 -2
View File
@@ -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",
]
+29 -2
View File
@@ -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()
+833 -234
View File
File diff suppressed because it is too large Load Diff
+130 -9
View File
@@ -17,6 +17,7 @@ from turnstone.core.storage._schema import (
intent_verdicts,
mcp_servers,
metadata,
model_definitions,
oidc_identities,
oidc_pending_states,
orgs,
@@ -44,6 +45,9 @@ from turnstone.core.storage._schema import (
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
from turnstone.core.storage._utils import (
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
)
from turnstone.core.storage._utils import (
ORG_MUTABLE as _ORG_MUTABLE,
)
@@ -103,7 +107,6 @@ class PostgreSQLBackend:
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -118,7 +121,6 @@ class PostgreSQLBackend:
"role": role,
"content": content,
"tool_name": tool_name,
"tool_args": tool_args,
"tool_call_id": tool_call_id,
"provider_data": provider_data,
"tool_calls": tool_calls,
@@ -136,7 +138,6 @@ class PostgreSQLBackend:
conversations.c.role,
conversations.c.content,
conversations.c.tool_name,
conversations.c.tool_args,
conversations.c.tool_call_id,
conversations.c.provider_data,
conversations.c.tool_calls,
@@ -146,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]:
@@ -382,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:
@@ -398,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:
@@ -410,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()
)
@@ -2644,6 +2669,102 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount > 0
# -- Model definitions -----------------------------------------------------
def create_model_definition(
self,
definition_id: str,
alias: str,
model: str,
provider: str = "openai",
base_url: str = "",
api_key: str = "",
context_window: int = 32768,
capabilities: str = "{}",
enabled: bool = True,
created_by: str = "",
) -> None:
from sqlalchemy.dialects import postgresql
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
postgresql.insert(model_definitions)
.values(
definition_id=definition_id,
alias=alias,
model=model,
provider=provider,
base_url=base_url,
api_key=api_key,
context_window=context_window,
capabilities=capabilities,
enabled=1 if enabled else 0,
created_by=created_by,
created=now,
updated=now,
)
.on_conflict_do_nothing()
)
conn.commit()
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(model_definitions.c.alias == alias)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
if enabled_only:
q = q.where(model_definitions.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
with self._engine.connect() as conn:
result = conn.execute(
sa.update(model_definitions)
.where(model_definitions.c.definition_id == definition_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_model_definition(self, definition_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
+48 -2
View File
@@ -21,7 +21,6 @@ class StorageBackend(Protocol):
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -33,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]:
@@ -181,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)."""
...
@@ -952,6 +960,44 @@ class StorageBackend(Protocol):
"""Delete an MCP server definition. Returns True if existed."""
...
# -- Model definitions -----------------------------------------------------
def create_model_definition(
self,
definition_id: str,
alias: str,
model: str,
provider: str = "openai",
base_url: str = "",
api_key: str = "",
context_window: int = 32768,
capabilities: str = "{}",
enabled: bool = True,
created_by: str = "",
) -> None:
"""Create a model definition. No-op if definition_id already exists."""
...
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
"""Return model definition dict or None."""
...
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
"""Return model definition dict by alias or None."""
...
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
"""Return model definitions ordered by alias."""
...
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
"""Update specified fields on a model definition. Returns True if found."""
...
def delete_model_definition(self, definition_id: str) -> bool:
"""Delete a model definition. Returns True if existed."""
...
# -- TLS / ACME (lacme Store) ----------------------------------------------
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
+23 -1
View File
@@ -35,7 +35,6 @@ conversations = sa.Table(
sa.Column("role", sa.Text, nullable=False),
sa.Column("content", sa.Text),
sa.Column("tool_name", sa.Text),
sa.Column("tool_args", sa.Text),
sa.Column("tool_call_id", sa.Text),
sa.Column("provider_data", sa.Text),
sa.Column("tool_calls", sa.Text),
@@ -520,6 +519,29 @@ sa.Index(
postgresql_where=mcp_servers.c.registry_name.isnot(None),
)
# ---------------------------------------------------------------------------
# Model definitions — database-backed model configuration
# ---------------------------------------------------------------------------
model_definitions = sa.Table(
"model_definitions",
metadata,
sa.Column("definition_id", sa.Text, primary_key=True),
sa.Column("alias", sa.Text, nullable=False, unique=True),
sa.Column("model", sa.Text, nullable=False),
sa.Column("provider", sa.Text, nullable=False, server_default="openai"),
sa.Column("base_url", sa.Text, nullable=False, server_default=""),
sa.Column("api_key", sa.Text, nullable=False, server_default=""),
sa.Column("context_window", sa.Integer, nullable=False, server_default="32768"),
sa.Column("capabilities", sa.Text, nullable=False, server_default="{}"),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
sa.Index("idx_model_definitions_enabled", model_definitions.c.enabled)
# ---------------------------------------------------------------------------
# OIDC identity tables
# ---------------------------------------------------------------------------
+146 -9
View File
@@ -17,6 +17,7 @@ from turnstone.core.storage._schema import (
intent_verdicts,
mcp_servers,
metadata,
model_definitions,
oidc_identities,
oidc_pending_states,
orgs,
@@ -44,6 +45,9 @@ from turnstone.core.storage._schema import (
from turnstone.core.storage._utils import (
MCP_SERVER_MUTABLE as _MCP_SERVER_MUTABLE,
)
from turnstone.core.storage._utils import (
MODEL_DEFINITION_MUTABLE as _MODEL_DEF_MUTABLE,
)
from turnstone.core.storage._utils import (
ORG_MUTABLE as _ORG_MUTABLE,
)
@@ -154,7 +158,6 @@ class SQLiteBackend:
role: str,
content: str | None,
tool_name: str | None = None,
tool_args: str | None = None,
tool_call_id: str | None = None,
provider_data: str | None = None,
tool_calls: str | None = None,
@@ -169,7 +172,6 @@ class SQLiteBackend:
"role": role,
"content": content,
"tool_name": tool_name,
"tool_args": tool_args,
"tool_call_id": tool_call_id,
"provider_data": provider_data,
"tool_calls": tool_calls,
@@ -200,7 +202,6 @@ class SQLiteBackend:
conversations.c.role,
conversations.c.content,
conversations.c.tool_name,
conversations.c.tool_args,
conversations.c.tool_call_id,
conversations.c.provider_data,
conversations.c.tool_calls,
@@ -211,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]:
@@ -456,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(
@@ -469,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(
@@ -479,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()
)
@@ -2693,6 +2736,100 @@ class SQLiteBackend:
conn.commit()
return result.rowcount > 0
# -- Model definitions -----------------------------------------------------
def create_model_definition(
self,
definition_id: str,
alias: str,
model: str,
provider: str = "openai",
base_url: str = "",
api_key: str = "",
context_window: int = 32768,
capabilities: str = "{}",
enabled: bool = True,
created_by: str = "",
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(model_definitions).prefix_with("OR IGNORE"),
{
"definition_id": definition_id,
"alias": alias,
"model": model,
"provider": provider,
"base_url": base_url,
"api_key": api_key,
"context_window": context_window,
"capabilities": capabilities,
"enabled": 1 if enabled else 0,
"created_by": created_by,
"created": now,
"updated": now,
},
)
conn.commit()
def get_model_definition(self, definition_id: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def get_model_definition_by_alias(self, alias: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(model_definitions).where(model_definitions.c.alias == alias)
).fetchone()
if row is None:
return None
return _row_to_dict(row, "enabled")
def list_model_definitions(self, enabled_only: bool = False) -> list[dict[str, Any]]:
with self._engine.connect() as conn:
q = sa.select(model_definitions).order_by(model_definitions.c.alias)
if enabled_only:
q = q.where(model_definitions.c.enabled == 1)
rows = conn.execute(q).fetchall()
return [_row_to_dict(r, "enabled") for r in rows]
def update_model_definition(self, definition_id: str, **fields: Any) -> bool:
fields = {k: v for k, v in fields.items() if k in _MODEL_DEF_MUTABLE}
fields["updated"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if "enabled" in fields:
fields["enabled"] = 1 if fields["enabled"] else 0
with self._engine.connect() as conn:
result = conn.execute(
sa.update(model_definitions)
.where(model_definitions.c.definition_id == definition_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_model_definition(self, definition_id: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(model_definitions).where(
model_definitions.c.definition_id == definition_id
)
)
conn.commit()
return result.rowcount > 0
# -- OIDC identity ---------------------------------------------------------
def create_oidc_identity(self, issuer: str, subject: str, user_id: str, email: str) -> None:
+56 -3
View File
@@ -80,6 +80,18 @@ MCP_SERVER_MUTABLE = frozenset(
"registry_meta",
}
)
MODEL_DEFINITION_MUTABLE = frozenset(
{
"alias",
"model",
"provider",
"base_url",
"api_key",
"context_window",
"capabilities",
"enabled",
}
)
VERDICT_MUTABLE = frozenset(
{
"user_decision",
@@ -136,8 +148,8 @@ def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]
def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
"""Reconstruct OpenAI message format from stored conversation rows.
Each *row* is a 7-element tuple of ``(role, content, tool_name,
tool_args, tool_call_id, provider_data, tool_calls_json)`` ordered
Each *row* is a 6-element tuple of ``(role, content, tool_name,
tool_call_id, provider_data, tool_calls_json)`` ordered
chronologically by row ID.
Post-migration 013 the only roles are ``user``, ``assistant``, and
@@ -146,7 +158,7 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
"""
messages: list[dict[str, Any]] = []
for row in rows:
role, content, _tool_name, _tool_args, tc_id, provider_data, tool_calls_json = row
role, content, _tool_name, tc_id, provider_data, tool_calls_json = row
if role == "user":
messages.append({"role": "user", "content": content or ""})
@@ -188,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,28 @@
"""Drop vestigial tool_args column from conversations table.
The tool_args column has not been written since migration 013 moved
tool call data into the tool_calls JSON column on assistant rows.
All existing rows have NULL in this column.
Revision ID: 027
Revises: 026
Create Date: 2026-03-28
"""
import sqlalchemy as sa
from alembic import op
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("conversations") as batch_op:
batch_op.drop_column("tool_args")
def downgrade() -> None:
with op.batch_alter_table("conversations") as batch_op:
batch_op.add_column(sa.Column("tool_args", sa.Text))
@@ -0,0 +1,54 @@
"""Create model_definitions table and grant admin.models permission.
Revision ID: 028
Revises: 027
Create Date: 2026-03-29
"""
import sqlalchemy as sa
from alembic import op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"model_definitions",
sa.Column("definition_id", sa.Text, primary_key=True),
sa.Column("alias", sa.Text, nullable=False, unique=True),
sa.Column("model", sa.Text, nullable=False),
sa.Column("provider", sa.Text, nullable=False, server_default="openai"),
sa.Column("base_url", sa.Text, nullable=False, server_default=""),
sa.Column("api_key", sa.Text, nullable=False, server_default=""),
sa.Column("context_window", sa.Integer, nullable=False, server_default="32768"),
sa.Column("capabilities", sa.Text, nullable=False, server_default="{}"),
sa.Column("enabled", sa.Integer, nullable=False, server_default="1"),
sa.Column("created_by", sa.Text, nullable=False, server_default=""),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
op.create_index("idx_model_definitions_enabled", "model_definitions", ["enabled"])
# Grant admin.models permission to the built-in admin role
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',admin.models' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%admin.models%'"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = REPLACE(permissions, ',admin.models', '') "
"WHERE role_id = 'builtin-admin'"
)
)
op.drop_table("model_definitions")
@@ -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
View File
@@ -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
+8 -1
View File
@@ -105,7 +105,14 @@ class NullUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
return True, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
pass
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
+1
View File
@@ -107,6 +107,7 @@ class ToolResultEvent(ServerEvent):
call_id: str = ""
name: str = ""
output: str = ""
is_error: bool = False
@dataclass
+222 -31
View File
@@ -382,22 +382,20 @@ class WebUI:
return approved, feedback
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
_metrics.record_tool_call(name)
with self._ws_lock:
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
self._ws_current_activity = ""
self._ws_activity_state = ""
self._broadcast_activity()
is_error = isinstance(output, str) and (
output.startswith("Error")
or output.startswith("Command timed out")
or output.startswith("Search timed out")
or output.startswith("Unknown tool:")
or output.startswith("JSON parse error:")
or output.startswith("MCP prompt timed out")
or output.startswith("MCP prompt error")
)
event: dict[str, Any] = {
"type": "tool_result",
"call_id": call_id,
@@ -487,6 +485,9 @@ class WebUI:
else:
WebUI._workstream_mgr.set_state(self.ws_id, ws_state)
self._broadcast_state(state)
# Also send to per-workstream listeners so the browser UI can track
# busy/idle transitions (stream_end fires per-segment, not per-turn).
self._enqueue({"type": "state_change", "state": state})
def on_rename(self, name: str) -> None:
"""Update the workstream's display name and broadcast to all clients."""
@@ -647,8 +648,11 @@ def _build_history(
if isinstance(content, str):
if content.startswith("Denied by user") or content.startswith("Blocked"):
entry["denied"] = True
elif (
content.startswith("Error")
# Use persisted flag if available, fall back to text
# heuristic for historical data that predates is_error.
if (
msg.get("is_error")
or content.startswith("Error")
or content.startswith("Command timed out")
or content.startswith("Search timed out")
or content.startswith("Unknown tool:")
@@ -801,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
# ---------------------------------------------------------------------------
@@ -1020,6 +1040,24 @@ async def list_skills_summary(request: Request) -> JSONResponse:
return JSONResponse({"skills": skills})
async def list_available_models(request: Request) -> JSONResponse:
"""GET /v1/api/models — list available model aliases."""
registry = getattr(request.app.state, "registry", None)
if registry is None:
return JSONResponse({"models": []})
models = []
for alias in registry.list_aliases():
cfg = registry.get_config(alias)
models.append(
{
"alias": cfg.alias,
"model": cfg.model,
"provider": cfg.provider,
}
)
return JSONResponse({"models": models})
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
"""Count workstream states for health/metrics endpoints."""
counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0)
@@ -1105,22 +1143,37 @@ def _make_watch_dispatch(ws: Workstream, session: ChatSession, ui: Any) -> Any:
pending = session._watch_pending
def dispatch(msg: str) -> None:
if ws.worker_thread and ws.worker_thread.is_alive():
# Workstream is busy — queue for drain at IDLE (Path A)
pending.put({"message": msg})
return
with ws._lock:
if ws.worker_thread and ws.worker_thread.is_alive():
# Workstream is busy — queue for drain at IDLE (Path A)
try:
pending.put_nowait({"message": msg})
except queue.Full:
log.warning(
"Watch pending queue full, dropping result for ws %s",
ws.id,
)
return
# Workstream is idle — start a worker thread (Path B)
def run() -> None:
try:
session.send(msg)
except Exception as exc:
if ui:
ui.on_error(f"Watch error: {exc}")
# Workstream is idle — start a worker thread (Path B)
# Mirrors the send_message() run() pattern for proper cleanup.
def run() -> None:
me = threading.current_thread()
try:
session.send(msg)
except GenerationCancelled:
if ws.worker_thread is me and ui:
ui.on_stream_end()
ui.on_state_change("idle")
except Exception as exc:
if ws.worker_thread is me and ui:
ui.on_error(f"Watch error: {exc}")
ui.on_stream_end()
ui.on_state_change("error")
t = threading.Thread(target=run, daemon=True)
ws.worker_thread = t
t.start()
t = threading.Thread(target=run, daemon=True)
ws.worker_thread = t
t.start()
return dispatch
@@ -1173,12 +1226,12 @@ async def send_message(request: Request) -> JSONResponse:
# If this thread was force-abandoned, ws.worker_thread will
# have been set to None — don't emit spurious events.
if ws.worker_thread is me:
ui._enqueue({"type": "stream_end"})
ui.on_stream_end()
ui.on_state_change("idle")
except Exception as e:
if ws.worker_thread is me:
ui.on_error(f"Error: {e}")
ui._enqueue({"type": "stream_end"})
ui.on_stream_end()
ui.on_state_change("error")
t = threading.Thread(target=run, daemon=True)
@@ -1294,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":
@@ -1306,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"):
@@ -1798,6 +1918,60 @@ def internal_mcp_status(request: Request) -> JSONResponse:
return JSONResponse({"servers": mcp_mgr.get_all_server_status()})
# -- internal model management -----------------------------------------------
def internal_model_reload(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/model-reload — rebuild registry from DB + config."""
from turnstone.core.model_registry import load_model_registry
from turnstone.core.storage._registry import get_storage
registry = getattr(request.app.state, "registry", None)
cli_args = getattr(request.app.state, "cli_model_args", None)
if registry is None or cli_args is None:
return JSONResponse({"status": "error", "reason": "no registry"}, status_code=503)
new_registry = load_model_registry(
base_url=cli_args["base_url"],
api_key=cli_args["api_key"],
model=cli_args["model"],
context_window=cli_args["context_window"],
provider=cli_args["provider"],
storage=get_storage(),
)
try:
registry.reload(
new_registry.models,
new_registry.default,
new_registry.fallback,
new_registry.agent_model,
)
except ValueError as exc:
return JSONResponse({"status": "error", "reason": str(exc)}, status_code=422)
finally:
new_registry.shutdown()
return JSONResponse({"status": "ok", "aliases": registry.list_aliases()})
def internal_model_status(request: Request) -> JSONResponse:
"""GET /v1/api/_internal/model-status — return this node's model aliases."""
registry = getattr(request.app.state, "registry", None)
if registry is None:
return JSONResponse({"models": {}})
models: dict[str, dict[str, Any]] = {}
for alias in registry.list_aliases():
cfg = registry.get_config(alias)
models[alias] = {
"model": cfg.model,
"provider": cfg.provider,
"source": cfg.source,
"context_window": cfg.context_window,
"enabled": True,
}
return JSONResponse({"models": models})
# ---------------------------------------------------------------------------
# Global SSE fan-out
# ---------------------------------------------------------------------------
@@ -1988,6 +2162,7 @@ def create_app(
Route("/api/dashboard", dashboard),
Route("/api/workstreams/saved", list_saved_workstreams),
Route("/api/skills", list_skills_summary),
Route("/api/models", list_available_models),
Route("/api/send", send_message, methods=["POST"]),
Route("/api/approve", approve, methods=["POST"]),
Route("/api/plan", plan_feedback, methods=["POST"]),
@@ -2011,6 +2186,12 @@ def create_app(
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
Route(
"/api/_internal/model-reload",
internal_model_reload,
methods=["POST"],
),
Route("/api/_internal/model-status", internal_model_status),
],
),
Route("/health", health),
@@ -2237,8 +2418,9 @@ def main() -> None:
else:
context_window = 32768
# Build model registry (reads [models.*] sections from config.toml)
# Build model registry (reads [models.*] + database model definitions)
from turnstone.core.model_registry import load_model_registry
from turnstone.core.storage._registry import get_storage as _get_storage
registry = load_model_registry(
base_url=base_url,
@@ -2246,11 +2428,11 @@ def main() -> None:
model=model,
context_window=context_window,
provider=provider_name,
storage=_get_storage(),
)
# Initialize MCP client (connects to configured MCP servers, if any)
from turnstone.core.mcp_client import create_mcp_client
from turnstone.core.storage._registry import get_storage as _get_storage
mcp_config_cli = args.mcp_config # CLI-only (no config.toml for this)
mcp_client = create_mcp_client(
@@ -2479,6 +2661,15 @@ def main() -> None:
config_store=config_store,
)
# Store CLI model args for hot-reload (internal_model_reload reads these)
app.state.cli_model_args = {
"base_url": base_url,
"api_key": api_key,
"model": model,
"context_window": context_window,
"provider": provider_name,
}
log.info("Server starting on http://%s:%s", args.host, args.port)
log.info("Model: %s", model)
if registry.count > 1:
+4
View File
@@ -28,6 +28,7 @@
--yellow: #fbbf24;
--cyan: #67e8f9;
--magenta: #c084fc;
--blue: #38bdf8;
--on-color: var(--bg);
/* Glow variants for LED effects */
@@ -37,6 +38,7 @@
--accent-glow-strong: rgba(229, 160, 66, 0.3);
--cyan-glow: rgba(103, 232, 249, 0.2);
--magenta-glow: rgba(192, 132, 252, 0.25);
--blue-glow: rgba(56, 189, 248, 0.25);
/* Structure */
--border: rgba(255, 255, 255, 0.06);
@@ -68,6 +70,7 @@
--yellow: #b45309;
--cyan: #0e7490;
--magenta: #7c3aed;
--blue: #0369a1;
--on-color: #ffffff;
--green-glow: rgba(4, 120, 87, 0.25);
--red-glow: rgba(220, 38, 38, 0.25);
@@ -75,6 +78,7 @@
--accent-glow-strong: rgba(140, 94, 27, 0.15);
--cyan-glow: rgba(14, 116, 144, 0.2);
--magenta-glow: rgba(124, 58, 237, 0.2);
--blue-glow: rgba(3, 105, 161, 0.2);
--border: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.12);
--code-bg: #f0f1f5;
+9 -1
View File
@@ -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"]
+30
View File
@@ -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 -3
View File
@@ -1,6 +1,6 @@
{
"name": "edit_file",
"description": "Replace an exact string in a file with new content. Fails if old_string is not found or matches multiple locations (use near_line to disambiguate). Requires read_file on the same path first — it will fail without this. Use for any modification to existing files: changing values, renaming, inserting code, adding docstrings. Prefer this over write_file for partial modifications. For multi-file edits when filenames are known, go directly to read_file + edit_file for each file — no need to search first. For generated content (docstrings, type hints), call edit_file with your best-effort content inline — e.g. edit_file(old_string='def process(...):', new_string='def process(...):\\n \"\"\"Process input data.\"\"\"'). When asked to change something in a file, follow through with edit_file after reading — reading alone is not enough.",
"description": "Replace exact strings in a file, or apply multiple replacements atomically. Requires read_file on the same path first. Two modes: (1) Single — old_string + new_string replaces a unique match (fails if multiple matches unless near_line disambiguates). (2) Batch — edits array with multiple old_string/new_string pairs applied atomically. Prefer this over write_file for partial modifications. For multi-file edits when filenames are known, go directly to read_file + edit_file for each file. When asked to change something in a file, follow through with edit_file after reading — reading alone is not enough.",
"parameters": {
"type": "object",
"properties": {
@@ -10,7 +10,7 @@
},
"old_string": {
"type": "string",
"description": "The exact text to find and replace."
"description": "The exact text to find and replace. For multiple edits in one call, use the edits array instead."
},
"new_string": {
"type": "string",
@@ -19,9 +19,26 @@
"near_line": {
"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.",
"items": {
"type": "object",
"properties": {
"old_string": { "type": "string" },
"new_string": { "type": "string" },
"near_line": { "type": "integer" }
},
"required": ["old_string", "new_string"]
}
}
},
"required": ["path", "old_string", "new_string"]
"required": ["path"]
},
"task_agent": true,
"primary_key": "old_string"
+1 -1
View 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": {
+5 -1
View File
@@ -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 -1
View File
@@ -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": {
+5
View File
@@ -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"]
+288 -26
View File
@@ -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";
@@ -378,20 +381,36 @@ Pane.prototype.handleEvent = function (evt) {
clearTimeout(this._forceTimeout);
this._forceTimeout = null;
}
// Render final markdown for the assistant message (existing code).
// Note: renderMarkdown is the project's sanitizing markdown renderer.
// Finalize the current streaming segment's markdown. This fires
// per-segment (between tool calls), NOT per-turn. Busy state is
// managed by state_change events instead.
if (this.currentAssistantEl && this.contentBuffer) {
this.currentAssistantEl.innerHTML = renderMarkdown(this.contentBuffer); // sanitized by renderMarkdown
this.currentAssistantEl.innerHTML = renderMarkdown(this.contentBuffer); // sanitized by renderMarkdown — see renderer.js
postRenderMarkdown(this.currentAssistantEl);
}
this.currentAssistantEl = null;
this.currentReasoningEl = null;
this.contentBuffer = "";
this.setBusy(false);
this.inputEl.focus();
this.scrollToBottom(true);
break;
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();
}
} else if (
evt.state === "thinking" ||
evt.state === "running" ||
evt.state === "attention"
) {
this.setBusy(true);
}
break;
case "tool_info":
this.showInlineToolBlock(evt.items, true);
break;
@@ -438,8 +457,10 @@ Pane.prototype.handleEvent = function (evt) {
break;
case "error":
// Show the error but don't change busy state — state_change
// handles idle/error transitions. on_error fires for non-terminal
// errors (tool parse failures, truncation) mid-turn too.
this.addErrorMessage(evt.message);
this.setBusy(false);
break;
case "busy_error":
@@ -454,8 +475,8 @@ Pane.prototype.handleEvent = function (evt) {
case "cancelled":
// Cancel requested but worker thread may still be finishing.
// Show "Cancelling..." state; stream_end will transition to ready.
// If stream_end already arrived (busy is false), the cancel is
// Show "Cancelling..." state; state_change will transition to ready.
// If state_change already arrived (busy is false), the cancel is
// already handled — don't re-enter the cancelling state.
if (!this.busy) break;
// Clear any prior timeouts first (duplicate cancelled events).
@@ -470,7 +491,7 @@ Pane.prototype.handleEvent = function (evt) {
this.scrollToBottom(true);
// After 2s, offer "Force Stop" for a harder cancel that abandons
// the stuck worker thread. Safety timeout at 10s auto-recovers
// if stream_end never arrives (connection drop).
// if state_change never arrives (connection drop).
var self = this;
this._cancelTimeout = setTimeout(function () {
if (self.busy) {
@@ -510,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":
@@ -537,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 = "";
@@ -619,11 +853,7 @@ Pane.prototype.replayHistory = function (messages) {
msg.denied ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
var isToolError =
msg.is_error ||
/^Error[:. \n]|^Command timed out|^Search timed out|^Unknown tool:|^JSON parse error:|^MCP prompt /.test(
stripped,
);
var isToolError = !!msg.is_error;
if (stripped && !isDenied) {
var out = document.createElement("div");
out.className =
@@ -647,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,
@@ -924,19 +1170,13 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
var stripped = stripAnsi(output || "").trim();
if (!stripped) return;
// Detect error by flag or content prefix
var hasError =
isError ||
/^Error[:. \n]|^Command timed out|^Search timed out|^Unknown tool:|^JSON parse error:|^MCP prompt /.test(
stripped,
);
// Style tool output as error when indicated by isError flag
var out = document.createElement("div");
out.className = "tool-output" + (hasError ? " tool-output-error" : "");
out.className = "tool-output" + (isError ? " tool-output-error" : "");
out.textContent = stripped;
// Mark the parent approval block as errored
if (hasError) {
if (isError) {
var parentBlock = target.closest(".approval-block");
if (parentBlock && !parentBlock.classList.contains("denied")) {
parentBlock.classList.add("error");
@@ -2139,10 +2379,32 @@ function showNewWsModal() {
if (e.target === overlay) hideNewWsModal();
};
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
var curModel = document.getElementById("model-name").textContent;
var modelInput = document.getElementById("new-ws-model");
modelInput.placeholder = curModel || "Default model";
modelInput.value = "";
modelSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = curModel
? "Default (" + curModel + ")"
: "Default model";
modelSelect.appendChild(defaultOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
})
.then(function (data) {
(data.models || []).forEach(function (m) {
var opt = document.createElement("option");
opt.value = m.alias;
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
});
})
.catch(function () {
/* ignore — default model still works */
});
var tplSelect = document.getElementById("new-ws-skill");
tplSelect.innerHTML = '<option value="">Use defaults</option>';
+1 -1
View File
@@ -83,7 +83,7 @@
<label for="new-ws-name">Name <span class="nws-hint">optional</span></label>
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
<label for="new-ws-model">Model <span class="nws-hint">optional</span></label>
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
<select id="new-ws-model"><option value="">Default model</option></select>
<label for="new-ws-skill">Skill <span class="nws-hint">optional</span></label>
<select id="new-ws-skill"><option value="">Use defaults</option></select>
<div id="new-ws-buttons">
+180 -2
View File
@@ -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
========================================================================== */
@@ -769,7 +947,7 @@ body { position: static; }
0%, 100% { border-left-color: var(--accent); }
50% { border-left-color: var(--accent-dim); }
}
.tool-output.collapsed { max-height: 150px; position: relative; }
.tool-output.collapsed { max-height: 150px; position: relative; overflow: hidden; }
.tool-output.collapsed::after {
content: 'click to expand';
position: absolute;
@@ -912,7 +1090,7 @@ body { position: static; }
max-height: 300px;
overflow-y: auto;
}
.plan-inline-body.collapsed { max-height: 150px; position: relative; }
.plan-inline-body.collapsed { max-height: 150px; position: relative; overflow: hidden; }
.plan-inline-body.collapsed::after {
content: 'click to expand';
position: absolute;
Generated
+188 -3
View File
@@ -1281,6 +1281,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
@@ -1446,6 +1455,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "numpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
{ url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
{ url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
{ url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
{ url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
{ url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
{ url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
{ url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
{ url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
{ url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
{ url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
{ url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
{ url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
]
[[package]]
name = "openai"
version = "2.30.0"
@@ -2161,6 +2249,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -2267,6 +2426,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
@@ -2335,7 +2506,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.9.1"
version = "0.9.4"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
@@ -2361,8 +2532,12 @@ all = [
{ name = "ddgs" },
{ name = "discord-py" },
{ name = "lacme" },
{ name = "numpy" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pytest" },
{ name = "redis" },
{ name = "scipy" },
{ name = "sympy" },
]
anthropic = [
{ name = "anthropic" },
@@ -2389,6 +2564,12 @@ mq = [
postgres = [
{ name = "psycopg", extra = ["binary"] },
]
sandbox = [
{ name = "numpy" },
{ name = "pytest" },
{ name = "scipy" },
{ name = "sympy" },
]
sim = [
{ name = "redis" },
]
@@ -2415,10 +2596,12 @@ requires-dist = [
{ name = "lacme", marker = "extra == 'tls'", specifier = ">=1.0.4" },
{ name = "mcp", specifier = ">=1.6" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
{ name = "numpy", marker = "extra == 'sandbox'", specifier = ">=2.0" },
{ name = "openai", specifier = ">=2.24" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pyjwt", specifier = ">=2.8" },
{ name = "pytest", marker = "extra == 'sandbox'", specifier = ">=9.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0" },
{ name = "python-frontmatter", specifier = ">=1.0" },
@@ -2427,15 +2610,17 @@ requires-dist = [
{ name = "redis", marker = "extra == 'mq'", specifier = ">=7.2" },
{ name = "redis", marker = "extra == 'sim'", specifier = ">=7.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" },
{ name = "scipy", marker = "extra == 'sandbox'", specifier = ">=1.14" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "sse-starlette", specifier = ">=2.0" },
{ name = "starlette", specifier = ">=0.45" },
{ name = "structlog", specifier = ">=24.1" },
{ name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg", "tls"], marker = "extra == 'all'" },
{ name = "sympy", marker = "extra == 'sandbox'", specifier = ">=1.13" },
{ name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg", "tls", "sandbox"], marker = "extra == 'all'" },
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6" },
{ name = "uvicorn", specifier = ">=0.34" },
]
provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "tls", "all"]
provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "tls", "sandbox", "all"]
[[package]]
name = "types-cffi"