Compare commits
38
Commits
7b37eb4602
...
5fe3198df7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe3198df7
|
||
|
|
206f36d826 | ||
|
|
cb5b46800f | ||
|
|
101b706be5 | ||
|
|
b04270950a | ||
|
|
9a5f587e74 | ||
|
|
4e35866740 | ||
|
|
74b8c17392 | ||
|
|
67cf8e42d9 | ||
|
|
154330c5a0 | ||
|
|
b50b033520 | ||
|
|
3b01ae8f18 | ||
|
|
dc41e9d5df | ||
|
|
49bd5d86b4 | ||
|
|
c8af77b36f | ||
|
|
41d5fa9800 | ||
|
|
5e7a1f1de6 | ||
|
|
73e9bad412 | ||
|
|
012212a998 | ||
|
|
f1cb6589bc | ||
|
|
539b57aded | ||
|
|
2d7a759fc0 | ||
|
|
078e5c757d | ||
|
|
47d2edf303 | ||
|
|
34d79651a8 | ||
|
|
01d0081321 | ||
|
|
e137866c0e | ||
|
|
b19ac80783 | ||
|
|
f961dd3a74 | ||
|
|
af7d37890c | ||
|
|
2cf01a4ca4 | ||
|
|
141289d973 | ||
|
|
46c8561173 | ||
|
|
61c4037033 | ||
|
|
8095852c7d | ||
|
|
dde339780f | ||
|
|
c0a17031c5 | ||
|
|
affa5e7cef |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
...
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ permissions:
|
||||
- 'api.cohere.ai'
|
||||
- 'api.groq.com'
|
||||
- 'openrouter.ai'
|
||||
- 'models.dev'
|
||||
- 'api.ai21.com'
|
||||
- 'api.cloudflare.com'
|
||||
- 'api.deepinfra.com'
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+1472
File diff suppressed because it is too large.
Load diff
@@ -1,2 +0,0 @@
|
||||
requests
|
||||
ruamel.yaml
|
||||
@@ -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()
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
mod access_token;
|
||||
pub mod catalog;
|
||||
mod claude_oauth;
|
||||
mod common;
|
||||
mod gemini_oauth;
|
||||
|
||||
+91
-9
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1268,8 +1359,6 @@ fn handle_send_message(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
||||
"status": "error",
|
||||
"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> {
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -695,6 +695,7 @@ nodes:
|
||||
type: script
|
||||
script: doubler.py
|
||||
state_updates: {}
|
||||
timeout: 60
|
||||
done:
|
||||
type: end
|
||||
output: "{{doubled}}"
|
||||
|
||||
+14
-8
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user