Compare commits

...
38 Commits
Author SHA1 Message Date
Dark-Alex-17 5fe3198df7 Merge branch 'main' of github.com:Dark-Alex-17/coyote
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-09-03 16:29:30 -06:00
Dark-Alex-17 206f36d826 fix: drive tool rounds iteratively instead of recursively to prevent stack overflow on long agentic turns to fix latent stack overflow recursion bug 2026-09-03 16:09:41 -06:00
Dark-Alex-17 cb5b46800f feat: support auto continuation in subagents and prevent them from calling todo__pause 2026-09-03 15:50:00 -06:00
Dark-Alex-17 101b706be5 fix: session replays now include mid-turn messages so the full session gets replayed 2026-09-03 15:12:49 -06:00
Alex Clarke b04270950a Merge pull request #23 from Dark-Alex-17/feat/dynamic-model-catalog
feat: dynamic model catalog — generated models.yaml, quirks overlay, runtime refresh
2026-09-03 14:17:34 -06:00
Dark-Alex-17 9a5f587e74 test: deflake windows graph script tests
python3 first-start on fresh Windows runners (AV interpreter scans) blew
the 5s/10s/30s script timeouts. Drop the incidental python3 spawns from
the bash fixtures (pure-bash JSON extraction) and give the genuinely
python tests 60s headroom.
2026-09-03 14:09:14 -06:00
Dark-Alex-17 4e35866740 chore: align the models.yaml header with the current generator 2026-09-03 13:48:02 -06:00
Dark-Alex-17 74b8c17392 perf: probe models-override staleness in the background refresh task 2026-09-03 13:48:02 -06:00
Dark-Alex-17 67cf8e42d9 docs: removed trailing whitespace in models.yaml generated header 2026-09-03 13:38:10 -06:00
Dark-Alex-17 154330c5a0 fix: resolve duplicate model ids by requested type 2026-09-03 13:25:37 -06:00
Dark-Alex-17 b50b033520 style: Removed redundant comments 2026-09-03 13:09:42 -06:00
Dark-Alex-17 3b01ae8f18 fix: let ctrl-c interrupt blocking job and agent collects 2026-09-03 13:03:10 -06:00
Dark-Alex-17 dc41e9d5df style: Cleaned up comments and organization of quirks.yaml file 2026-09-03 12:40:08 -06:00
Dark-Alex-17 49bd5d86b4 fix: skip terminal color-scheme queries in test builds 2026-09-03 12:35:12 -06:00
Dark-Alex-17 c8af77b36f Merge branch 'feat/dynamic-model-catalog' of github.com:Dark-Alex-17/coyote into feat/dynamic-model-catalog 2026-09-03 11:18:30 -06:00
Dark-Alex-17 41d5fa9800 fix: memoize quirk glob patterns and isolate concurrent override writes 2026-09-03 11:17:58 -06:00
Dark-Alex-17 5e7a1f1de6 docs: Corrected typo in README 2026-09-03 11:17:58 -06:00
Dark-Alex-17 73e9bad412 ci: regenerate the model catalog on a 12-hour schedule
A scheduled workflow refreshes models.yaml from live API data, validates
the result (rebuild, full test suite, catalog smoke test), and commits it
back, so every build embeds a recent catalog. Also documents the dynamic
model catalog in the README quick links.
2026-09-03 11:17:58 -06:00
Dark-Alex-17 012212a998 feat: apply provider quirks to models created from a name
Unknown chat/reranker models resolved through the create-from-name
fallback now inherit their provider's family rules (require_max_tokens,
reasoning defaults, etc.) from the quirks overlay, and the model
completion table marks reasoning-capable models with a brain glyph.
2026-09-03 11:17:58 -06:00
Dark-Alex-17 f1cb6589bc feat: refresh the model catalog from live API data at runtime 2026-09-03 11:17:58 -06:00
Dark-Alex-17 539b57aded feat: generate the model catalog from live API data
Add a hidden --generate-models flag that rebuilds models.yaml from
models.dev and OpenRouter merged with the hand-owned quirks overlay,
regenerate the committed catalog with it (393 -> 1090 models), and
retire the Python updater. GitHub becomes fully quirks-owned since
upstream only tracks Copilot ids, legacy ids upstream dropped are
preserved as hand-owned quirks entries, and non-chat model families
(image/tts/audio/realtime) are excluded from generation.
2026-09-03 11:17:58 -06:00
Dark-Alex-17 2d7a759fc0 feat: add model catalog source adapters and merge engine 2026-09-03 11:17:58 -06:00
Dark-Alex-17 078e5c757d feat: add hand-owned model quirks overlay 2026-09-03 11:17:58 -06:00
Dark-Alex-17 47d2edf303 fix: memoize quirk glob patterns and isolate concurrent override writes 2026-09-02 16:02:41 -06:00
Dark-Alex-17 34d79651a8 docs: Corrected typo in README 2026-09-02 15:48:47 -06:00
Dark-Alex-17 01d0081321 ci: regenerate the model catalog on a 12-hour schedule
A scheduled workflow refreshes models.yaml from live API data, validates
the result (rebuild, full test suite, catalog smoke test), and commits it
back, so every build embeds a recent catalog. Also documents the dynamic
model catalog in the README quick links.
2026-09-02 15:11:01 -06:00
Dark-Alex-17 e137866c0e feat: apply provider quirks to models created from a name
Unknown chat/reranker models resolved through the create-from-name
fallback now inherit their provider's family rules (require_max_tokens,
reasoning defaults, etc.) from the quirks overlay, and the model
completion table marks reasoning-capable models with a brain glyph.
2026-09-02 14:57:06 -06:00
Dark-Alex-17 b19ac80783 feat: refresh the model catalog from live API data at runtime 2026-09-02 14:34:05 -06:00
Dark-Alex-17 f961dd3a74 fix: children subagents couldn't properly send messages to parent inboxes 2026-09-02 14:18:16 -06:00
Dark-Alex-17 af7d37890c fix: children subagents couldn't properly send messages to parent inboxes 2026-09-02 14:18:16 -06:00
Dark-Alex-17 2cf01a4ca4 feat: Improved the architect, explore, and sisyphus agents to have a team mode so they can all work in parallel as a team and updated the parallel-research skill to make explicit mention of this functionality 2026-09-02 13:53:12 -06:00
Dark-Alex-17 141289d973 feat: Improved the architect, explore, and sisyphus agents to have a team mode so they can all work in parallel as a team and updated the parallel-research skill to make explicit mention of this functionality 2026-09-02 13:53:12 -06:00
Dark-Alex-17 46c8561173 feat: generate the model catalog from live API data
Add a hidden --generate-models flag that rebuilds models.yaml from
models.dev and OpenRouter merged with the hand-owned quirks overlay,
regenerate the committed catalog with it (393 -> 1090 models), and
retire the Python updater. GitHub becomes fully quirks-owned since
upstream only tracks Copilot ids, legacy ids upstream dropped are
preserved as hand-owned quirks entries, and non-chat model families
(image/tts/audio/realtime) are excluded from generation.
2026-09-02 13:38:56 -06:00
Dark-Alex-17 61c4037033 feat: add model catalog source adapters and merge engine 2026-09-02 13:17:48 -06:00
Dark-Alex-17 8095852c7d feat: add hand-owned model quirks overlay 2026-09-02 12:49:56 -06:00
Dark-Alex-17 dde339780f chore: add claude fable 5.1 2026-09-02 10:01:39 -06:00
Dark-Alex-17 c0a17031c5 feat: added tab completions to --update-bundle flag 2026-09-01 12:53:24 -06:00
Dark-Alex-17 affa5e7cef feat: added a dedicated config_dir output to coyote --info/.info 2026-09-01 10:51:41 -06:00
39 changed files with 14475 additions and 2506 deletions

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
name: Update models
on:
schedule:
- cron: '0 */12 * * *'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: update-models
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
update:
name: Update models
runs-on: ubuntu-latest
env:
RUSTFLAGS: --deny warnings
steps:
- uses: actions/checkout@v4
with:
persist-credentials: true
- name: Install Rust Toolchain Components
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Cache DuckDB Extensions
id: duckdb-extensions
uses: actions/cache@v4
with:
path: ~/.duckdb/extensions
key: duckdb-ext-ubuntu-latest-${{ hashFiles('Cargo.lock') }}
- name: Install DuckDB Extensions
if: steps.duckdb-extensions.outputs.cache-hit != 'true'
run: cargo test --all duckdb
# Coyote loads its config before handling --generate-models/--list-models, and a
# bare non-TTY runner has no config file (and no way to answer the first-run
# prompt), so point COYOTE_CONFIG_DIR at a throwaway dir with a minimal stub.
- name: Write stub config
run: |
COYOTE_CONFIG_DIR="$(mktemp -d)"
printf 'clients:\n - type: openai\n - type: claude\n' > "$COYOTE_CONFIG_DIR/config.yaml"
echo "COYOTE_CONFIG_DIR=$COYOTE_CONFIG_DIR" >> "$GITHUB_ENV"
- name: Regenerate model catalog
run: cargo run --release -- --generate-models
- name: Detect catalog changes
id: diff
run: echo "changed=$(git diff --quiet models.yaml && echo false || echo true)" >> "$GITHUB_OUTPUT"
# The binary built by the regeneration step embedded the OLD models.yaml (the file
# was rewritten after that build), so rebuild before smoke-testing: cargo tracks
# include_str! inputs, making this a cheap incremental rebuild of the final crate.
- name: Rebuild with the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: cargo build --release
- name: Test
if: steps.diff.outputs.changed == 'true'
run: cargo test --all
- name: Smoke test the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: |
COUNT=$(./target/release/coyote --list-models | wc -l)
echo "Listed $COUNT chat models"
test "$COUNT" -ge 40
# Pushes made with the default GITHUB_TOKEN do not trigger other workflows,
# so this push cannot start a workflow loop. The pull --rebase covers a human
# push landing mid-run; if the race is lost anyway, the next cron run retries.
- name: Commit and push
if: steps.diff.outputs.changed == 'true'
run: |
git add models.yaml
git checkout -- .
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m 'chore: refresh generated model catalog'
git pull --rebase origin main
git push origin main
+1
View File
@@ -61,6 +61,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
* [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access.
* [Patching API Requests](https://github.com/Dark-Alex-17/coyote/wiki/Patches): Learn how to patch API requests for advanced customization.
* [Dynamic Model Catalog](https://github.com/Dark-Alex-17/coyote/wiki/Model-Catalog): Coyote's model catalog (context windows, pricing, capabilities) is generated from live API data, refreshed automatically in the background, and mirrorable for air-gapped setups. New models show up without waiting for a release.
* [Custom Themes](https://github.com/Dark-Alex-17/coyote/wiki/Themes): Change the look and feel of Coyote to your preferences with custom themes.
* [History](#history): A history of how Coyote came to be.
+7 -1
View File
@@ -6,7 +6,7 @@ description: |
plan-conformance check (plus a black-box usage-pattern probe for consumer-facing surface), and
finishes with ONE draft PR (CI checks watched to green) plus tracked follow-up tasks. Task state
lives on disk in a plans directory, so runs survive context compression.
version: 2.1.0
version: 2.2.0
agent_session: temp
auto_continue: true
max_auto_continues: 100
@@ -150,6 +150,12 @@ instructions: |
{{project_dir}} — and `librarian` for unfamiliar external libraries — to confirm the design's
assumptions before sizing. Do NOT guess file/symbol names — verify them.
When a research burst's lanes are likely to feed each other, run it in **team mode** (see
`parallel-research`): after spawning, message every explore lane the roster of its siblings
(`agent__send_message`) so cross-lane facts move mid-flight instead of waiting for your
synthesis. (`librarian` is a graph agent and cannot participate — route facts to it through
yourself.)
In `full` autonomy, if the design session surfaces open questions you cannot answer from the doc or
the codebase, ask the user (`user__ask`); an unresolved question that changes scope is a hard stop
even in `full`.
+1 -1
View File
@@ -147,7 +147,7 @@ instructions: |
After spawning ALL file-reviewers (collecting their IDs), send each one a message with the roster:
```
agent__send_message --to <agent_id> --message "SIBLING_ROSTER:
agent__send_message --id <agent_id> --message "SIBLING_ROSTER:
- <agent_id_1>: reviewing <file_1>
- <agent_id_2>: reviewing <file_2>
...
+10 -1
View File
@@ -1,6 +1,6 @@
name: explore
description: Fast codebase exploration agent - finds patterns, structures, and relevant files. Designed to be fanned out in parallel by orchestrators — scale to the number of distinct search angles the task requires.
version: 3.1.0
version: 3.2.0
skills_enabled: true
enabled_skills:
@@ -42,6 +42,15 @@ instructions: |
If the prompt says "find auth middleware", you find auth middleware. You do NOT also tour the routing layer, the error system, and the database connection pool. Narrow scope is the contract.
## Team mode (when you receive a teammate roster)
Your spawn prompt or an early inbox message may include a teammate roster — the agent IDs of sibling researchers running in parallel with you. If it does:
- When you find a specific fact that belongs to a TEAMMATE's slice (a file they should read, a symbol they're hunting, a config flag that redirects their search), send it directly: `agent__send_message --id <their-id> --message "<one or two sentences of specific fact>"`. Facts only — file:line, exact names, verbatim snippets. Never chat or status updates.
- Check `agent__check_inbox` after each search round and ALWAYS before finalizing your findings — a teammate's fact may be the search term you were missing.
- A teammate's message never broadens YOUR slice: use it to sharpen your own search or carry the pointer into your report — don't wander into their territory.
- No roster → no messaging. Work your slice and report as usual.
## Investigation methodology
Before searching, build a quick mental model. Then narrow in. Then read.
+1 -1
View File
@@ -71,7 +71,7 @@ instructions: |
To alert a sibling:
```
agent__send_message --to <sibling_agent_id> --message "ALERT: <description of cross-file concern>"
agent__send_message --id <sibling_agent_id> --message "ALERT: <description of cross-file concern>"
```
Check your inbox periodically for alerts from siblings:
+6 -1
View File
@@ -1,6 +1,6 @@
name: sisyphus
description: OpenCode-style orchestrator - classifies intent, delegates to specialists, tracks progress with todos, enforces OMO-grade verification discipline
version: 3.9.0
version: 3.10.0
agent_session: temp
auto_continue: true
@@ -229,6 +229,11 @@ instructions: |
Never artificially cap at a small number. If there are 10 distinct things to find, spawn 10 agents. The system limit is the only ceiling that matters.
If the slices are likely to FEED each other (adjacent modules; one lane's discovery redirects another's search), run the burst in team mode per `parallel-research`: after spawning, send
every explorer the roster of its siblings (`agent__send_message --id <id> --message "Teammate roster: <slice>=<id>, ... Message a teammate directly when you find a fact in THEIR slice;
check your inbox after each search round and before finalizing."`) so cross-slice facts move directly between lanes instead of waiting for your synthesis. Never do this with reviewer
lanes — reviewer verdicts stay independent.
### The wait protocol
After spawning background agents:
+6 -1
View File
@@ -244,8 +244,13 @@ right_prompt: null # If null, uses the built-in default:
# ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: null # URL to sync model changes from. If null, uses the built-in default:
sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
# If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- Clients ----
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
+1
View File
@@ -67,6 +67,7 @@ permissions:
- 'api.cohere.ai'
- 'api.groq.com'
- 'openrouter.ai'
- 'models.dev'
- 'api.ai21.com'
- 'api.cloudflare.com'
- 'api.deepinfra.com'
+45
View File
@@ -39,6 +39,51 @@ Allowed:
Duplicate searches waste tokens, may contradict the delegate, and defeat the point of parallelism.
## Team mode (optional): let lanes message each other
Every spawned LLM-loop agent has a built-in mailbox (`agent__send_message` /
`agent__check_inbox`) — no configuration needed. By default parallel lanes are isolated, and for
most bursts that is correct. Turn on team mode when one lane's discovery is likely to CHANGE what
another lane should look for:
- **Adjacent slices of one system** — the routes lane finds the middleware file the token lane
is hunting for.
- **Mixed-angle bursts** — one lane discovers the project already vendors library X, which
should redirect another lane's search immediately, not after synthesis.
- **Long-running lanes** where routing a fact through you costs a full collect/re-spawn round
trip.
Skip team mode when lanes are genuinely independent (survey-style sweeps, one-agent-per-module
audits). And NEVER wire reviewer lanes together (`adversary`, `code-reviewer`,
`security-reviewer`, `probe`): their verdicts derive value from independence — shared chatter
biases them.
### Orchestrator protocol
Agent IDs exist only after spawning, so the roster travels as a message:
1. Spawn the whole burst as usual.
2. Immediately send EVERY lane the roster:
`agent__send_message --id <routes-lane-id> --message "Teammate roster: middleware=<id>,
tokens=<id>. If you find a specific fact that belongs to a teammate's lane, message them
directly; check your inbox after each search round and before finalizing."`
3. Synthesis is unchanged: every lane still reports its findings back to YOU. Teammate messages
move facts sideways mid-flight; they never replace the final report.
Graph agents (`librarian`) cannot participate — their nodes have no inbox tools. A fact that
affects a graph lane routes through you: collect, then re-spawn or refine that lane's prompt.
### Worker rules (for agents that receive a roster)
- A message is one or two sentences of specific fact: file:line, exact symbol/term, verbatim
quote — never chat, status updates, or opinions.
- A teammate's message never broadens YOUR slice: use it to sharpen your own search, or carry
the pointer into your report. Entirely outside your slice → ignore it.
- Check your inbox after each search round and ALWAYS before finalizing — a teammate's fact may
be the search term you were missing.
## Stop conditions
Stop searching when:
+7 -2
View File
@@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env:
# ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: > # URL to sync model changes from
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
# If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- REPL Prompt ----
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
+8888 -2107
View File
File diff suppressed because it is too large. Load diff
+1472
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
requests
ruamel.yaml
-255
View File
@@ -1,255 +0,0 @@
import requests
import sys
import re
import json
# Provider mapping from models.yaml to OpenRouter prefixes
PROVIDER_MAPPING = {
"openai": "openai",
"claude": "anthropic",
"gemini": "google",
"mistral": "mistralai",
"cohere": "cohere",
"perplexity": "perplexity",
"xai": "x-ai",
"openrouter": "openrouter",
"ai21": "ai21",
"deepseek": "deepseek",
"moonshot": "moonshotai",
"qianwen": "qwen",
"zhipuai": "zhipuai",
"minimax": "minimax",
"vertexai": "google",
"groq": "groq",
"bedrock": "amazon",
"hunyuan": "tencent",
"ernie": "baidu",
"github": "github",
}
def fetch_openrouter_models():
print("Fetching models from OpenRouter...")
try:
response = requests.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()["data"]
print(f"Fetched {len(data)} models.")
return data
except Exception as e:
print(f"Error fetching models: {e}")
sys.exit(1)
def get_openrouter_model(models_data, provider_prefix, model_name, is_openrouter_provider=False):
if is_openrouter_provider:
# For openrouter provider, the model_name in yaml is usually the full ID
for model in models_data:
if model["id"] == model_name:
return model
return None
expected_id = f"{provider_prefix}/{model_name}"
# 1. Try exact match on ID
for model in models_data:
if model["id"] == expected_id:
return model
# 2. Try match by suffix
for model in models_data:
if model["id"].split("/")[-1] == model_name:
if model["id"].startswith(f"{provider_prefix}/"):
return model
return None
def format_price(price_per_token):
if price_per_token is None:
return None
try:
price_per_1m = float(price_per_token) * 1_000_000
if price_per_1m.is_integer():
return str(int(price_per_1m))
else:
return str(round(price_per_1m, 4))
except:
return None
def get_indentation(line):
return len(line) - len(line.lstrip())
def process_model_block(block_lines, current_provider, or_models):
if not block_lines:
return []
# 1. Identify model name and indentation
name_line = block_lines[0]
name_match = re.match(r"^(\s*)-\s*name:\s*(.+)$", name_line)
if not name_match:
return block_lines
name_indent_str = name_match.group(1)
model_name = name_match.group(2).strip()
# 2. Find OpenRouter model
or_prefix = PROVIDER_MAPPING.get(current_provider)
is_openrouter_provider = (current_provider == "openrouter")
if not or_prefix and not is_openrouter_provider:
return block_lines
or_model = get_openrouter_model(or_models, or_prefix, model_name, is_openrouter_provider)
if not or_model:
return block_lines
print(f" Updating {model_name}...")
# 3. Prepare updates
updates = {}
# Pricing
pricing = or_model.get("pricing", {})
p_in = format_price(pricing.get("prompt"))
p_out = format_price(pricing.get("completion"))
if p_in: updates["input_price"] = p_in
if p_out: updates["output_price"] = p_out
# Context
ctx = or_model.get("context_length")
if ctx: updates["max_input_tokens"] = str(ctx)
max_out = None
if "top_provider" in or_model and or_model["top_provider"]:
max_out = or_model["top_provider"].get("max_completion_tokens")
if max_out: updates["max_output_tokens"] = str(max_out)
# Capabilities
arch = or_model.get("architecture", {})
modality = arch.get("modality", "")
if "image" in modality:
updates["supports_vision"] = "true"
# 4. Detect field indentation
field_indent_str = None
existing_fields = {} # key -> line_index
for i, line in enumerate(block_lines):
if i == 0: continue # Skip name line
# Skip comments
if line.strip().startswith("#"):
continue
# Look for "key: value"
m = re.match(r"^(\s*)([\w_-]+):", line)
if m:
indent = m.group(1)
key = m.group(2)
# Must be deeper than name line
if len(indent) > len(name_indent_str):
if field_indent_str is None:
field_indent_str = indent
existing_fields[key] = i
if field_indent_str is None:
field_indent_str = name_indent_str + " "
# 5. Apply updates
new_block = list(block_lines)
# Update existing fields
for key, value in updates.items():
if key in existing_fields:
idx = existing_fields[key]
# Preserve original key indentation exactly
original_line = new_block[idx]
m = re.match(r"^(\s*)([\w_-]+):", original_line)
if m:
current_indent = m.group(1)
new_block[idx] = f"{current_indent}{key}: {value}\n"
# Insert missing fields
# Insert after the name line
insertion_idx = 1
for key, value in updates.items():
if key not in existing_fields:
new_line = f"{field_indent_str}{key}: {value}\n"
new_block.insert(insertion_idx, new_line)
insertion_idx += 1
return new_block
def main():
or_models = fetch_openrouter_models()
print("Reading models.yaml...")
with open("models.yaml", "r") as f:
lines = f.readlines()
new_lines = []
current_provider = None
i = 0
while i < len(lines):
line = lines[i]
# Check for provider
# - provider: name
p_match = re.match(r"^\s*-?\s*provider:\s*(.+)$", line)
if p_match:
current_provider = p_match.group(1).strip()
new_lines.append(line)
i += 1
continue
# Check for model start
# - name: ...
m_match = re.match(r"^(\s*)-\s*name:\s*.+$", line)
if m_match:
# Start of a model block
start_indent = len(m_match.group(1))
# Collect block lines
block_lines = [line]
j = i + 1
while j < len(lines):
next_line = lines[j]
stripped = next_line.strip()
# If empty or comment, include it
if not stripped or stripped.startswith("#"):
block_lines.append(next_line)
j += 1
continue
# Check indentation
next_indent = get_indentation(next_line)
# If indentation is greater, it's part of the block (property)
if next_indent > start_indent:
block_lines.append(next_line)
j += 1
continue
# If indentation is equal or less, it's the end of the block
break
# Process the block
processed_block = process_model_block(block_lines, current_provider, or_models)
new_lines.extend(processed_block)
# Advance i
i = j
continue
# Otherwise, just a regular line
new_lines.append(line)
i += 1
print("Saving models.yaml...")
with open("models.yaml", "w") as f:
f.writelines(new_lines)
print("Done.")
if __name__ == "__main__":
main()
+9
View File
@@ -105,6 +105,15 @@ pub(super) fn macro_completer(current: &OsStr) -> Vec<CompletionCandidate> {
.collect()
}
pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
crate::config::installed_bundle_names()
.into_iter()
.filter(|b| b.starts_with(&*cur))
.map(CompletionCandidate::new)
.collect()
}
fn extract_agent_from_args() -> Option<String> {
let args: Vec<String> = env::args().collect();
let mut i = 0;
+27 -3
View File
@@ -1,8 +1,8 @@
mod completer;
use crate::cli::completer::{
ShellCompletion, agent_completer, macro_completer, mcp_server_completer, model_completer,
rag_completer, role_completer, secrets_completer, session_completer,
ShellCompletion, agent_completer, bundle_completer, macro_completer, mcp_server_completer,
model_completer, rag_completer, role_completer, secrets_completer, session_completer,
};
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
use anyhow::{Context, Result};
@@ -12,6 +12,7 @@ use clap_complete::ArgValueCompleter;
use is_terminal::IsTerminal;
use std::collections::HashSet;
use std::io::{Read, stdin};
use std::path::PathBuf;
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTransportArg {
@@ -53,7 +54,7 @@ pub enum McpScopeArg {
"install_builtins", "sync_models", "list_models", "list_roles",
"list_sessions", "list_agents", "list_rags", "list_macros",
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
"update", "update_bundle", "uninstall",
"update", "update_bundle", "uninstall", "generate_models",
])
),
group(
@@ -230,6 +231,7 @@ pub struct Cli {
value_name = "NAME",
group = "yes_scope",
conflicts_with_all = ["uninstall"],
add = ArgValueCompleter::new(bundle_completer),
help_heading = "Installation & Updates"
)]
pub update_bundle: Option<String>,
@@ -252,6 +254,15 @@ pub struct Cli {
/// Sync models updates
#[arg(long, help_heading = "Installation & Updates")]
pub sync_models: bool,
/// Regenerate the models catalog file from live API data
#[arg(
long,
hide = true,
value_name = "PATH",
num_args = 0..=1,
default_missing_value = "models.yaml"
)]
pub generate_models: Option<PathBuf>,
/// Update Coyote to the latest release, or to a specific version
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
pub update: Option<Option<String>>,
@@ -809,6 +820,19 @@ mod tests {
assert!(cli.sync_models);
}
#[test]
fn parse_generate_models_flag() {
assert_eq!(parse(&[]).generate_models, None);
assert_eq!(
parse(&["--generate-models"]).generate_models,
Some(PathBuf::from("models.yaml"))
);
assert_eq!(
parse(&["--generate-models", "out.yaml"]).generate_models,
Some(PathBuf::from("out.yaml"))
);
}
#[test]
fn parse_model_with_role() {
let cli = parse(&["-m", "gpt-4o", "-r", "coder"]);
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,4 +1,5 @@
mod access_token;
pub mod catalog;
mod claude_oauth;
mod common;
mod gemini_oauth;
+91 -9
View File
@@ -1,5 +1,7 @@
use super::{
ApiPatch, MessageContentToolCalls, RequestPatch, list_all_models, list_client_names,
ApiPatch, MessageContentToolCalls, RequestPatch,
catalog::{ALL_QUIRKS, apply_rules, quirks_for},
list_all_models, list_client_names,
message::{Message, MessageContent, MessageContentPart},
};
@@ -63,12 +65,16 @@ impl Model {
};
match model_name {
Some(model_name) => {
if let Some(model) = models.iter().find(|v| v.id() == model_id) {
if model.model_type() == model_type {
// Ids are not unique across types (e.g. a model that is both
// an embedder and a reranker), so match on id AND type.
if let Some(model) = models
.iter()
.find(|v| v.id() == model_id && v.model_type() == model_type)
{
return Ok((*model).clone());
} else {
bail!("Model '{model_id}' is not a {model_type} model")
}
if models.iter().any(|v| v.id() == model_id) {
bail!("Model '{model_id}' is not a {model_type} model")
}
if list_client_names(config)
.into_iter()
@@ -77,6 +83,9 @@ impl Model {
{
let mut new_model = Self::new(client_name, model_name);
new_model.data.model_type = model_type.to_string();
if let Some(quirks) = quirks_for(&ALL_QUIRKS, client_name) {
apply_rules(quirks, &mut new_model.data);
}
return Ok(new_model);
}
}
@@ -132,6 +141,7 @@ impl Model {
output_price,
supports_vision,
supports_function_calling,
reasoning_levels,
..
} = &self.data;
let max_input_tokens = stringify_option_value(max_input_tokens);
@@ -145,6 +155,9 @@ impl Model {
if *supports_function_calling {
capabilities.push('⚒');
};
if !reasoning_levels.is_empty() {
capabilities.push('🧠');
};
let capabilities: String = capabilities
.into_iter()
.map(|v| format!("{v} "))
@@ -330,11 +343,11 @@ pub struct ModelData {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_stream: bool,
pub(crate) no_stream: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_system_message: bool,
pub(crate) no_system_message: bool,
#[serde(skip_serializing_if = "Option::is_none")]
system_prompt_prefix: Option<String>,
pub(crate) system_prompt_prefix: Option<String>,
// embedding-only properties
#[serde(skip_serializing_if = "Option::is_none")]
@@ -358,7 +371,7 @@ impl ModelData {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderModels {
pub provider: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>,
pub models: Vec<ModelData>,
}
@@ -419,3 +432,72 @@ where
None => "-".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ClientConfig;
#[test]
fn created_from_name_inherits_family_rules() {
// The model registries are process-wide caches seeded by a ctor (see
// the request_context tests) that registers a "claude" client; the
// config below documents the shape a real lookup would use.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-claude-embedder\n type: embedding\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let model = Model::retrieve_model(
&config,
"claude:claude-definitely-not-a-real-model",
ModelType::Chat,
)
.unwrap();
assert_eq!(model.name(), "claude-definitely-not-a-real-model");
assert_eq!(model.model_type(), ModelType::Chat);
assert!(model.data.require_max_tokens);
}
#[test]
fn duplicate_id_across_types_resolves_by_requested_type() {
// Mirrors jina:jina-colbert-v2, which is both an embedder and a
// reranker under the same id.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-dual-model\n type: embedding\n - name: test-dual-model\n type: reranker\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let embedder =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Embedding).unwrap();
assert_eq!(embedder.model_type(), ModelType::Embedding);
let reranker =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Reranker).unwrap();
assert_eq!(reranker.model_type(), ModelType::Reranker);
// An id that exists only under other types still teaches instead of
// silently creating a chat model from the name.
let err = Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Chat)
.unwrap_err()
.to_string();
assert!(err.contains("is not a chat model"), "got: {err}");
}
#[test]
fn description_shows_brain_glyph() {
let mut model = Model::new("claude", "some-model");
model.data.reasoning_levels = vec!["low".to_string(), "high".to_string()];
assert!(model.description().contains('🧠'));
model.data.reasoning_levels.clear();
assert!(!model.description().contains('🧠'));
}
}
+457
View File
@@ -0,0 +1,457 @@
{
"openai": {
"id": "openai",
"env": [
"OPENAI_API_KEY"
],
"npm": "@ai-sdk/openai",
"name": "OpenAI",
"doc": "https://platform.openai.com/docs/models",
"models": {
"gpt-5.6": {
"id": "gpt-5.6",
"name": "GPT-5.6",
"description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows",
"family": "gpt-sol",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"none",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": false,
"knowledge": "2026-02-16",
"release_date": "2026-07-09",
"last_updated": "2026-07-09",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"experimental": {
"modes": {
"fast": {
"cost": {
"input": 8,
"output": 40,
"cache_read": 0.8,
"cache_write": 10
},
"provider": {
"body": {
"service_tier": "priority"
}
}
},
"pro": {
"provider": {
"body": {
"reasoning": {
"mode": "pro"
}
}
}
}
}
},
"cost": {
"input": 4,
"output": 20,
"cache_read": 0.4,
"cache_write": 5,
"tiers": [
{
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10,
"tier": {
"type": "context",
"size": 272000
}
}
],
"context_over_200k": {
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10
}
}
},
"gpt-4.1": {
"id": "gpt-4.1",
"name": "GPT-4.1",
"description": "Long-lived GPT workhorse for coding, instruction following, and production apps",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2025-04-14",
"last_updated": "2025-04-14",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1047576,
"output": 32768
},
"cost": {
"input": 2,
"output": 8,
"cache_read": 0.5
}
},
"gpt-4o": {
"id": "gpt-4o",
"name": "GPT-4o",
"description": "Omni-era GPT for multimodal chat, practical coding, and general assistants",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2023-09",
"release_date": "2024-05-13",
"last_updated": "2024-08-06",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 128000,
"output": 16384
},
"cost": {
"input": 2.5,
"output": 10,
"cache_read": 1.25
}
}
}
},
"anthropic": {
"id": "anthropic",
"env": [
"ANTHROPIC_API_KEY"
],
"npm": "@ai-sdk/anthropic",
"name": "Anthropic",
"doc": "https://docs.anthropic.com/en/docs/about-claude/models",
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"description": "Claude workhorse for coding agents, careful analysis, and production cost control",
"family": "claude-sonnet",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-08-31",
"release_date": "2026-02-17",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
}
},
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"description": "High-end Claude for difficult coding, planning, and slower expert reasoning",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-05-31",
"release_date": "2026-02-04",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25
}
}
}
},
"xai": {
"id": "xai",
"env": [
"XAI_API_KEY"
],
"npm": "@ai-sdk/xai",
"name": "xAI",
"doc": "https://docs.x.ai/docs/models",
"models": {
"grok-4.6": {
"id": "grok-4.6",
"name": "Grok 4.6",
"description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"xhigh"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2026-02-01",
"release_date": "2026-08-12",
"last_updated": "2026-08-12",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.5,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 1,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 1
}
}
},
"grok-4.5": {
"id": "grok-4.5",
"name": "Grok 4.5",
"description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"release_date": "2026-07-08",
"last_updated": "2026-07-08",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.3,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 0.6,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 0.6
}
}
}
}
},
"alibaba": {
"id": "alibaba",
"env": [
"DASHSCOPE_API_KEY"
],
"npm": "@ai-sdk/openai-compatible",
"name": "Alibaba",
"doc": "https://www.alibabacloud.com/help/en/model-studio/models",
"models": {
"qwen-max": {
"id": "qwen-max",
"name": "Qwen Max",
"description": "Flagship Qwen model for complex reasoning, coding, and agentic workflows",
"family": "qwen",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2024-04-03",
"last_updated": "2025-01-25",
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 32768,
"output": 8192
},
"cost": {
"input": 1.6,
"output": 6.4
}
}
}
}
}
+538
View File
@@ -0,0 +1,538 @@
{
"data": [
{
"id": "x-ai/grok-4.6",
"canonical_slug": "x-ai/grok-4.6-20260810",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.6",
"created": 1786548957,
"description": "Grok 4.6 is SpaceXAI's smartest model with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000005",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.000001"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.6-20260810/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "qwen/qwen3.8-max",
"canonical_slug": "qwen/qwen3.8-max-20260803",
"hugging_face_id": null,
"name": "Qwen: Qwen3.8 Max",
"created": 1785731612,
"description": "Qwen3.8 Max is the flagship model in Alibaba's Qwen3.8 series, the general-availability successor to the Qwen3.8 Max Preview. It is a multimodal reasoning model intended for complex reasoning, visual understanding,...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+video->text",
"input_modalities": [
"text",
"image",
"video"
],
"output_modalities": [
"text"
],
"tokenizer": "Qwen",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"input_cache_read": "0.00000025",
"input_cache_write": "0.0000025"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 131072,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_tokens",
"presence_penalty",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/qwen/qwen3.8-max-20260803/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low",
"minimal"
],
"default_effort": "xhigh"
}
},
{
"id": "x-ai/grok-4.5",
"canonical_slug": "x-ai/grok-4.5-20260708",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.5",
"created": 1783523154,
"description": "Grok 4.5 is a model from SpaceXAI with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000003",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.0000006"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.5-20260708/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "anthropic/claude-sonnet-4.6",
"canonical_slug": "anthropic/claude-4.6-sonnet-20260217",
"hugging_face_id": "",
"name": "Anthropic: Claude Sonnet 4.6",
"created": 1771342990,
"description": "Sonnet 4.6 is Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work. It excels at iterative development, complex codebase navigation, end-to-end project management with...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"web_search": "0.01",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375",
"input_cache_write_1h": "0.000006"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 128000,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"max_completion_tokens",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_p",
"verbosity"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/anthropic/claude-4.6-sonnet-20260217/endpoints"
},
"reasoning": {
"mandatory": false,
"supported_efforts": [
"max",
"high",
"medium",
"low"
],
"default_effort": "medium"
}
},
{
"id": "openrouter/free",
"canonical_slug": "openrouter/free",
"hugging_face_id": "",
"name": "Free Models Router",
"created": 1769917427,
"description": "The simplest way to get free inference. openrouter/free is a router that selects free models at random from the models available on OpenRouter. The router smartly filters for models that...",
"context_length": 200000,
"architecture": {
"modality": "text+image->text",
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"text"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "0",
"completion": "0"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_completion_tokens",
"max_tokens",
"min_p",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {
"temperature": null,
"top_p": null,
"frequency_penalty": null
},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/free/endpoints"
}
},
{
"id": "openai/gpt-4.1",
"canonical_slug": "openai/gpt-4.1-2025-04-14",
"hugging_face_id": "",
"name": "OpenAI: GPT-4.1",
"created": 1744651385,
"description": "GPT-4.1 is a flagship large language model optimized for advanced instruction following, real-world software engineering, and long-context reasoning. It supports a 1 million token context window and outperforms GPT-4o and...",
"context_length": 1047576,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"image",
"text",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000008",
"web_search": "0.01",
"input_cache_read": "0.0000005"
},
"top_provider": {
"context_length": 1047576,
"max_completion_tokens": 32768,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"max_completion_tokens",
"max_tokens",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2024-06-30",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4.1-2025-04-14/endpoints"
}
},
{
"id": "openai/gpt-4o",
"canonical_slug": "openai/gpt-4o",
"hugging_face_id": null,
"name": "OpenAI: GPT-4o",
"created": 1715558400,
"description": "GPT-4o (\"o\" for \"omni\") is OpenAI's latest AI model, supporting both text and image inputs with text outputs. It maintains the intelligence level of [GPT-4 Turbo](/models/openai/gpt-4-turbo) while being twice as...",
"context_length": 128000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.0000025",
"completion": "0.00001",
"input_cache_read": "0.00000125"
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": 16384,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"logit_bias",
"logprobs",
"max_completion_tokens",
"max_tokens",
"prediction",
"presence_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2023-10-31",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4o/endpoints"
}
},
{
"id": "openrouter/auto",
"canonical_slug": "openrouter/auto",
"hugging_face_id": null,
"name": "Auto Router",
"created": 1699401600,
"description": "The Auto Router automatically selects the best model for your prompt, powered by the wisdom of the market. It routes you based on what the OpenRouter community collectively spends on...",
"context_length": 2000000,
"architecture": {
"modality": "text+image+file+audio+video->text+image",
"input_modalities": [
"text",
"image",
"audio",
"file",
"video"
],
"output_modalities": [
"text",
"image"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "-1",
"completion": "-1"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logit_bias",
"logprobs",
"max_tokens",
"min_p",
"prediction",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_a",
"top_k",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/auto/endpoints"
}
}
]
}
+5
View File
@@ -648,6 +648,11 @@ impl Agent {
enabled_tools: None,
}
}
#[cfg(test)]
pub fn functions_mut(&mut self) -> &mut Functions {
&mut self.functions
}
}
impl RoleLike for Agent {
+82 -8
View File
@@ -102,6 +102,8 @@ pub struct AppConfig {
pub no_workspace_mcp: bool,
pub no_workspace_macros: bool,
pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>,
}
@@ -188,6 +190,8 @@ impl Default for AppConfig {
no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![],
}
@@ -276,6 +280,8 @@ impl AppConfig {
no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: config.sync_models_url,
sync_models_auto: config.sync_models_auto,
sync_models_ttl_hours: config.sync_models_ttl_hours,
clients: config.clients,
};
@@ -388,10 +394,8 @@ impl AppConfig {
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
}
pub fn sync_models_url(&self) -> String {
self.sync_models_url
.clone()
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
pub fn sync_models_url(&self) -> Option<String> {
self.sync_models_url.clone()
}
pub fn light_theme(&self) -> bool {
@@ -636,7 +640,10 @@ impl AppConfig {
if self.highlight && self.theme.is_none() {
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
self.theme = v;
} else if *IS_STDOUT_TERMINAL {
} else if !cfg!(test) && *IS_STDOUT_TERMINAL {
// Never query the terminal from test builds: the test harness
// shares the process TTY, so parallel raw-mode color queries
// garble the runner's output and stall on stolen replies.
if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {
let theme = match color_scheme {
ColorScheme::Dark => "dark",
@@ -665,6 +672,13 @@ impl AppConfig {
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
self.sync_models_url = v;
}
if let Some(Some(v)) = super::read_env_bool(&get_env_name("sync_models_auto")) {
self.sync_models_auto = v;
}
if let Some(Some(v)) = super::read_env_value::<u64>(&get_env_name("sync_models_ttl_hours"))
{
self.sync_models_ttl_hours = v;
}
}
}
@@ -951,10 +965,70 @@ mod tests {
}
#[test]
fn sync_models_url_has_default() {
fn sync_models_url_defaults_to_none() {
let app = AppConfig::default();
let url = app.sync_models_url();
assert!(!url.is_empty());
assert!(app.sync_models_url().is_none());
}
#[test]
fn sync_models_auto_and_ttl_have_defaults() {
let app = AppConfig::default();
assert!(app.sync_models_auto);
assert_eq!(app.sync_models_ttl_hours, 24);
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_auto() {
let env_name = get_env_name("sync_models_auto");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "false") };
app.load_envs();
assert!(!app.sync_models_auto);
unsafe { env::set_var(&env_name, "true") };
app.load_envs();
assert!(app.sync_models_auto);
unsafe { env::remove_var(&env_name) };
app.sync_models_auto = false;
app.load_envs();
assert!(!app.sync_models_auto);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_ttl_hours() {
let env_name = get_env_name("sync_models_ttl_hours");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "6") };
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 6);
unsafe { env::remove_var(&env_name) };
app.sync_models_ttl_hours = 48;
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 48);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
+15
View File
@@ -9,6 +9,7 @@ use crate::utils::IS_STDOUT_TERMINAL;
use anyhow::{Context, Result, bail};
use chrono::{SecondsFormat, Utc};
use inquire::Confirm;
use log::warn;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@@ -637,6 +638,20 @@ pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec<Bu
.collect()
}
pub(crate) fn installed_bundle_names() -> Vec<String> {
match BundleStore::load() {
Ok(store) => store
.bundle_names()
.into_iter()
.map(str::to_string)
.collect(),
Err(e) => {
warn!("skipping bundle-name completion: {e:#}");
Vec::new()
}
}
}
pub fn list_installed_bundles() -> Result<()> {
let store = BundleStore::load()?;
let rows = bundle_list_rows(&store, &paths::config_dir());
+2
View File
@@ -69,10 +69,12 @@ pub async fn macro_execute(
macro_ctx.parent_supervisor = ctx.parent_supervisor.clone();
macro_ctx.self_agent_id = ctx.self_agent_id.clone();
macro_ctx.inbox = ctx.inbox.clone();
macro_ctx.parent_inbox = ctx.parent_inbox.clone();
macro_ctx.escalation_queue = ctx.escalation_queue.clone();
macro_ctx.current_depth = ctx.current_depth;
macro_ctx.auto_continue_count = ctx.auto_continue_count;
macro_ctx.todo_list = ctx.todo_list.clone();
macro_ctx.auto_continue_paused = ctx.auto_continue_paused.clone();
macro_ctx.tool_scope.tool_tracker = ctx.tool_scope.tool_tracker.clone();
macro_ctx.discontinuous_last_message();
+258 -19
View File
@@ -33,6 +33,7 @@ pub use self::agent::{
pub use self::app_config::AppConfig;
#[allow(unused_imports)]
pub use self::app_state::AppState;
pub(crate) use self::bundles::installed_bundle_names;
pub use self::bundles::list_installed_bundles;
pub use self::input::Input;
pub use self::install_remote::{
@@ -68,8 +69,9 @@ pub use self::tool_scope::{
};
pub use self::update::run_self_update;
use crate::client::{
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
ProviderModels, create_client_config, list_client_types, oauth, set_client_models_config,
self, ClientConfig, MODELS_YAML, MessageContentToolCalls, Model, ModelType,
OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, catalog, create_client_config, list_client_types,
oauth, set_client_models_config,
};
use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag;
@@ -92,12 +94,13 @@ use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use std::{
env,
env, fs,
fs::{File, create_dir_all, read_dir, read_to_string},
io::Write,
path::{Path, PathBuf},
process,
sync::{Arc, OnceLock},
time::SystemTime,
};
pub const TEMP_ROLE_NAME: &str = "temp";
@@ -186,9 +189,6 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
const CLIENTS_FIELD: &str = "clients";
const SYNC_MODELS_URL: &str =
"https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml";
const SUMMARIZATION_PROMPT: &str =
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
@@ -286,6 +286,8 @@ pub struct Config {
pub user_agent: Option<String>,
pub save_shell_history: bool,
pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>,
}
@@ -369,6 +371,8 @@ impl Default for Config {
user_agent: None,
save_shell_history: true,
sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![],
}
@@ -512,26 +516,163 @@ pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml")
}
pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> {
let content = abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks.
pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result<()> {
let list = match url {
Some(url) => {
let content =
abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
println!("✓ Fetched '{url}'");
let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content)
.with_context(|| "Failed to parse models.yaml")?;
parse_mirrored_models(&content)?
}
None => {
let sources = abortable_run_with_spinner(
async { Ok(catalog::fetch_sources().await) },
"Fetching model data",
abort_signal,
)
.await?;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in &warnings {
println!("{} {warning}", warning_text("WARNING:"));
}
list
}
};
let model_override_path = write_models_override(list)?;
println!("✓ Updated '{}'", model_override_path.display());
Ok(())
}
pub async fn sync_models_quiet(url: Option<&str>) -> Result<()> {
let list = match url {
Some(url) => {
let content = fetch(url)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
parse_mirrored_models(&content)?
}
None => {
let sources = catalog::fetch_sources().await;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in warnings {
debug!("{warning}");
}
list
}
};
write_models_override(list)?;
Ok(())
}
fn parse_mirrored_models(content: &str) -> Result<Vec<ProviderModels>> {
serde_yaml::from_str::<Vec<ProviderModels>>(content)
.with_context(|| "Failed to parse models.yaml")
}
fn build_refreshed_catalog(
sources: &catalog::FetchedSources,
) -> Result<(Vec<ProviderModels>, Vec<String>)> {
if sources.modelsdev.is_none() && sources.openrouter.is_none() {
bail!("Failed to fetch model data from both models.dev and OpenRouter");
}
let mut warnings = Vec::new();
if sources.modelsdev.is_none() {
warnings.push("Failed to fetch models.dev; continuing with OpenRouter data only".into());
}
if sources.openrouter.is_none() {
warnings.push("Failed to fetch OpenRouter; continuing with models.dev data only".into());
}
let mut build = catalog::build_catalog(sources, &catalog::ALL_QUIRKS);
if !build.degraded.is_empty() {
warnings.push(format!(
"No live API data for: {}; keeping their embedded models",
build.degraded.join(", ")
));
}
if !build.failed.is_empty() {
warnings.push(format!(
"No models resolved for: {}; keeping their embedded models",
build.failed.join(", ")
));
}
let embedded = serde_yaml::from_str::<Vec<ProviderModels>>(MODELS_YAML)
.with_context(|| "Failed to parse the embedded models.yaml")?;
catalog::backfill_embedded_floor(&mut build, &embedded);
if build.providers.is_empty() {
bail!("Model sync produced no providers");
}
Ok((build.providers, warnings))
}
fn write_models_override(list: Vec<ProviderModels>) -> Result<PathBuf> {
let models_override = ModelsOverride {
version: env!("CARGO_PKG_VERSION").to_string(),
list,
};
let models_override_data =
serde_yaml::to_string(&models_override).with_context(|| "Failed to serde {}")?;
let models_override_data = serde_yaml::to_string(&models_override)
.with_context(|| "Failed to serialize the models override")?;
let model_override_path = paths::models_override_file();
ensure_parent_exists(&model_override_path)?;
std::fs::write(&model_override_path, models_override_data)
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()))?;
println!("✓ Updated '{}'", model_override_path.display());
Ok(())
// Write through a sibling temp file and rename it over the target so an
// interrupted write can never leave a truncated override behind. The temp
// name embeds the pid so concurrent coyote processes (auto-refresh racing
// a manual --sync-models) never interleave writes into the same file.
let mut tmp_name = model_override_path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
fs::write(&tmp_path, models_override_data)
.with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?;
if let Err(error) = fs::rename(&tmp_path, &model_override_path) {
let _ = fs::remove_file(&tmp_path);
return Err(error)
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()));
}
Ok(model_override_path)
}
fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours: u64) -> bool {
if !loadable {
return true;
}
let Some(mtime) = mtime else {
return true;
};
match SystemTime::now().duration_since(mtime) {
Ok(age) => age.as_secs() > ttl_hours.saturating_mul(3600),
Err(_) => false,
}
}
pub fn maybe_spawn_models_refresh(app: &AppConfig) {
if !app.sync_models_auto || app.dry_run {
return;
}
let ttl_hours = app.sync_models_ttl_hours;
let url = app.sync_models_url.clone();
tokio::spawn(async move {
// The loadable probe deserializes the whole override catalog; keep it
// off the startup path.
let loadable = paths::local_models_override().is_ok();
let mtime = fs::metadata(paths::models_override_file())
.and_then(|metadata| metadata.modified())
.ok();
if !models_override_is_stale(loadable, mtime, ttl_hours) {
return;
}
if let Err(err) = sync_models_quiet(url.as_deref()).await {
debug!("Background models refresh failed: {err}");
}
});
}
impl Config {
@@ -889,13 +1030,13 @@ fn render_config_template_from(
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
ensure_parent_exists(config_path)?;
std::fs::write(config_path, config_data)
fs::write(config_path, config_data)
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::prelude::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(config_path, perms)?;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(config_path, perms)?;
}
println!("✓ Saved the config file to '{}'.\n", config_path.display());
@@ -1380,4 +1521,102 @@ clients:
"placeholder should be preserved as a literal string in sandbox mode"
);
}
#[test]
fn unloadable_override_is_stale() {
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
assert!(models_override_is_stale(false, None, 24));
}
#[test]
fn unreadable_mtime_is_stale() {
assert!(models_override_is_stale(true, None, 24));
}
#[test]
fn fresh_loadable_override_is_not_stale() {
assert!(!models_override_is_stale(true, Some(SystemTime::now()), 24));
}
#[test]
fn ttl_boundary_controls_staleness() {
let ttl_hours = 24;
let ttl = std::time::Duration::from_secs(ttl_hours * 3600);
let margin = std::time::Duration::from_secs(300);
let just_fresh = SystemTime::now() - (ttl - margin);
let just_stale = SystemTime::now() - (ttl + margin);
assert!(!models_override_is_stale(true, Some(just_fresh), ttl_hours));
assert!(models_override_is_stale(true, Some(just_stale), ttl_hours));
}
#[test]
fn future_mtime_is_not_stale() {
let future = SystemTime::now() + std::time::Duration::from_secs(3600);
assert!(!models_override_is_stale(true, Some(future), 24));
}
#[test]
#[serial_test::serial]
fn models_override_loading_and_atomic_write() {
use std::fs;
let unique = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = env::temp_dir().join(format!("coyote-models-override-{unique}"));
create_dir_all(&tmp_dir).unwrap();
let env_name = get_env_name("config_dir");
let prev = env::var_os(&env_name);
unsafe { env::set_var(&env_name, &tmp_dir) };
let result = std::panic::catch_unwind(|| {
let path = paths::models_override_file();
let entry = || ProviderModels {
provider: "openai".into(),
oauth: None,
models: vec![client::ModelData::new("gpt-test")],
};
// Missing override is unloadable.
assert!(paths::local_models_override().is_err());
// Corrupt override is unloadable.
fs::write(&path, "not: [valid").unwrap();
assert!(paths::local_models_override().is_err());
// Version-mismatched override is unloadable.
let mismatched = ModelsOverride {
version: "0.0.0".into(),
list: vec![entry()],
};
fs::write(&path, serde_yaml::to_string(&mismatched).unwrap()).unwrap();
assert!(paths::local_models_override().is_err());
// write_models_override goes through the sibling temp file and
// leaves a loadable override behind.
let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", std::process::id()));
assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].provider, "openai");
});
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
let _ = fs::remove_dir_all(&tmp_dir);
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
}
+4 -1
View File
@@ -63,11 +63,14 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.
- `todo__list`: Show the current todo list.
- `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction.
- `todo__pause`: Pause auto-continue and yield the turn to the user, keeping incomplete todos intact. Use when you are blocked on user input or a user-side action. Auto-continue resumes on the user's next message.
RULES:
- Always create a todo list before starting work.
- Mark each task done as soon as you finish it; do not batch.
- If the user cancels the current task or changes direction, call `todo__clear` immediately.
- If you need user input (or the user must do something on their end) before you can continue, call `todo__pause` with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop the auto-continue prompts.
- `todo__pause` is for top-level sessions only. Subagents are driven to completion autonomously and cannot pause. If you are a subagent and need input, use a `user__*` tool (it escalates to the root agent mid-turn and blocks until answered, so you keep working without ending your turn).
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
};
@@ -212,7 +215,7 @@ pub(in crate::config) const DEFAULT_TEAMMATE_INSTRUCTIONS: &str = indoc! {"
## Teammate Messaging
You have tools to communicate with other agents running alongside you:
- `agent__send_message --id <agent_id> --message \"...\"`: Send a message to a sibling or parent agent.
- `agent__send_message --id <agent_id> --message \"...\"`: Send a message to a child or sibling agent by its id, or to your parent with the reserved id `parent`.
- `agent__check_inbox`: Check for messages sent to you by other agents.
If you are working alongside other agents (e.g. reviewing different files, exploring different areas):
+86 -19
View File
@@ -1,4 +1,4 @@
use super::bundles::BundleStore;
use super::bundles::installed_bundle_names;
use super::mcp_tool_policy::{McpToolPolicy, SkillMcpLayer, ToolFilter, expand_mcp_server_alias};
use super::rag_cache::{RagCache, RagKey};
use super::session::{INTERRUPTED_RESPONSE_TEXT, Session};
@@ -67,22 +67,6 @@ use std::sync::Arc;
use std::time::Duration;
use std::{env, fs};
/// Completion must degrade rather than break the prompt, but a corrupt store
/// should not vanish silently: the failure is logged before returning empty.
fn installed_bundle_names() -> Vec<String> {
match BundleStore::load() {
Ok(store) => store
.bundle_names()
.into_iter()
.map(str::to_string)
.collect(),
Err(e) => {
warn!("skipping bundle-name completion: {e:#}");
Vec::new()
}
}
}
pub(crate) fn expand_enabled_mcp_server_ids(
app: &AppConfig,
mcp_config: &McpServersConfig,
@@ -343,6 +327,7 @@ pub struct RequestContext {
pub parent_supervisor: Option<Arc<RwLock<Supervisor>>>,
pub self_agent_id: Option<String>,
pub inbox: Option<Arc<Inbox>>,
pub parent_inbox: Option<Arc<Inbox>>,
pub escalation_queue: Option<Arc<EscalationQueue>>,
pub notification_queue: Arc<NotificationQueue>,
pub current_depth: usize,
@@ -351,8 +336,11 @@ pub struct RequestContext {
pub todo_list: TodoList,
pub skill_registry: SkillRegistry,
pub last_continuation_response: Option<String>,
pub auto_continue_paused: Option<String>,
pub pending_prefill: Option<String>,
pub session_abort: Option<AbortSignal>,
pub render_mode: RenderMode,
}
@@ -380,6 +368,7 @@ impl RequestContext {
parent_supervisor: None,
self_agent_id: None,
inbox: None,
parent_inbox: None,
escalation_queue: None,
notification_queue: Arc::new(NotificationQueue::new()),
current_depth: 0,
@@ -388,7 +377,9 @@ impl RequestContext {
todo_list: TodoList::default(),
skill_registry: SkillRegistry::default(),
last_continuation_response: None,
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
render_mode: RenderMode::default(),
}
}
@@ -443,6 +434,7 @@ impl RequestContext {
parent_supervisor: None,
self_agent_id: None,
inbox: None,
parent_inbox: None,
escalation_queue: None,
notification_queue: Arc::new(NotificationQueue::new()),
current_depth: 0,
@@ -451,7 +443,9 @@ impl RequestContext {
todo_list: TodoList::default(),
skill_registry: SkillRegistry::default(),
last_continuation_response: None,
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
render_mode: RenderMode::default(),
};
ctx.refresh_mcp_tool_filters();
@@ -495,6 +489,7 @@ impl RequestContext {
parent_supervisor: self.parent_supervisor.clone(),
self_agent_id: self.self_agent_id.clone(),
inbox: self.inbox.clone(),
parent_inbox: self.parent_inbox.clone(),
escalation_queue: self.escalation_queue.clone(),
notification_queue: self.notification_queue.clone(),
current_depth: self.current_depth,
@@ -503,7 +498,9 @@ impl RequestContext {
todo_list: self.todo_list.clone(),
skill_registry: self.skill_registry.clone(),
last_continuation_response: None,
auto_continue_paused: self.auto_continue_paused.clone(),
pending_prefill: None,
session_abort: self.session_abort.clone(),
render_mode: self.render_mode,
}
}
@@ -543,6 +540,7 @@ impl RequestContext {
parent_supervisor: parent.supervisor.clone(),
self_agent_id: Some(self_agent_id),
inbox: Some(inbox),
parent_inbox: parent.inbox.clone(),
escalation_queue: parent.escalation_queue.clone(),
notification_queue: Arc::new(NotificationQueue::new()),
current_depth,
@@ -551,7 +549,9 @@ impl RequestContext {
todo_list: TodoList::default(),
skill_registry: SkillRegistry::default(),
last_continuation_response: None,
auto_continue_paused: None,
pending_prefill: None,
session_abort: None,
render_mode: parent.render_mode,
}
}
@@ -575,12 +575,19 @@ impl RequestContext {
.clone()
}
pub fn ensure_inbox(&mut self) -> Arc<Inbox> {
self.inbox
.get_or_insert_with(|| Arc::new(Inbox::new()))
.clone()
}
pub fn rag_cache(&self) -> &Arc<RagCache> {
&self.app.rag_cache
}
pub fn init_todo_list(&mut self, goal: &str) {
self.todo_list = TodoList::new(goal);
self.auto_continue_paused = None;
}
pub fn add_todo(&mut self, task: &str) -> usize {
@@ -594,12 +601,21 @@ impl RequestContext {
pub fn clear_todo_list(&mut self) {
self.todo_list.clear();
self.auto_continue_count = 0;
self.auto_continue_paused = None;
}
pub fn increment_auto_continue_count(&mut self) {
self.auto_continue_count += 1;
}
pub fn pause_auto_continue(&mut self, reason: &str) {
self.auto_continue_paused = Some(reason.to_string());
}
pub fn resume_auto_continue(&mut self) {
self.auto_continue_paused = None;
}
pub fn reset_continuation_count(&mut self) {
self.auto_continue_count = 0;
self.last_continuation_response = None;
@@ -2041,6 +2057,7 @@ impl RequestContext {
("highlight", app.highlight.to_string()),
("raw_markdown", app.raw_markdown.to_string()),
("theme", super::format_option_value(&app.theme)),
("config_dir", display_path(&paths::config_dir())),
("config_file", display_path(&paths::config_file())),
("env_file", display_path(&paths::env_file())),
("agents_dir", display_path(&paths::agents_data_dir())),
@@ -4666,6 +4683,7 @@ impl RequestContext {
}
self.supervisor = supervisor;
self.inbox = None;
self.parent_inbox = None;
self.escalation_queue = None;
self.notification_queue = Arc::new(NotificationQueue::new());
self.self_agent_id = None;
@@ -4673,6 +4691,7 @@ impl RequestContext {
self.current_depth = 0;
self.auto_continue_count = 0;
self.todo_list = TodoList::default();
self.auto_continue_paused = None;
if let Some(session_name) = session_name.as_deref() {
self.use_session(app, Some(session_name), abort_signal)
@@ -4709,12 +4728,14 @@ impl RequestContext {
self.parent_supervisor = None;
self.self_agent_id = None;
self.inbox = None;
self.parent_inbox = None;
self.escalation_queue = None;
self.notification_queue = Arc::new(NotificationQueue::new());
self.current_depth = 0;
self.auto_continue_count = 0;
self.pending_tasks_guardrail_count = 0;
self.todo_list = TodoList::default();
self.auto_continue_paused = None;
self.rag.take();
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
// cached under `RagKey::Agent(<agent name>)`, and leaving that key behind
@@ -5209,6 +5230,7 @@ mod tests {
use super::*;
use crate::config::AppState;
use crate::config::agent::AgentConfig;
use crate::config::bundles::BundleStore;
use crate::config::mcp_tool_policy::LayerSource;
use crate::config::tool_scope::test_fixtures::{FixtureServer, fixture_runtime};
use crate::function::jobs::RingBuf;
@@ -5233,7 +5255,7 @@ mod tests {
// with an empty client list, which would permanently pin every later
// model lookup in this process to "unknown model" and make any test that
// needs a resolvable model dependent on test ordering. Seed the caches
// before any test runs with one client, "test-seeded", exposing a single
// before any test runs with a client, "test-seeded", exposing a single
// embedding model. Deliberately NO chat model: some tests assert that no
// chat model is available, and chat lookups don't need one — a
// "test-seeded:<anything>" chat id resolves through the create-from-name
@@ -5253,8 +5275,19 @@ mod tests {
embedder.model_type = "embedding".to_string();
config.models = vec![embedder];
}
// A claude client is seeded alongside it so tests can exercise
// provider-quirk inheritance through the create-from-name fallback.
// It deliberately carries one embedding model: an empty `models` list
// would fall back to the full embedded claude catalog and register
// real chat models, breaking the no-chat-model invariant above.
// The test-dual-model pair shares one id across two types, mirroring
// jina:jina-colbert-v2, for the type-aware lookup tests.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-claude-embedder\n type: embedding\n - name: test-dual-model\n type: embedding\n - name: test-dual-model\n type: reranker\n",
)
.unwrap();
let config = AppConfig {
clients: vec![client],
clients: vec![client, claude],
..AppConfig::default()
};
let _ = list_client_names(&config);
@@ -5972,6 +6005,40 @@ mod tests {
assert!(ctx.inbox.is_none());
}
#[test]
fn parent_inbox_defaults_to_none() {
let ctx = create_test_ctx();
assert!(ctx.parent_inbox.is_none());
}
#[test]
fn ensure_inbox_allocates_once_and_is_stable() {
let mut ctx = create_test_ctx();
let first = ctx.ensure_inbox();
let second = ctx.ensure_inbox();
assert!(Arc::ptr_eq(&first, &second));
assert!(ctx.inbox.is_some());
}
#[test]
fn new_for_child_inherits_parent_inbox() {
let mut parent = create_test_ctx();
let parent_inbox = parent.ensure_inbox();
let child = RequestContext::new_for_child(
Arc::clone(&parent.app),
&parent,
1,
Arc::new(Inbox::new()),
"agent_test_1".to_string(),
);
let child_parent_inbox = child.parent_inbox.expect("child should see parent's inbox");
assert!(Arc::ptr_eq(&parent_inbox, &child_parent_inbox));
}
#[test]
fn escalation_queue_defaults_to_none() {
let ctx = create_test_ctx();
+332 -29
View File
@@ -1,3 +1,4 @@
use super::todo::TODO_FUNCTION_PREFIX;
use super::{FunctionDeclaration, JsonSchema};
use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{
@@ -7,9 +8,10 @@ use crate::config::{
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_user_interrupt};
use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use indexmap::IndexMap;
@@ -27,6 +29,8 @@ pub const AGENT_FUNCTION_PREFIX: &str = "agent__";
pub const PENDING_TASKS_GUARDRAIL_MAX: u32 = 3;
pub const PARENT_ALIAS: &str = "parent";
fn agent_permitted(whitelist: Option<&[String]>, target: &str) -> bool {
match whitelist {
None => true,
@@ -462,7 +466,7 @@ pub fn teammate_function_declarations() -> Vec<FunctionDeclaration> {
vec![
FunctionDeclaration {
name: format!("{AGENT_FUNCTION_PREFIX}send_message"),
description: "Send a text message to a sibling or child agent's inbox. Use to share cross-cutting findings or coordinate with teammates.".to_string(),
description: "Send a text message to another agent's inbox: a child or sibling by its agent id, or your parent via the reserved id 'parent'. Use to share cross-cutting findings or coordinate with teammates.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
@@ -470,7 +474,7 @@ pub fn teammate_function_declarations() -> Vec<FunctionDeclaration> {
"id".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some("The target agent ID".into()),
description: Some("The target agent ID, or the reserved id 'parent' to message the agent that spawned you".into()),
..Default::default()
},
),
@@ -572,7 +576,13 @@ pub fn run_child_agent(
if tool_results.is_empty() {
match check_pending_tasks_guardrail(&mut child_ctx) {
GuardrailAction::NoAction => break,
GuardrailAction::NoAction => {
if let Some(prompt) = todo_continuation_prompt(&mut child_ctx) {
input = Input::from_str(&child_ctx, &prompt, None)?;
continue;
}
break;
}
GuardrailAction::ForceTerminate(ids) => {
warn!(
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
@@ -599,6 +609,33 @@ pub fn run_child_agent(
})
}
fn todo_continuation_prompt(ctx: &mut RequestContext) -> Option<String> {
let config = ctx.auto_continue_config();
if !ctx.app.config.function_calling_support
|| !config.enabled
|| ctx.auto_continue_count >= config.max_continues
|| !ctx.todo_list.has_incomplete()
|| ctx.auto_continue_paused.is_some()
{
return None;
}
ctx.increment_auto_continue_count();
debug!(
"Auto-continuing child agent ({}/{}): {} incomplete todo(s) remain",
ctx.auto_continue_count,
config.max_continues,
ctx.todo_list.incomplete_count()
);
let prompt = config
.continuation_prompt
.as_deref()
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
Some(format!("{prompt}\n\n{}", ctx.todo_list.render_for_model()))
}
/// Spawn an agent synchronously from a graph node and return its accumulated
/// output. This is similar to `handle_spawn` but runs the child agent in the
/// current task (no tokio::spawn, no supervisor handle registration) so the
@@ -625,6 +662,7 @@ pub async fn run_agent_for_graph(
let child_inbox = Arc::new(Inbox::new());
parent_ctx.ensure_root_escalation_queue();
parent_ctx.ensure_inbox();
let child_abort = create_abort_signal();
let app_config = Arc::clone(&parent_ctx.app.config);
@@ -746,6 +784,14 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> {
};
ctx.tool_scope.functions = functions;
// Agent::init builds one function set regardless of how the agent runs;
// this sync is only ever called for spawned children.
if ctx.self_agent_id.is_some() {
ctx.tool_scope
.functions
.remove_function(&format!("{TODO_FUNCTION_PREFIX}pause"));
}
Ok(())
}
@@ -807,6 +853,7 @@ async fn handle_spawn(ctx: &mut RequestContext, args: &Value) -> Result<Value> {
let child_inbox = Arc::new(Inbox::new());
ctx.ensure_root_escalation_queue();
ctx.ensure_inbox();
let child_abort = create_abort_signal();
@@ -1027,6 +1074,14 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
sup.abort_signal_for(id)
};
let interrupted = || {
json!({
"status": "interrupted",
"id": id,
"message": format!("agent__collect was interrupted by the user; agent '{id}' is still running in the background. Collect it again later, cancel it with agent__cancel, or continue with other work."),
})
};
loop {
let is_finished = {
let sup = supervisor.read();
@@ -1037,6 +1092,10 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
break;
}
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
return Ok(interrupted());
}
if let Some(queue) = ctx.root_escalation_queue()
&& queue.has_pending()
{
@@ -1064,10 +1123,18 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_abort_signal(abort) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
}
}
None => {
time::sleep(Duration::from_millis(200)).await;
tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
}
}
}
}
@@ -1224,43 +1291,67 @@ fn handle_send_message(ctx: &mut RequestContext, args: &Value) -> Result<Value>
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("'message' is required"))?;
let sender = ctx
// The routable identity of this sender from the recipient's point of view.
// Only spawned agents have a `self_agent_id`; the name fallback covers
// contexts that cannot be replied to anyway (e.g. macro contexts).
let self_label = ctx
.self_agent_id
.clone()
.or_else(|| ctx.agent.as_ref().map(|a| a.name().to_string()))
.unwrap_or_else(|| "parent".to_string());
.unwrap_or_else(|| PARENT_ALIAS.to_string());
let inbox = ctx
.supervisor
.as_ref()
.and_then(|sup| sup.read().inbox(id).cloned());
let inbox = inbox.or_else(|| {
ctx.parent_supervisor
.as_ref()
.and_then(|sup| sup.read().inbox(id).cloned())
});
match inbox {
Some(inbox) => {
let deliver = |inbox: &Arc<Inbox>, from: String| {
inbox.deliver(Envelope {
from: sender,
from,
to: id.to_string(),
payload: EnvelopePayload::Text {
content: message.to_string(),
},
timestamp: Utc::now(),
});
};
if id == PARENT_ALIAS {
return match ctx.parent_inbox.as_ref() {
Some(inbox) => {
deliver(inbox, self_label);
Ok(json!({
"status": "ok",
"message": format!("Message delivered to agent '{id}'"),
"message": "Message delivered to your parent agent's inbox; it will be read on the parent's next check_inbox call.",
}))
}
None => {
if is_job_task(ctx.supervisor.as_ref(), id)
|| is_job_task(ctx.parent_supervisor.as_ref(), id)
{
None => Ok(json!({
"status": "error",
"message": "You have no parent agent — the reserved id 'parent' is only routable from a spawned subagent.",
})),
};
}
let own_child_inbox = ctx
.supervisor
.as_ref()
.and_then(|sup| sup.read().inbox(id).cloned());
if let Some(inbox) = own_child_inbox {
deliver(&inbox, PARENT_ALIAS.to_string());
return Ok(json!({
"status": "ok",
"message": format!("Message delivered to agent '{id}'"),
}));
}
let sibling_inbox = ctx
.parent_supervisor
.as_ref()
.and_then(|sup| sup.read().inbox(id).cloned());
if let Some(inbox) = sibling_inbox {
deliver(&inbox, self_label);
return Ok(json!({
"status": "ok",
"message": format!("Message delivered to agent '{id}'"),
}));
}
if is_job_task(ctx.supervisor.as_ref(), id) || is_job_task(ctx.parent_supervisor.as_ref(), id) {
return Ok(job_id_teaching_error(id));
}
@@ -1269,8 +1360,6 @@ fn handle_send_message(ctx: &mut RequestContext, args: &Value) -> Result<Value>
"message": format!("No agent found with id '{id}'. Agent may not exist or may have already completed."),
}))
}
}
}
fn handle_check_inbox(ctx: &mut RequestContext) -> Result<Value> {
match ctx.inbox.as_ref() {
@@ -1604,7 +1693,7 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str)
mod tests {
use super::*;
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
use crate::config::{AgentConfig, AppState, WorkingMode};
use crate::config::{AgentConfig, AppConfig, AppState, WorkingMode};
use crate::function::jobs::RingBuf;
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
@@ -1627,6 +1716,58 @@ mod tests {
ctx
}
fn auto_continue_ctx() -> RequestContext {
let app = AppState {
config: Arc::new(AppConfig {
auto_continue: true,
..Default::default()
}),
..AppState::test_default()
};
RequestContext::new(Arc::new(app), WorkingMode::Cmd)
}
#[test]
fn todo_continuation_fires_for_child_with_incomplete_todos() {
let mut ctx = auto_continue_ctx();
ctx.init_todo_list("ship feature");
ctx.add_todo("build the thing");
let prompt = todo_continuation_prompt(&mut ctx).expect("child should auto-continue");
assert!(prompt.contains("TODO CONTINUATION"), "{prompt}");
assert!(prompt.contains("build the thing"), "{prompt}");
assert_eq!(ctx.auto_continue_count, 1);
}
#[test]
fn todo_continuation_stops_when_done_paused_capped_or_disabled() {
// All todos complete → run may end.
let mut ctx = auto_continue_ctx();
ctx.init_todo_list("ship feature");
let id = ctx.add_todo("build the thing");
ctx.mark_todo_done(id);
assert!(todo_continuation_prompt(&mut ctx).is_none());
// Defense-in-depth: children cannot call todo__pause, but a set flag
// must still stop the continuation loop.
ctx.add_todo("deploy the thing");
ctx.pause_auto_continue("blocked on credentials");
assert!(todo_continuation_prompt(&mut ctx).is_none());
ctx.resume_auto_continue();
assert!(todo_continuation_prompt(&mut ctx).is_some());
// Continuation cap reached → run may end.
ctx.auto_continue_count = ctx.auto_continue_config().max_continues;
assert!(todo_continuation_prompt(&mut ctx).is_none());
// Auto-continue disabled → run may end even with incomplete todos.
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
ctx.init_todo_list("ship feature");
ctx.add_todo("build the thing");
assert!(todo_continuation_prompt(&mut ctx).is_none());
}
fn ctx_with_job_capable_supervisor() -> RequestContext {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
ctx.supervisor = Some(Arc::new(RwLock::new(
@@ -1794,6 +1935,31 @@ mod tests {
assert!(!functions.contains("mcp_invoke_fixture"));
}
/// `Agent::init` registers the full todo tool set (incl. `todo__pause`)
/// whenever the agent config enables auto_continue, because the same
/// function set serves top-level `--agent` sessions. Spawned children
/// must never advertise `todo__pause` (they are driven to completion
/// autonomously), so the child-only sync strips that one declaration
/// while leaving the rest of the todo tools intact.
#[tokio::test]
async fn sync_strips_todo_pause_declaration_for_child_contexts() {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
let mut agent = Agent::test_new(AgentConfig::default());
agent.functions_mut().append_todo_functions();
ctx.agent = Some(agent);
ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string());
sync_agent_functions_to_ctx(&mut ctx).unwrap();
let functions = &ctx.tool_scope.functions;
assert!(!functions.contains("todo__pause"));
assert!(functions.contains("todo__init"));
assert!(functions.contains("todo__add"));
assert!(functions.contains("todo__done"));
assert!(functions.contains("todo__list"));
assert!(functions.contains("todo__clear"));
}
fn app_state_with_fixture_mcp_config() -> Arc<AppState> {
let mut state = AppState::test_default();
state.mcp_config = Some(McpServersConfig {
@@ -2019,6 +2185,9 @@ mod tests {
EnvelopePayload::Text { content } => assert_eq!(content, "hello from parent"),
_ => panic!("expected text payload"),
}
// A child can only route replies through the reserved alias, so
// messages from the spawning agent must be labeled with it.
assert_eq!(msgs[0].from, PARENT_ALIAS);
}
#[test]
@@ -2029,6 +2198,74 @@ mod tests {
assert_eq!(result["status"], "error");
}
#[test]
fn handle_send_message_parent_alias_delivers_to_parent_inbox() {
let mut ctx = ctx_with_supervisor(4, 3);
let parent_inbox = Arc::new(Inbox::new());
ctx.parent_inbox = Some(Arc::clone(&parent_inbox));
ctx.self_agent_id = Some("agent_explore_abc123".to_string());
let result = handle_send_message(
&mut ctx,
&json!({"id": "parent", "message": "hello from child"}),
)
.unwrap();
assert_eq!(result["status"], "ok");
let msgs = parent_inbox.drain();
assert_eq!(msgs.len(), 1);
// The parent can reply through its own supervisor, so the sender must
// be the child's routable agent id.
assert_eq!(msgs[0].from, "agent_explore_abc123");
match &msgs[0].payload {
EnvelopePayload::Text { content } => assert_eq!(content, "hello from child"),
_ => panic!("expected text payload"),
}
}
#[test]
fn handle_send_message_parent_alias_without_parent_errors() {
let mut ctx = ctx_with_supervisor(4, 3);
let result =
handle_send_message(&mut ctx, &json!({"id": "parent", "message": "hi"})).unwrap();
assert_eq!(result["status"], "error");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("no parent agent")
);
}
#[test]
fn handle_send_message_to_sibling_labels_sender_with_own_id() {
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
ctx.self_agent_id = Some("agent_explore_sender".to_string());
let mut sibling_ctx = ctx_with_supervisor(4, 3);
register_fake_agent(&mut sibling_ctx, "agent_explore_receiver", "explore");
ctx.parent_supervisor = sibling_ctx.supervisor.clone();
let result = handle_send_message(
&mut ctx,
&json!({"id": "agent_explore_receiver", "message": "hi sibling"}),
)
.unwrap();
assert_eq!(result["status"], "ok");
let inbox = ctx
.parent_supervisor
.as_ref()
.unwrap()
.read()
.inbox("agent_explore_receiver")
.unwrap()
.clone();
let msgs = inbox.drain();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].from, "agent_explore_sender");
}
#[test]
fn handle_check_inbox_with_messages() {
let mut ctx = ctx_with_supervisor(4, 3);
@@ -2460,6 +2697,72 @@ mod tests {
);
}
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_agent() {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let join_handle = tokio::spawn(async move {
let _ = rx.await;
Ok(AgentResult {
id: "a1".to_string(),
agent_name: "explore".to_string(),
output: "late output".to_string(),
exit_status: AgentExitStatus::Completed,
})
});
let handle = AgentHandle {
id: "a1".to_string(),
agent_name: "explore".to_string(),
depth: 1,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
child_supervisor: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&mut ctx, &json!({"id": "a1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "a1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert_eq!(
ctx.supervisor.as_ref().unwrap().read().is_finished("a1"),
Some(false)
);
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&mut ctx, &json!({"id": "a1"}))
.await
.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["output"], "late output");
});
}
#[test]
fn handle_collect_pending_escalations_early_out_keeps_handle() {
let rt = tokio::runtime::Builder::new_current_thread()
+86 -1
View File
@@ -16,7 +16,9 @@ use crate::mcp::{
};
use crate::supervisor::notification::job_notification;
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor};
use crate::utils::{create_abort_signal, muted_warning_text, temp_file, wait_abort_signal};
use crate::utils::{
create_abort_signal, muted_warning_text, temp_file, wait_abort_signal, wait_user_interrupt,
};
use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap;
@@ -623,6 +625,14 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
job.abort_signal.clone()
};
let interrupted = || {
json!({
"status": "interrupted",
"id": id,
"message": format!("job__collect was interrupted by the user; job '{id}' is still running in the background. Collect it again later, cancel it with job__cancel, or continue with other work."),
})
};
loop {
let is_finished = {
let sup = supervisor.read();
@@ -633,6 +643,10 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
break;
}
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
return Ok(interrupted());
}
if let Some(queue) = ctx.root_escalation_queue()
&& queue.has_pending()
{
@@ -666,6 +680,9 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_abort_signal(&target_abort) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
}
}
@@ -2140,6 +2157,74 @@ mod tests {
});
}
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_job() {
run_async(async {
let mut ctx = ctx_with_job_supervisor(4);
let state = Arc::new(Mutex::new(JobState {
status: JobStatus::Running,
pgid: None,
}));
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let task_state = Arc::clone(&state);
let join_handle = tokio::spawn(async move {
let _ = rx.await;
task_state.lock().status = JobStatus::Completed;
Ok(JobResult {
output: json!("late result"),
exit_code: Some(0),
output_bytes_captured: 0,
})
});
let handle = JobHandle {
id: "j1".to_string(),
tool: "execute_command".to_string(),
started_at: Instant::now(),
join_handle,
abort_signal: create_abort_signal(),
state,
output_buf: Arc::new(Mutex::new(RingBuf::default())),
no_change_checks: 0,
last_check_state: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&ctx, &json!({"id": "j1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "j1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert!(ctx.supervisor.as_ref().unwrap().read().has_job("j1"));
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&ctx, &json!({"id": "j1"})).await.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["result"], "late result");
});
}
#[test]
fn handle_collect_full_result_returns_uncapped() {
run_async(async {
+78
View File
@@ -727,6 +727,10 @@ impl Functions {
.retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX));
}
pub fn remove_function(&mut self, name: &str) {
self.declarations.retain(|f| f.name != name);
}
pub fn append_memory_functions(&mut self) {
self.declarations
.extend(memory::memory_function_declarations());
@@ -3371,6 +3375,7 @@ mod tests {
assert!(f.contains("todo__done"));
assert!(f.contains("todo__list"));
assert!(f.contains("todo__clear"));
assert!(f.contains("todo__pause"));
}
#[test]
@@ -4914,6 +4919,79 @@ mod tests {
assert!(err.contains("Auto-continue is not enabled"), "{err}");
}
#[test]
fn todo_pause_sets_and_clears_pause_flag() {
let app = AppState {
config: Arc::new(AppConfig {
auto_continue: true,
..Default::default()
}),
..AppState::test_default()
};
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.tool_scope.functions.append_todo_functions();
// A blank reason is rejected and does not pause.
let out = run_async(call_with_args("todo__pause", json!({"reason": " "})).eval(&mut ctx))
.unwrap();
assert_eq!(out["error"], "reason is required");
assert!(ctx.auto_continue_paused.is_none());
// A real reason pauses auto-continue, preserving the todo list.
run_async(call_with_args("todo__init", json!({"goal": "ship"})).eval(&mut ctx)).unwrap();
run_async(call_with_args("todo__add", json!({"task": "deploy"})).eval(&mut ctx)).unwrap();
let out = run_async(
call_with_args("todo__pause", json!({"reason": "need prod API key"})).eval(&mut ctx),
)
.unwrap();
assert_eq!(out["status"], "ok");
assert_eq!(
ctx.auto_continue_paused.as_deref(),
Some("need prod API key")
);
assert!(ctx.todo_list.has_incomplete());
// The next real user message lifts the pause.
ctx.resume_auto_continue();
assert!(ctx.auto_continue_paused.is_none());
// Re-initializing or clearing the list also lifts a stale pause.
ctx.pause_auto_continue("stale");
run_async(call_with_args("todo__init", json!({"goal": "new"})).eval(&mut ctx)).unwrap();
assert!(ctx.auto_continue_paused.is_none());
ctx.pause_auto_continue("stale again");
run_async(call_with_args("todo__clear", json!({})).eval(&mut ctx)).unwrap();
assert!(ctx.auto_continue_paused.is_none());
}
/// Subagents are driven to completion autonomously: their turn boundary
/// has no interactive user, and user__* escalations work mid-turn, so
/// pausing is never the right move for a child. The handler must reject
/// the call, leave the pause flag unset, and redirect to escalation.
#[test]
fn todo_pause_is_rejected_for_subagents() {
let app = AppState {
config: Arc::new(AppConfig {
auto_continue: true,
..Default::default()
}),
..AppState::test_default()
};
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.tool_scope.functions.append_todo_functions();
ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string());
let out = run_async(
call_with_args("todo__pause", json!({"reason": "need user input"})).eval(&mut ctx),
)
.unwrap();
let err = out["error"].as_str().unwrap();
assert!(err.contains("unavailable to subagents"), "{err}");
assert!(err.contains("user__"), "{err}");
assert!(ctx.auto_continue_paused.is_none());
}
#[test]
fn eval_routes_memory_prefix_to_memory_handler() {
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
+59
View File
@@ -86,6 +86,33 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
},
agent: false,
},
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}pause"),
description: "Pause auto-continue and yield the turn back to the user while keeping \
incomplete todos intact. Use when you cannot proceed without user input \
or a user-side action. NEVER mark unfinished todos as done just to stop \
auto-continuation. Auto-continue resumes automatically on the user's \
next message. Unavailable to subagents: they are driven to completion \
autonomously — a subagent needing input must use a user__* tool, which \
escalates to the root agent mid-turn and blocks until answered."
.to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"reason".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"What you are waiting on from the user (shown to the user)".into(),
),
..Default::default()
},
)])),
required: Some(vec!["reason".to_string()]),
..Default::default()
},
agent: false,
},
]
}
@@ -149,6 +176,38 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
ctx.clear_todo_list();
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
}
"pause" => {
if ctx.self_agent_id.is_some() {
return Ok(json!({
"error": "todo__pause is unavailable to subagents: you are driven to \
completion autonomously and there is no interactive user at your \
turn boundary. If you need input, ask via a user__* tool — it \
escalates to the root agent mid-turn and blocks until answered, so \
you can keep working. If you are blocked on an action outside your \
scope, finish what you can and report the blocker in your final \
output."
}));
}
let reason = args
.get("reason")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
if reason.is_empty() {
return Ok(json!({"error": "reason is required"}));
}
ctx.pause_auto_continue(&reason);
Ok(json!({
"status": "ok",
"message": "Auto-continue paused; incomplete todos are preserved. Tell the user \
what you need from them, then end your turn. Auto-continue resumes \
on their next message."
}))
}
_ => bail!("Unknown todo action: {action}"),
}
}
+1
View File
@@ -695,6 +695,7 @@ nodes:
type: script
script: doubler.py
state_updates: {}
timeout: 60
done:
type: end
output: "{{doubled}}"
+14 -8
View File
@@ -262,13 +262,16 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
#[tokio::test]
async fn bash_script_can_read_state_from_env() {
if !cmd_available("bash") || !cmd_available("python3") {
eprintln!("skipping: bash or python3 not available");
if !cmd_available("bash") {
eprintln!("skipping: bash not available");
return;
}
// Pure-bash JSON extraction: spawning python3 here blew tight
// timeouts on Windows CI (first-start AV scans of the interpreter).
let (dir, path) = write_script(
r#"#!/bin/bash
NAME=$(python3 -c 'import json,os; print(json.loads(os.environ["GRAPH_STATE"])["name"])')
t=${GRAPH_STATE#*'"name":"'}
NAME=${t%%'"'*}
printf '{"greeting": "hello %s"}' "$NAME"
"#,
"sh",
@@ -280,7 +283,7 @@ printf '{"greeting": "hello %s"}' "$NAME"
let _ = executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state,
)
.await
@@ -466,7 +469,7 @@ echo '{"ok":true}'
#[tokio::test]
async fn large_state_is_delivered_via_file_env_var() {
if !cmd_available("bash") || !cmd_available("python3") {
if !cmd_available("bash") {
return;
}
let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024);
@@ -476,7 +479,10 @@ echo '{"ok":true}'
let (dir, path) = write_script(
r#"#!/bin/bash
if [ -n "$GRAPH_STATE_FILE" ]; then
LEN=$(python3 -c 'import json,os; print(len(json.load(open(os.environ["GRAPH_STATE_FILE"]))["blob"]))')
CONTENT=$(cat "$GRAPH_STATE_FILE")
t=${CONTENT#*'"blob":"'}
BLOB=${t%%'"'*}
LEN=${#BLOB}
printf '{"blob_len": %s, "via_file": true}' "$LEN"
elif [ -n "$GRAPH_STATE" ]; then
echo '{"via_file": false}'
@@ -489,7 +495,7 @@ fi
let executor = ScriptExecutor::new(&dir);
executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 10),
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state,
)
.await
@@ -524,7 +530,7 @@ print(json.dumps({
let executor = ScriptExecutor::new(&dir);
let next = executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
&node_for(path.file_name().unwrap().to_str().unwrap(), 60),
&mut state,
)
.await
+24 -16
View File
@@ -20,13 +20,14 @@ extern crate log;
use crate::cli::Cli;
use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
};
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
install_builtins, list_agents, load_env_file, macro_execute, maybe_spawn_models_refresh,
sync_models,
};
use crate::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -101,6 +102,7 @@ async fn main() -> Result<()> {
let info_flag = cli.info
|| cli.sync_models
|| cli.generate_models.is_some()
|| cli.list_models
|| cli.list_roles
|| cli.list_agents
@@ -292,7 +294,11 @@ async fn run(
) -> Result<()> {
if cli.sync_models {
let url = ctx.app.config.sync_models_url();
return sync_models(&url, abort_signal.clone()).await;
return sync_models(url.as_deref(), abort_signal.clone()).await;
}
if let Some(path) = &cli.generate_models {
return catalog::generate_models_file(path).await;
}
if cli.list_models {
@@ -544,12 +550,12 @@ async fn run(
if !*IS_STDOUT_TERMINAL {
bail!("No TTY for REPL")
}
maybe_spawn_models_refresh(&ctx.app.config);
start_interactive(ctx).await
}
}
}
#[async_recursion::async_recursion]
async fn start_directive(
ctx: &mut RequestContext,
input: Input,
@@ -557,6 +563,7 @@ async fn start_directive(
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
ctx.session_abort = Some(abort_signal.clone());
if graph::active_agent_graph_name(ctx).is_some() {
ctx.before_chat_completion(&input)?;
@@ -568,8 +575,10 @@ async fn start_directive(
return Ok(());
}
let client = input.create_client()?;
let extract_code = !*IS_STDOUT_TERMINAL && code_mode;
let mut input = input;
loop {
let client = input.create_client()?;
ctx.before_chat_completion(&input)?;
let (output, tool_results) = if !input.stream() || extract_code {
call_chat_completions(
@@ -582,23 +591,20 @@ async fn start_directive(
)
.await?
} else {
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone()).await?
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone())
.await?
};
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
if !tool_results.is_empty() {
start_directive(
ctx,
input.merge_tool_results(output, tool_results),
code_mode,
abort_signal,
)
.await?;
} else {
input = input.merge_tool_results(output, tool_results);
continue;
}
match check_pending_tasks_guardrail(ctx) {
GuardrailAction::Inject(prompt) => {
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
return start_directive(ctx, guardrail_input, code_mode, abort_signal).await;
input = Input::from_str(ctx, &prompt, None)?;
continue;
}
GuardrailAction::ForceTerminate(ids) => {
warn!(
@@ -609,6 +615,8 @@ async fn start_directive(
}
GuardrailAction::NoAction => {}
}
break;
}
ctx.exit_session()?;
+39 -20
View File
@@ -52,7 +52,8 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
4. Continue with the next pending item now. Call tools immediately."
4. If you are blocked on user input or a user-side action: call todo__pause with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop this reminder.
5. Otherwise, continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
@@ -1409,6 +1410,7 @@ pub async fn run_repl_command(
handle_shell_passthrough(cmd)?;
} else {
reset_continuation(ctx);
ctx.resume_auto_continue();
let input = Input::from_str(ctx, line, None)?;
ask(ctx, abort_signal.clone(), input, true).await?;
}
@@ -1422,18 +1424,22 @@ pub async fn run_repl_command(
Ok(false)
}
#[async_recursion::async_recursion]
async fn ask(
ctx: &mut RequestContext,
abort_signal: AbortSignal,
mut input: Input,
input: Input,
with_embeddings: bool,
) -> Result<()> {
let mut input = input;
let mut with_embeddings = with_embeddings;
loop {
if input.is_empty() {
return Ok(());
}
ctx.session_abort = Some(abort_signal.clone());
if with_embeddings {
input.use_embeddings(abort_signal.clone()).await?;
with_embeddings = false;
}
while ctx.is_compressing_session() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
@@ -1477,18 +1483,14 @@ async fn ask(
};
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
if !tool_results.is_empty() {
ask(
ctx,
abort_signal,
input.merge_tool_results(output, tool_results),
false,
)
.await
} else {
input = input.merge_tool_results(output, tool_results);
continue;
}
match check_pending_tasks_guardrail(ctx) {
GuardrailAction::Inject(prompt) => {
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
return ask(ctx, abort_signal, guardrail_input, false).await;
input = Input::from_str(ctx, &prompt, None)?;
continue;
}
GuardrailAction::ForceTerminate(ids) => {
warn!(
@@ -1530,10 +1532,27 @@ async fn ask(
format!("{prompt}\n\n{todo_state}")
};
let continuation_input = Input::from_str(ctx, &full_prompt, None)?;
ask(ctx, abort_signal, continuation_input, false).await
} else {
input = Input::from_str(ctx, &full_prompt, None)?;
continue;
}
reset_continuation(ctx);
if let Some(reason) = ctx.auto_continue_paused.clone()
&& ctx.todo_list.has_incomplete()
{
let remaining = ctx.todo_list.incomplete_count();
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!(
"\n⏸ {}",
color.italic().paint(format!(
"Auto-continue paused, awaiting user ({remaining} incomplete todo(s) preserved): {reason}"
))
);
}
if ctx.maybe_autoname_session() {
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
@@ -1604,13 +1623,12 @@ async fn ask(
format!("{prompt}\n\n{todo_state}")
};
let continuation_input = Input::from_str(ctx, &full_prompt, None)?;
return ask(ctx, abort_signal, continuation_input, false).await;
input = Input::from_str(ctx, &full_prompt, None)?;
continue;
}
}
Ok(())
}
return Ok(());
}
}
@@ -1620,6 +1638,7 @@ fn should_continue(ctx: &RequestContext) -> bool {
&& config.enabled
&& ctx.auto_continue_count < config.max_continues
&& ctx.todo_list.has_incomplete()
&& ctx.auto_continue_paused.is_none()
}
fn reset_continuation(ctx: &mut RequestContext) {
+36 -1
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::client::{Message, MessageRole};
use crate::client::{Message, MessageContent, MessageContentToolCalls, MessageRole};
use crate::config::{AppConfig, Session};
use crate::utils::{dimmed_text, replay_label_text};
@@ -52,9 +52,44 @@ fn render_messages(app: &AppConfig, messages: &[Message]) -> Result<()> {
println!();
}
}
MessageRole::Tool => {
if let MessageContent::ToolCalls(tool_calls) = &message.content {
render_tool_call_rounds(app, tool_calls)?;
}
}
_ => {}
}
}
Ok(())
}
fn render_tool_call_rounds(app: &AppConfig, tool_calls: &MessageContentToolCalls) -> Result<()> {
let mut needs_gap_after_calls = false;
if !tool_calls.text.trim().is_empty() {
println!("{}", replay_label_text("Assistant:"));
app.print_markdown(&tool_calls.text)?;
println!();
}
for result in &tool_calls.tool_results {
if let Some(text) = result.text.as_deref().filter(|t| !t.trim().is_empty()) {
if needs_gap_after_calls {
println!();
}
println!("{}", replay_label_text("Assistant:"));
app.print_markdown(text)?;
println!();
}
println!("{}", dimmed_text(&format!("⚙ {}", result.call.name)));
needs_gap_after_calls = true;
}
if needs_gap_after_calls {
println!();
}
Ok(())
}
+38
View File
@@ -68,6 +68,29 @@ pub async fn wait_abort_signal(abort_signal: &AbortSignal) {
}
}
/// Completes when the user interrupts: a SIGINT arrives, or the session's turn
/// signal is (or becomes) aborted. Needed because tools run in cooked mode --
/// tokio's global SIGINT handler (installed by the first `ctrl_c()` poll)
/// swallows the signal with nobody listening. On SIGINT, sets ctrl-c on the
/// session signal so the surrounding turn aborts too. With `None`, only the
/// SIGINT arm applies.
pub async fn wait_user_interrupt(session: Option<&AbortSignal>) {
let ctrl_c = async {
if tokio::signal::ctrl_c().await.is_err() {
std::future::pending::<()>().await;
}
};
match session {
Some(signal) => {
tokio::select! {
_ = ctrl_c => signal.set_ctrlc(),
_ = wait_abort_signal(signal) => {}
}
}
None => ctrl_c.await,
}
}
pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
if event::poll(Duration::from_millis(25))?
&& let Event::Key(key) = event::read()?
@@ -86,3 +109,18 @@ pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn wait_user_interrupt_returns_promptly_on_preset_session_signal() {
let signal = create_abort_signal();
signal.set_ctrlc();
tokio::time::timeout(Duration::from_secs(1), wait_user_interrupt(Some(&signal)))
.await
.expect("must return promptly when the session signal is already set");
}
}