mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74cbe5c2ea |
@@ -54,7 +54,7 @@ jobs:
|
||||
- group: features
|
||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
|
||||
- group: extensions
|
||||
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
||||
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
|
||||
- group: routines
|
||||
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
|
||||
steps:
|
||||
|
||||
Generated
+12
-12
@@ -157,7 +157,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4134,7 +4134,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5472,7 +5472,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6154,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6354,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6379,7 +6379,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7179,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8029,7 +8029,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
|
||||
| `models` | ✅ | 🚧 | P1 | `models list [<provider>]` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set <model>`, `models set-provider <provider> [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "near-intents",
|
||||
"display_name": "Near Intents",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Token resolution, balance queries, and reverse lookups for NEAR Intents (Defuse protocol)",
|
||||
"keywords": ["near", "intents", "defuse", "defi", "trading", "token"],
|
||||
"source": {
|
||||
"dir": "tools-src/near-intents",
|
||||
"capabilities": "near-intents-tool.capabilities.json",
|
||||
"crate_name": "near-intents-tool"
|
||||
},
|
||||
"artifacts": {},
|
||||
"tags": ["trading"]
|
||||
}
|
||||
@@ -1440,7 +1440,6 @@ fn handle_text_response(
|
||||
/// This is a simplified version of the full dispatcher loop:
|
||||
/// - Max 3-5 iterations (configurable)
|
||||
/// - Sequential tool execution (not parallel)
|
||||
/// - Uses the owner's live autonomous tool scope when lightweight tools are enabled
|
||||
/// - Auto-approval of non-Always tools
|
||||
/// - No hooks or approval dialogs
|
||||
async fn execute_lightweight_with_tools(
|
||||
|
||||
+67
-25
@@ -954,7 +954,10 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Get pending approval for this thread
|
||||
// Get pending approval for this thread.
|
||||
// The take-verify sequence is atomic under a single lock acquisition
|
||||
// to prevent a TOCTOU race where a concurrent operation could modify
|
||||
// or delete the thread between take and restore (#1486).
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess
|
||||
@@ -972,33 +975,31 @@ impl Agent {
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
|
||||
thread.take_pending_approval()
|
||||
};
|
||||
let taken = match thread.take_pending_approval() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
// Verify request ID while still holding the lock — atomic with take
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != taken.request_id
|
||||
{
|
||||
// Restore atomically under same lock
|
||||
thread.await_approval(taken);
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.await_approval(pending);
|
||||
}
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
taken
|
||||
// Lock dropped here — pending approval validated
|
||||
};
|
||||
|
||||
if approved {
|
||||
// If always, add to auto-approved set
|
||||
@@ -2204,6 +2205,47 @@ mod tests {
|
||||
assert!(t.pending_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_request_id_mismatch_restores_pending() {
|
||||
// Regression test for #1486: after a request_id mismatch, the pending
|
||||
// approval must still be intact (take + verify + restore is atomic).
|
||||
use crate::agent::session::{PendingApproval, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
|
||||
let correct_request_id = Uuid::new_v4();
|
||||
let pending = PendingApproval {
|
||||
request_id: correct_request_id,
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
display_parameters: serde_json::json!({}),
|
||||
description: "test".to_string(),
|
||||
tool_call_id: "call_0".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
assert_eq!(thread.state, ThreadState::AwaitingApproval);
|
||||
|
||||
// Simulate: take, verify mismatch, restore -- all must be atomic
|
||||
let taken = thread.take_pending_approval().unwrap();
|
||||
assert_eq!(taken.request_id, correct_request_id);
|
||||
// On mismatch, restore
|
||||
thread.await_approval(taken);
|
||||
// Must still be in AwaitingApproval with pending intact
|
||||
assert_eq!(thread.state, ThreadState::AwaitingApproval);
|
||||
assert!(thread.pending_approval.is_some());
|
||||
assert_eq!(
|
||||
thread.pending_approval.as_ref().unwrap().request_id,
|
||||
correct_request_id
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to extract the approval message without needing a full Agent instance
|
||||
fn extract_approval_message(
|
||||
session: &crate::agent::session::Session,
|
||||
|
||||
@@ -25,7 +25,6 @@ pub mod import;
|
||||
mod logs;
|
||||
mod mcp;
|
||||
pub mod memory;
|
||||
mod models;
|
||||
pub mod oauth_defaults;
|
||||
mod pairing;
|
||||
mod registry;
|
||||
@@ -46,7 +45,6 @@ pub use logs::{LogsCommand, run_logs_command};
|
||||
pub use mcp::{McpCommand, run_mcp_command};
|
||||
pub use memory::MemoryCommand;
|
||||
pub use memory::run_memory_command_with_db;
|
||||
pub use models::{ModelsCommand, run_models_command};
|
||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||
pub use registry::{RegistryCommand, run_registry_command};
|
||||
pub use routines::{RoutinesCommand, run_routines_command};
|
||||
@@ -219,14 +217,6 @@ pub enum Command {
|
||||
)]
|
||||
Hooks(HooksCommand),
|
||||
|
||||
/// Manage LLM providers and models
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage LLM providers and models",
|
||||
long_about = "List providers, view current configuration, and set active provider/model.\nExamples:\n ironclaw models list\n ironclaw models list openai --verbose\n ironclaw models status\n ironclaw models set gpt-4o\n ironclaw models set-provider anthropic --model claude-sonnet-4-6-20250514"
|
||||
)]
|
||||
Models(ModelsCommand),
|
||||
|
||||
/// Probe external dependencies and validate configuration
|
||||
#[command(
|
||||
about = "Run diagnostics",
|
||||
|
||||
@@ -1,864 +0,0 @@
|
||||
//! Models management CLI commands.
|
||||
//!
|
||||
//! Provides subcommands for listing providers, viewing current model
|
||||
//! configuration, and setting the active provider/model. Settings are
|
||||
//! persisted to both `config.toml` and `~/.ironclaw/.env` so changes
|
||||
//! take effect immediately (no DB connection required).
|
||||
|
||||
use clap::Subcommand;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::llm::registry::ProviderRegistry;
|
||||
use crate::settings::Settings;
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ModelsCommand {
|
||||
/// List providers (or available models for a specific provider)
|
||||
List {
|
||||
/// Show only a specific provider (by ID or alias)
|
||||
provider: Option<String>,
|
||||
|
||||
/// Show detailed information (env vars, base URL, protocol)
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Show current model configuration
|
||||
Status {
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Set the default model
|
||||
Set {
|
||||
/// Model name (e.g., "gpt-5-mini", "claude-sonnet-4-6-20250514")
|
||||
model: String,
|
||||
},
|
||||
|
||||
/// Set the LLM provider
|
||||
SetProvider {
|
||||
/// Provider ID or alias (e.g., "openai", "anthropic", "ollama")
|
||||
provider: String,
|
||||
|
||||
/// Also set the model (defaults to provider's default model)
|
||||
#[arg(long)]
|
||||
model: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run the models CLI subcommand.
|
||||
pub async fn run_models_command(
|
||||
cmd: ModelsCommand,
|
||||
config_path: Option<&Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
ModelsCommand::List {
|
||||
provider,
|
||||
verbose,
|
||||
json,
|
||||
} => {
|
||||
if let Some(ref id) = provider {
|
||||
cmd_show_provider(id, verbose, json, config_path).await
|
||||
} else {
|
||||
cmd_list_providers(verbose, json, config_path).await
|
||||
}
|
||||
}
|
||||
ModelsCommand::Status { json } => cmd_status(json, config_path),
|
||||
ModelsCommand::Set { model } => cmd_set_model(&model, config_path),
|
||||
ModelsCommand::SetProvider { provider, model } => {
|
||||
cmd_set_provider(&provider, model.as_deref(), config_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────
|
||||
|
||||
/// Resolve the currently active backend and model from env + settings.
|
||||
fn resolve_active(config_path: Option<&Path>) -> (String, String) {
|
||||
let settings = load_settings(config_path);
|
||||
resolve_active_from_settings(&settings)
|
||||
}
|
||||
|
||||
/// Resolve active backend + model from a pre-loaded Settings.
|
||||
fn resolve_active_from_settings(settings: &Settings) -> (String, String) {
|
||||
let backend = std::env::var("LLM_BACKEND")
|
||||
.ok()
|
||||
.or_else(|| settings.llm_backend.clone())
|
||||
.unwrap_or_else(|| "nearai".to_string());
|
||||
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
let canonical_backend = registry
|
||||
.find(&backend)
|
||||
.map(|d| d.id.clone())
|
||||
.unwrap_or_else(|| backend.clone());
|
||||
|
||||
let model = if canonical_backend == "nearai" {
|
||||
std::env::var("NEARAI_MODEL")
|
||||
.ok()
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| "qwen2.5-72b-instruct:free".to_string())
|
||||
} else if let Some(def) = registry.find(&canonical_backend) {
|
||||
std::env::var(&def.model_env)
|
||||
.ok()
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| def.default_model.clone())
|
||||
} else {
|
||||
settings
|
||||
.selected_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
};
|
||||
|
||||
(canonical_backend, model)
|
||||
}
|
||||
|
||||
fn load_settings(config_path: Option<&Path>) -> Settings {
|
||||
if let Some(path) = config_path {
|
||||
Settings::load_toml(path).ok().flatten().unwrap_or_default()
|
||||
} else {
|
||||
let toml_path = config_toml_path();
|
||||
if toml_path.exists() {
|
||||
Settings::load_toml(&toml_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Settings::load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_settings(settings: &Settings, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||
let path = config_path
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(config_toml_path);
|
||||
|
||||
settings
|
||||
.save_toml(&path)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_toml_path() -> std::path::PathBuf {
|
||||
crate::bootstrap::ironclaw_base_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// Try to fetch the live model list from a provider.
|
||||
///
|
||||
/// Best-effort: returns `None` if config loading, provider creation, or the
|
||||
/// `list_models()` call fails (missing API key, network error, etc.).
|
||||
async fn try_fetch_models(provider_id: &str, config_path: Option<&Path>) -> Option<Vec<String>> {
|
||||
let config = crate::config::Config::from_env_with_toml(config_path)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
// Override backend to the requested provider so create_llm_provider
|
||||
// constructs the right one.
|
||||
let mut llm_config = config.llm.clone();
|
||||
llm_config.backend = provider_id.to_string();
|
||||
|
||||
// For registry providers, resolve the RegistryProviderConfig if not
|
||||
// already set for this backend.
|
||||
if provider_id != "nearai" && provider_id != "bedrock" {
|
||||
let registry = ProviderRegistry::load();
|
||||
if let Some(def) = registry.find(provider_id)
|
||||
&& llm_config
|
||||
.provider
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.provider_id != def.id)
|
||||
{
|
||||
// Build a minimal RegistryProviderConfig from env + registry
|
||||
let api_key = def
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok());
|
||||
if def.api_key_required && api_key.is_none() {
|
||||
return None;
|
||||
}
|
||||
let base_url = def.default_base_url.clone().unwrap_or_default();
|
||||
llm_config.provider = Some(crate::llm::RegistryProviderConfig {
|
||||
protocol: def.protocol,
|
||||
provider_id: def.id.clone(),
|
||||
model: def.default_model.clone(),
|
||||
api_key: api_key.map(secrecy::SecretString::from),
|
||||
base_url,
|
||||
extra_headers: Vec::new(),
|
||||
oauth_token: None,
|
||||
is_codex_chatgpt: false,
|
||||
refresh_token: None,
|
||||
auth_path: None,
|
||||
cache_retention: Default::default(),
|
||||
unsupported_params: def.unsupported_params.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
|
||||
let provider = crate::llm::create_llm_provider(&llm_config, session)
|
||||
.await
|
||||
.ok()?;
|
||||
provider.list_models().await.ok().filter(|m| !m.is_empty())
|
||||
}
|
||||
|
||||
/// Print available models section (text output).
|
||||
fn print_model_list(models: &Option<Vec<String>>, active_model: Option<&String>) {
|
||||
match models {
|
||||
Some(models) => {
|
||||
println!("\n Available models ({}):", models.len());
|
||||
for m in models {
|
||||
let marker = active_model
|
||||
.filter(|a| a.as_str() == m)
|
||||
.map(|_| " (active)")
|
||||
.unwrap_or("");
|
||||
println!(" {}{}", m, marker);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!(
|
||||
"\n Could not fetch model list (missing credentials or provider unavailable)."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Also update `~/.ironclaw/.env` so changes take effect immediately.
|
||||
///
|
||||
/// Skipped when `config_path` is `Some` (custom `--config`), because the user
|
||||
/// is explicitly targeting a different config file and we must not pollute the
|
||||
/// default profile's `.env`.
|
||||
fn sync_to_dotenv(config_path: Option<&Path>, vars: &[(&str, &str)]) {
|
||||
if config_path.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = crate::bootstrap::upsert_bootstrap_vars(vars) {
|
||||
eprintln!("Warning: failed to update .env: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── status ───────────────────────────────────────────────────────
|
||||
|
||||
fn cmd_status(json: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||
let settings = load_settings(config_path);
|
||||
let (backend, model) = resolve_active_from_settings(&settings);
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
let fallback = std::env::var("NEARAI_FALLBACK_MODEL").ok();
|
||||
let cheap = std::env::var("NEARAI_CHEAP_MODEL").ok();
|
||||
|
||||
let description = if backend == "nearai" {
|
||||
"NEAR AI inference (default)".to_string()
|
||||
} else {
|
||||
registry
|
||||
.find(&backend)
|
||||
.map(|d| d.description.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if json {
|
||||
let v = serde_json::json!({
|
||||
"provider": backend,
|
||||
"model": model,
|
||||
"description": description,
|
||||
"fallback_model": fallback,
|
||||
"cheap_model": cheap,
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Provider: {} ({})", backend, description);
|
||||
println!("Model: {}", model);
|
||||
if let Some(ref fb) = fallback {
|
||||
println!("Fallback: {}", fb);
|
||||
}
|
||||
if let Some(ref ch) = cheap {
|
||||
println!("Cheap: {}", ch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── set ──────────────────────────────────────────────────────────
|
||||
|
||||
fn cmd_set_model(model: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
|
||||
let trimmed = model.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Model name cannot be empty");
|
||||
}
|
||||
|
||||
let mut settings = load_settings(config_path);
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
// Warn if model name doesn't match any known provider's default model
|
||||
let known_model = registry.all().iter().any(|d| d.default_model == trimmed)
|
||||
|| trimmed.contains("qwen") // nearai models
|
||||
|| trimmed.contains("llama")
|
||||
|| trimmed.contains("gpt")
|
||||
|| trimmed.contains("claude")
|
||||
|| trimmed.contains("gemini")
|
||||
|| trimmed.contains("mistral");
|
||||
if !known_model {
|
||||
eprintln!(
|
||||
"Warning: '{}' is not a recognized model name. Proceeding anyway.",
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
|
||||
settings.selected_model = Some(trimmed.to_string());
|
||||
save_settings(&settings, config_path)?;
|
||||
|
||||
let backend = std::env::var("LLM_BACKEND")
|
||||
.ok()
|
||||
.or_else(|| settings.llm_backend.clone())
|
||||
.unwrap_or_else(|| "nearai".to_string());
|
||||
|
||||
// Also write to .env so the change takes effect immediately
|
||||
let model_env = if backend == "nearai" {
|
||||
"NEARAI_MODEL".to_string()
|
||||
} else {
|
||||
registry
|
||||
.find(&backend)
|
||||
.map(|d| d.model_env.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if !model_env.is_empty() {
|
||||
sync_to_dotenv(config_path, &[(&model_env, trimmed)]);
|
||||
}
|
||||
|
||||
println!("Model set to '{}' (provider: {})", trimmed, backend);
|
||||
println!(
|
||||
"Saved to {}",
|
||||
config_path
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── set-provider ─────────────────────────────────────────────────
|
||||
|
||||
fn cmd_set_provider(
|
||||
provider: &str,
|
||||
model: Option<&str>,
|
||||
config_path: Option<&Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
let registry = ProviderRegistry::load();
|
||||
|
||||
// Validate and normalize provider
|
||||
let canonical_id = if provider == "nearai" || provider == "near_ai" || provider == "near" {
|
||||
"nearai".to_string()
|
||||
} else {
|
||||
let def = registry.find(provider).ok_or_else(|| {
|
||||
let known: Vec<&str> = std::iter::once("nearai")
|
||||
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||
.collect();
|
||||
anyhow::anyhow!(
|
||||
"Unknown provider '{}'. Known providers: {}",
|
||||
provider,
|
||||
known.join(", ")
|
||||
)
|
||||
})?;
|
||||
def.id.clone()
|
||||
};
|
||||
|
||||
// Resolve model: explicit > provider default
|
||||
let resolved_model = if let Some(m) = model {
|
||||
m.to_string()
|
||||
} else if canonical_id == "nearai" {
|
||||
"qwen2.5-72b-instruct:free".to_string()
|
||||
} else if let Some(def) = registry.find(&canonical_id) {
|
||||
def.default_model.clone()
|
||||
} else {
|
||||
"default".to_string()
|
||||
};
|
||||
|
||||
let mut settings = load_settings(config_path);
|
||||
settings.llm_backend = Some(canonical_id.clone());
|
||||
settings.selected_model = Some(resolved_model.clone());
|
||||
save_settings(&settings, config_path)?;
|
||||
|
||||
// Also write to .env so the change takes effect immediately
|
||||
let model_env = if canonical_id == "nearai" {
|
||||
"NEARAI_MODEL".to_string()
|
||||
} else {
|
||||
registry
|
||||
.find(&canonical_id)
|
||||
.map(|d| d.model_env.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut vars: Vec<(&str, &str)> = vec![("LLM_BACKEND", &canonical_id)];
|
||||
if !model_env.is_empty() {
|
||||
vars.push((&model_env, &resolved_model));
|
||||
}
|
||||
sync_to_dotenv(config_path, &vars);
|
||||
|
||||
println!(
|
||||
"Provider set to '{}', model set to '{}'",
|
||||
canonical_id, resolved_model
|
||||
);
|
||||
println!(
|
||||
"Saved to {}",
|
||||
config_path
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| config_toml_path().display().to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────
|
||||
|
||||
/// List all providers with their default models.
|
||||
async fn cmd_list_providers(
|
||||
verbose: bool,
|
||||
json: bool,
|
||||
config_path: Option<&Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
let registry = ProviderRegistry::load();
|
||||
let (active_backend, active_model) = resolve_active(config_path);
|
||||
|
||||
if json {
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// NEAR AI (not in registry)
|
||||
let nearai_active = active_backend == "nearai";
|
||||
entries.push(serde_json::json!({
|
||||
"id": "nearai",
|
||||
"description": "NEAR AI inference (default)",
|
||||
"default_model": "qwen2.5-72b-instruct:free",
|
||||
"active": nearai_active,
|
||||
"active_model": if nearai_active { Some(&active_model) } else { None },
|
||||
}));
|
||||
|
||||
for def in registry.all() {
|
||||
let is_active = active_backend == def.id;
|
||||
let mut v = serde_json::json!({
|
||||
"id": def.id,
|
||||
"description": def.description,
|
||||
"default_model": def.default_model,
|
||||
"protocol": format!("{:?}", def.protocol),
|
||||
"active": is_active,
|
||||
});
|
||||
if is_active {
|
||||
v["active_model"] = serde_json::json!(active_model);
|
||||
}
|
||||
if verbose {
|
||||
v["aliases"] = serde_json::json!(def.aliases);
|
||||
v["model_env"] = serde_json::json!(def.model_env);
|
||||
v["api_key_env"] = serde_json::json!(def.api_key_env);
|
||||
v["api_key_required"] = serde_json::json!(def.api_key_required);
|
||||
if let Some(ref url) = def.default_base_url {
|
||||
v["base_url"] = serde_json::json!(url);
|
||||
}
|
||||
if let Some(ref setup) = def.setup {
|
||||
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||
}
|
||||
}
|
||||
entries.push(v);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let providers = registry.all();
|
||||
|
||||
println!("Active: {} (model: {})\n", active_backend, active_model);
|
||||
println!(
|
||||
"{} provider(s) available:\n",
|
||||
providers.len() + 1 // +1 for NEAR AI
|
||||
);
|
||||
|
||||
// NEAR AI (not in registry)
|
||||
let nearai_marker = if active_backend == "nearai" { " *" } else { "" };
|
||||
if verbose {
|
||||
println!(" nearai{}", nearai_marker);
|
||||
println!(" Description: NEAR AI inference (default)");
|
||||
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||
println!(" Model env: NEARAI_MODEL");
|
||||
if active_backend == "nearai" {
|
||||
println!(" Active model: {}", active_model);
|
||||
}
|
||||
println!();
|
||||
} else {
|
||||
println!(
|
||||
" {:<22} {:<40} NEAR AI inference (default)",
|
||||
format!("nearai{nearai_marker}"),
|
||||
"qwen2.5-72b-instruct:free"
|
||||
);
|
||||
}
|
||||
|
||||
for def in providers {
|
||||
let is_active = active_backend == def.id;
|
||||
let marker = if is_active { " *" } else { "" };
|
||||
|
||||
if verbose {
|
||||
println!(" {}{}", def.id, marker);
|
||||
println!(" Description: {}", def.description);
|
||||
println!(" Default model: {}", def.default_model);
|
||||
println!(" Protocol: {:?}", def.protocol);
|
||||
println!(" Model env: {}", def.model_env);
|
||||
if let Some(ref env) = def.api_key_env {
|
||||
println!(
|
||||
" API key env: {} ({})",
|
||||
env,
|
||||
if def.api_key_required {
|
||||
"required"
|
||||
} else {
|
||||
"optional"
|
||||
}
|
||||
);
|
||||
}
|
||||
if let Some(ref url) = def.default_base_url {
|
||||
println!(" Base URL: {}", url);
|
||||
}
|
||||
if !def.aliases.is_empty() {
|
||||
println!(" Aliases: {}", def.aliases.join(", "));
|
||||
}
|
||||
if is_active {
|
||||
println!(" Active model: {}", active_model);
|
||||
}
|
||||
println!();
|
||||
} else {
|
||||
let model_display = if is_active {
|
||||
active_model.clone()
|
||||
} else {
|
||||
def.default_model.clone()
|
||||
};
|
||||
println!(
|
||||
" {:<22} {:<40} {}",
|
||||
format!("{}{marker}", def.id),
|
||||
model_display,
|
||||
def.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !verbose {
|
||||
println!();
|
||||
println!("* = active provider. Use --verbose for details.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show details for a specific provider.
|
||||
async fn cmd_show_provider(
|
||||
id: &str,
|
||||
verbose: bool,
|
||||
json: bool,
|
||||
config_path: Option<&Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
let registry = ProviderRegistry::load();
|
||||
let (active_backend, active_model) = resolve_active(config_path);
|
||||
|
||||
// Resolve canonical ID for model fetching
|
||||
let canonical_id = if id == "nearai" || id == "near_ai" || id == "near" {
|
||||
"nearai".to_string()
|
||||
} else {
|
||||
registry
|
||||
.find(id)
|
||||
.map(|d| d.id.clone())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
};
|
||||
|
||||
// Try to fetch live model list from the provider
|
||||
let live_models = try_fetch_models(&canonical_id, config_path).await;
|
||||
|
||||
// Check NEAR AI first (not in registry)
|
||||
if id == "nearai" || id == "near_ai" || id == "near" {
|
||||
let is_active = active_backend == "nearai";
|
||||
if json {
|
||||
let mut v = serde_json::json!({
|
||||
"id": "nearai",
|
||||
"description": "NEAR AI inference (default)",
|
||||
"default_model": "qwen2.5-72b-instruct:free",
|
||||
"model_env": "NEARAI_MODEL",
|
||||
"active": is_active,
|
||||
});
|
||||
if is_active {
|
||||
v["active_model"] = serde_json::json!(active_model);
|
||||
}
|
||||
if let Some(ref models) = live_models {
|
||||
v["available_models"] = serde_json::json!(models);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
} else {
|
||||
println!("Provider: nearai");
|
||||
println!(" Description: NEAR AI inference (default)");
|
||||
println!(" Default model: qwen2.5-72b-instruct:free");
|
||||
println!(" Model env: NEARAI_MODEL");
|
||||
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||
if is_active {
|
||||
println!(" Active model: {}", active_model);
|
||||
}
|
||||
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let def = registry.find(id).ok_or_else(|| {
|
||||
let known: Vec<&str> = std::iter::once("nearai")
|
||||
.chain(registry.all().iter().map(|d| d.id.as_str()))
|
||||
.collect();
|
||||
anyhow::anyhow!(
|
||||
"Unknown provider '{}'. Known providers: {}",
|
||||
id,
|
||||
known.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let is_active = active_backend == def.id;
|
||||
|
||||
if json {
|
||||
let mut v = serde_json::json!({
|
||||
"id": def.id,
|
||||
"description": def.description,
|
||||
"protocol": format!("{:?}", def.protocol),
|
||||
"default_model": def.default_model,
|
||||
"model_env": def.model_env,
|
||||
"api_key_env": def.api_key_env,
|
||||
"api_key_required": def.api_key_required,
|
||||
"aliases": def.aliases,
|
||||
"active": is_active,
|
||||
});
|
||||
if let Some(ref url) = def.default_base_url {
|
||||
v["base_url"] = serde_json::json!(url);
|
||||
}
|
||||
if let Some(ref setup) = def.setup {
|
||||
v["can_list_models"] = serde_json::json!(setup.can_list_models());
|
||||
v["display_name"] = serde_json::json!(setup.display_name());
|
||||
}
|
||||
if is_active {
|
||||
v["active_model"] = serde_json::json!(active_model);
|
||||
}
|
||||
if verbose && !def.unsupported_params.is_empty() {
|
||||
v["unsupported_params"] = serde_json::json!(def.unsupported_params);
|
||||
}
|
||||
if let Some(ref models) = live_models {
|
||||
v["available_models"] = serde_json::json!(models);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Provider: {}", def.id);
|
||||
println!(" Description: {}", def.description);
|
||||
println!(" Protocol: {:?}", def.protocol);
|
||||
println!(" Default model: {}", def.default_model);
|
||||
println!(" Model env: {}", def.model_env);
|
||||
if let Some(ref env) = def.api_key_env {
|
||||
println!(
|
||||
" API key env: {} ({})",
|
||||
env,
|
||||
if def.api_key_required {
|
||||
"required"
|
||||
} else {
|
||||
"optional"
|
||||
}
|
||||
);
|
||||
}
|
||||
if let Some(ref url) = def.default_base_url {
|
||||
println!(" Base URL: {}", url);
|
||||
}
|
||||
if !def.aliases.is_empty() {
|
||||
println!(" Aliases: {}", def.aliases.join(", "));
|
||||
}
|
||||
if let Some(ref setup) = def.setup {
|
||||
println!(
|
||||
" List models: {}",
|
||||
if setup.can_list_models() {
|
||||
"supported"
|
||||
} else {
|
||||
"not supported"
|
||||
}
|
||||
);
|
||||
println!(" Display name: {}", setup.display_name());
|
||||
}
|
||||
if !def.unsupported_params.is_empty() {
|
||||
println!(" Unsupported: {}", def.unsupported_params.join(", "));
|
||||
}
|
||||
println!(" Active: {}", if is_active { "yes" } else { "no" });
|
||||
if is_active {
|
||||
println!(" Active model: {}", active_model);
|
||||
}
|
||||
print_model_list(&live_models, is_active.then_some(&active_model));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_active_defaults_to_nearai() {
|
||||
let settings = Settings::default();
|
||||
assert!(settings.llm_backend.is_none());
|
||||
assert!(settings.selected_model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_loads_all_providers() {
|
||||
let registry = ProviderRegistry::load();
|
||||
let all = registry.all();
|
||||
assert!(
|
||||
all.len() >= 10,
|
||||
"should have at least 10 built-in providers, got {}",
|
||||
all.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_find_by_alias() {
|
||||
let registry = ProviderRegistry::load();
|
||||
let def = registry
|
||||
.find("claude")
|
||||
.expect("claude alias should resolve");
|
||||
assert_eq!(def.id, "anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_providers_have_description() {
|
||||
let registry = ProviderRegistry::load();
|
||||
for def in registry.all() {
|
||||
assert!(
|
||||
!def.description.is_empty(),
|
||||
"provider {} should have a description",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_model_persists_to_toml() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
cmd_set_model("gpt-5-mini", Some(&toml_path)).expect("set model");
|
||||
|
||||
let settings = Settings::load_toml(&toml_path)
|
||||
.expect("read toml")
|
||||
.expect("should have settings");
|
||||
assert_eq!(settings.selected_model.as_deref(), Some("gpt-5-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_provider_validates_unknown() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
let result = cmd_set_provider("nonexistent_provider", None, Some(&toml_path));
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Unknown provider"),
|
||||
"should mention unknown provider: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_provider_persists_to_toml() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider");
|
||||
|
||||
let settings = Settings::load_toml(&toml_path)
|
||||
.expect("read toml")
|
||||
.expect("should have settings");
|
||||
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||
assert_eq!(
|
||||
settings.selected_model.as_deref(),
|
||||
Some("llama-3.3-70b-versatile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_provider_with_custom_model() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
cmd_set_provider("anthropic", Some("claude-opus-4-6"), Some(&toml_path))
|
||||
.expect("set provider with model");
|
||||
|
||||
let settings = Settings::load_toml(&toml_path)
|
||||
.expect("read toml")
|
||||
.expect("should have settings");
|
||||
assert_eq!(settings.llm_backend.as_deref(), Some("anthropic"));
|
||||
assert_eq!(settings.selected_model.as_deref(), Some("claude-opus-4-6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_config_does_not_pollute_default_dotenv() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
// With a custom config path, sync_to_dotenv should be a no-op
|
||||
// (it returns early when config_path is Some).
|
||||
// We verify by checking that cmd_set_provider succeeds without
|
||||
// trying to write to the default ~/.ironclaw/.env.
|
||||
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider with custom config");
|
||||
|
||||
let settings = Settings::load_toml(&toml_path)
|
||||
.expect("read toml")
|
||||
.expect("should have settings");
|
||||
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
|
||||
// The key assertion is that no error was thrown trying to write
|
||||
// to the default .env — sync_to_dotenv skipped it.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_model_rejects_empty_name() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
let result = cmd_set_model("", Some(&toml_path));
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("cannot be empty"),
|
||||
"should reject empty model name"
|
||||
);
|
||||
|
||||
let result2 = cmd_set_model(" ", Some(&toml_path));
|
||||
assert!(result2.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_provider_normalizes_alias() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let toml_path = dir.path().join("config.toml");
|
||||
|
||||
cmd_set_provider("claude", None, Some(&toml_path)).expect("set via alias");
|
||||
|
||||
let settings = Settings::load_toml(&toml_path)
|
||||
.expect("read toml")
|
||||
.expect("should have settings");
|
||||
assert_eq!(
|
||||
settings.llm_backend.as_deref(),
|
||||
Some("anthropic"),
|
||||
"alias should be normalized to canonical ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-47
@@ -340,8 +340,8 @@ async fn create(
|
||||
prompt: prompt.to_string(),
|
||||
context_paths: Vec::new(),
|
||||
max_tokens: 4096,
|
||||
use_tools: true,
|
||||
max_tool_rounds: 3,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 0,
|
||||
},
|
||||
guardrails: RoutineGuardrails {
|
||||
cooldown: std::time::Duration::from_secs(cooldown_secs),
|
||||
@@ -685,7 +685,6 @@ fn truncate(s: &str, max_chars: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::routine::RoutineAction;
|
||||
|
||||
#[test]
|
||||
fn format_relative_future() {
|
||||
@@ -744,48 +743,4 @@ mod tests {
|
||||
assert!(notify.on_failure); // safety: test-only assertion
|
||||
assert!(!notify.on_success); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn cli_create_defaults_lightweight_routines_to_tools_enabled() {
|
||||
let harness = crate::testing::TestHarnessBuilder::new().build().await;
|
||||
let db = harness.db.clone();
|
||||
|
||||
run_routines_command(
|
||||
RoutinesCommand::Create {
|
||||
name: "cli-digest".to_string(),
|
||||
schedule: "0 0 9 * * *".to_string(),
|
||||
prompt: "Prepare the morning digest.".to_string(),
|
||||
description: "CLI created routine".to_string(),
|
||||
timezone: Some("UTC".to_string()),
|
||||
cooldown: 300,
|
||||
notify_channel: None,
|
||||
},
|
||||
db.clone(),
|
||||
"user1",
|
||||
)
|
||||
.await
|
||||
.expect("create routine");
|
||||
|
||||
let routine = db
|
||||
.get_routine_by_name("user1", "cli-digest")
|
||||
.await
|
||||
.expect("get routine by name")
|
||||
.expect("cli-digest should exist");
|
||||
|
||||
match routine.action {
|
||||
RoutineAction::Lightweight {
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
..
|
||||
} => {
|
||||
assert!(
|
||||
use_tools,
|
||||
"CLI-created lightweight routines should default to tools"
|
||||
);
|
||||
assert_eq!(max_tool_rounds, 3);
|
||||
}
|
||||
other => panic!("expected lightweight action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ Commands:
|
||||
service Manage OS service
|
||||
skills Manage skills
|
||||
hooks Manage lifecycle hooks
|
||||
models Manage LLM providers and models
|
||||
doctor Run diagnostics
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
|
||||
@@ -20,7 +20,6 @@ Commands:
|
||||
service Manage OS service
|
||||
skills Manage skills
|
||||
hooks Manage lifecycle hooks
|
||||
models Manage LLM providers and models
|
||||
doctor Run diagnostics
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
|
||||
@@ -23,7 +23,6 @@ Commands:
|
||||
service Manage OS service
|
||||
skills Manage skills
|
||||
hooks Manage lifecycle hooks
|
||||
models Manage LLM providers and models
|
||||
doctor Run diagnostics
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
|
||||
@@ -23,7 +23,6 @@ Commands:
|
||||
service Manage OS service
|
||||
skills Manage skills
|
||||
hooks Manage lifecycle hooks
|
||||
models Manage LLM providers and models
|
||||
doctor Run diagnostics
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
|
||||
@@ -142,11 +142,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
init_cli_tracing();
|
||||
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
|
||||
}
|
||||
Some(Command::Models(models_cmd)) => {
|
||||
init_cli_tracing();
|
||||
return ironclaw::cli::run_models_command(models_cmd.clone(), cli.config.as_deref())
|
||||
.await;
|
||||
}
|
||||
Some(Command::Doctor) => {
|
||||
init_cli_tracing();
|
||||
return ironclaw::cli::run_doctor_command().await;
|
||||
|
||||
@@ -140,8 +140,7 @@ fn execution_properties() -> Value {
|
||||
},
|
||||
"use_tools": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Only applies to lightweight mode. New lightweight routines default this to true; when enabled, the routine can use the owner's live autonomous tool scope."
|
||||
"description": "Only applies to lightweight mode. When true, safe non-approval tools are available."
|
||||
},
|
||||
"max_tool_rounds": {
|
||||
"type": "integer",
|
||||
@@ -291,7 +290,7 @@ fn routine_request_discovery_schema() -> Value {
|
||||
fn lightweight_execution_variant() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'. New lightweight routines default to tools enabled unless execution.use_tools=false is set.",
|
||||
"description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'.",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
@@ -305,8 +304,7 @@ fn lightweight_execution_variant() -> Value {
|
||||
},
|
||||
"use_tools": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Defaults to true for new lightweight routines. When enabled, the routine can use the owner's live autonomous tool scope."
|
||||
"description": "When true, safe non-approval tools are available."
|
||||
},
|
||||
"max_tool_rounds": {
|
||||
"type": "integer",
|
||||
@@ -337,7 +335,7 @@ fn full_job_execution_variant() -> Value {
|
||||
fn execution_discovery_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"description": "Optional execution settings. Omit this block for the default lightweight mode with tools enabled.",
|
||||
"description": "Optional execution settings. Omit this block for the default lightweight mode.",
|
||||
"properties": execution_properties(),
|
||||
"oneOf": [
|
||||
lightweight_execution_variant(),
|
||||
@@ -410,8 +408,7 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
|
||||
"execution.mode='full_job' uses the owner's live autonomous tool scope and ignores use_tools, max_tool_rounds, and context_paths.".into(),
|
||||
],
|
||||
notes: vec![
|
||||
"Omitting execution defaults to lightweight mode with tools enabled.".into(),
|
||||
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
|
||||
"Omitting execution defaults to lightweight mode.".into(),
|
||||
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
|
||||
"advanced.cooldown_secs defaults to 300.".into(),
|
||||
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
|
||||
@@ -855,15 +852,11 @@ fn parse_execution_mode(value: Option<String>) -> Result<NormalizedExecutionMode
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_routine_execution(
|
||||
params: &Value,
|
||||
default_use_tools: bool,
|
||||
) -> Result<NormalizedExecutionRequest, ToolError> {
|
||||
fn parse_routine_execution(params: &Value) -> Result<NormalizedExecutionRequest, ToolError> {
|
||||
let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?;
|
||||
let context_paths =
|
||||
string_array_field(params, "execution", "context_paths", &["context_paths"]);
|
||||
let use_tools =
|
||||
bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(default_use_tools);
|
||||
let use_tools = bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(false);
|
||||
let max_tool_rounds = u64_field(params, "execution", "max_tool_rounds", &["max_tool_rounds"])
|
||||
.unwrap_or(3)
|
||||
.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64)
|
||||
@@ -895,7 +888,7 @@ fn parse_routine_create_request(
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let trigger = parse_routine_trigger(params)?;
|
||||
let execution = parse_routine_execution(params, true)?;
|
||||
let execution = parse_routine_execution(params)?;
|
||||
let delivery = parse_routine_delivery(params);
|
||||
let cooldown_secs =
|
||||
u64_field(params, "advanced", "cooldown_secs", &["cooldown_secs"]).unwrap_or(300);
|
||||
@@ -1870,56 +1863,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lightweight_create_with_tools_enabled_by_default() {
|
||||
let params = serde_json::json!({
|
||||
"name": "manual-check",
|
||||
"prompt": "Inspect the repo for issues.",
|
||||
"request": {
|
||||
"kind": "manual"
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = parse_routine_create_request(¶ms).expect("parse default lightweight");
|
||||
|
||||
assert!(
|
||||
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
|
||||
"expected lightweight execution mode",
|
||||
);
|
||||
assert!(
|
||||
parsed.execution.use_tools,
|
||||
"new lightweight routines should default use_tools=true",
|
||||
);
|
||||
assert_eq!(parsed.execution.max_tool_rounds, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_lightweight_create_with_explicit_tools_disabled() {
|
||||
let params = serde_json::json!({
|
||||
"name": "manual-check",
|
||||
"prompt": "Inspect the repo for issues.",
|
||||
"request": {
|
||||
"kind": "manual"
|
||||
},
|
||||
"execution": {
|
||||
"use_tools": false
|
||||
}
|
||||
});
|
||||
|
||||
let parsed =
|
||||
parse_routine_create_request(¶ms).expect("parse lightweight with tools disabled");
|
||||
|
||||
assert!(
|
||||
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
|
||||
"expected lightweight execution mode",
|
||||
);
|
||||
assert!(
|
||||
!parsed.execution.use_tools,
|
||||
"explicit use_tools=false should be preserved",
|
||||
);
|
||||
assert_eq!(parsed.execution.max_tool_rounds, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_context_paths_with_trim_drop_empty_and_stable_dedupe() {
|
||||
let params = serde_json::json!({
|
||||
@@ -2258,20 +2201,6 @@ mod tests {
|
||||
.any(|rule| rule.contains("request.kind='cron'")),
|
||||
"summary should explain cron requirement",
|
||||
);
|
||||
assert!(
|
||||
summary
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.contains("lightweight mode with tools enabled")),
|
||||
"summary should mention the new lightweight default",
|
||||
);
|
||||
assert!(
|
||||
summary
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.contains("execution.use_tools=false")),
|
||||
"summary should mention the text-only opt-out",
|
||||
);
|
||||
assert!(
|
||||
summary
|
||||
.notes
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
"""OAuth URL parameter validation e2e tests.
|
||||
|
||||
Tests for bug #992: Google OAuth URL broken when initiated from Telegram.
|
||||
Specifically verifies that OAuth query parameters are correctly formatted:
|
||||
- "client_id" (with underscore) NOT "clientid" (without underscore)
|
||||
- All standard OAuth parameters are present and correctly encoded
|
||||
- URLs are consistent across channels (web, Telegram, etc.)
|
||||
|
||||
The test verifies:
|
||||
1. OAuth URL is generated with correct parameters
|
||||
2. URL works with the OAuth provider (Google)
|
||||
3. Extra parameters (access_type, prompt) are preserved
|
||||
"""
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import pytest
|
||||
|
||||
from helpers import api_post, api_get
|
||||
|
||||
|
||||
async def _extract_oauth_params(auth_url: str) -> dict:
|
||||
"""Extract and validate OAuth query parameters from auth_url.
|
||||
|
||||
Returns dict with parsed parameters:
|
||||
{
|
||||
'client_id': '...',
|
||||
'redirect_uri': '...',
|
||||
'response_type': 'code',
|
||||
'scope': '...',
|
||||
'state': '...',
|
||||
'access_type': '...',
|
||||
'prompt': '...',
|
||||
...
|
||||
}
|
||||
"""
|
||||
parsed = urlparse(auth_url)
|
||||
qs = parse_qs(parsed.query)
|
||||
|
||||
# Convert lists to single values for easier testing
|
||||
params = {k: v[0] if len(v) > 0 else v for k, v in qs.items()}
|
||||
return params
|
||||
|
||||
|
||||
async def _get_extension(ironclaw_server, name):
|
||||
"""Get a specific extension from the extensions list, or None."""
|
||||
r = await api_get(ironclaw_server, "/api/extensions")
|
||||
for ext in r.json().get("extensions", []):
|
||||
if ext["name"] == name:
|
||||
return ext
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def installed_gmail(ironclaw_server):
|
||||
"""Installs the 'gmail' extension before a test and removes it after.
|
||||
|
||||
This fixture handles the setup and teardown of the Gmail extension,
|
||||
ensuring a clean state for each test.
|
||||
"""
|
||||
# Ensure Gmail is not installed before test
|
||||
ext = await _get_extension(ironclaw_server, "gmail")
|
||||
if ext:
|
||||
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Install Gmail
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/install",
|
||||
json={"name": "gmail"},
|
||||
timeout=180,
|
||||
)
|
||||
assert r.status_code == 200, f"Gmail install failed: {r.text}"
|
||||
assert r.json().get("success") is True, f"Install failed: {r.json().get('message', '')}"
|
||||
|
||||
yield
|
||||
|
||||
# Teardown: remove gmail
|
||||
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
|
||||
assert r.status_code == 200, f"Gmail removal failed: {r.text}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_url(ironclaw_server, installed_gmail):
|
||||
"""Generate and return an OAuth auth URL.
|
||||
|
||||
Requires Gmail to be installed (depends on installed_gmail fixture).
|
||||
"""
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/gmail/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("success") is True, f"Setup failed: {data.get('message', '')}"
|
||||
|
||||
url = data.get("auth_url")
|
||||
assert url is not None, f"Expected auth_url in response: {data}"
|
||||
assert "accounts.google.com" in url, f"auth_url should point to Google: {url}"
|
||||
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_params(auth_url):
|
||||
"""Extract and return OAuth parameters from auth_url.
|
||||
|
||||
Depends on auth_url fixture.
|
||||
"""
|
||||
return await _extract_oauth_params(auth_url)
|
||||
|
||||
|
||||
# ─ OAuth URL parameter validation tests ────────────────────────────────
|
||||
|
||||
async def test_oauth_url_has_client_id_not_clientid(oauth_params, auth_url):
|
||||
"""Verify OAuth URL has 'client_id' (with underscore), NOT 'clientid'.
|
||||
|
||||
Bug #992: Ensure the parameter name is correct across all channels.
|
||||
"""
|
||||
params = oauth_params
|
||||
|
||||
# The bug: "clientid" appears instead of "client_id"
|
||||
# Verify the CORRECT parameter name exists
|
||||
assert "client_id" in params, (
|
||||
f"OAuth URL missing 'client_id' parameter. "
|
||||
f"URL: {auth_url}\nParams: {params}"
|
||||
)
|
||||
assert params["client_id"], "client_id should have a value"
|
||||
|
||||
# Verify the INCORRECT parameter name does NOT exist
|
||||
assert "clientid" not in params, (
|
||||
f"OAuth URL should NOT have 'clientid' (without underscore). "
|
||||
f"Bug #992: URL: {auth_url}\nParams: {params}"
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_url_has_required_parameters(oauth_params):
|
||||
"""Verify all required OAuth 2.0 parameters are present."""
|
||||
params = oauth_params
|
||||
|
||||
# Required OAuth 2.0 parameters
|
||||
required = ["client_id", "response_type", "redirect_uri", "scope", "state"]
|
||||
for param in required:
|
||||
assert param in params, (
|
||||
f"Missing required OAuth parameter: {param}. "
|
||||
f"Params: {params}"
|
||||
)
|
||||
assert params[param], f"Parameter '{param}' should have a non-empty value"
|
||||
|
||||
# Validate specific values
|
||||
assert params["response_type"] == "code", "Should use authorization_code flow"
|
||||
assert "oauth" in params["redirect_uri"], "Redirect URI should be an OAuth callback"
|
||||
|
||||
|
||||
async def test_oauth_url_has_extra_params(oauth_params):
|
||||
"""Verify extra_params from capabilities.json are included."""
|
||||
params = oauth_params
|
||||
|
||||
# Google-specific extra_params from gmail-tool.capabilities.json
|
||||
assert "access_type" in params, (
|
||||
"Should include 'access_type' from extra_params"
|
||||
)
|
||||
assert params["access_type"] == "offline", (
|
||||
"access_type should be 'offline' for Gmail"
|
||||
)
|
||||
|
||||
assert "prompt" in params, (
|
||||
"Should include 'prompt' from extra_params"
|
||||
)
|
||||
assert params["prompt"] == "consent", (
|
||||
"prompt should be 'consent' for Gmail"
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_url_is_valid_google_oauth(auth_url):
|
||||
"""Verify the URL is a valid Google OAuth 2.0 authorization URL."""
|
||||
# Verify scheme and host
|
||||
parsed = urlparse(auth_url)
|
||||
assert parsed.scheme == "https", "OAuth URL must use HTTPS"
|
||||
assert "accounts.google.com" in parsed.netloc, "Must be Google's OAuth endpoint"
|
||||
assert parsed.path == "/o/oauth2/v2/auth", "Must use Google OAuth 2.0 endpoint"
|
||||
|
||||
|
||||
async def test_oauth_url_state_is_unique(ironclaw_server, installed_gmail, oauth_params, auth_url):
|
||||
"""Verify CSRF state is present and unique per request."""
|
||||
# Get a new OAuth URL
|
||||
r = await api_post(
|
||||
ironclaw_server,
|
||||
"/api/extensions/gmail/setup",
|
||||
json={"secrets": {}},
|
||||
timeout=30,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
new_auth_url = r.json().get("auth_url")
|
||||
assert new_auth_url is not None
|
||||
|
||||
# Extract state from both URLs
|
||||
original_params = oauth_params
|
||||
new_params = await _extract_oauth_params(new_auth_url)
|
||||
|
||||
original_state = original_params.get("state")
|
||||
new_state = new_params.get("state")
|
||||
|
||||
assert original_state is not None, "Should have state parameter"
|
||||
assert new_state is not None, "New request should have state parameter"
|
||||
assert original_state != new_state, (
|
||||
"CSRF state should be unique per request (for security)"
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_url_escaping(auth_url):
|
||||
"""Verify URL query parameters are properly escaped."""
|
||||
# Verify special characters in values are URL-encoded
|
||||
# For example, scopes contain spaces which should be %20
|
||||
assert "%20" in auth_url or "+" in auth_url or "%2B" in auth_url or " " not in auth_url, (
|
||||
"OAuth URL should properly encode special characters in parameters"
|
||||
)
|
||||
|
||||
|
||||
# ─ Telegram-specific tests (when Telegram channel is available) ──────────
|
||||
|
||||
class TestOAuthURLViaTelegram:
|
||||
"""Test OAuth URL generation specifically via Telegram channel.
|
||||
|
||||
These tests would verify that the same OAuth URL works correctly when
|
||||
transmitted through the Telegram WASM channel (as opposed to web gateway).
|
||||
|
||||
Currently marked as xfail pending Telegram channel setup in E2E tests.
|
||||
"""
|
||||
|
||||
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
|
||||
async def test_telegram_oauth_url_has_correct_parameters(self):
|
||||
"""Verify OAuth URL sent via Telegram has correct parameter names."""
|
||||
# This test would:
|
||||
# 1. Send a message via Telegram that triggers OAuth
|
||||
# 2. Capture the status update sent to Telegram
|
||||
# 3. Extract the auth_url from the message
|
||||
# 4. Verify it has "client_id" not "clientid"
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
|
||||
async def test_telegram_oauth_url_can_be_regenerated(self):
|
||||
"""Verify OAuth URL can be regenerated when requested via Telegram."""
|
||||
# This test would verify that the bug #992 symptom
|
||||
# "URL cannot be regenerated when asked" is fixed.
|
||||
# If the URL is cached incorrectly, regeneration would fail.
|
||||
pass
|
||||
@@ -205,11 +205,11 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 5: routine_manual_create_defaults_to_tools_enabled
|
||||
// Test 5: routine_manual_create
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_manual_create_defaults_to_tools_enabled() {
|
||||
async fn routine_manual_create() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_manual_create.json"
|
||||
@@ -235,51 +235,10 @@ mod tests {
|
||||
.expect("get_routine_by_name")
|
||||
.expect("manual-triage should exist");
|
||||
|
||||
assert!(matches!(routine.trigger, Trigger::Manual));
|
||||
assert!(
|
||||
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if *use_tools),
|
||||
"manual routine should default to lightweight with tools enabled: {:?}",
|
||||
routine.action
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 6: routine_manual_create_explicit_no_tools
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn routine_manual_create_explicit_no_tools() {
|
||||
let trace = LlmTrace::from_file(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/llm_traces/tools/routine_manual_create_no_tools.json"
|
||||
))
|
||||
.expect("failed to load routine_manual_create_no_tools.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Create a manual routine for quiet text-only bug triage")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
let routine = rig
|
||||
.database()
|
||||
.get_routine_by_name("test-user", "manual-triage-no-tools")
|
||||
.await
|
||||
.expect("get_routine_by_name")
|
||||
.expect("manual-triage-no-tools should exist");
|
||||
|
||||
assert!(matches!(routine.trigger, Trigger::Manual));
|
||||
assert!(
|
||||
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if !*use_tools),
|
||||
"manual routine should preserve explicit use_tools=false: {:?}",
|
||||
"manual routine should default to lightweight without tools: {:?}",
|
||||
routine.action
|
||||
);
|
||||
|
||||
@@ -287,7 +246,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 7: routine_history
|
||||
// Test 6: routine_history
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
@@ -324,7 +283,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 8: routine_system_event_emit
|
||||
// Test 7: routine_system_event_emit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"model_name": "test-routine-manual-create-no-tools",
|
||||
"expects": {
|
||||
"tools_used": ["routine_create"],
|
||||
"all_tools_succeeded": true,
|
||||
"min_responses": 1
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"response": {
|
||||
"type": "tool_calls",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_rc_manual_2",
|
||||
"name": "routine_create",
|
||||
"arguments": {
|
||||
"name": "manual-triage-no-tools",
|
||||
"trigger_type": "manual",
|
||||
"prompt": "Summarize the latest bug reports when this routine is fired.",
|
||||
"execution": {
|
||||
"use_tools": false
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"input_tokens": 90,
|
||||
"output_tokens": 24
|
||||
}
|
||||
},
|
||||
{
|
||||
"response": {
|
||||
"type": "text",
|
||||
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
|
||||
"input_tokens": 140,
|
||||
"output_tokens": 18
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "near-intents-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Near Intents tools for token resolution, balance queries, and reverse lookups (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Near Intents tools for token resolution, balance queries, and reverse lookups on the Defuse protocol.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["resolve_token", "reverse_resolve_token", "get_balance"],
|
||||
"description": "Which action to perform"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Token reference to resolve (for resolve_token)"
|
||||
},
|
||||
"list_all": {
|
||||
"type": "boolean",
|
||||
"description": "Return all tokens (for resolve_token)",
|
||||
"default": false
|
||||
},
|
||||
"asset_id": {
|
||||
"type": "string",
|
||||
"description": "Defuse asset ID (for reverse_resolve_token)"
|
||||
},
|
||||
"account_id": {
|
||||
"type": "string",
|
||||
"description": "NEAR wallet address (for get_balance)"
|
||||
},
|
||||
"token_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Specific defuse asset IDs to query (for get_balance)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "1click.chaindefuser.com",
|
||||
"path_prefix": "/v0/tokens",
|
||||
"methods": ["GET"]
|
||||
},
|
||||
{
|
||||
"host": "rpc.mainnet.near.org",
|
||||
"path_prefix": "/",
|
||||
"methods": ["POST"]
|
||||
}
|
||||
],
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["trading", "near", "defi", "intents"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user