Merge branch 'main' of github.com:Dark-Alex-17/coyote
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s

This commit is contained in:
Dark-Alex-17 committed 2026-09-03 16:29:30 -06:00
commit 5fe3198df7
31 files changed
+14989 -3282

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
name: Update models
on:
schedule:
- cron: '0 */12 * * *'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: update-models
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
update:
name: Update models
runs-on: ubuntu-latest
env:
RUSTFLAGS: --deny warnings
steps:
- uses: actions/checkout@v4
with:
persist-credentials: true
- name: Install Rust Toolchain Components
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Cache DuckDB Extensions
id: duckdb-extensions
uses: actions/cache@v4
with:
path: ~/.duckdb/extensions
key: duckdb-ext-ubuntu-latest-${{ hashFiles('Cargo.lock') }}
- name: Install DuckDB Extensions
if: steps.duckdb-extensions.outputs.cache-hit != 'true'
run: cargo test --all duckdb
# Coyote loads its config before handling --generate-models/--list-models, and a
# bare non-TTY runner has no config file (and no way to answer the first-run
# prompt), so point COYOTE_CONFIG_DIR at a throwaway dir with a minimal stub.
- name: Write stub config
run: |
COYOTE_CONFIG_DIR="$(mktemp -d)"
printf 'clients:\n - type: openai\n - type: claude\n' > "$COYOTE_CONFIG_DIR/config.yaml"
echo "COYOTE_CONFIG_DIR=$COYOTE_CONFIG_DIR" >> "$GITHUB_ENV"
- name: Regenerate model catalog
run: cargo run --release -- --generate-models
- name: Detect catalog changes
id: diff
run: echo "changed=$(git diff --quiet models.yaml && echo false || echo true)" >> "$GITHUB_OUTPUT"
# The binary built by the regeneration step embedded the OLD models.yaml (the file
# was rewritten after that build), so rebuild before smoke-testing: cargo tracks
# include_str! inputs, making this a cheap incremental rebuild of the final crate.
- name: Rebuild with the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: cargo build --release
- name: Test
if: steps.diff.outputs.changed == 'true'
run: cargo test --all
- name: Smoke test the refreshed catalog
if: steps.diff.outputs.changed == 'true'
run: |
COUNT=$(./target/release/coyote --list-models | wc -l)
echo "Listed $COUNT chat models"
test "$COUNT" -ge 40
# Pushes made with the default GITHUB_TOKEN do not trigger other workflows,
# so this push cannot start a workflow loop. The pull --rebase covers a human
# push landing mid-run; if the race is lost anyway, the next cron run retries.
- name: Commit and push
if: steps.diff.outputs.changed == 'true'
run: |
git add models.yaml
git checkout -- .
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m 'chore: refresh generated model catalog'
git pull --rebase origin main
git push origin main
+1
View File
@@ -61,6 +61,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [Client Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Clients): Configuration instructions for various LLM providers.
* [Authentication (API Key & OAuth)](https://github.com/Dark-Alex-17/coyote/wiki/Clients#authentication): Authenticate with API keys or OAuth for subscription-based access.
* [Patching API Requests](https://github.com/Dark-Alex-17/coyote/wiki/Patches): Learn how to patch API requests for advanced customization.
* [Dynamic Model Catalog](https://github.com/Dark-Alex-17/coyote/wiki/Model-Catalog): Coyote's model catalog (context windows, pricing, capabilities) is generated from live API data, refreshed automatically in the background, and mirrorable for air-gapped setups. New models show up without waiting for a release.
* [Custom Themes](https://github.com/Dark-Alex-17/coyote/wiki/Themes): Change the look and feel of Coyote to your preferences with custom themes.
* [History](#history): A history of how Coyote came to be.
+6 -1
View File
@@ -244,8 +244,13 @@ right_prompt: null # If null, uses the built-in default:
# ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: null # URL to sync model changes from. If null, uses the built-in default:
sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
# If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- Clients ----
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
+1
View File
@@ -67,6 +67,7 @@ permissions:
- 'api.cohere.ai'
- 'api.groq.com'
- 'openrouter.ai'
- 'models.dev'
- 'api.ai21.com'
- 'api.cloudflare.com'
- 'api.deepinfra.com'
+7 -2
View File
@@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env:
# ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: > # URL to sync model changes from
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
# If null, fetches fresh model data from models.dev + OpenRouter.
# Set a URL to mirror a full models.yaml instead, e.g.:
# https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup
# once it goes stale
sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale
# ---- REPL Prompt ----
# Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
+9534 -2793
View File
File diff suppressed because it is too large. Load diff
+1472
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
requests
ruamel.yaml
-255
View File
@@ -1,255 +0,0 @@
import requests
import sys
import re
import json
# Provider mapping from models.yaml to OpenRouter prefixes
PROVIDER_MAPPING = {
"openai": "openai",
"claude": "anthropic",
"gemini": "google",
"mistral": "mistralai",
"cohere": "cohere",
"perplexity": "perplexity",
"xai": "x-ai",
"openrouter": "openrouter",
"ai21": "ai21",
"deepseek": "deepseek",
"moonshot": "moonshotai",
"qianwen": "qwen",
"zhipuai": "zhipuai",
"minimax": "minimax",
"vertexai": "google",
"groq": "groq",
"bedrock": "amazon",
"hunyuan": "tencent",
"ernie": "baidu",
"github": "github",
}
def fetch_openrouter_models():
print("Fetching models from OpenRouter...")
try:
response = requests.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()["data"]
print(f"Fetched {len(data)} models.")
return data
except Exception as e:
print(f"Error fetching models: {e}")
sys.exit(1)
def get_openrouter_model(models_data, provider_prefix, model_name, is_openrouter_provider=False):
if is_openrouter_provider:
# For openrouter provider, the model_name in yaml is usually the full ID
for model in models_data:
if model["id"] == model_name:
return model
return None
expected_id = f"{provider_prefix}/{model_name}"
# 1. Try exact match on ID
for model in models_data:
if model["id"] == expected_id:
return model
# 2. Try match by suffix
for model in models_data:
if model["id"].split("/")[-1] == model_name:
if model["id"].startswith(f"{provider_prefix}/"):
return model
return None
def format_price(price_per_token):
if price_per_token is None:
return None
try:
price_per_1m = float(price_per_token) * 1_000_000
if price_per_1m.is_integer():
return str(int(price_per_1m))
else:
return str(round(price_per_1m, 4))
except:
return None
def get_indentation(line):
return len(line) - len(line.lstrip())
def process_model_block(block_lines, current_provider, or_models):
if not block_lines:
return []
# 1. Identify model name and indentation
name_line = block_lines[0]
name_match = re.match(r"^(\s*)-\s*name:\s*(.+)$", name_line)
if not name_match:
return block_lines
name_indent_str = name_match.group(1)
model_name = name_match.group(2).strip()
# 2. Find OpenRouter model
or_prefix = PROVIDER_MAPPING.get(current_provider)
is_openrouter_provider = (current_provider == "openrouter")
if not or_prefix and not is_openrouter_provider:
return block_lines
or_model = get_openrouter_model(or_models, or_prefix, model_name, is_openrouter_provider)
if not or_model:
return block_lines
print(f" Updating {model_name}...")
# 3. Prepare updates
updates = {}
# Pricing
pricing = or_model.get("pricing", {})
p_in = format_price(pricing.get("prompt"))
p_out = format_price(pricing.get("completion"))
if p_in: updates["input_price"] = p_in
if p_out: updates["output_price"] = p_out
# Context
ctx = or_model.get("context_length")
if ctx: updates["max_input_tokens"] = str(ctx)
max_out = None
if "top_provider" in or_model and or_model["top_provider"]:
max_out = or_model["top_provider"].get("max_completion_tokens")
if max_out: updates["max_output_tokens"] = str(max_out)
# Capabilities
arch = or_model.get("architecture", {})
modality = arch.get("modality", "")
if "image" in modality:
updates["supports_vision"] = "true"
# 4. Detect field indentation
field_indent_str = None
existing_fields = {} # key -> line_index
for i, line in enumerate(block_lines):
if i == 0: continue # Skip name line
# Skip comments
if line.strip().startswith("#"):
continue
# Look for "key: value"
m = re.match(r"^(\s*)([\w_-]+):", line)
if m:
indent = m.group(1)
key = m.group(2)
# Must be deeper than name line
if len(indent) > len(name_indent_str):
if field_indent_str is None:
field_indent_str = indent
existing_fields[key] = i
if field_indent_str is None:
field_indent_str = name_indent_str + " "
# 5. Apply updates
new_block = list(block_lines)
# Update existing fields
for key, value in updates.items():
if key in existing_fields:
idx = existing_fields[key]
# Preserve original key indentation exactly
original_line = new_block[idx]
m = re.match(r"^(\s*)([\w_-]+):", original_line)
if m:
current_indent = m.group(1)
new_block[idx] = f"{current_indent}{key}: {value}\n"
# Insert missing fields
# Insert after the name line
insertion_idx = 1
for key, value in updates.items():
if key not in existing_fields:
new_line = f"{field_indent_str}{key}: {value}\n"
new_block.insert(insertion_idx, new_line)
insertion_idx += 1
return new_block
def main():
or_models = fetch_openrouter_models()
print("Reading models.yaml...")
with open("models.yaml", "r") as f:
lines = f.readlines()
new_lines = []
current_provider = None
i = 0
while i < len(lines):
line = lines[i]
# Check for provider
# - provider: name
p_match = re.match(r"^\s*-?\s*provider:\s*(.+)$", line)
if p_match:
current_provider = p_match.group(1).strip()
new_lines.append(line)
i += 1
continue
# Check for model start
# - name: ...
m_match = re.match(r"^(\s*)-\s*name:\s*.+$", line)
if m_match:
# Start of a model block
start_indent = len(m_match.group(1))
# Collect block lines
block_lines = [line]
j = i + 1
while j < len(lines):
next_line = lines[j]
stripped = next_line.strip()
# If empty or comment, include it
if not stripped or stripped.startswith("#"):
block_lines.append(next_line)
j += 1
continue
# Check indentation
next_indent = get_indentation(next_line)
# If indentation is greater, it's part of the block (property)
if next_indent > start_indent:
block_lines.append(next_line)
j += 1
continue
# If indentation is equal or less, it's the end of the block
break
# Process the block
processed_block = process_model_block(block_lines, current_provider, or_models)
new_lines.extend(processed_block)
# Advance i
i = j
continue
# Otherwise, just a regular line
new_lines.append(line)
i += 1
print("Saving models.yaml...")
with open("models.yaml", "w") as f:
f.writelines(new_lines)
print("Done.")
if __name__ == "__main__":
main()
+24 -1
View File
@@ -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(
@@ -253,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>>,
@@ -810,6 +820,19 @@ mod tests {
assert!(cli.sync_models);
}
#[test]
fn parse_generate_models_flag() {
assert_eq!(parse(&[]).generate_models, None);
assert_eq!(
parse(&["--generate-models"]).generate_models,
Some(PathBuf::from("models.yaml"))
);
assert_eq!(
parse(&["--generate-models", "out.yaml"]).generate_models,
Some(PathBuf::from("out.yaml"))
);
}
#[test]
fn parse_model_with_role() {
let cli = parse(&["-m", "gpt-4o", "-r", "coder"]);
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,4 +1,5 @@
mod access_token;
pub mod catalog;
mod claude_oauth;
mod common;
mod gemini_oauth;
+93 -11
View File
@@ -1,5 +1,7 @@
use super::{
ApiPatch, MessageContentToolCalls, RequestPatch, list_all_models, list_client_names,
ApiPatch, MessageContentToolCalls, RequestPatch,
catalog::{ALL_QUIRKS, apply_rules, quirks_for},
list_all_models, list_client_names,
message::{Message, MessageContent, MessageContentPart},
};
@@ -63,12 +65,16 @@ impl Model {
};
match model_name {
Some(model_name) => {
if let Some(model) = models.iter().find(|v| v.id() == model_id) {
if model.model_type() == model_type {
return Ok((*model).clone());
} else {
bail!("Model '{model_id}' is not a {model_type} model")
}
// 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());
}
if models.iter().any(|v| v.id() == model_id) {
bail!("Model '{model_id}' is not a {model_type} model")
}
if list_client_names(config)
.into_iter()
@@ -77,6 +83,9 @@ impl Model {
{
let mut new_model = Self::new(client_name, model_name);
new_model.data.model_type = model_type.to_string();
if let Some(quirks) = quirks_for(&ALL_QUIRKS, client_name) {
apply_rules(quirks, &mut new_model.data);
}
return Ok(new_model);
}
}
@@ -132,6 +141,7 @@ impl Model {
output_price,
supports_vision,
supports_function_calling,
reasoning_levels,
..
} = &self.data;
let max_input_tokens = stringify_option_value(max_input_tokens);
@@ -145,6 +155,9 @@ impl Model {
if *supports_function_calling {
capabilities.push('⚒');
};
if !reasoning_levels.is_empty() {
capabilities.push('🧠');
};
let capabilities: String = capabilities
.into_iter()
.map(|v| format!("{v} "))
@@ -330,11 +343,11 @@ pub struct ModelData {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_stream: bool,
pub(crate) no_stream: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
no_system_message: bool,
pub(crate) no_system_message: bool,
#[serde(skip_serializing_if = "Option::is_none")]
system_prompt_prefix: Option<String>,
pub(crate) system_prompt_prefix: Option<String>,
// embedding-only properties
#[serde(skip_serializing_if = "Option::is_none")]
@@ -358,7 +371,7 @@ impl ModelData {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderModels {
pub provider: String,
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>,
pub models: Vec<ModelData>,
}
@@ -419,3 +432,72 @@ where
None => "-".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::ClientConfig;
#[test]
fn created_from_name_inherits_family_rules() {
// The model registries are process-wide caches seeded by a ctor (see
// the request_context tests) that registers a "claude" client; the
// config below documents the shape a real lookup would use.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-claude-embedder\n type: embedding\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let model = Model::retrieve_model(
&config,
"claude:claude-definitely-not-a-real-model",
ModelType::Chat,
)
.unwrap();
assert_eq!(model.name(), "claude-definitely-not-a-real-model");
assert_eq!(model.model_type(), ModelType::Chat);
assert!(model.data.require_max_tokens);
}
#[test]
fn duplicate_id_across_types_resolves_by_requested_type() {
// Mirrors jina:jina-colbert-v2, which is both an embedder and a
// reranker under the same id.
let claude: ClientConfig = serde_yaml::from_str(
"type: claude\nmodels:\n - name: test-dual-model\n type: embedding\n - name: test-dual-model\n type: reranker\n",
)
.unwrap();
let config = AppConfig {
clients: vec![claude],
..AppConfig::default()
};
let embedder =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Embedding).unwrap();
assert_eq!(embedder.model_type(), ModelType::Embedding);
let reranker =
Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Reranker).unwrap();
assert_eq!(reranker.model_type(), ModelType::Reranker);
// An id that exists only under other types still teaches instead of
// silently creating a chat model from the name.
let err = Model::retrieve_model(&config, "claude:test-dual-model", ModelType::Chat)
.unwrap_err()
.to_string();
assert!(err.contains("is not a chat model"), "got: {err}");
}
#[test]
fn description_shows_brain_glyph() {
let mut model = Model::new("claude", "some-model");
model.data.reasoning_levels = vec!["low".to_string(), "high".to_string()];
assert!(model.description().contains('🧠'));
model.data.reasoning_levels.clear();
assert!(!model.description().contains('🧠'));
}
}
+457
View File
@@ -0,0 +1,457 @@
{
"openai": {
"id": "openai",
"env": [
"OPENAI_API_KEY"
],
"npm": "@ai-sdk/openai",
"name": "OpenAI",
"doc": "https://platform.openai.com/docs/models",
"models": {
"gpt-5.6": {
"id": "gpt-5.6",
"name": "GPT-5.6",
"description": "Frontier GPT-5.6 model for complex professional work, coding, and agentic workflows",
"family": "gpt-sol",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"none",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": false,
"knowledge": "2026-02-16",
"release_date": "2026-07-09",
"last_updated": "2026-07-09",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1050000,
"input": 922000,
"output": 128000
},
"experimental": {
"modes": {
"fast": {
"cost": {
"input": 8,
"output": 40,
"cache_read": 0.8,
"cache_write": 10
},
"provider": {
"body": {
"service_tier": "priority"
}
}
},
"pro": {
"provider": {
"body": {
"reasoning": {
"mode": "pro"
}
}
}
}
}
},
"cost": {
"input": 4,
"output": 20,
"cache_read": 0.4,
"cache_write": 5,
"tiers": [
{
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10,
"tier": {
"type": "context",
"size": 272000
}
}
],
"context_over_200k": {
"input": 8,
"output": 30,
"cache_read": 0.8,
"cache_write": 10
}
}
},
"gpt-4.1": {
"id": "gpt-4.1",
"name": "GPT-4.1",
"description": "Long-lived GPT workhorse for coding, instruction following, and production apps",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2025-04-14",
"last_updated": "2025-04-14",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1047576,
"output": 32768
},
"cost": {
"input": 2,
"output": 8,
"cache_read": 0.5
}
},
"gpt-4o": {
"id": "gpt-4o",
"name": "GPT-4o",
"description": "Omni-era GPT for multimodal chat, practical coding, and general assistants",
"family": "gpt",
"attachment": true,
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2023-09",
"release_date": "2024-05-13",
"last_updated": "2024-08-06",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 128000,
"output": 16384
},
"cost": {
"input": 2.5,
"output": 10,
"cache_read": 1.25
}
}
}
},
"anthropic": {
"id": "anthropic",
"env": [
"ANTHROPIC_API_KEY"
],
"npm": "@ai-sdk/anthropic",
"name": "Anthropic",
"doc": "https://docs.anthropic.com/en/docs/about-claude/models",
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"description": "Claude workhorse for coding agents, careful analysis, and production cost control",
"family": "claude-sonnet",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-08-31",
"release_date": "2026-02-17",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
}
},
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"description": "High-end Claude for difficult coding, planning, and slower expert reasoning",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"max"
]
},
{
"type": "budget_tokens",
"min": 1024
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2025-05-31",
"release_date": "2026-02-04",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 1000000,
"output": 128000
},
"cost": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25
}
}
}
},
"xai": {
"id": "xai",
"env": [
"XAI_API_KEY"
],
"npm": "@ai-sdk/xai",
"name": "xAI",
"doc": "https://docs.x.ai/docs/models",
"models": {
"grok-4.6": {
"id": "grok-4.6",
"name": "Grok 4.6",
"description": "xAI's frontier model for long-running agents, coding, knowledge work, and visual projects",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high",
"xhigh"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"knowledge": "2026-02-01",
"release_date": "2026-08-12",
"last_updated": "2026-08-12",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.5,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 1,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 1
}
}
},
"grok-4.5": {
"id": "grok-4.5",
"name": "Grok 4.5",
"description": "xAI's Grok model for chat, coding, agentic tools, and lower hallucination risk",
"family": "grok",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"low",
"medium",
"high"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"release_date": "2026-07-08",
"last_updated": "2026-07-08",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 500000,
"output": 500000
},
"cost": {
"input": 2,
"output": 6,
"cache_read": 0.3,
"tiers": [
{
"input": 4,
"output": 12,
"cache_read": 0.6,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 4,
"output": 12,
"cache_read": 0.6
}
}
}
}
},
"alibaba": {
"id": "alibaba",
"env": [
"DASHSCOPE_API_KEY"
],
"npm": "@ai-sdk/openai-compatible",
"name": "Alibaba",
"doc": "https://www.alibabacloud.com/help/en/model-studio/models",
"models": {
"qwen-max": {
"id": "qwen-max",
"name": "Qwen Max",
"description": "Flagship Qwen model for complex reasoning, coding, and agentic workflows",
"family": "qwen",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"knowledge": "2024-04",
"release_date": "2024-04-03",
"last_updated": "2025-01-25",
"modalities": {
"input": [
"text"
],
"output": [
"text"
]
},
"open_weights": false,
"limit": {
"context": 32768,
"output": 8192
},
"cost": {
"input": 1.6,
"output": 6.4
}
}
}
}
}
+538
View File
@@ -0,0 +1,538 @@
{
"data": [
{
"id": "x-ai/grok-4.6",
"canonical_slug": "x-ai/grok-4.6-20260810",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.6",
"created": 1786548957,
"description": "Grok 4.6 is SpaceXAI's smartest model with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000005",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.000001"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.6-20260810/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "qwen/qwen3.8-max",
"canonical_slug": "qwen/qwen3.8-max-20260803",
"hugging_face_id": null,
"name": "Qwen: Qwen3.8 Max",
"created": 1785731612,
"description": "Qwen3.8 Max is the flagship model in Alibaba's Qwen3.8 series, the general-availability successor to the Qwen3.8 Max Preview. It is a multimodal reasoning model intended for complex reasoning, visual understanding,...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+video->text",
"input_modalities": [
"text",
"image",
"video"
],
"output_modalities": [
"text"
],
"tokenizer": "Qwen",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"input_cache_read": "0.00000025",
"input_cache_write": "0.0000025"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 131072,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_tokens",
"presence_penalty",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/qwen/qwen3.8-max-20260803/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"xhigh",
"high",
"medium",
"low",
"minimal"
],
"default_effort": "xhigh"
}
},
{
"id": "x-ai/grok-4.5",
"canonical_slug": "x-ai/grok-4.5-20260708",
"hugging_face_id": null,
"name": "SpaceXAI: Grok 4.5",
"created": 1783523154,
"description": "Grok 4.5 is a model from SpaceXAI with frontier performance on coding, knowledge work, and STEM.",
"context_length": 500000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Grok",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000006",
"web_search": "0.005",
"input_cache_read": "0.0000003",
"overrides": [
{
"min_prompt_tokens": 200000,
"prompt": "0.000004",
"completion": "0.000012",
"input_cache_read": "0.0000006"
}
]
},
"top_provider": {
"context_length": 500000,
"max_completion_tokens": 450000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"logprobs",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/x-ai/grok-4.5-20260708/endpoints"
},
"reasoning": {
"mandatory": true,
"default_enabled": true,
"supported_efforts": [
"high",
"medium",
"low"
],
"default_effort": "high"
}
},
{
"id": "anthropic/claude-sonnet-4.6",
"canonical_slug": "anthropic/claude-4.6-sonnet-20260217",
"hugging_face_id": "",
"name": "Anthropic: Claude Sonnet 4.6",
"created": 1771342990,
"description": "Sonnet 4.6 is Anthropic's most capable Sonnet-class model yet, with frontier performance across coding, agents, and professional work. It excels at iterative development, complex codebase navigation, end-to-end project management with...",
"context_length": 1000000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"web_search": "0.01",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375",
"input_cache_write_1h": "0.000006"
},
"top_provider": {
"context_length": 1000000,
"max_completion_tokens": 128000,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"include_reasoning",
"max_completion_tokens",
"max_tokens",
"reasoning",
"reasoning_effort",
"response_format",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_p",
"verbosity"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/anthropic/claude-4.6-sonnet-20260217/endpoints"
},
"reasoning": {
"mandatory": false,
"supported_efforts": [
"max",
"high",
"medium",
"low"
],
"default_effort": "medium"
}
},
{
"id": "openrouter/free",
"canonical_slug": "openrouter/free",
"hugging_face_id": "",
"name": "Free Models Router",
"created": 1769917427,
"description": "The simplest way to get free inference. openrouter/free is a router that selects free models at random from the models available on OpenRouter. The router smartly filters for models that...",
"context_length": 200000,
"architecture": {
"modality": "text+image->text",
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"text"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "0",
"completion": "0"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logprobs",
"max_completion_tokens",
"max_tokens",
"min_p",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_k",
"top_logprobs",
"top_p"
],
"default_parameters": {
"temperature": null,
"top_p": null,
"frequency_penalty": null
},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/free/endpoints"
}
},
{
"id": "openai/gpt-4.1",
"canonical_slug": "openai/gpt-4.1-2025-04-14",
"hugging_face_id": "",
"name": "OpenAI: GPT-4.1",
"created": 1744651385,
"description": "GPT-4.1 is a flagship large language model optimized for advanced instruction following, real-world software engineering, and long-context reasoning. It supports a 1 million token context window and outperforms GPT-4o and...",
"context_length": 1047576,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"image",
"text",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.000002",
"completion": "0.000008",
"web_search": "0.01",
"input_cache_read": "0.0000005"
},
"top_provider": {
"context_length": 1047576,
"max_completion_tokens": 32768,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"max_completion_tokens",
"max_tokens",
"response_format",
"seed",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_p"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2024-06-30",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4.1-2025-04-14/endpoints"
}
},
{
"id": "openai/gpt-4o",
"canonical_slug": "openai/gpt-4o",
"hugging_face_id": null,
"name": "OpenAI: GPT-4o",
"created": 1715558400,
"description": "GPT-4o (\"o\" for \"omni\") is OpenAI's latest AI model, supporting both text and image inputs with text outputs. It maintains the intelligence level of [GPT-4 Turbo](/models/openai/gpt-4-turbo) while being twice as...",
"context_length": 128000,
"architecture": {
"modality": "text+image+file->text",
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"tokenizer": "GPT",
"instruct_type": null
},
"pricing": {
"prompt": "0.0000025",
"completion": "0.00001",
"input_cache_read": "0.00000125"
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": 16384,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"logit_bias",
"logprobs",
"max_completion_tokens",
"max_tokens",
"prediction",
"presence_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": "2023-10-31",
"expiration_date": null,
"links": {
"details": "/api/v1/models/openai/gpt-4o/endpoints"
}
},
{
"id": "openrouter/auto",
"canonical_slug": "openrouter/auto",
"hugging_face_id": null,
"name": "Auto Router",
"created": 1699401600,
"description": "The Auto Router automatically selects the best model for your prompt, powered by the wisdom of the market. It routes you based on what the OpenRouter community collectively spends on...",
"context_length": 2000000,
"architecture": {
"modality": "text+image+file+audio+video->text+image",
"input_modalities": [
"text",
"image",
"audio",
"file",
"video"
],
"output_modalities": [
"text",
"image"
],
"tokenizer": "Router",
"instruct_type": null
},
"pricing": {
"prompt": "-1",
"completion": "-1"
},
"top_provider": {
"context_length": null,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"frequency_penalty",
"include_reasoning",
"logit_bias",
"logprobs",
"max_tokens",
"min_p",
"prediction",
"presence_penalty",
"reasoning",
"reasoning_effort",
"repetition_penalty",
"response_format",
"seed",
"stop",
"structured_outputs",
"temperature",
"tool_choice",
"tools",
"top_a",
"top_k",
"top_logprobs",
"top_p",
"web_search_options"
],
"default_parameters": {},
"supported_voices": null,
"knowledge_cutoff": null,
"expiration_date": null,
"links": {
"details": "/api/v1/models/openrouter/auto/endpoints"
}
}
]
}
+5
View File
@@ -648,6 +648,11 @@ impl Agent {
enabled_tools: None,
}
}
#[cfg(test)]
pub fn functions_mut(&mut self) -> &mut Functions {
&mut self.functions
}
}
impl RoleLike for Agent {
+82 -8
View File
@@ -102,6 +102,8 @@ pub struct AppConfig {
pub no_workspace_mcp: bool,
pub no_workspace_macros: bool,
pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>,
}
@@ -188,6 +190,8 @@ impl Default for AppConfig {
no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![],
}
@@ -276,6 +280,8 @@ impl AppConfig {
no_workspace_mcp: false,
no_workspace_macros: false,
sync_models_url: config.sync_models_url,
sync_models_auto: config.sync_models_auto,
sync_models_ttl_hours: config.sync_models_ttl_hours,
clients: config.clients,
};
@@ -388,10 +394,8 @@ impl AppConfig {
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
}
pub fn sync_models_url(&self) -> String {
self.sync_models_url
.clone()
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
pub fn sync_models_url(&self) -> Option<String> {
self.sync_models_url.clone()
}
pub fn light_theme(&self) -> bool {
@@ -636,7 +640,10 @@ impl AppConfig {
if self.highlight && self.theme.is_none() {
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
self.theme = v;
} else if *IS_STDOUT_TERMINAL {
} else if !cfg!(test) && *IS_STDOUT_TERMINAL {
// Never query the terminal from test builds: the test harness
// shares the process TTY, so parallel raw-mode color queries
// garble the runner's output and stall on stolen replies.
if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {
let theme = match color_scheme {
ColorScheme::Dark => "dark",
@@ -665,6 +672,13 @@ impl AppConfig {
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
self.sync_models_url = v;
}
if let Some(Some(v)) = super::read_env_bool(&get_env_name("sync_models_auto")) {
self.sync_models_auto = v;
}
if let Some(Some(v)) = super::read_env_value::<u64>(&get_env_name("sync_models_ttl_hours"))
{
self.sync_models_ttl_hours = v;
}
}
}
@@ -951,10 +965,70 @@ mod tests {
}
#[test]
fn sync_models_url_has_default() {
fn sync_models_url_defaults_to_none() {
let app = AppConfig::default();
let url = app.sync_models_url();
assert!(!url.is_empty());
assert!(app.sync_models_url().is_none());
}
#[test]
fn sync_models_auto_and_ttl_have_defaults() {
let app = AppConfig::default();
assert!(app.sync_models_auto);
assert_eq!(app.sync_models_ttl_hours, 24);
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_auto() {
let env_name = get_env_name("sync_models_auto");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "false") };
app.load_envs();
assert!(!app.sync_models_auto);
unsafe { env::set_var(&env_name, "true") };
app.load_envs();
assert!(app.sync_models_auto);
unsafe { env::remove_var(&env_name) };
app.sync_models_auto = false;
app.load_envs();
assert!(!app.sync_models_auto);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_sync_models_ttl_hours() {
let env_name = get_env_name("sync_models_ttl_hours");
let prev = env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { env::set_var(&env_name, "6") };
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 6);
unsafe { env::remove_var(&env_name) };
app.sync_models_ttl_hours = 48;
app.load_envs();
assert_eq!(app.sync_models_ttl_hours, 48);
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
}
#[test]
+1
View File
@@ -74,6 +74,7 @@ pub async fn macro_execute(
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();
+260 -22
View File
@@ -69,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;
@@ -93,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";
@@ -187,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: ";
@@ -287,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>,
}
@@ -370,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![],
}
@@ -513,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)
.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")?;
/// 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}'");
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 {
@@ -890,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());
@@ -1381,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);
}
}
}
+3
View File
@@ -63,11 +63,14 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {"
- `todo__done`: Mark a task done by id. Call this immediately after completing each step.
- `todo__list`: Show the current todo list.
- `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction.
- `todo__pause`: Pause auto-continue and yield the turn to the user, keeping incomplete todos intact. Use when you are blocked on user input or a user-side action. Auto-continue resumes on the user's next message.
RULES:
- Always create a todo list before starting work.
- Mark each task done as soon as you finish it; do not batch.
- If the user cancels the current task or changes direction, call `todo__clear` immediately.
- If you need user input (or the user must do something on their end) before you can continue, call `todo__pause` with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop the auto-continue prompts.
- `todo__pause` is for top-level sessions only. Subagents are driven to completion autonomously and cannot pause. If you are a subagent and need input, use a `user__*` tool (it escalates to the root agent mid-turn and blocks until answered, so you keep working without ending your turn).
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
};
+36 -2
View File
@@ -336,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,
}
@@ -374,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(),
}
}
@@ -438,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();
@@ -491,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,
}
}
@@ -540,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,
}
}
@@ -576,6 +587,7 @@ impl RequestContext {
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 {
@@ -589,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;
@@ -4670,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)
@@ -4713,6 +4735,7 @@ impl RequestContext {
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
@@ -5232,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
@@ -5252,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);
+210 -4
View File
@@ -1,3 +1,4 @@
use super::todo::TODO_FUNCTION_PREFIX;
use super::{FunctionDeclaration, JsonSchema};
use crate::client::{Model, ModelType, call_chat_completions};
use crate::config::{
@@ -7,9 +8,10 @@ use crate::config::{
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
use crate::supervisor::notification::agent_notification;
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal};
use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_user_interrupt};
use crate::graph;
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
use anyhow::{Context, Result, anyhow, bail};
use chrono::Utc;
use indexmap::IndexMap;
@@ -574,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: {:?}",
@@ -601,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
@@ -749,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(())
}
@@ -1031,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();
@@ -1041,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()
{
@@ -1068,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());
}
}
}
}
}
@@ -1630,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};
@@ -1653,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(
@@ -1820,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 {
@@ -2557,6 +2697,72 @@ mod tests {
);
}
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_agent() {
run_async(async {
let mut ctx = ctx_with_supervisor(4, 3);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let join_handle = tokio::spawn(async move {
let _ = rx.await;
Ok(AgentResult {
id: "a1".to_string(),
agent_name: "explore".to_string(),
output: "late output".to_string(),
exit_status: AgentExitStatus::Completed,
})
});
let handle = AgentHandle {
id: "a1".to_string(),
agent_name: "explore".to_string(),
depth: 1,
inbox: Arc::new(Inbox::new()),
abort_signal: create_abort_signal(),
join_handle,
child_supervisor: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&mut ctx, &json!({"id": "a1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "a1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert_eq!(
ctx.supervisor.as_ref().unwrap().read().is_finished("a1"),
Some(false)
);
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&mut ctx, &json!({"id": "a1"}))
.await
.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["output"], "late output");
});
}
#[test]
fn handle_collect_pending_escalations_early_out_keeps_handle() {
let rt = tokio::runtime::Builder::new_current_thread()
+86 -1
View File
@@ -16,7 +16,9 @@ use crate::mcp::{
};
use crate::supervisor::notification::job_notification;
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor};
use crate::utils::{create_abort_signal, muted_warning_text, temp_file, wait_abort_signal};
use crate::utils::{
create_abort_signal, muted_warning_text, temp_file, wait_abort_signal, wait_user_interrupt,
};
use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap;
@@ -623,6 +625,14 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
job.abort_signal.clone()
};
let interrupted = || {
json!({
"status": "interrupted",
"id": id,
"message": format!("job__collect was interrupted by the user; job '{id}' is still running in the background. Collect it again later, cancel it with job__cancel, or continue with other work."),
})
};
loop {
let is_finished = {
let sup = supervisor.read();
@@ -633,6 +643,10 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
break;
}
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
return Ok(interrupted());
}
if let Some(queue) = ctx.root_escalation_queue()
&& queue.has_pending()
{
@@ -666,6 +680,9 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
tokio::select! {
_ = time::sleep(Duration::from_millis(200)) => {}
_ = wait_abort_signal(&target_abort) => {}
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
return Ok(interrupted());
}
}
}
@@ -2140,6 +2157,74 @@ mod tests {
});
}
#[test]
fn handle_collect_session_interrupt_returns_and_keeps_job() {
run_async(async {
let mut ctx = ctx_with_job_supervisor(4);
let state = Arc::new(Mutex::new(JobState {
status: JobStatus::Running,
pgid: None,
}));
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let task_state = Arc::clone(&state);
let join_handle = tokio::spawn(async move {
let _ = rx.await;
task_state.lock().status = JobStatus::Completed;
Ok(JobResult {
output: json!("late result"),
exit_code: Some(0),
output_bytes_captured: 0,
})
});
let handle = JobHandle {
id: "j1".to_string(),
tool: "execute_command".to_string(),
started_at: Instant::now(),
join_handle,
abort_signal: create_abort_signal(),
state,
output_buf: Arc::new(Mutex::new(RingBuf::default())),
no_change_checks: 0,
last_check_state: None,
};
ctx.supervisor
.as_ref()
.unwrap()
.write()
.register(handle)
.unwrap();
let session = create_abort_signal();
session.set_ctrlc();
ctx.session_abort = Some(session.clone());
let result = time::timeout(
Duration::from_secs(2),
handle_collect(&ctx, &json!({"id": "j1"})),
)
.await
.expect("interrupted collect must return promptly")
.unwrap();
assert_eq!(result["status"], "interrupted");
assert_eq!(result["id"], "j1");
assert!(
result["message"]
.as_str()
.unwrap()
.contains("still running")
);
assert!(ctx.supervisor.as_ref().unwrap().read().has_job("j1"));
session.reset();
tx.send(()).unwrap();
let collected = handle_collect(&ctx, &json!({"id": "j1"})).await.unwrap();
assert_eq!(collected["status"], "completed");
assert_eq!(collected["result"], "late result");
});
}
#[test]
fn handle_collect_full_result_returns_uncapped() {
run_async(async {
+78
View File
@@ -727,6 +727,10 @@ impl Functions {
.retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX));
}
pub fn remove_function(&mut self, name: &str) {
self.declarations.retain(|f| f.name != name);
}
pub fn append_memory_functions(&mut self) {
self.declarations
.extend(memory::memory_function_declarations());
@@ -3371,6 +3375,7 @@ mod tests {
assert!(f.contains("todo__done"));
assert!(f.contains("todo__list"));
assert!(f.contains("todo__clear"));
assert!(f.contains("todo__pause"));
}
#[test]
@@ -4914,6 +4919,79 @@ mod tests {
assert!(err.contains("Auto-continue is not enabled"), "{err}");
}
#[test]
fn todo_pause_sets_and_clears_pause_flag() {
let app = AppState {
config: Arc::new(AppConfig {
auto_continue: true,
..Default::default()
}),
..AppState::test_default()
};
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.tool_scope.functions.append_todo_functions();
// A blank reason is rejected and does not pause.
let out = run_async(call_with_args("todo__pause", json!({"reason": " "})).eval(&mut ctx))
.unwrap();
assert_eq!(out["error"], "reason is required");
assert!(ctx.auto_continue_paused.is_none());
// A real reason pauses auto-continue, preserving the todo list.
run_async(call_with_args("todo__init", json!({"goal": "ship"})).eval(&mut ctx)).unwrap();
run_async(call_with_args("todo__add", json!({"task": "deploy"})).eval(&mut ctx)).unwrap();
let out = run_async(
call_with_args("todo__pause", json!({"reason": "need prod API key"})).eval(&mut ctx),
)
.unwrap();
assert_eq!(out["status"], "ok");
assert_eq!(
ctx.auto_continue_paused.as_deref(),
Some("need prod API key")
);
assert!(ctx.todo_list.has_incomplete());
// The next real user message lifts the pause.
ctx.resume_auto_continue();
assert!(ctx.auto_continue_paused.is_none());
// Re-initializing or clearing the list also lifts a stale pause.
ctx.pause_auto_continue("stale");
run_async(call_with_args("todo__init", json!({"goal": "new"})).eval(&mut ctx)).unwrap();
assert!(ctx.auto_continue_paused.is_none());
ctx.pause_auto_continue("stale again");
run_async(call_with_args("todo__clear", json!({})).eval(&mut ctx)).unwrap();
assert!(ctx.auto_continue_paused.is_none());
}
/// Subagents are driven to completion autonomously: their turn boundary
/// has no interactive user, and user__* escalations work mid-turn, so
/// pausing is never the right move for a child. The handler must reject
/// the call, leave the pause flag unset, and redirect to escalation.
#[test]
fn todo_pause_is_rejected_for_subagents() {
let app = AppState {
config: Arc::new(AppConfig {
auto_continue: true,
..Default::default()
}),
..AppState::test_default()
};
let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd);
ctx.tool_scope.functions.append_todo_functions();
ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string());
let out = run_async(
call_with_args("todo__pause", json!({"reason": "need user input"})).eval(&mut ctx),
)
.unwrap();
let err = out["error"].as_str().unwrap();
assert!(err.contains("unavailable to subagents"), "{err}");
assert!(err.contains("user__"), "{err}");
assert!(ctx.auto_continue_paused.is_none());
}
#[test]
fn eval_routes_memory_prefix_to_memory_handler() {
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
+59
View File
@@ -86,6 +86,33 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
},
agent: false,
},
FunctionDeclaration {
name: format!("{TODO_FUNCTION_PREFIX}pause"),
description: "Pause auto-continue and yield the turn back to the user while keeping \
incomplete todos intact. Use when you cannot proceed without user input \
or a user-side action. NEVER mark unfinished todos as done just to stop \
auto-continuation. Auto-continue resumes automatically on the user's \
next message. Unavailable to subagents: they are driven to completion \
autonomously — a subagent needing input must use a user__* tool, which \
escalates to the root agent mid-turn and blocks until answered."
.to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
"reason".to_string(),
JsonSchema {
type_value: Some("string".to_string()),
description: Some(
"What you are waiting on from the user (shown to the user)".into(),
),
..Default::default()
},
)])),
required: Some(vec!["reason".to_string()]),
..Default::default()
},
agent: false,
},
]
}
@@ -149,6 +176,38 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
ctx.clear_todo_list();
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
}
"pause" => {
if ctx.self_agent_id.is_some() {
return Ok(json!({
"error": "todo__pause is unavailable to subagents: you are driven to \
completion autonomously and there is no interactive user at your \
turn boundary. If you need input, ask via a user__* tool — it \
escalates to the root agent mid-turn and blocks until answered, so \
you can keep working. If you are blocked on an action outside your \
scope, finish what you can and report the blocker in your final \
output."
}));
}
let reason = args
.get("reason")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
if reason.is_empty() {
return Ok(json!({"error": "reason is required"}));
}
ctx.pause_auto_continue(&reason);
Ok(json!({
"status": "ok",
"message": "Auto-continue paused; incomplete todos are preserved. Tell the user \
what you need from them, then end your turn. Auto-continue resumes \
on their next message."
}))
}
_ => bail!("Unknown todo action: {action}"),
}
}
+1
View File
@@ -695,6 +695,7 @@ nodes:
type: script
script: doubler.py
state_updates: {}
timeout: 60
done:
type: end
output: "{{doubled}}"
+14 -8
View File
@@ -262,13 +262,16 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
#[tokio::test]
async fn bash_script_can_read_state_from_env() {
if !cmd_available("bash") || !cmd_available("python3") {
eprintln!("skipping: bash or python3 not available");
if !cmd_available("bash") {
eprintln!("skipping: bash not available");
return;
}
// Pure-bash JSON extraction: spawning python3 here blew tight
// timeouts on Windows CI (first-start AV scans of the interpreter).
let (dir, path) = write_script(
r#"#!/bin/bash
NAME=$(python3 -c 'import json,os; print(json.loads(os.environ["GRAPH_STATE"])["name"])')
t=${GRAPH_STATE#*'"name":"'}
NAME=${t%%'"'*}
printf '{"greeting": "hello %s"}' "$NAME"
"#,
"sh",
@@ -280,7 +283,7 @@ printf '{"greeting": "hello %s"}' "$NAME"
let _ = executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state,
)
.await
@@ -466,7 +469,7 @@ echo '{"ok":true}'
#[tokio::test]
async fn large_state_is_delivered_via_file_env_var() {
if !cmd_available("bash") || !cmd_available("python3") {
if !cmd_available("bash") {
return;
}
let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024);
@@ -476,7 +479,10 @@ echo '{"ok":true}'
let (dir, path) = write_script(
r#"#!/bin/bash
if [ -n "$GRAPH_STATE_FILE" ]; then
LEN=$(python3 -c 'import json,os; print(len(json.load(open(os.environ["GRAPH_STATE_FILE"]))["blob"]))')
CONTENT=$(cat "$GRAPH_STATE_FILE")
t=${CONTENT#*'"blob":"'}
BLOB=${t%%'"'*}
LEN=${#BLOB}
printf '{"blob_len": %s, "via_file": true}' "$LEN"
elif [ -n "$GRAPH_STATE" ]; then
echo '{"via_file": false}'
@@ -489,7 +495,7 @@ fi
let executor = ScriptExecutor::new(&dir);
executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 10),
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
&mut state,
)
.await
@@ -524,7 +530,7 @@ print(json.dumps({
let executor = ScriptExecutor::new(&dir);
let next = executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
&node_for(path.file_name().unwrap().to_str().unwrap(), 60),
&mut state,
)
.await
+39 -31
View File
@@ -20,13 +20,14 @@ extern crate log;
use crate::cli::Cli;
use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
};
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
install_builtins, list_agents, load_env_file, macro_execute, maybe_spawn_models_refresh,
sync_models,
};
use crate::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -101,6 +102,7 @@ async fn main() -> Result<()> {
let info_flag = cli.info
|| cli.sync_models
|| cli.generate_models.is_some()
|| cli.list_models
|| cli.list_roles
|| cli.list_agents
@@ -292,7 +294,11 @@ async fn run(
) -> Result<()> {
if cli.sync_models {
let url = ctx.app.config.sync_models_url();
return sync_models(&url, abort_signal.clone()).await;
return sync_models(url.as_deref(), abort_signal.clone()).await;
}
if let Some(path) = &cli.generate_models {
return catalog::generate_models_file(path).await;
}
if cli.list_models {
@@ -544,12 +550,12 @@ async fn run(
if !*IS_STDOUT_TERMINAL {
bail!("No TTY for REPL")
}
maybe_spawn_models_refresh(&ctx.app.config);
start_interactive(ctx).await
}
}
}
#[async_recursion::async_recursion]
async fn start_directive(
ctx: &mut RequestContext,
input: Input,
@@ -557,6 +563,7 @@ async fn start_directive(
abort_signal: AbortSignal,
) -> Result<()> {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
ctx.session_abort = Some(abort_signal.clone());
if graph::active_agent_graph_name(ctx).is_some() {
ctx.before_chat_completion(&input)?;
@@ -568,37 +575,36 @@ async fn start_directive(
return Ok(());
}
let client = input.create_client()?;
let extract_code = !*IS_STDOUT_TERMINAL && code_mode;
ctx.before_chat_completion(&input)?;
let (output, tool_results) = if !input.stream() || extract_code {
call_chat_completions(
&input,
true,
extract_code,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await?
} else {
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone()).await?
};
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
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(
&input,
true,
extract_code,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await?
} else {
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() {
input = input.merge_tool_results(output, tool_results);
continue;
}
if !tool_results.is_empty() {
start_directive(
ctx,
input.merge_tool_results(output, tool_results),
code_mode,
abort_signal,
)
.await?;
} else {
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()?;
+159 -140
View File
@@ -52,7 +52,8 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
4. Continue with the next pending item now. Call tools immediately."
4. If you are blocked on user input or a user-side action: call todo__pause with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop this reminder.
5. Otherwise, continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
@@ -1409,6 +1410,7 @@ pub async fn run_repl_command(
handle_shell_passthrough(cmd)?;
} else {
reset_continuation(ctx);
ctx.resume_auto_continue();
let input = Input::from_str(ctx, line, None)?;
ask(ctx, abort_signal.clone(), input, true).await?;
}
@@ -1422,73 +1424,73 @@ 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<()> {
if input.is_empty() {
return Ok(());
}
if with_embeddings {
input.use_embeddings(abort_signal.clone()).await?;
}
while ctx.is_compressing_session() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let app = Arc::clone(&ctx.app.config);
if graph::active_agent_graph_name(ctx).is_some() {
ctx.before_chat_completion(&input)?;
let output =
graph::run_active_agent_graph(ctx, &input.text(), abort_signal.clone()).await?;
app.print_markdown(&output)?;
ctx.after_chat_completion(app.as_ref(), &input, &output, &[])?;
return Ok(());
}
let client = input.create_client()?;
ctx.before_chat_completion(&input)?;
let (output, tool_results) = {
let result = if input.stream() {
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone())
.await
} else {
call_chat_completions(
&input,
true,
false,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await
};
match result {
Ok(v) => v,
Err(err) => {
ctx.on_chat_completion_error(app.as_ref(), &input);
return Err(err);
}
let mut input = input;
let mut with_embeddings = with_embeddings;
loop {
if input.is_empty() {
return Ok(());
}
};
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 {
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;
}
let app = Arc::clone(&ctx.app.config);
if graph::active_agent_graph_name(ctx).is_some() {
ctx.before_chat_completion(&input)?;
let output =
graph::run_active_agent_graph(ctx, &input.text(), abort_signal.clone()).await?;
app.print_markdown(&output)?;
ctx.after_chat_completion(app.as_ref(), &input, &output, &[])?;
return Ok(());
}
let client = input.create_client()?;
ctx.before_chat_completion(&input)?;
let (output, tool_results) = {
let result = if input.stream() {
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone())
.await
} else {
call_chat_completions(
&input,
true,
false,
client.as_ref(),
ctx,
abort_signal.clone(),
)
.await
};
match result {
Ok(v) => v,
Err(err) => {
ctx.on_chat_completion_error(app.as_ref(), &input);
return Err(err);
}
}
};
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
if !tool_results.is_empty() {
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,87 +1532,103 @@ 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 {
reset_continuation(ctx);
if ctx.maybe_autoname_session() {
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!("\n📢 {}", color.italic().paint("Autonaming the session."),);
if let Err(err) = ctx.autoname_session(app.as_ref()).await {
warn!("Failed to autonaming the session: {err}");
}
if let Some(session) = ctx.session.as_mut() {
session.set_autonaming(false);
}
}
let needs_compression = ctx
.session
.as_ref()
.is_some_and(|s| s.needs_compression(app.compression_threshold));
if needs_compression {
let agent_can_continue_after_compress =
should_continue(ctx) && !abort_signal.aborted_ctrlc();
if let Some(session) = ctx.session.as_mut() {
session.set_compressing(true);
}
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
if let Err(err) = ctx.compress_session().await {
warn!("Failed to compress the session: {err}");
}
if let Some(session) = ctx.session.as_mut() {
session.set_compressing(false);
}
if agent_can_continue_after_compress {
let full_prompt = {
let config = ctx.auto_continue_config();
let todo_state = ctx.todo_list.render_for_model();
let remaining = ctx.todo_list.incomplete_count();
ctx.increment_auto_continue_count();
let count = ctx.auto_continue_count;
let max = config.max_continues;
let prompt = config
.continuation_prompt
.as_deref()
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!(
"\n📋 {}",
color.italic().paint(format!(
"Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain"
))
);
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;
}
}
Ok(())
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
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!("\n📢 {}", color.italic().paint("Autonaming the session."),);
if let Err(err) = ctx.autoname_session(app.as_ref()).await {
warn!("Failed to autonaming the session: {err}");
}
if let Some(session) = ctx.session.as_mut() {
session.set_autonaming(false);
}
}
let needs_compression = ctx
.session
.as_ref()
.is_some_and(|s| s.needs_compression(app.compression_threshold));
if needs_compression {
let agent_can_continue_after_compress =
should_continue(ctx) && !abort_signal.aborted_ctrlc();
if let Some(session) = ctx.session.as_mut() {
session.set_compressing(true);
}
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!("\n📢 {}", color.italic().paint("Compressing the session."),);
if let Err(err) = ctx.compress_session().await {
warn!("Failed to compress the session: {err}");
}
if let Some(session) = ctx.session.as_mut() {
session.set_compressing(false);
}
if agent_can_continue_after_compress {
let full_prompt = {
let config = ctx.auto_continue_config();
let todo_state = ctx.todo_list.render_for_model();
let remaining = ctx.todo_list.incomplete_count();
ctx.increment_auto_continue_count();
let count = ctx.auto_continue_count;
let max = config.max_continues;
let prompt = config
.continuation_prompt
.as_deref()
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
let color = if app.light_theme() {
nu_ansi_term::Color::LightGray
} else {
nu_ansi_term::Color::DarkGray
};
eprintln!(
"\n📋 {}",
color.italic().paint(format!(
"Auto-continuing after compression ({count}/{max}): {remaining} incomplete todo(s) remain"
))
);
format!("{prompt}\n\n{todo_state}")
};
input = Input::from_str(ctx, &full_prompt, None)?;
continue;
}
}
return Ok(());
}
}
@@ -1620,6 +1638,7 @@ fn should_continue(ctx: &RequestContext) -> bool {
&& config.enabled
&& ctx.auto_continue_count < config.max_continues
&& ctx.todo_list.has_incomplete()
&& ctx.auto_continue_paused.is_none()
}
fn reset_continuation(ctx: &mut RequestContext) {
+36 -1
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use crate::client::{Message, MessageRole};
use crate::client::{Message, MessageContent, MessageContentToolCalls, MessageRole};
use crate::config::{AppConfig, Session};
use crate::utils::{dimmed_text, replay_label_text};
@@ -52,9 +52,44 @@ fn render_messages(app: &AppConfig, messages: &[Message]) -> Result<()> {
println!();
}
}
MessageRole::Tool => {
if let MessageContent::ToolCalls(tool_calls) = &message.content {
render_tool_call_rounds(app, tool_calls)?;
}
}
_ => {}
}
}
Ok(())
}
fn render_tool_call_rounds(app: &AppConfig, tool_calls: &MessageContentToolCalls) -> Result<()> {
let mut needs_gap_after_calls = false;
if !tool_calls.text.trim().is_empty() {
println!("{}", replay_label_text("Assistant:"));
app.print_markdown(&tool_calls.text)?;
println!();
}
for result in &tool_calls.tool_results {
if let Some(text) = result.text.as_deref().filter(|t| !t.trim().is_empty()) {
if needs_gap_after_calls {
println!();
}
println!("{}", replay_label_text("Assistant:"));
app.print_markdown(text)?;
println!();
}
println!("{}", dimmed_text(&format!("⚙ {}", result.call.name)));
needs_gap_after_calls = true;
}
if needs_gap_after_calls {
println!();
}
Ok(())
}
+38
View File
@@ -68,6 +68,29 @@ pub async fn wait_abort_signal(abort_signal: &AbortSignal) {
}
}
/// Completes when the user interrupts: a SIGINT arrives, or the session's turn
/// signal is (or becomes) aborted. Needed because tools run in cooked mode --
/// tokio's global SIGINT handler (installed by the first `ctrl_c()` poll)
/// swallows the signal with nobody listening. On SIGINT, sets ctrl-c on the
/// session signal so the surrounding turn aborts too. With `None`, only the
/// SIGINT arm applies.
pub async fn wait_user_interrupt(session: Option<&AbortSignal>) {
let ctrl_c = async {
if tokio::signal::ctrl_c().await.is_err() {
std::future::pending::<()>().await;
}
};
match session {
Some(signal) => {
tokio::select! {
_ = ctrl_c => signal.set_ctrlc(),
_ = wait_abort_signal(signal) => {}
}
}
None => ctrl_c.await,
}
}
pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
if event::poll(Duration::from_millis(25))?
&& let Event::Key(key) = event::read()?
@@ -86,3 +109,18 @@ pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn wait_user_interrupt_returns_promptly_on_preset_session_signal() {
let signal = create_abort_signal();
signal.set_ctrlc();
tokio::time::timeout(Duration::from_secs(1), wait_user_interrupt(Some(&signal)))
.await
.expect("must return promptly when the session signal is already set");
}
}