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