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.
|
* [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.
|
* [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.
|
* [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.
|
* [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.
|
* [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 ----
|
# ---- Miscellaneous ----
|
||||||
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
|
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
|
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
|
# 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 ----
|
# ---- Clients ----
|
||||||
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
|
# 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.cohere.ai'
|
||||||
- 'api.groq.com'
|
- 'api.groq.com'
|
||||||
- 'openrouter.ai'
|
- 'openrouter.ai'
|
||||||
|
- 'models.dev'
|
||||||
- 'api.ai21.com'
|
- 'api.ai21.com'
|
||||||
- 'api.cloudflare.com'
|
- 'api.cloudflare.com'
|
||||||
- 'api.deepinfra.com'
|
- 'api.deepinfra.com'
|
||||||
|
|||||||
+7
-2
@@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env:
|
|||||||
# ---- Miscellaneous ----
|
# ---- Miscellaneous ----
|
||||||
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
|
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
|
save_shell_history: true # Whether to save shell execution command to the history file
|
||||||
sync_models_url: > # URL to sync model changes from
|
sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
|
||||||
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml
|
# 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 ----
|
# ---- 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
|
# 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 is_terminal::IsTerminal;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::{Read, stdin};
|
use std::io::{Read, stdin};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum McpTransportArg {
|
pub enum McpTransportArg {
|
||||||
@@ -53,7 +54,7 @@ pub enum McpScopeArg {
|
|||||||
"install_builtins", "sync_models", "list_models", "list_roles",
|
"install_builtins", "sync_models", "list_models", "list_roles",
|
||||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||||
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
||||||
"update", "update_bundle", "uninstall",
|
"update", "update_bundle", "uninstall", "generate_models",
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
group(
|
group(
|
||||||
@@ -253,6 +254,15 @@ pub struct Cli {
|
|||||||
/// Sync models updates
|
/// Sync models updates
|
||||||
#[arg(long, help_heading = "Installation & Updates")]
|
#[arg(long, help_heading = "Installation & Updates")]
|
||||||
pub sync_models: bool,
|
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
|
/// Update Coyote to the latest release, or to a specific version
|
||||||
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
|
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
|
||||||
pub update: Option<Option<String>>,
|
pub update: Option<Option<String>>,
|
||||||
@@ -810,6 +820,19 @@ mod tests {
|
|||||||
assert!(cli.sync_models);
|
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]
|
#[test]
|
||||||
fn parse_model_with_role() {
|
fn parse_model_with_role() {
|
||||||
let cli = parse(&["-m", "gpt-4o", "-r", "coder"]);
|
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;
|
mod access_token;
|
||||||
|
pub mod catalog;
|
||||||
mod claude_oauth;
|
mod claude_oauth;
|
||||||
mod common;
|
mod common;
|
||||||
mod gemini_oauth;
|
mod gemini_oauth;
|
||||||
|
|||||||
+93
-11
@@ -1,5 +1,7 @@
|
|||||||
use super::{
|
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},
|
message::{Message, MessageContent, MessageContentPart},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,12 +65,16 @@ impl Model {
|
|||||||
};
|
};
|
||||||
match model_name {
|
match model_name {
|
||||||
Some(model_name) => {
|
Some(model_name) => {
|
||||||
if let Some(model) = models.iter().find(|v| v.id() == model_id) {
|
// Ids are not unique across types (e.g. a model that is both
|
||||||
if model.model_type() == model_type {
|
// an embedder and a reranker), so match on id AND type.
|
||||||
return Ok((*model).clone());
|
if let Some(model) = models
|
||||||
} else {
|
.iter()
|
||||||
bail!("Model '{model_id}' is not a {model_type} model")
|
.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)
|
if list_client_names(config)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -77,6 +83,9 @@ impl Model {
|
|||||||
{
|
{
|
||||||
let mut new_model = Self::new(client_name, model_name);
|
let mut new_model = Self::new(client_name, model_name);
|
||||||
new_model.data.model_type = model_type.to_string();
|
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);
|
return Ok(new_model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +141,7 @@ impl Model {
|
|||||||
output_price,
|
output_price,
|
||||||
supports_vision,
|
supports_vision,
|
||||||
supports_function_calling,
|
supports_function_calling,
|
||||||
|
reasoning_levels,
|
||||||
..
|
..
|
||||||
} = &self.data;
|
} = &self.data;
|
||||||
let max_input_tokens = stringify_option_value(max_input_tokens);
|
let max_input_tokens = stringify_option_value(max_input_tokens);
|
||||||
@@ -145,6 +155,9 @@ impl Model {
|
|||||||
if *supports_function_calling {
|
if *supports_function_calling {
|
||||||
capabilities.push('⚒');
|
capabilities.push('⚒');
|
||||||
};
|
};
|
||||||
|
if !reasoning_levels.is_empty() {
|
||||||
|
capabilities.push('🧠');
|
||||||
|
};
|
||||||
let capabilities: String = capabilities
|
let capabilities: String = capabilities
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| format!("{v} "))
|
.map(|v| format!("{v} "))
|
||||||
@@ -330,11 +343,11 @@ pub struct ModelData {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub default_reasoning_effort: Option<String>,
|
pub default_reasoning_effort: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[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")]
|
#[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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
system_prompt_prefix: Option<String>,
|
pub(crate) system_prompt_prefix: Option<String>,
|
||||||
|
|
||||||
// embedding-only properties
|
// embedding-only properties
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -358,7 +371,7 @@ impl ModelData {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderModels {
|
pub struct ProviderModels {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub oauth: Option<OAuthConfig>,
|
pub oauth: Option<OAuthConfig>,
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
}
|
}
|
||||||
@@ -419,3 +432,72 @@ where
|
|||||||
None => "-".to_string(),
|
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,
|
enabled_tools: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn functions_mut(&mut self) -> &mut Functions {
|
||||||
|
&mut self.functions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoleLike for Agent {
|
impl RoleLike for Agent {
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ pub struct AppConfig {
|
|||||||
pub no_workspace_mcp: bool,
|
pub no_workspace_mcp: bool,
|
||||||
pub no_workspace_macros: bool,
|
pub no_workspace_macros: bool,
|
||||||
pub sync_models_url: Option<String>,
|
pub sync_models_url: Option<String>,
|
||||||
|
pub sync_models_auto: bool,
|
||||||
|
pub sync_models_ttl_hours: u64,
|
||||||
|
|
||||||
pub clients: Vec<ClientConfig>,
|
pub clients: Vec<ClientConfig>,
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,8 @@ impl Default for AppConfig {
|
|||||||
no_workspace_mcp: false,
|
no_workspace_mcp: false,
|
||||||
no_workspace_macros: false,
|
no_workspace_macros: false,
|
||||||
sync_models_url: None,
|
sync_models_url: None,
|
||||||
|
sync_models_auto: true,
|
||||||
|
sync_models_ttl_hours: 24,
|
||||||
|
|
||||||
clients: vec![],
|
clients: vec![],
|
||||||
}
|
}
|
||||||
@@ -276,6 +280,8 @@ impl AppConfig {
|
|||||||
no_workspace_mcp: false,
|
no_workspace_mcp: false,
|
||||||
no_workspace_macros: false,
|
no_workspace_macros: false,
|
||||||
sync_models_url: config.sync_models_url,
|
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,
|
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."))
|
.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 {
|
pub fn sync_models_url(&self) -> Option<String> {
|
||||||
self.sync_models_url
|
self.sync_models_url.clone()
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn light_theme(&self) -> bool {
|
pub fn light_theme(&self) -> bool {
|
||||||
@@ -636,7 +640,10 @@ impl AppConfig {
|
|||||||
if self.highlight && self.theme.is_none() {
|
if self.highlight && self.theme.is_none() {
|
||||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
|
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
|
||||||
self.theme = v;
|
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()) {
|
if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {
|
||||||
let theme = match color_scheme {
|
let theme = match color_scheme {
|
||||||
ColorScheme::Dark => "dark",
|
ColorScheme::Dark => "dark",
|
||||||
@@ -665,6 +672,13 @@ impl AppConfig {
|
|||||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
|
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
|
||||||
self.sync_models_url = v;
|
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]
|
#[test]
|
||||||
fn sync_models_url_has_default() {
|
fn sync_models_url_defaults_to_none() {
|
||||||
let app = AppConfig::default();
|
let app = AppConfig::default();
|
||||||
let url = app.sync_models_url();
|
assert!(app.sync_models_url().is_none());
|
||||||
assert!(!url.is_empty());
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub async fn macro_execute(
|
|||||||
macro_ctx.current_depth = ctx.current_depth;
|
macro_ctx.current_depth = ctx.current_depth;
|
||||||
macro_ctx.auto_continue_count = ctx.auto_continue_count;
|
macro_ctx.auto_continue_count = ctx.auto_continue_count;
|
||||||
macro_ctx.todo_list = ctx.todo_list.clone();
|
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.tool_scope.tool_tracker = ctx.tool_scope.tool_tracker.clone();
|
||||||
macro_ctx.discontinuous_last_message();
|
macro_ctx.discontinuous_last_message();
|
||||||
|
|
||||||
|
|||||||
+260
-22
@@ -69,8 +69,9 @@ pub use self::tool_scope::{
|
|||||||
};
|
};
|
||||||
pub use self::update::run_self_update;
|
pub use self::update::run_self_update;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
self, ClientConfig, MODELS_YAML, MessageContentToolCalls, Model, ModelType,
|
||||||
ProviderModels, create_client_config, list_client_types, oauth, set_client_models_config,
|
OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, catalog, create_client_config, list_client_types,
|
||||||
|
oauth, set_client_models_config,
|
||||||
};
|
};
|
||||||
use crate::function::{FunctionDeclaration, Functions};
|
use crate::function::{FunctionDeclaration, Functions};
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
@@ -93,12 +94,13 @@ use serde_json::json;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env, fs,
|
||||||
fs::{File, create_dir_all, read_dir, read_to_string},
|
fs::{File, create_dir_all, read_dir, read_to_string},
|
||||||
io::Write,
|
io::Write,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process,
|
process,
|
||||||
sync::{Arc, OnceLock},
|
sync::{Arc, OnceLock},
|
||||||
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const TEMP_ROLE_NAME: &str = "temp";
|
pub const TEMP_ROLE_NAME: &str = "temp";
|
||||||
@@ -187,9 +189,6 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
|||||||
|
|
||||||
const CLIENTS_FIELD: &str = "clients";
|
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 =
|
const SUMMARIZATION_PROMPT: &str =
|
||||||
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
|
"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: ";
|
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 user_agent: Option<String>,
|
||||||
pub save_shell_history: bool,
|
pub save_shell_history: bool,
|
||||||
pub sync_models_url: Option<String>,
|
pub sync_models_url: Option<String>,
|
||||||
|
pub sync_models_auto: bool,
|
||||||
|
pub sync_models_ttl_hours: u64,
|
||||||
|
|
||||||
pub clients: Vec<ClientConfig>,
|
pub clients: Vec<ClientConfig>,
|
||||||
}
|
}
|
||||||
@@ -370,6 +371,8 @@ impl Default for Config {
|
|||||||
user_agent: None,
|
user_agent: None,
|
||||||
save_shell_history: true,
|
save_shell_history: true,
|
||||||
sync_models_url: None,
|
sync_models_url: None,
|
||||||
|
sync_models_auto: true,
|
||||||
|
sync_models_ttl_hours: 24,
|
||||||
|
|
||||||
clients: vec![],
|
clients: vec![],
|
||||||
}
|
}
|
||||||
@@ -513,26 +516,163 @@ pub fn list_sessions() -> Vec<String> {
|
|||||||
list_file_names(default_sessions_dir(), ".yaml")
|
list_file_names(default_sessions_dir(), ".yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> {
|
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
|
||||||
let content = abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
|
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
|
||||||
.await
|
/// APIs merged with the embedded quirks.
|
||||||
.with_context(|| format!("Failed to fetch '{url}'"))?;
|
pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result<()> {
|
||||||
println!("✓ Fetched '{url}'");
|
let list = match url {
|
||||||
let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content)
|
Some(url) => {
|
||||||
.with_context(|| "Failed to parse models.yaml")?;
|
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 {
|
let models_override = ModelsOverride {
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
list,
|
list,
|
||||||
};
|
};
|
||||||
let models_override_data =
|
let models_override_data = serde_yaml::to_string(&models_override)
|
||||||
serde_yaml::to_string(&models_override).with_context(|| "Failed to serde {}")?;
|
.with_context(|| "Failed to serialize the models override")?;
|
||||||
|
|
||||||
let model_override_path = paths::models_override_file();
|
let model_override_path = paths::models_override_file();
|
||||||
ensure_parent_exists(&model_override_path)?;
|
ensure_parent_exists(&model_override_path)?;
|
||||||
std::fs::write(&model_override_path, models_override_data)
|
// Write through a sibling temp file and rename it over the target so an
|
||||||
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()))?;
|
// interrupted write can never leave a truncated override behind. The temp
|
||||||
println!("✓ Updated '{}'", model_override_path.display());
|
// name embeds the pid so concurrent coyote processes (auto-refresh racing
|
||||||
Ok(())
|
// 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 {
|
impl Config {
|
||||||
@@ -890,13 +1030,13 @@ fn render_config_template_from(
|
|||||||
|
|
||||||
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> {
|
||||||
ensure_parent_exists(config_path)?;
|
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()))?;
|
.with_context(|| format!("Failed to write to '{}'", config_path.display()))?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::prelude::PermissionsExt;
|
use std::os::unix::prelude::PermissionsExt;
|
||||||
let perms = std::fs::Permissions::from_mode(0o600);
|
let perms = fs::Permissions::from_mode(0o600);
|
||||||
std::fs::set_permissions(config_path, perms)?;
|
fs::set_permissions(config_path, perms)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✓ Saved the config file to '{}'.\n", config_path.display());
|
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"
|
"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__done`: Mark a task done by id. Call this immediately after completing each step.
|
||||||
- `todo__list`: Show the current todo list.
|
- `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__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:
|
RULES:
|
||||||
- Always create a todo list before starting work.
|
- Always create a todo list before starting work.
|
||||||
- Mark each task done as soon as you finish it; do not batch.
|
- 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 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."
|
- 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 todo_list: TodoList,
|
||||||
pub skill_registry: SkillRegistry,
|
pub skill_registry: SkillRegistry,
|
||||||
pub last_continuation_response: Option<String>,
|
pub last_continuation_response: Option<String>,
|
||||||
|
pub auto_continue_paused: Option<String>,
|
||||||
pub pending_prefill: Option<String>,
|
pub pending_prefill: Option<String>,
|
||||||
|
|
||||||
|
pub session_abort: Option<AbortSignal>,
|
||||||
|
|
||||||
pub render_mode: RenderMode,
|
pub render_mode: RenderMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +377,9 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
|
session_abort: None,
|
||||||
render_mode: RenderMode::default(),
|
render_mode: RenderMode::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,7 +443,9 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
|
session_abort: None,
|
||||||
render_mode: RenderMode::default(),
|
render_mode: RenderMode::default(),
|
||||||
};
|
};
|
||||||
ctx.refresh_mcp_tool_filters();
|
ctx.refresh_mcp_tool_filters();
|
||||||
@@ -491,7 +498,9 @@ impl RequestContext {
|
|||||||
todo_list: self.todo_list.clone(),
|
todo_list: self.todo_list.clone(),
|
||||||
skill_registry: self.skill_registry.clone(),
|
skill_registry: self.skill_registry.clone(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: self.auto_continue_paused.clone(),
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
|
session_abort: self.session_abort.clone(),
|
||||||
render_mode: self.render_mode,
|
render_mode: self.render_mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,7 +549,9 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
|
session_abort: None,
|
||||||
render_mode: parent.render_mode,
|
render_mode: parent.render_mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,6 +587,7 @@ impl RequestContext {
|
|||||||
|
|
||||||
pub fn init_todo_list(&mut self, goal: &str) {
|
pub fn init_todo_list(&mut self, goal: &str) {
|
||||||
self.todo_list = TodoList::new(goal);
|
self.todo_list = TodoList::new(goal);
|
||||||
|
self.auto_continue_paused = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_todo(&mut self, task: &str) -> usize {
|
pub fn add_todo(&mut self, task: &str) -> usize {
|
||||||
@@ -589,12 +601,21 @@ impl RequestContext {
|
|||||||
pub fn clear_todo_list(&mut self) {
|
pub fn clear_todo_list(&mut self) {
|
||||||
self.todo_list.clear();
|
self.todo_list.clear();
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
|
self.auto_continue_paused = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_auto_continue_count(&mut self) {
|
pub fn increment_auto_continue_count(&mut self) {
|
||||||
self.auto_continue_count += 1;
|
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) {
|
pub fn reset_continuation_count(&mut self) {
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.last_continuation_response = None;
|
self.last_continuation_response = None;
|
||||||
@@ -4670,6 +4691,7 @@ impl RequestContext {
|
|||||||
self.current_depth = 0;
|
self.current_depth = 0;
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.todo_list = TodoList::default();
|
self.todo_list = TodoList::default();
|
||||||
|
self.auto_continue_paused = None;
|
||||||
|
|
||||||
if let Some(session_name) = session_name.as_deref() {
|
if let Some(session_name) = session_name.as_deref() {
|
||||||
self.use_session(app, Some(session_name), abort_signal)
|
self.use_session(app, Some(session_name), abort_signal)
|
||||||
@@ -4713,6 +4735,7 @@ impl RequestContext {
|
|||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.pending_tasks_guardrail_count = 0;
|
self.pending_tasks_guardrail_count = 0;
|
||||||
self.todo_list = TodoList::default();
|
self.todo_list = TodoList::default();
|
||||||
|
self.auto_continue_paused = None;
|
||||||
self.rag.take();
|
self.rag.take();
|
||||||
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
|
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
|
||||||
// cached under `RagKey::Agent(<agent name>)`, and leaving that key behind
|
// 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
|
// with an empty client list, which would permanently pin every later
|
||||||
// model lookup in this process to "unknown model" and make any test that
|
// model lookup in this process to "unknown model" and make any test that
|
||||||
// needs a resolvable model dependent on test ordering. Seed the caches
|
// 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
|
// embedding model. Deliberately NO chat model: some tests assert that no
|
||||||
// chat model is available, and chat lookups don't need one — a
|
// chat model is available, and chat lookups don't need one — a
|
||||||
// "test-seeded:<anything>" chat id resolves through the create-from-name
|
// "test-seeded:<anything>" chat id resolves through the create-from-name
|
||||||
@@ -5252,8 +5275,19 @@ mod tests {
|
|||||||
embedder.model_type = "embedding".to_string();
|
embedder.model_type = "embedding".to_string();
|
||||||
config.models = vec![embedder];
|
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 {
|
let config = AppConfig {
|
||||||
clients: vec![client],
|
clients: vec![client, claude],
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let _ = list_client_names(&config);
|
let _ = list_client_names(&config);
|
||||||
|
|||||||
+210
-4
@@ -1,3 +1,4 @@
|
|||||||
|
use super::todo::TODO_FUNCTION_PREFIX;
|
||||||
use super::{FunctionDeclaration, JsonSchema};
|
use super::{FunctionDeclaration, JsonSchema};
|
||||||
use crate::client::{Model, ModelType, call_chat_completions};
|
use crate::client::{Model, ModelType, call_chat_completions};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
@@ -7,9 +8,10 @@ use crate::config::{
|
|||||||
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
use crate::supervisor::mailbox::{Envelope, EnvelopePayload, Inbox};
|
||||||
use crate::supervisor::notification::agent_notification;
|
use crate::supervisor::notification::agent_notification;
|
||||||
use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, TaskKind};
|
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::graph;
|
||||||
|
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
@@ -574,7 +576,13 @@ pub fn run_child_agent(
|
|||||||
|
|
||||||
if tool_results.is_empty() {
|
if tool_results.is_empty() {
|
||||||
match check_pending_tasks_guardrail(&mut child_ctx) {
|
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) => {
|
GuardrailAction::ForceTerminate(ids) => {
|
||||||
warn!(
|
warn!(
|
||||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
"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
|
/// 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
|
/// 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
|
/// 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;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1074,14 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
|||||||
sup.abort_signal_for(id)
|
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 {
|
loop {
|
||||||
let is_finished = {
|
let is_finished = {
|
||||||
let sup = supervisor.read();
|
let sup = supervisor.read();
|
||||||
@@ -1041,6 +1092,10 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
|
||||||
|
return Ok(interrupted());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(queue) = ctx.root_escalation_queue()
|
if let Some(queue) = ctx.root_escalation_queue()
|
||||||
&& queue.has_pending()
|
&& queue.has_pending()
|
||||||
{
|
{
|
||||||
@@ -1068,10 +1123,18 @@ async fn handle_collect(ctx: &mut RequestContext, args: &Value) -> Result<Value>
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = time::sleep(Duration::from_millis(200)) => {}
|
_ = time::sleep(Duration::from_millis(200)) => {}
|
||||||
_ = wait_abort_signal(abort) => {}
|
_ = wait_abort_signal(abort) => {}
|
||||||
|
_ = wait_user_interrupt(ctx.session_abort.as_ref()) => {
|
||||||
|
return Ok(interrupted());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
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::function::jobs::RingBuf;
|
||||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||||
@@ -1653,6 +1716,58 @@ mod tests {
|
|||||||
ctx
|
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 {
|
fn ctx_with_job_capable_supervisor() -> RequestContext {
|
||||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||||
ctx.supervisor = Some(Arc::new(RwLock::new(
|
ctx.supervisor = Some(Arc::new(RwLock::new(
|
||||||
@@ -1820,6 +1935,31 @@ mod tests {
|
|||||||
assert!(!functions.contains("mcp_invoke_fixture"));
|
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> {
|
fn app_state_with_fixture_mcp_config() -> Arc<AppState> {
|
||||||
let mut state = AppState::test_default();
|
let mut state = AppState::test_default();
|
||||||
state.mcp_config = Some(McpServersConfig {
|
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]
|
#[test]
|
||||||
fn handle_collect_pending_escalations_early_out_keeps_handle() {
|
fn handle_collect_pending_escalations_early_out_keeps_handle() {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
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::notification::job_notification;
|
||||||
use crate::supervisor::{JobHandle, JobResult, JobState, JobStatus, Supervisor};
|
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 anyhow::{Context, Result, anyhow, bail};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
@@ -623,6 +625,14 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
|
|||||||
job.abort_signal.clone()
|
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 {
|
loop {
|
||||||
let is_finished = {
|
let is_finished = {
|
||||||
let sup = supervisor.read();
|
let sup = supervisor.read();
|
||||||
@@ -633,6 +643,10 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.session_abort.as_ref().is_some_and(|s| s.aborted()) {
|
||||||
|
return Ok(interrupted());
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(queue) = ctx.root_escalation_queue()
|
if let Some(queue) = ctx.root_escalation_queue()
|
||||||
&& queue.has_pending()
|
&& queue.has_pending()
|
||||||
{
|
{
|
||||||
@@ -666,6 +680,9 @@ async fn handle_collect(ctx: &RequestContext, args: &Value) -> Result<Value> {
|
|||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = time::sleep(Duration::from_millis(200)) => {}
|
_ = time::sleep(Duration::from_millis(200)) => {}
|
||||||
_ = wait_abort_signal(&target_abort) => {}
|
_ = 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]
|
#[test]
|
||||||
fn handle_collect_full_result_returns_uncapped() {
|
fn handle_collect_full_result_returns_uncapped() {
|
||||||
run_async(async {
|
run_async(async {
|
||||||
|
|||||||
@@ -727,6 +727,10 @@ impl Functions {
|
|||||||
.retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX));
|
.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) {
|
pub fn append_memory_functions(&mut self) {
|
||||||
self.declarations
|
self.declarations
|
||||||
.extend(memory::memory_function_declarations());
|
.extend(memory::memory_function_declarations());
|
||||||
@@ -3371,6 +3375,7 @@ mod tests {
|
|||||||
assert!(f.contains("todo__done"));
|
assert!(f.contains("todo__done"));
|
||||||
assert!(f.contains("todo__list"));
|
assert!(f.contains("todo__list"));
|
||||||
assert!(f.contains("todo__clear"));
|
assert!(f.contains("todo__clear"));
|
||||||
|
assert!(f.contains("todo__pause"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4914,6 +4919,79 @@ mod tests {
|
|||||||
assert!(err.contains("Auto-continue is not enabled"), "{err}");
|
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]
|
#[test]
|
||||||
fn eval_routes_memory_prefix_to_memory_handler() {
|
fn eval_routes_memory_prefix_to_memory_handler() {
|
||||||
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
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,
|
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();
|
ctx.clear_todo_list();
|
||||||
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
|
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}"),
|
_ => bail!("Unknown todo action: {action}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,6 +695,7 @@ nodes:
|
|||||||
type: script
|
type: script
|
||||||
script: doubler.py
|
script: doubler.py
|
||||||
state_updates: {}
|
state_updates: {}
|
||||||
|
timeout: 60
|
||||||
done:
|
done:
|
||||||
type: end
|
type: end
|
||||||
output: "{{doubled}}"
|
output: "{{doubled}}"
|
||||||
|
|||||||
+14
-8
@@ -262,13 +262,16 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_script_can_read_state_from_env() {
|
async fn bash_script_can_read_state_from_env() {
|
||||||
if !cmd_available("bash") || !cmd_available("python3") {
|
if !cmd_available("bash") {
|
||||||
eprintln!("skipping: bash or python3 not available");
|
eprintln!("skipping: bash not available");
|
||||||
return;
|
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(
|
let (dir, path) = write_script(
|
||||||
r#"#!/bin/bash
|
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"
|
printf '{"greeting": "hello %s"}' "$NAME"
|
||||||
"#,
|
"#,
|
||||||
"sh",
|
"sh",
|
||||||
@@ -280,7 +283,7 @@ printf '{"greeting": "hello %s"}' "$NAME"
|
|||||||
|
|
||||||
let _ = executor
|
let _ = executor
|
||||||
.execute(
|
.execute(
|
||||||
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
|
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -466,7 +469,7 @@ echo '{"ok":true}'
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn large_state_is_delivered_via_file_env_var() {
|
async fn large_state_is_delivered_via_file_env_var() {
|
||||||
if !cmd_available("bash") || !cmd_available("python3") {
|
if !cmd_available("bash") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024);
|
let big = "x".repeat(MAX_STATE_SIZE_BYTES + 1024);
|
||||||
@@ -476,7 +479,10 @@ echo '{"ok":true}'
|
|||||||
let (dir, path) = write_script(
|
let (dir, path) = write_script(
|
||||||
r#"#!/bin/bash
|
r#"#!/bin/bash
|
||||||
if [ -n "$GRAPH_STATE_FILE" ]; then
|
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"
|
printf '{"blob_len": %s, "via_file": true}' "$LEN"
|
||||||
elif [ -n "$GRAPH_STATE" ]; then
|
elif [ -n "$GRAPH_STATE" ]; then
|
||||||
echo '{"via_file": false}'
|
echo '{"via_file": false}'
|
||||||
@@ -489,7 +495,7 @@ fi
|
|||||||
let executor = ScriptExecutor::new(&dir);
|
let executor = ScriptExecutor::new(&dir);
|
||||||
executor
|
executor
|
||||||
.execute(
|
.execute(
|
||||||
&node_for(path.file_name().unwrap().to_str().unwrap(), 10),
|
&node_for(path.file_name().unwrap().to_str().unwrap(), 30),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -524,7 +530,7 @@ print(json.dumps({
|
|||||||
let executor = ScriptExecutor::new(&dir);
|
let executor = ScriptExecutor::new(&dir);
|
||||||
let next = executor
|
let next = executor
|
||||||
.execute(
|
.execute(
|
||||||
&node_for(path.file_name().unwrap().to_str().unwrap(), 5),
|
&node_for(path.file_name().unwrap().to_str().unwrap(), 60),
|
||||||
&mut state,
|
&mut state,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+39
-31
@@ -20,13 +20,14 @@ extern crate log;
|
|||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
use crate::client::{
|
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::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
|
||||||
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
|
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::config::{memory, paths};
|
||||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||||
@@ -101,6 +102,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let info_flag = cli.info
|
let info_flag = cli.info
|
||||||
|| cli.sync_models
|
|| cli.sync_models
|
||||||
|
|| cli.generate_models.is_some()
|
||||||
|| cli.list_models
|
|| cli.list_models
|
||||||
|| cli.list_roles
|
|| cli.list_roles
|
||||||
|| cli.list_agents
|
|| cli.list_agents
|
||||||
@@ -292,7 +294,11 @@ async fn run(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if cli.sync_models {
|
if cli.sync_models {
|
||||||
let url = ctx.app.config.sync_models_url();
|
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 {
|
if cli.list_models {
|
||||||
@@ -544,12 +550,12 @@ async fn run(
|
|||||||
if !*IS_STDOUT_TERMINAL {
|
if !*IS_STDOUT_TERMINAL {
|
||||||
bail!("No TTY for REPL")
|
bail!("No TTY for REPL")
|
||||||
}
|
}
|
||||||
|
maybe_spawn_models_refresh(&ctx.app.config);
|
||||||
start_interactive(ctx).await
|
start_interactive(ctx).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_recursion::async_recursion]
|
|
||||||
async fn start_directive(
|
async fn start_directive(
|
||||||
ctx: &mut RequestContext,
|
ctx: &mut RequestContext,
|
||||||
input: Input,
|
input: Input,
|
||||||
@@ -557,6 +563,7 @@ async fn start_directive(
|
|||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
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() {
|
if graph::active_agent_graph_name(ctx).is_some() {
|
||||||
ctx.before_chat_completion(&input)?;
|
ctx.before_chat_completion(&input)?;
|
||||||
@@ -568,37 +575,36 @@ async fn start_directive(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = input.create_client()?;
|
|
||||||
let extract_code = !*IS_STDOUT_TERMINAL && code_mode;
|
let extract_code = !*IS_STDOUT_TERMINAL && code_mode;
|
||||||
ctx.before_chat_completion(&input)?;
|
let mut input = input;
|
||||||
let (output, tool_results) = if !input.stream() || extract_code {
|
loop {
|
||||||
call_chat_completions(
|
let client = input.create_client()?;
|
||||||
&input,
|
ctx.before_chat_completion(&input)?;
|
||||||
true,
|
let (output, tool_results) = if !input.stream() || extract_code {
|
||||||
extract_code,
|
call_chat_completions(
|
||||||
client.as_ref(),
|
&input,
|
||||||
ctx,
|
true,
|
||||||
abort_signal.clone(),
|
extract_code,
|
||||||
)
|
client.as_ref(),
|
||||||
.await?
|
ctx,
|
||||||
} else {
|
abort_signal.clone(),
|
||||||
call_chat_completions_streaming(&input, client.as_ref(), ctx, abort_signal.clone()).await?
|
)
|
||||||
};
|
.await?
|
||||||
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
|
} 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) {
|
match check_pending_tasks_guardrail(ctx) {
|
||||||
GuardrailAction::Inject(prompt) => {
|
GuardrailAction::Inject(prompt) => {
|
||||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
input = Input::from_str(ctx, &prompt, None)?;
|
||||||
return start_directive(ctx, guardrail_input, code_mode, abort_signal).await;
|
continue;
|
||||||
}
|
}
|
||||||
GuardrailAction::ForceTerminate(ids) => {
|
GuardrailAction::ForceTerminate(ids) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -609,6 +615,8 @@ async fn start_directive(
|
|||||||
}
|
}
|
||||||
GuardrailAction::NoAction => {}
|
GuardrailAction::NoAction => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.exit_session()?;
|
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.
|
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\
|
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.
|
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(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
|
||||||
@@ -1409,6 +1410,7 @@ pub async fn run_repl_command(
|
|||||||
handle_shell_passthrough(cmd)?;
|
handle_shell_passthrough(cmd)?;
|
||||||
} else {
|
} else {
|
||||||
reset_continuation(ctx);
|
reset_continuation(ctx);
|
||||||
|
ctx.resume_auto_continue();
|
||||||
let input = Input::from_str(ctx, line, None)?;
|
let input = Input::from_str(ctx, line, None)?;
|
||||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||||
}
|
}
|
||||||
@@ -1422,73 +1424,73 @@ pub async fn run_repl_command(
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_recursion::async_recursion]
|
|
||||||
async fn ask(
|
async fn ask(
|
||||||
ctx: &mut RequestContext,
|
ctx: &mut RequestContext,
|
||||||
abort_signal: AbortSignal,
|
abort_signal: AbortSignal,
|
||||||
mut input: Input,
|
input: Input,
|
||||||
with_embeddings: bool,
|
with_embeddings: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if input.is_empty() {
|
let mut input = input;
|
||||||
return Ok(());
|
let mut with_embeddings = with_embeddings;
|
||||||
}
|
loop {
|
||||||
if with_embeddings {
|
if input.is_empty() {
|
||||||
input.use_embeddings(abort_signal.clone()).await?;
|
return Ok(());
|
||||||
}
|
|
||||||
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.session_abort = Some(abort_signal.clone());
|
||||||
ctx.after_chat_completion(app.as_ref(), &input, &output, &tool_results)?;
|
if with_embeddings {
|
||||||
if !tool_results.is_empty() {
|
input.use_embeddings(abort_signal.clone()).await?;
|
||||||
ask(
|
with_embeddings = false;
|
||||||
ctx,
|
}
|
||||||
abort_signal,
|
while ctx.is_compressing_session() {
|
||||||
input.merge_tool_results(output, tool_results),
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
false,
|
}
|
||||||
)
|
|
||||||
.await
|
let app = Arc::clone(&ctx.app.config);
|
||||||
} else {
|
|
||||||
|
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) {
|
match check_pending_tasks_guardrail(ctx) {
|
||||||
GuardrailAction::Inject(prompt) => {
|
GuardrailAction::Inject(prompt) => {
|
||||||
let guardrail_input = Input::from_str(ctx, &prompt, None)?;
|
input = Input::from_str(ctx, &prompt, None)?;
|
||||||
return ask(ctx, abort_signal, guardrail_input, false).await;
|
continue;
|
||||||
}
|
}
|
||||||
GuardrailAction::ForceTerminate(ids) => {
|
GuardrailAction::ForceTerminate(ids) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1530,87 +1532,103 @@ async fn ask(
|
|||||||
|
|
||||||
format!("{prompt}\n\n{todo_state}")
|
format!("{prompt}\n\n{todo_state}")
|
||||||
};
|
};
|
||||||
let continuation_input = Input::from_str(ctx, &full_prompt, None)?;
|
input = Input::from_str(ctx, &full_prompt, None)?;
|
||||||
ask(ctx, abort_signal, continuation_input, false).await
|
continue;
|
||||||
} 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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
&& config.enabled
|
||||||
&& ctx.auto_continue_count < config.max_continues
|
&& ctx.auto_continue_count < config.max_continues
|
||||||
&& ctx.todo_list.has_incomplete()
|
&& ctx.todo_list.has_incomplete()
|
||||||
|
&& ctx.auto_continue_paused.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_continuation(ctx: &mut RequestContext) {
|
fn reset_continuation(ctx: &mut RequestContext) {
|
||||||
|
|||||||
+36
-1
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::client::{Message, MessageRole};
|
use crate::client::{Message, MessageContent, MessageContentToolCalls, MessageRole};
|
||||||
use crate::config::{AppConfig, Session};
|
use crate::config::{AppConfig, Session};
|
||||||
use crate::utils::{dimmed_text, replay_label_text};
|
use crate::utils::{dimmed_text, replay_label_text};
|
||||||
|
|
||||||
@@ -52,9 +52,44 @@ fn render_messages(app: &AppConfig, messages: &[Message]) -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MessageRole::Tool => {
|
||||||
|
if let MessageContent::ToolCalls(tool_calls) = &message.content {
|
||||||
|
render_tool_call_rounds(app, tool_calls)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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> {
|
pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
|
||||||
if event::poll(Duration::from_millis(25))?
|
if event::poll(Duration::from_millis(25))?
|
||||||
&& let Event::Key(key) = event::read()?
|
&& let Event::Key(key) = event::read()?
|
||||||
@@ -86,3 +109,18 @@ pub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
Ok(false)
|
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