Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b74c5cfda8 | |||
| 02688eb124 | |||
| 2c92cf913b | |||
| 3c117d2d7b | |||
| 1f7c3c99e4 | |||
| 92940a3d16 | |||
| d77c616905 | |||
| ac12470c27 | |||
| c5a1148ae9 | |||
| 440ad6e5b5 | |||
| 2e41cb56f6 | |||
| 2227fadb66 | |||
| 162efbb49c | |||
| 58b98c59a8 |
+4
-1
@@ -31,6 +31,7 @@ include = [
|
||||
"/LICENSE*",
|
||||
"/README.md",
|
||||
"/web/dist/**/*",
|
||||
"/tool_descriptions/**/*",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
@@ -215,7 +216,7 @@ landlock = { version = "0.4", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["observability-prometheus", "channel-nostr"]
|
||||
default = ["observability-prometheus", "channel-nostr", "skill-creation"]
|
||||
channel-nostr = ["dep:nostr-sdk"]
|
||||
hardware = ["nusb", "tokio-serial"]
|
||||
channel-matrix = ["dep:matrix-sdk"]
|
||||
@@ -240,6 +241,8 @@ metrics = ["observability-prometheus"]
|
||||
probe = ["dep:probe-rs"]
|
||||
# rag-pdf = PDF ingestion for datasheet RAG
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
# skill-creation = Autonomous skill creation from successful multi-step tasks
|
||||
skill-creation = []
|
||||
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
|
||||
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
|
||||
# WASM plugin system (extism-based)
|
||||
|
||||
@@ -183,6 +183,8 @@ Delegate sub-agent configurations. Each key under `[agents]` defines a named sub
|
||||
| `agentic` | `false` | Enable multi-turn tool-call loop mode for the sub-agent |
|
||||
| `allowed_tools` | `[]` | Tool allowlist for agentic mode |
|
||||
| `max_iterations` | `10` | Max tool-call iterations for agentic mode |
|
||||
| `timeout_secs` | `120` | Timeout in seconds for non-agentic provider calls (1–3600) |
|
||||
| `agentic_timeout_secs` | `300` | Timeout in seconds for agentic sub-agent loops (1–3600) |
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -199,11 +201,13 @@ max_depth = 2
|
||||
agentic = true
|
||||
allowed_tools = ["web_search", "http_request", "file_read"]
|
||||
max_iterations = 8
|
||||
agentic_timeout_secs = 600
|
||||
|
||||
[agents.coder]
|
||||
provider = "ollama"
|
||||
model = "qwen2.5-coder:32b"
|
||||
temperature = 0.2
|
||||
timeout_secs = 60
|
||||
```
|
||||
|
||||
## `[runtime]`
|
||||
|
||||
+149
-1
@@ -767,6 +767,140 @@ run_guided_installer() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_default_config_and_workspace() {
|
||||
# Creates a minimal config.toml and workspace scaffold files when the
|
||||
# onboard wizard was skipped (e.g. --skip-build --prefer-prebuilt, or
|
||||
# Docker mode without an API key).
|
||||
#
|
||||
# $1 — config directory (e.g. ~/.zeroclaw or $docker_data_dir/.zeroclaw)
|
||||
# $2 — workspace directory (e.g. ~/.zeroclaw/workspace or $docker_data_dir/workspace)
|
||||
# $3 — provider name (default: openrouter)
|
||||
local config_dir="$1"
|
||||
local workspace_dir="$2"
|
||||
local provider="${3:-openrouter}"
|
||||
|
||||
mkdir -p "$config_dir" "$workspace_dir"
|
||||
|
||||
# --- config.toml ---
|
||||
local config_path="$config_dir/config.toml"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
step_dot "Creating default config.toml"
|
||||
cat > "$config_path" <<TOML
|
||||
# ZeroClaw configuration — generated by install.sh
|
||||
# Edit this file or run 'zeroclaw onboard' to reconfigure.
|
||||
|
||||
default_provider = "${provider}"
|
||||
workspace_dir = "${workspace_dir}"
|
||||
TOML
|
||||
if [[ -n "${API_KEY:-}" ]]; then
|
||||
printf 'api_key = "%s"\n' "$API_KEY" >> "$config_path"
|
||||
fi
|
||||
if [[ -n "${MODEL:-}" ]]; then
|
||||
printf 'default_model = "%s"\n' "$MODEL" >> "$config_path"
|
||||
fi
|
||||
chmod 600 "$config_path" 2>/dev/null || true
|
||||
step_ok "Default config.toml created at $config_path"
|
||||
else
|
||||
step_dot "config.toml already exists, skipping"
|
||||
fi
|
||||
|
||||
# --- Workspace scaffold ---
|
||||
local subdirs=(sessions memory state cron skills)
|
||||
for dir in "${subdirs[@]}"; do
|
||||
mkdir -p "$workspace_dir/$dir"
|
||||
done
|
||||
|
||||
# Seed workspace markdown files only if they don't already exist.
|
||||
local user_name="${USER:-User}"
|
||||
local agent_name="ZeroClaw"
|
||||
|
||||
_write_if_missing() {
|
||||
local filepath="$1"
|
||||
local content="$2"
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
printf '%s\n' "$content" > "$filepath"
|
||||
fi
|
||||
}
|
||||
|
||||
_write_if_missing "$workspace_dir/IDENTITY.md" \
|
||||
"# IDENTITY.md — Who Am I?
|
||||
|
||||
- **Name:** ${agent_name}
|
||||
- **Creature:** A Rust-forged AI — fast, lean, and relentless
|
||||
- **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.
|
||||
|
||||
---
|
||||
|
||||
Update this file as you evolve. Your identity is yours to shape."
|
||||
|
||||
_write_if_missing "$workspace_dir/USER.md" \
|
||||
"# USER.md — Who You're Helping
|
||||
|
||||
## About You
|
||||
- **Name:** ${user_name}
|
||||
- **Timezone:** UTC
|
||||
- **Languages:** English
|
||||
|
||||
## Preferences
|
||||
- (Add your preferences here)
|
||||
|
||||
## Work Context
|
||||
- (Add your work context here)
|
||||
|
||||
---
|
||||
*Update this anytime. The more ${agent_name} knows, the better it helps.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/MEMORY.md" \
|
||||
"# MEMORY.md — Long-Term Memory
|
||||
|
||||
## Key Facts
|
||||
(Add important facts here)
|
||||
|
||||
## Decisions & Preferences
|
||||
(Record decisions and preferences here)
|
||||
|
||||
## Lessons Learned
|
||||
(Document mistakes and insights here)
|
||||
|
||||
## Open Loops
|
||||
(Track unfinished tasks and follow-ups here)"
|
||||
|
||||
_write_if_missing "$workspace_dir/AGENTS.md" \
|
||||
"# AGENTS.md — ${agent_name} Personal Assistant
|
||||
|
||||
## Every Session (required)
|
||||
|
||||
Before doing anything else:
|
||||
|
||||
1. Read SOUL.md — this is who you are
|
||||
2. Read USER.md — this is who you're helping
|
||||
3. Use memory_recall for recent context
|
||||
|
||||
---
|
||||
*Add your own conventions, style, and rules.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/SOUL.md" \
|
||||
"# SOUL.md — Who You Are
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.**
|
||||
**Have opinions.** You're allowed to disagree.
|
||||
**Be resourceful before asking.** Try to figure it out first.
|
||||
**Earn trust through competence.**
|
||||
|
||||
## Identity
|
||||
|
||||
You are **${agent_name}**. Built in Rust. 3MB binary. Zero bloat.
|
||||
|
||||
---
|
||||
*This file is yours to evolve.*"
|
||||
|
||||
step_ok "Workspace scaffold ready at $workspace_dir"
|
||||
|
||||
unset -f _write_if_missing
|
||||
}
|
||||
|
||||
resolve_container_cli() {
|
||||
local requested_cli
|
||||
requested_cli="${ZEROCLAW_CONTAINER_CLI:-docker}"
|
||||
@@ -884,10 +1018,17 @@ run_docker_bootstrap() {
|
||||
-v "$config_mount" \
|
||||
-v "$workspace_mount" \
|
||||
"$docker_image" \
|
||||
"${onboard_cmd[@]}"
|
||||
"${onboard_cmd[@]}" || true
|
||||
else
|
||||
info "Docker image ready. Run zeroclaw onboard inside the container to configure."
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist on the host even when
|
||||
# onboard was skipped, failed, or ran non-interactively inside the container.
|
||||
ensure_default_config_and_workspace \
|
||||
"$docker_data_dir/.zeroclaw" \
|
||||
"$docker_data_dir/workspace" \
|
||||
"$PROVIDER"
|
||||
}
|
||||
|
||||
SCRIPT_PATH="${BASH_SOURCE[0]:-$0}"
|
||||
@@ -1314,6 +1455,13 @@ elif [[ -z "$ZEROCLAW_BIN" ]]; then
|
||||
warn "ZeroClaw binary not found — cannot configure provider"
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist even when onboard was
|
||||
# skipped, unavailable, or failed (e.g. --skip-build --prefer-prebuilt
|
||||
# without an API key, or when the binary could not run onboard).
|
||||
_native_config_dir="${ZEROCLAW_CONFIG_DIR:-$HOME/.zeroclaw}"
|
||||
_native_workspace_dir="${ZEROCLAW_WORKSPACE:-$_native_config_dir/workspace}"
|
||||
ensure_default_config_and_workspace "$_native_config_dir" "$_native_workspace_dir" "$PROVIDER"
|
||||
|
||||
# --- Gateway service management ---
|
||||
if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
# Try to install and start the gateway service
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::agent::dispatcher::{
|
||||
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
|
||||
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
|
||||
use crate::config::Config;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::observability::{self, Observer, ObserverEvent};
|
||||
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
|
||||
@@ -40,6 +41,7 @@ pub struct Agent {
|
||||
route_model_by_hint: HashMap<String, String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder {
|
||||
@@ -64,6 +66,7 @@ pub struct AgentBuilder {
|
||||
route_model_by_hint: Option<HashMap<String, String>>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
}
|
||||
|
||||
impl AgentBuilder {
|
||||
@@ -90,6 +93,7 @@ impl AgentBuilder {
|
||||
route_model_by_hint: None,
|
||||
allowed_tools: None,
|
||||
response_cache: None,
|
||||
tool_descriptions: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +211,11 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tool_descriptions(mut self, tool_descriptions: Option<ToolDescriptions>) -> Self {
|
||||
self.tool_descriptions = tool_descriptions;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Agent> {
|
||||
let mut tools = self
|
||||
.tools
|
||||
@@ -257,6 +266,7 @@ impl AgentBuilder {
|
||||
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
|
||||
allowed_tools: allowed,
|
||||
response_cache: self.response_cache,
|
||||
tool_descriptions: self.tool_descriptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -456,6 +466,7 @@ impl Agent {
|
||||
skills_prompt_mode: self.skills_prompt_mode,
|
||||
identity_config: Some(&self.identity_config),
|
||||
dispatcher_instructions: &instructions,
|
||||
tool_descriptions: self.tool_descriptions.as_ref(),
|
||||
};
|
||||
self.prompt_builder.build(&ctx)
|
||||
}
|
||||
|
||||
+280
-63
@@ -1,5 +1,6 @@
|
||||
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
|
||||
use crate::config::Config;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::multimodal;
|
||||
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
|
||||
@@ -17,7 +18,7 @@ use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -33,6 +34,29 @@ const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
|
||||
/// Matches the channel-side constant in `channels/mod.rs`.
|
||||
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
|
||||
|
||||
/// Callback type for checking if model has been switched during tool execution.
|
||||
/// Returns Some((provider, model)) if a switch was requested, None otherwise.
|
||||
pub type ModelSwitchCallback = Arc<Mutex<Option<(String, String)>>>;
|
||||
|
||||
/// Global model switch request state - used for runtime model switching via model_switch tool.
|
||||
/// This is set by the model_switch tool and checked by the agent loop.
|
||||
#[allow(clippy::type_complexity)]
|
||||
static MODEL_SWITCH_REQUEST: LazyLock<Arc<Mutex<Option<(String, String)>>>> =
|
||||
LazyLock::new(|| Arc::new(Mutex::new(None)));
|
||||
|
||||
/// Get the global model switch request state
|
||||
pub fn get_model_switch_state() -> ModelSwitchCallback {
|
||||
Arc::clone(&MODEL_SWITCH_REQUEST)
|
||||
}
|
||||
|
||||
/// Clear any pending model switch request
|
||||
pub fn clear_model_switch_request() {
|
||||
if let Ok(guard) = MODEL_SWITCH_REQUEST.lock() {
|
||||
let mut guard = guard;
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||
match pattern.find('*') {
|
||||
None => pattern == name,
|
||||
@@ -2118,6 +2142,31 @@ pub(crate) fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|source| source.is::<ToolLoopCancelled>())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ModelSwitchRequested {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelSwitchRequested {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"model switch requested to {} {}",
|
||||
self.provider, self.model
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ModelSwitchRequested {}
|
||||
|
||||
pub(crate) fn is_model_switch_requested(err: &anyhow::Error) -> Option<(String, String)> {
|
||||
err.chain()
|
||||
.filter_map(|source| source.downcast_ref::<ModelSwitchRequested>())
|
||||
.map(|e| (e.provider.clone(), e.model.clone()))
|
||||
.next()
|
||||
}
|
||||
|
||||
/// Execute a single turn of the agent loop: send messages, parse tool calls,
|
||||
/// execute tools, and loop until the LLM produces a final text response.
|
||||
/// When `silent` is true, suppresses stdout (for channel use).
|
||||
@@ -2137,6 +2186,7 @@ pub(crate) async fn agent_turn(
|
||||
excluded_tools: &[String],
|
||||
dedup_exempt_tools: &[String],
|
||||
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
model_switch_callback: Option<ModelSwitchCallback>,
|
||||
) -> Result<String> {
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
@@ -2157,6 +2207,7 @@ pub(crate) async fn agent_turn(
|
||||
excluded_tools,
|
||||
dedup_exempt_tools,
|
||||
activated_tools,
|
||||
model_switch_callback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2362,6 +2413,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
excluded_tools: &[String],
|
||||
dedup_exempt_tools: &[String],
|
||||
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
model_switch_callback: Option<ModelSwitchCallback>,
|
||||
) -> Result<String> {
|
||||
let max_iterations = if max_tool_iterations == 0 {
|
||||
DEFAULT_MAX_TOOL_ITERATIONS
|
||||
@@ -2370,9 +2422,10 @@ pub(crate) async fn run_tool_call_loop(
|
||||
};
|
||||
|
||||
let turn_id = Uuid::new_v4().to_string();
|
||||
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
|
||||
|
||||
for iteration in 0..max_iterations {
|
||||
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
|
||||
|
||||
if cancellation_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
@@ -2380,6 +2433,28 @@ pub(crate) async fn run_tool_call_loop(
|
||||
return Err(ToolLoopCancelled.into());
|
||||
}
|
||||
|
||||
// Check if model switch was requested via model_switch tool
|
||||
if let Some(ref callback) = model_switch_callback {
|
||||
if let Ok(guard) = callback.lock() {
|
||||
if let Some((new_provider, new_model)) = guard.as_ref() {
|
||||
if new_provider != provider_name || new_model != model {
|
||||
tracing::info!(
|
||||
"Model switch detected: {} {} -> {} {}",
|
||||
provider_name,
|
||||
model,
|
||||
new_provider,
|
||||
new_model
|
||||
);
|
||||
return Err(ModelSwitchRequested {
|
||||
provider: new_provider.clone(),
|
||||
model: new_model.clone(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild tool_specs each iteration so newly activated deferred tools appear.
|
||||
let mut tool_specs: Vec<crate::tools::ToolSpec> = tools_registry
|
||||
.iter()
|
||||
@@ -3004,7 +3079,10 @@ pub(crate) async fn run_tool_call_loop(
|
||||
|
||||
/// Build the tool instruction block for the system prompt so the LLM knows
|
||||
/// how to invoke tools.
|
||||
pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
|
||||
pub(crate) fn build_tool_instructions(
|
||||
tools_registry: &[Box<dyn Tool>],
|
||||
tool_descriptions: Option<&ToolDescriptions>,
|
||||
) -> String {
|
||||
let mut instructions = String::new();
|
||||
instructions.push_str("\n## Tool Use Protocol\n\n");
|
||||
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
|
||||
@@ -3020,11 +3098,14 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
|
||||
instructions.push_str("### Available Tools\n\n");
|
||||
|
||||
for tool in tools_registry {
|
||||
let desc = tool_descriptions
|
||||
.and_then(|td| td.get(tool.name()))
|
||||
.unwrap_or_else(|| tool.description());
|
||||
let _ = writeln!(
|
||||
instructions,
|
||||
"**{}**: {}\nParameters: `{}`\n",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
desc,
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
@@ -3199,28 +3280,32 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// ── Resolve provider ─────────────────────────────────────────
|
||||
let provider_name = provider_override
|
||||
let mut provider_name = provider_override
|
||||
.as_deref()
|
||||
.or(config.default_provider.as_deref())
|
||||
.unwrap_or("openrouter");
|
||||
.unwrap_or("openrouter")
|
||||
.to_string();
|
||||
|
||||
let model_name = model_override
|
||||
let mut model_name = model_override
|
||||
.as_deref()
|
||||
.or(config.default_model.as_deref())
|
||||
.unwrap_or("anthropic/claude-sonnet-4");
|
||||
.unwrap_or("anthropic/claude-sonnet-4")
|
||||
.to_string();
|
||||
|
||||
let provider_runtime_options = providers::provider_runtime_options_from_config(&config);
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
let mut provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
&provider_name,
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
model_name,
|
||||
&model_name,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
let model_switch_callback = get_model_switch_state();
|
||||
|
||||
observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: provider_name.to_string(),
|
||||
model: model_name.to_string(),
|
||||
@@ -3246,6 +3331,16 @@ pub async fn run(
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
@@ -3364,7 +3459,7 @@ pub async fn run(
|
||||
let native_tools = provider.supports_native_tools();
|
||||
let mut system_prompt = crate::channels::build_system_prompt_with_mode(
|
||||
&config.workspace_dir,
|
||||
model_name,
|
||||
&model_name,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
@@ -3375,7 +3470,7 @@ pub async fn run(
|
||||
|
||||
// Append structured tool-use instructions with schemas (only for non-native providers)
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry));
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
|
||||
}
|
||||
|
||||
// Append deferred MCP tool names so the LLM knows what is available
|
||||
@@ -3447,27 +3542,93 @@ pub async fn run(
|
||||
let excluded_tools =
|
||||
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, &msg);
|
||||
|
||||
let response = run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
#[allow(unused_assignments)]
|
||||
let mut response = String::new();
|
||||
loop {
|
||||
match run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
&provider_name,
|
||||
&model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
Some(model_switch_callback.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
response = resp;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
|
||||
tracing::info!(
|
||||
"Model switch requested, switching from {} {} to {} {}",
|
||||
provider_name,
|
||||
model_name,
|
||||
new_provider,
|
||||
new_model
|
||||
);
|
||||
|
||||
provider = providers::create_routed_provider_with_options(
|
||||
&new_provider,
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&new_model,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
provider_name = new_provider;
|
||||
model_name = new_model;
|
||||
|
||||
clear_model_switch_request();
|
||||
|
||||
observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: provider_name.to_string(),
|
||||
model: model_name.to_string(),
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After successful multi-step execution, attempt autonomous skill creation.
|
||||
#[cfg(feature = "skill-creation")]
|
||||
if config.skills.skill_creation.enabled {
|
||||
let tool_calls = crate::skills::creator::extract_tool_calls_from_history(&history);
|
||||
if tool_calls.len() >= 2 {
|
||||
let creator = crate::skills::creator::SkillCreator::new(
|
||||
config.workspace_dir.clone(),
|
||||
config.skills.skill_creation.clone(),
|
||||
);
|
||||
match creator.create_from_execution(&msg, &tool_calls, None).await {
|
||||
Ok(Some(slug)) => {
|
||||
tracing::info!(slug, "Auto-created skill from execution");
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("Skill creation skipped (duplicate or disabled)");
|
||||
}
|
||||
Err(e) => tracing::warn!("Skill creation failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
final_output = response.clone();
|
||||
println!("{response}");
|
||||
observer.record_event(&ObserverEvent::TurnComplete);
|
||||
@@ -3609,32 +3770,66 @@ pub async fn run(
|
||||
&user_input,
|
||||
);
|
||||
|
||||
let response = match run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
eprintln!("\nError: {e}\n");
|
||||
continue;
|
||||
let response = loop {
|
||||
match run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
&provider_name,
|
||||
&model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle.as_ref(),
|
||||
Some(model_switch_callback.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => break resp,
|
||||
Err(e) => {
|
||||
if let Some((new_provider, new_model)) = is_model_switch_requested(&e) {
|
||||
tracing::info!(
|
||||
"Model switch requested, switching from {} {} to {} {}",
|
||||
provider_name,
|
||||
model_name,
|
||||
new_provider,
|
||||
new_model
|
||||
);
|
||||
|
||||
provider = providers::create_routed_provider_with_options(
|
||||
&new_provider,
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&new_model,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
provider_name = new_provider;
|
||||
model_name = new_model;
|
||||
|
||||
clear_model_switch_request();
|
||||
|
||||
observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: provider_name.to_string(),
|
||||
model: model_name.to_string(),
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
eprintln!("\nError: {e}\n");
|
||||
break String::new();
|
||||
}
|
||||
}
|
||||
};
|
||||
final_output = response.clone();
|
||||
@@ -3652,7 +3847,7 @@ pub async fn run(
|
||||
if let Ok(compacted) = auto_compact_history(
|
||||
&mut history,
|
||||
provider.as_ref(),
|
||||
model_name,
|
||||
&model_name,
|
||||
config.agent.max_history_messages,
|
||||
config.agent.max_context_tokens,
|
||||
)
|
||||
@@ -3832,6 +4027,16 @@ pub async fn process_message(
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&config.workspace_dir);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
("shell", "Execute terminal commands."),
|
||||
@@ -3897,7 +4102,7 @@ pub async fn process_message(
|
||||
config.skills.prompt_injection_mode,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry));
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
|
||||
}
|
||||
if !deferred_section.is_empty() {
|
||||
system_prompt.push('\n');
|
||||
@@ -3946,6 +4151,7 @@ pub async fn process_message(
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle_pm.as_ref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4403,6 +4609,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("provider without vision support should fail");
|
||||
@@ -4451,6 +4658,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized payload must fail");
|
||||
@@ -4493,6 +4701,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("valid multimodal payload should pass");
|
||||
@@ -4621,6 +4830,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("parallel execution should complete");
|
||||
@@ -4692,6 +4902,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish after deduplicating repeated calls");
|
||||
@@ -4759,6 +4970,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("non-interactive shell should succeed for low-risk command");
|
||||
@@ -4817,6 +5029,7 @@ mod tests {
|
||||
&[],
|
||||
&exempt,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish with exempt tool executing twice");
|
||||
@@ -4895,6 +5108,7 @@ mod tests {
|
||||
&[],
|
||||
&exempt,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("loop should complete");
|
||||
@@ -4950,6 +5164,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("native fallback id flow should complete");
|
||||
@@ -5018,6 +5233,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
Some(&activated),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("wrapper path should execute activated tools");
|
||||
@@ -5596,7 +5812,7 @@ Tail"#;
|
||||
std::path::Path::new("/tmp"),
|
||||
));
|
||||
let tools = tools::default_tools(security);
|
||||
let instructions = build_tool_instructions(&tools);
|
||||
let instructions = build_tool_instructions(&tools, None);
|
||||
|
||||
assert!(instructions.contains("## Tool Use Protocol"));
|
||||
assert!(instructions.contains("<tool_call>"));
|
||||
@@ -6909,6 +7125,7 @@ Let me check the result."#;
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should complete");
|
||||
|
||||
+15
-1
@@ -1,4 +1,5 @@
|
||||
use crate::config::IdentityConfig;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::identity;
|
||||
use crate::skills::Skill;
|
||||
use crate::tools::Tool;
|
||||
@@ -17,6 +18,9 @@ pub struct PromptContext<'a> {
|
||||
pub skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
|
||||
pub identity_config: Option<&'a IdentityConfig>,
|
||||
pub dispatcher_instructions: &'a str,
|
||||
/// Locale-aware tool descriptions. When present, tool descriptions in
|
||||
/// prompts are resolved from the locale file instead of hardcoded values.
|
||||
pub tool_descriptions: Option<&'a ToolDescriptions>,
|
||||
}
|
||||
|
||||
pub trait PromptSection: Send + Sync {
|
||||
@@ -124,11 +128,15 @@ impl PromptSection for ToolsSection {
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::from("## Tools\n\n");
|
||||
for tool in ctx.tools {
|
||||
let desc = ctx
|
||||
.tool_descriptions
|
||||
.and_then(|td: &ToolDescriptions| td.get(tool.name()))
|
||||
.unwrap_or_else(|| tool.description());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
desc,
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
@@ -317,6 +325,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: Some(&identity_config),
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let section = IdentitySection;
|
||||
@@ -345,6 +354,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
assert!(prompt.contains("## Tools"));
|
||||
@@ -380,6 +390,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
@@ -418,6 +429,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Compact,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
@@ -439,6 +451,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
@@ -477,6 +490,7 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
};
|
||||
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
|
||||
+25
-2
@@ -2251,6 +2251,7 @@ async fn process_channel_message(
|
||||
},
|
||||
ctx.tool_call_dedup_exempt.as_ref(),
|
||||
ctx.activated_tools.as_ref(),
|
||||
None,
|
||||
),
|
||||
) => LlmExecutionResult::Completed(result),
|
||||
};
|
||||
@@ -3228,12 +3229,16 @@ fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Chan
|
||||
.telegram
|
||||
.as_ref()
|
||||
.context("Telegram channel is not configured")?;
|
||||
let ack = tg
|
||||
.ack_reactions
|
||||
.unwrap_or(config.channels_config.ack_reactions);
|
||||
Ok(Arc::new(
|
||||
TelegramChannel::new(
|
||||
tg.bot_token.clone(),
|
||||
tg.allowed_users.clone(),
|
||||
tg.mention_only,
|
||||
)
|
||||
.with_ack_reactions(ack)
|
||||
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
|
||||
.with_transcription(config.transcription.clone())
|
||||
.with_workspace_dir(config.workspace_dir.clone()),
|
||||
@@ -3321,6 +3326,9 @@ fn collect_configured_channels(
|
||||
let mut channels = Vec::new();
|
||||
|
||||
if let Some(ref tg) = config.channels_config.telegram {
|
||||
let ack = tg
|
||||
.ack_reactions
|
||||
.unwrap_or(config.channels_config.ack_reactions);
|
||||
channels.push(ConfiguredChannel {
|
||||
display_name: "Telegram",
|
||||
channel: Arc::new(
|
||||
@@ -3329,6 +3337,7 @@ fn collect_configured_channels(
|
||||
tg.allowed_users.clone(),
|
||||
tg.mention_only,
|
||||
)
|
||||
.with_ack_reactions(ack)
|
||||
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
|
||||
.with_transcription(config.transcription.clone())
|
||||
.with_workspace_dir(config.workspace_dir.clone()),
|
||||
@@ -3930,6 +3939,16 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
|
||||
let skills = crate::skills::load_skills_with_config(&workspace, &config);
|
||||
|
||||
// ── Load locale-aware tool descriptions ────────────────────────
|
||||
let i18n_locale = config
|
||||
.locale
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(crate::i18n::detect_locale);
|
||||
let i18n_search_dirs = crate::i18n::default_search_dirs(&workspace);
|
||||
let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
|
||||
|
||||
// Collect tool descriptions for the prompt
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
(
|
||||
@@ -4009,7 +4028,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
config.skills.prompt_injection_mode,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
|
||||
system_prompt.push_str(&build_tool_instructions(
|
||||
tools_registry.as_ref(),
|
||||
Some(&i18n_descs),
|
||||
));
|
||||
}
|
||||
|
||||
// Append deferred MCP tool names so the LLM knows what is available
|
||||
@@ -6759,7 +6781,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
"build_system_prompt should not emit protocol block directly"
|
||||
);
|
||||
|
||||
prompt.push_str(&build_tool_instructions(&[]));
|
||||
prompt.push_str(&build_tool_instructions(&[], None));
|
||||
|
||||
assert_eq!(
|
||||
prompt.matches("## Tool Use Protocol").count(),
|
||||
@@ -8667,6 +8689,7 @@ This is an example JSON object for profile settings."#;
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
match build_channel_by_id(&config, "telegram") {
|
||||
Ok(channel) => assert_eq!(channel.name(), "telegram"),
|
||||
|
||||
@@ -332,6 +332,7 @@ pub struct TelegramChannel {
|
||||
transcription: Option<crate::config::TranscriptionConfig>,
|
||||
voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
ack_reactions: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -370,9 +371,16 @@ impl TelegramChannel {
|
||||
transcription: None,
|
||||
voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
|
||||
workspace_dir: None,
|
||||
ack_reactions: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure whether Telegram-native acknowledgement reactions are sent.
|
||||
pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
|
||||
self.ack_reactions = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure workspace directory for saving downloaded attachments.
|
||||
pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
|
||||
self.workspace_dir = Some(dir);
|
||||
@@ -2689,13 +2697,15 @@ Ensure only one `zeroclaw` process is using this bot token."
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some((reaction_chat_id, reaction_message_id)) =
|
||||
Self::extract_update_message_target(update)
|
||||
{
|
||||
self.try_add_ack_reaction_nonblocking(
|
||||
reaction_chat_id,
|
||||
reaction_message_id,
|
||||
);
|
||||
if self.ack_reactions {
|
||||
if let Some((reaction_chat_id, reaction_message_id)) =
|
||||
Self::extract_update_message_target(update)
|
||||
{
|
||||
self.try_add_ack_reaction_nonblocking(
|
||||
reaction_chat_id,
|
||||
reaction_message_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send "typing" indicator immediately when we receive a message
|
||||
@@ -4681,4 +4691,24 @@ mod tests {
|
||||
// the agent loop will return ProviderCapabilityError before calling
|
||||
// the provider, and the channel will send "⚠️ Error: ..." to the user.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_reactions_defaults_to_true() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
|
||||
assert!(ch.ack_reactions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_ack_reactions_false_disables_reactions() {
|
||||
let ch =
|
||||
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(false);
|
||||
assert!(!ch.ack_reactions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_ack_reactions_true_keeps_reactions() {
|
||||
let ch =
|
||||
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true);
|
||||
assert!(ch.ack_reactions);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -22,11 +22,11 @@ pub use schema::{
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig, ProjectIntelConfig,
|
||||
ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
SwarmConfig, SwarmStrategy, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
||||
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
WorkspaceConfig,
|
||||
SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillCreationConfig, SkillsConfig,
|
||||
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, TelegramConfig,
|
||||
ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig,
|
||||
WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
@@ -55,6 +55,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
};
|
||||
|
||||
let discord = DiscordConfig {
|
||||
|
||||
@@ -339,6 +339,17 @@ pub struct Config {
|
||||
/// Plugin system configuration (`[plugins]`).
|
||||
#[serde(default)]
|
||||
pub plugins: PluginsConfig,
|
||||
|
||||
/// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
|
||||
///
|
||||
/// When set, tool descriptions shown in system prompts are loaded from
|
||||
/// `tool_descriptions/<locale>.toml`. Falls back to English, then to
|
||||
/// hardcoded descriptions.
|
||||
///
|
||||
/// If omitted or empty, the locale is auto-detected from `ZEROCLAW_LOCALE`,
|
||||
/// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
/// Multi-client workspace isolation configuration.
|
||||
@@ -449,6 +460,14 @@ pub struct DelegateAgentConfig {
|
||||
/// Maximum tool-call iterations in agentic mode.
|
||||
#[serde(default = "default_max_tool_iterations")]
|
||||
pub max_iterations: usize,
|
||||
/// Timeout in seconds for non-agentic provider calls.
|
||||
/// Defaults to 120 when unset. Must be between 1 and 3600.
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Timeout in seconds for agentic sub-agent loops.
|
||||
/// Defaults to 300 when unset. Must be between 1 and 3600.
|
||||
#[serde(default)]
|
||||
pub agentic_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
// ── Swarms ──────────────────────────────────────────────────────
|
||||
@@ -1163,6 +1182,34 @@ pub struct SkillsConfig {
|
||||
/// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
|
||||
#[serde(default)]
|
||||
pub prompt_injection_mode: SkillsPromptInjectionMode,
|
||||
/// Autonomous skill creation from successful multi-step task executions.
|
||||
#[serde(default)]
|
||||
pub skill_creation: SkillCreationConfig,
|
||||
}
|
||||
|
||||
/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct SkillCreationConfig {
|
||||
/// Enable automatic skill creation after successful multi-step tasks.
|
||||
/// Default: `false`.
|
||||
pub enabled: bool,
|
||||
/// Maximum number of auto-generated skills to keep.
|
||||
/// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
|
||||
pub max_skills: usize,
|
||||
/// Embedding similarity threshold for deduplication.
|
||||
/// Skills with descriptions more similar than this value are skipped.
|
||||
pub similarity_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for SkillCreationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multimodal (image) handling configuration (`[multimodal]` section).
|
||||
@@ -4511,6 +4558,11 @@ pub struct TelegramConfig {
|
||||
/// Direct messages are always processed.
|
||||
#[serde(default)]
|
||||
pub mention_only: bool,
|
||||
/// Override for the top-level `ack_reactions` setting. When `None`, the
|
||||
/// channel falls back to `[channels_config].ack_reactions`. When set
|
||||
/// explicitly, it takes precedence.
|
||||
#[serde(default)]
|
||||
pub ack_reactions: Option<bool>,
|
||||
}
|
||||
|
||||
impl ChannelConfig for TelegramConfig {
|
||||
@@ -5991,6 +6043,7 @@ impl Default for Config {
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6400,6 +6453,45 @@ fn read_codex_openai_api_key() -> Option<String> {
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// Ensure that essential bootstrap files exist in the workspace directory.
|
||||
///
|
||||
/// When the workspace is created outside of `zeroclaw onboard` (e.g., non-tty
|
||||
/// daemon/cron sessions), these files would otherwise be missing. This function
|
||||
/// creates sensible defaults that allow the agent to operate with a basic identity.
|
||||
async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
(
|
||||
"IDENTITY.md",
|
||||
"# IDENTITY.md — Who Am I?\n\n\
|
||||
I am ZeroClaw, an autonomous AI agent.\n\n\
|
||||
## Traits\n\
|
||||
- Helpful, precise, and safety-conscious\n\
|
||||
- I prioritize clarity and correctness\n",
|
||||
),
|
||||
(
|
||||
"SOUL.md",
|
||||
"# SOUL.md — Who You Are\n\n\
|
||||
You are ZeroClaw, an autonomous AI agent.\n\n\
|
||||
## Core Principles\n\
|
||||
- Be helpful and accurate\n\
|
||||
- Respect user intent and boundaries\n\
|
||||
- Ask before taking destructive actions\n\
|
||||
- Prefer safe, reversible operations\n",
|
||||
),
|
||||
];
|
||||
|
||||
for (filename, content) in defaults {
|
||||
let path = workspace_dir.join(filename);
|
||||
if !path.exists() {
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create default {filename} in workspace"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn load_or_init() -> Result<Self> {
|
||||
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
|
||||
@@ -6416,6 +6508,8 @@ impl Config {
|
||||
.await
|
||||
.context("Failed to create workspace directory")?;
|
||||
|
||||
ensure_bootstrap_files(&workspace_dir).await?;
|
||||
|
||||
if config_path.exists() {
|
||||
// Warn if config file is world-readable (may contain API keys)
|
||||
#[cfg(unix)]
|
||||
@@ -7252,6 +7346,31 @@ impl Config {
|
||||
anyhow::bail!("security.nevis: {msg}");
|
||||
}
|
||||
|
||||
// Delegate agent timeouts
|
||||
const MAX_DELEGATE_TIMEOUT_SECS: u64 = 3600;
|
||||
for (name, agent) in &self.agents {
|
||||
if let Some(timeout) = agent.timeout_secs {
|
||||
if timeout == 0 {
|
||||
anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
|
||||
}
|
||||
if timeout > MAX_DELEGATE_TIMEOUT_SECS {
|
||||
anyhow::bail!(
|
||||
"agents.{name}.timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(timeout) = agent.agentic_timeout_secs {
|
||||
if timeout == 0 {
|
||||
anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
|
||||
}
|
||||
if timeout > MAX_DELEGATE_TIMEOUT_SECS {
|
||||
anyhow::bail!(
|
||||
"agents.{name}.agentic_timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transcription
|
||||
{
|
||||
let dp = self.transcription.default_provider.trim();
|
||||
@@ -8360,6 +8479,7 @@ default_temperature = 0.7
|
||||
draft_update_interval_ms: default_draft_update_interval_ms(),
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
}),
|
||||
discord: None,
|
||||
slack: None,
|
||||
@@ -8427,6 +8547,7 @@ default_temperature = 0.7
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
@@ -8760,6 +8881,7 @@ tool_dispatcher = "xml"
|
||||
knowledge: KnowledgeConfig::default(),
|
||||
linkedin: LinkedInConfig::default(),
|
||||
plugins: PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
config.save().await.unwrap();
|
||||
@@ -8818,6 +8940,8 @@ tool_dispatcher = "xml"
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8942,6 +9066,7 @@ tool_dispatcher = "xml"
|
||||
draft_update_interval_ms: 500,
|
||||
interrupt_on_new_message: true,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tc).unwrap();
|
||||
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -11256,6 +11381,7 @@ require_otp_to_resume = true
|
||||
draft_update_interval_ms: default_draft_update_interval_ms(),
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
|
||||
// Save (triggers encryption)
|
||||
@@ -11811,4 +11937,109 @@ require_otp_to_resume = true
|
||||
"Debug output must show [REDACTED] for client_secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn telegram_config_ack_reactions_false_deserializes() {
|
||||
let toml_str = r#"
|
||||
bot_token = "123:ABC"
|
||||
allowed_users = ["alice"]
|
||||
ack_reactions = false
|
||||
"#;
|
||||
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.ack_reactions, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn telegram_config_ack_reactions_true_deserializes() {
|
||||
let toml_str = r#"
|
||||
bot_token = "123:ABC"
|
||||
allowed_users = ["alice"]
|
||||
ack_reactions = true
|
||||
"#;
|
||||
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.ack_reactions, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn telegram_config_ack_reactions_missing_defaults_to_none() {
|
||||
let toml_str = r#"
|
||||
bot_token = "123:ABC"
|
||||
allowed_users = ["alice"]
|
||||
"#;
|
||||
let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.ack_reactions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn telegram_config_ack_reactions_channel_overrides_top_level() {
|
||||
let tg_toml = r#"
|
||||
bot_token = "123:ABC"
|
||||
allowed_users = ["alice"]
|
||||
ack_reactions = false
|
||||
"#;
|
||||
let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
|
||||
let top_level_ack = true;
|
||||
let effective = tg.ack_reactions.unwrap_or(top_level_ack);
|
||||
assert!(
|
||||
!effective,
|
||||
"channel-level false must override top-level true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn telegram_config_ack_reactions_falls_back_to_top_level() {
|
||||
let tg_toml = r#"
|
||||
bot_token = "123:ABC"
|
||||
allowed_users = ["alice"]
|
||||
"#;
|
||||
let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
|
||||
let top_level_ack = false;
|
||||
let effective = tg.ack_reactions.unwrap_or(top_level_ack);
|
||||
assert!(
|
||||
!effective,
|
||||
"must fall back to top-level false when channel omits field"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bootstrap files ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
async fn ensure_bootstrap_files_creates_missing_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().join("workspace");
|
||||
tokio::fs::create_dir_all(&ws).await.unwrap();
|
||||
|
||||
ensure_bootstrap_files(&ws).await.unwrap();
|
||||
|
||||
let soul = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
|
||||
let identity = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(soul.contains("SOUL.md"));
|
||||
assert!(identity.contains("IDENTITY.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn ensure_bootstrap_files_does_not_overwrite_existing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().join("workspace");
|
||||
tokio::fs::create_dir_all(&ws).await.unwrap();
|
||||
|
||||
let custom = "# My custom SOUL";
|
||||
tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
|
||||
|
||||
ensure_bootstrap_files(&ws).await.unwrap();
|
||||
|
||||
let soul = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
|
||||
assert_eq!(
|
||||
soul, custom,
|
||||
"ensure_bootstrap_files must not overwrite existing files"
|
||||
);
|
||||
|
||||
// IDENTITY.md should still be created since it was missing
|
||||
let identity = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(identity.contains("IDENTITY.md"));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -17,7 +17,10 @@ pub use store::{
|
||||
add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run,
|
||||
remove_job, reschedule_after_run, update_job,
|
||||
};
|
||||
pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
pub use types::{
|
||||
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
|
||||
Schedule, SessionTarget,
|
||||
};
|
||||
|
||||
/// Validate a shell command against the full security policy (allowlist + risk gate).
|
||||
///
|
||||
|
||||
+66
-1
@@ -1,6 +1,32 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Try to deserialize a `serde_json::Value` as `T`. If the value is a JSON
|
||||
/// string that looks like an object (i.e. the LLM double-serialized it), parse
|
||||
/// the inner string first and then deserialize the resulting object. This
|
||||
/// provides backward-compatible handling for both `Value::Object` and
|
||||
/// `Value::String` representations.
|
||||
pub fn deserialize_maybe_stringified<T: serde::de::DeserializeOwned>(
|
||||
v: &serde_json::Value,
|
||||
) -> Result<T, serde_json::Error> {
|
||||
// Fast path: value is already the right shape (object, array, etc.)
|
||||
match serde_json::from_value::<T>(v.clone()) {
|
||||
Ok(parsed) => Ok(parsed),
|
||||
Err(first_err) => {
|
||||
// If it's a string, try parsing the string as JSON first.
|
||||
if let Some(s) = v.as_str() {
|
||||
let s = s.trim();
|
||||
if s.starts_with('{') || s.starts_with('[') {
|
||||
if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
return serde_json::from_value::<T>(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobType {
|
||||
@@ -154,7 +180,46 @@ pub struct CronJobPatch {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::JobType;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_object() {
|
||||
let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_string_with_tz() {
|
||||
let val = serde_json::Value::String(
|
||||
r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
|
||||
);
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
match sched {
|
||||
Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
|
||||
_ => panic!("expected Cron variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_every_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_invalid_string_returns_error() {
|
||||
let val = serde_json::Value::String("not json at all".to_string());
|
||||
assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_type_try_from_accepts_known_values_case_insensitive() {
|
||||
|
||||
@@ -642,6 +642,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
assert!(has_supervised_channels(&config));
|
||||
}
|
||||
@@ -755,6 +756,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
|
||||
let target = resolve_heartbeat_delivery(&config).unwrap();
|
||||
@@ -771,6 +773,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
|
||||
let target = resolve_heartbeat_delivery(&config).unwrap();
|
||||
|
||||
@@ -1281,6 +1281,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
config.agents.insert(
|
||||
@@ -1295,6 +1297,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+15
-13
@@ -633,6 +633,21 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
println!(" 🌐 Public URL: {url}");
|
||||
}
|
||||
println!(" 🌐 Web Dashboard: http://{display_addr}/");
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||
println!(" ┌──────────────┐");
|
||||
println!(" │ {code} │");
|
||||
println!(" └──────────────┘");
|
||||
println!();
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
println!();
|
||||
} else {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
println!();
|
||||
}
|
||||
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
|
||||
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
||||
if whatsapp_channel.is_some() {
|
||||
@@ -656,19 +671,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
}
|
||||
println!(" GET /health — health check");
|
||||
println!(" GET /metrics — Prometheus metrics");
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||
println!(" ┌──────────────┐");
|
||||
println!(" │ {code} │");
|
||||
println!(" └──────────────┘");
|
||||
println!(" Send: POST /pair with header X-Pairing-Code: {code}");
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
} else {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
}
|
||||
println!(" Press Ctrl+C to stop.\n");
|
||||
|
||||
crate::health::mark_component_ok("gateway");
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
//! Internationalization support for tool descriptions.
|
||||
//!
|
||||
//! Loads tool descriptions from TOML locale files in `tool_descriptions/`.
|
||||
//! Falls back to English when a locale file or specific key is missing,
|
||||
//! and ultimately falls back to the hardcoded `tool.description()` value
|
||||
//! if no file-based description exists.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Container for locale-specific tool descriptions loaded from TOML files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDescriptions {
|
||||
/// Descriptions from the requested locale (may be empty if file missing).
|
||||
locale_descriptions: HashMap<String, String>,
|
||||
/// English fallback descriptions (always loaded when locale != "en").
|
||||
english_fallback: HashMap<String, String>,
|
||||
/// The resolved locale tag (e.g. "en", "zh-CN").
|
||||
locale: String,
|
||||
}
|
||||
|
||||
/// TOML structure: `[tools]` table mapping tool name -> description string.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct DescriptionFile {
|
||||
#[serde(default)]
|
||||
tools: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ToolDescriptions {
|
||||
/// Load descriptions for the given locale.
|
||||
///
|
||||
/// `search_dirs` lists directories to probe for `tool_descriptions/<locale>.toml`.
|
||||
/// The first directory containing a matching file wins.
|
||||
///
|
||||
/// Resolution:
|
||||
/// 1. Look up tool name in the locale file.
|
||||
/// 2. If missing (or locale file absent), look up in `en.toml`.
|
||||
/// 3. If still missing, callers fall back to `tool.description()`.
|
||||
pub fn load(locale: &str, search_dirs: &[PathBuf]) -> Self {
|
||||
let locale_descriptions = load_locale_file(locale, search_dirs);
|
||||
|
||||
let english_fallback = if locale == "en" {
|
||||
HashMap::new()
|
||||
} else {
|
||||
load_locale_file("en", search_dirs)
|
||||
};
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
locale_keys = locale_descriptions.len(),
|
||||
english_keys = english_fallback.len(),
|
||||
"tool descriptions loaded"
|
||||
);
|
||||
|
||||
Self {
|
||||
locale_descriptions,
|
||||
english_fallback,
|
||||
locale: locale.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the description for a tool by name.
|
||||
///
|
||||
/// Returns `Some(description)` if found in the locale file or English fallback.
|
||||
/// Returns `None` if neither file contains the key (caller should use hardcoded).
|
||||
pub fn get(&self, tool_name: &str) -> Option<&str> {
|
||||
self.locale_descriptions
|
||||
.get(tool_name)
|
||||
.or_else(|| self.english_fallback.get(tool_name))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// The resolved locale tag.
|
||||
pub fn locale(&self) -> &str {
|
||||
&self.locale
|
||||
}
|
||||
|
||||
/// Create an empty instance that always returns `None` (hardcoded fallback).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
locale_descriptions: HashMap::new(),
|
||||
english_fallback: HashMap::new(),
|
||||
locale: "en".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the user's preferred locale from environment variables.
|
||||
///
|
||||
/// Checks `ZEROCLAW_LOCALE`, then `LANG`, then `LC_ALL`.
|
||||
/// Returns "en" if none are set or parseable.
|
||||
pub fn detect_locale() -> String {
|
||||
if let Ok(val) = std::env::var("ZEROCLAW_LOCALE") {
|
||||
let val = val.trim().to_string();
|
||||
if !val.is_empty() {
|
||||
return normalize_locale(&val);
|
||||
}
|
||||
}
|
||||
for var in &["LANG", "LC_ALL"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
let locale = normalize_locale(&val);
|
||||
if locale != "C" && locale != "POSIX" && !locale.is_empty() {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
/// Normalize a raw locale string (e.g. "zh_CN.UTF-8") to a tag we use
|
||||
/// for file lookup (e.g. "zh-CN").
|
||||
fn normalize_locale(raw: &str) -> String {
|
||||
// Strip encoding suffix (.UTF-8, .utf8, etc.)
|
||||
let base = raw.split('.').next().unwrap_or(raw);
|
||||
// Replace underscores with hyphens for BCP-47-ish consistency
|
||||
base.replace('_', "-")
|
||||
}
|
||||
|
||||
/// Build the default set of search directories for locale files.
|
||||
///
|
||||
/// 1. The workspace directory itself (for project-local overrides).
|
||||
/// 2. The binary's parent directory (for installed distributions).
|
||||
/// 3. The compile-time `CARGO_MANIFEST_DIR` as a final fallback during dev.
|
||||
pub fn default_search_dirs(workspace_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![workspace_dir.to_path_buf()];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
dirs.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// During development, also check the project root (where Cargo.toml lives).
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !dirs.contains(&manifest_dir) {
|
||||
dirs.push(manifest_dir);
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Try to load and parse a locale TOML file from the first matching search dir.
|
||||
fn load_locale_file(locale: &str, search_dirs: &[PathBuf]) -> HashMap<String, String> {
|
||||
let filename = format!("tool_descriptions/{locale}.toml");
|
||||
|
||||
for dir in search_dirs {
|
||||
let path = dir.join(&filename);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => match toml::from_str::<DescriptionFile>(&contents) {
|
||||
Ok(parsed) => {
|
||||
debug!(path = %path.display(), keys = parsed.tools.len(), "loaded locale file");
|
||||
return parsed.tools;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(path = %path.display(), error = %e, "failed to parse locale file");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
// File not found in this directory, try next.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
"no locale file found in any search directory"
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Helper: create a temp dir with a `tool_descriptions/<locale>.toml` file.
|
||||
fn write_locale_file(dir: &Path, locale: &str, content: &str) {
|
||||
let td = dir.join("tool_descriptions");
|
||||
fs::create_dir_all(&td).unwrap();
|
||||
fs::write(td.join(format!("{locale}.toml")), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_english_descriptions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.get("nonexistent"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_english_when_locale_key_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"zh-CN",
|
||||
r#"[tools]
|
||||
shell = "在工作区目录中执行 shell 命令"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("zh-CN", &[tmp.path().to_path_buf()]);
|
||||
// Translated key returns Chinese.
|
||||
assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
|
||||
// Missing key falls back to English.
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.locale(), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_locale_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
"#,
|
||||
);
|
||||
// Request a locale that has no file.
|
||||
let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
|
||||
// Falls back to English.
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.locale(), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_no_files_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_always_returns_none() {
|
||||
let descs = ToolDescriptions::empty();
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_from_env() {
|
||||
// Save and restore env.
|
||||
let saved = std::env::var("ZEROCLAW_LOCALE").ok();
|
||||
let saved_lang = std::env::var("LANG").ok();
|
||||
|
||||
std::env::set_var("ZEROCLAW_LOCALE", "ja-JP");
|
||||
assert_eq!(detect_locale(), "ja-JP");
|
||||
|
||||
std::env::remove_var("ZEROCLAW_LOCALE");
|
||||
std::env::set_var("LANG", "zh_CN.UTF-8");
|
||||
assert_eq!(detect_locale(), "zh-CN");
|
||||
|
||||
// Restore.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("ZEROCLAW_LOCALE", v),
|
||||
None => std::env::remove_var("ZEROCLAW_LOCALE"),
|
||||
}
|
||||
match saved_lang {
|
||||
Some(v) => std::env::set_var("LANG", v),
|
||||
None => std::env::remove_var("LANG"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_locale_strips_encoding() {
|
||||
assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
|
||||
assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
|
||||
assert_eq!(normalize_locale("fr"), "fr");
|
||||
assert_eq!(normalize_locale("pt_BR"), "pt-BR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_locale_overrides_env() {
|
||||
// This tests the precedence logic: if config provides a locale,
|
||||
// it should be used instead of detect_locale().
|
||||
// The actual override happens at the call site in prompt.rs / loop_.rs,
|
||||
// so here we just verify ToolDescriptions works with an explicit locale.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"de",
|
||||
r#"[tools]
|
||||
shell = "Einen Shell-Befehl im Arbeitsverzeichnis ausführen"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("de", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(
|
||||
descs.get("shell"),
|
||||
Some("Einen Shell-Befehl im Arbeitsverzeichnis ausführen")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -840,6 +840,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
let entries = all_integrations();
|
||||
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) mod hardware;
|
||||
pub(crate) mod health;
|
||||
pub(crate) mod heartbeat;
|
||||
pub mod hooks;
|
||||
pub mod i18n;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod integrations;
|
||||
pub mod memory;
|
||||
|
||||
@@ -89,6 +89,7 @@ mod hardware;
|
||||
mod health;
|
||||
mod heartbeat;
|
||||
mod hooks;
|
||||
mod i18n;
|
||||
mod identity;
|
||||
mod integrations;
|
||||
mod memory;
|
||||
|
||||
@@ -193,6 +193,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
println!(
|
||||
@@ -567,6 +568,7 @@ async fn run_quick_setup_with_home(
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
config.save().await?;
|
||||
@@ -3683,6 +3685,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
}
|
||||
ChannelMenuChoice::Discord => {
|
||||
|
||||
@@ -1320,11 +1320,12 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("llama.cpp");
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new_with_vision(
|
||||
"llama.cpp",
|
||||
base_url,
|
||||
Some(llama_cpp_key),
|
||||
AuthStyle::Bearer,
|
||||
true,
|
||||
)))
|
||||
}
|
||||
"sglang" => {
|
||||
|
||||
+250
-41
@@ -16,8 +16,10 @@ use std::time::Duration;
|
||||
|
||||
/// Check if an error is non-retryable (client errors that won't resolve with retries).
|
||||
pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|
||||
// Context window errors are NOT non-retryable — they can be recovered
|
||||
// by truncating conversation history, so let the retry loop handle them.
|
||||
if is_context_window_exceeded(err) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4xx errors are generally non-retryable (bad request, auth failure, etc.),
|
||||
@@ -75,6 +77,7 @@ fn is_context_window_exceeded(err: &anyhow::Error) -> bool {
|
||||
let lower = err.to_string().to_lowercase();
|
||||
let hints = [
|
||||
"exceeds the context window",
|
||||
"exceeds the available context size",
|
||||
"context window of this model",
|
||||
"maximum context length",
|
||||
"context length exceeded",
|
||||
@@ -197,6 +200,35 @@ fn compact_error_detail(err: &anyhow::Error) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Truncate conversation history by dropping the oldest non-system messages.
|
||||
/// Returns the number of messages dropped. Keeps at least the system message
|
||||
/// (if any) and the most recent user message.
|
||||
fn truncate_for_context(messages: &mut Vec<ChatMessage>) -> usize {
|
||||
// Find all non-system message indices
|
||||
let non_system: Vec<usize> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.role != "system")
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Keep at least the last non-system message (most recent user turn)
|
||||
if non_system.len() <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Drop the oldest half of non-system messages
|
||||
let drop_count = non_system.len() / 2;
|
||||
let indices_to_remove: Vec<usize> = non_system[..drop_count].to_vec();
|
||||
|
||||
// Remove in reverse order to preserve indices
|
||||
for &idx in indices_to_remove.iter().rev() {
|
||||
messages.remove(idx);
|
||||
}
|
||||
|
||||
drop_count
|
||||
}
|
||||
|
||||
fn push_failure(
|
||||
failures: &mut Vec<String>,
|
||||
provider_name: &str,
|
||||
@@ -338,6 +370,25 @@ impl Provider for ReliableProvider {
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: no history to truncate
|
||||
// in chat_with_system, bail immediately.
|
||||
if is_context_window_exceeded(&e) {
|
||||
let error_detail = compact_error_detail(&e);
|
||||
push_failure(
|
||||
&mut failures,
|
||||
provider_name,
|
||||
current_model,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
"non_retryable",
|
||||
&error_detail,
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -376,14 +427,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -435,6 +478,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -442,22 +487,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_history(messages, current_model, temperature)
|
||||
.chat_with_history(&effective_messages, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -494,14 +556,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,6 +613,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -566,22 +622,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_tools(messages, tools, current_model, temperature)
|
||||
.chat_with_tools(&effective_messages, tools, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -618,14 +691,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -669,6 +734,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = request.messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -676,23 +743,40 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
let req = ChatRequest {
|
||||
messages: request.messages,
|
||||
messages: &effective_messages,
|
||||
tools: request.tools,
|
||||
};
|
||||
match provider.chat(req, current_model, temperature).await {
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -729,14 +813,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1071,7 +1147,8 @@ mod tests {
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"model overloaded, try again later"
|
||||
)));
|
||||
assert!(is_non_retryable(&anyhow::anyhow!(
|
||||
// Context window errors are now recoverable (not non-retryable)
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"OpenAI Codex stream error: Your input exceeds the context window of this model."
|
||||
)));
|
||||
}
|
||||
@@ -1107,7 +1184,7 @@ mod tests {
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("context window"));
|
||||
assert!(msg.contains("skipped"));
|
||||
// chat_with_system has no history to truncate, so it bails immediately
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -1980,4 +2057,136 @@ mod tests {
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── Context window truncation tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn context_window_error_is_not_non_retryable() {
|
||||
// Context window errors should be recoverable via truncation
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"exceeds the context window"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"maximum context length exceeded"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"too many tokens in the request"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!("token limit exceeded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_context_window_exceeded_detects_llamacpp() {
|
||||
assert!(is_context_window_exceeded(&anyhow::anyhow!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_drops_oldest_non_system() {
|
||||
let mut messages = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("msg1"),
|
||||
ChatMessage::assistant("resp1"),
|
||||
ChatMessage::user("msg2"),
|
||||
ChatMessage::assistant("resp2"),
|
||||
ChatMessage::user("msg3"),
|
||||
];
|
||||
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
|
||||
// 5 non-system messages, drop oldest half = 2
|
||||
assert_eq!(dropped, 2);
|
||||
// System message preserved
|
||||
assert_eq!(messages[0].role, "system");
|
||||
// Remaining messages should be the newer ones
|
||||
assert_eq!(messages.len(), 4); // system + 3 remaining non-system
|
||||
// The last message should still be the most recent user message
|
||||
assert_eq!(messages.last().unwrap().content, "msg3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_preserves_system_and_last_message() {
|
||||
// Only one non-system message: nothing to drop
|
||||
let mut messages = vec![ChatMessage::system("sys"), ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 2);
|
||||
|
||||
// No system message, only one user message
|
||||
let mut messages = vec![ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
|
||||
/// Mock that fails with context error on first N calls, then succeeds.
|
||||
/// Tracks the number of messages received on each call.
|
||||
struct ContextOverflowMock {
|
||||
calls: Arc<AtomicUsize>,
|
||||
fail_until_attempt: usize,
|
||||
message_counts: parking_lot::Mutex<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ContextOverflowMock {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.message_counts.lock().push(messages.len());
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
);
|
||||
}
|
||||
Ok("recovered after truncation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_truncates_on_context_overflow() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mock = ContextOverflowMock {
|
||||
calls: Arc::clone(&calls),
|
||||
fail_until_attempt: 1, // fail first call, succeed after truncation
|
||||
message_counts: parking_lot::Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let provider = ReliableProvider::new(
|
||||
vec![("local".into(), Box::new(mock) as Box<dyn Provider>)],
|
||||
3,
|
||||
1,
|
||||
);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system("system prompt"),
|
||||
ChatMessage::user("old message 1"),
|
||||
ChatMessage::assistant("old response 1"),
|
||||
ChatMessage::user("old message 2"),
|
||||
ChatMessage::assistant("old response 2"),
|
||||
ChatMessage::user("current question"),
|
||||
];
|
||||
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "local-model", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "recovered after truncation");
|
||||
// Should have been called twice: once with full messages, once with truncated
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
// Autonomous skill creation from successful multi-step task executions.
|
||||
//
|
||||
// After the agent completes a multi-step tool-call sequence, this module
|
||||
// can persist the execution as a reusable skill definition (SKILL.toml)
|
||||
// under `~/.zeroclaw/workspace/skills/<slug>/`.
|
||||
|
||||
use crate::config::SkillCreationConfig;
|
||||
use crate::memory::embeddings::EmbeddingProvider;
|
||||
use crate::memory::vector::cosine_similarity;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A record of a single tool call executed during a task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallRecord {
|
||||
pub name: String,
|
||||
pub args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates reusable skill definitions from successful multi-step executions.
|
||||
pub struct SkillCreator {
|
||||
workspace_dir: PathBuf,
|
||||
config: SkillCreationConfig,
|
||||
}
|
||||
|
||||
impl SkillCreator {
|
||||
pub fn new(workspace_dir: PathBuf, config: SkillCreationConfig) -> Self {
|
||||
Self {
|
||||
workspace_dir,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to create a skill from a successful multi-step task execution.
|
||||
/// Returns `Ok(Some(slug))` if a skill was created, `Ok(None)` if skipped
|
||||
/// (disabled, duplicate, or insufficient tool calls).
|
||||
pub async fn create_from_execution(
|
||||
&self,
|
||||
task_description: &str,
|
||||
tool_calls: &[ToolCallRecord],
|
||||
embedding_provider: Option<&dyn EmbeddingProvider>,
|
||||
) -> Result<Option<String>> {
|
||||
if !self.config.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if tool_calls.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Deduplicate via embeddings when an embedding provider is available.
|
||||
if let Some(provider) = embedding_provider {
|
||||
if provider.name() != "none" && self.is_duplicate(task_description, provider).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let slug = Self::generate_slug(task_description);
|
||||
if !Self::validate_slug(&slug) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Enforce LRU limit before writing a new skill.
|
||||
self.enforce_lru_limit().await?;
|
||||
|
||||
let skill_dir = self.skills_dir().join(&slug);
|
||||
tokio::fs::create_dir_all(&skill_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to create skill directory: {}", skill_dir.display())
|
||||
})?;
|
||||
|
||||
let toml_content = Self::generate_skill_toml(&slug, task_description, tool_calls);
|
||||
let toml_path = skill_dir.join("SKILL.toml");
|
||||
tokio::fs::write(&toml_path, toml_content.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(Some(slug))
|
||||
}
|
||||
|
||||
/// Generate a URL-safe slug from a task description.
|
||||
/// Alphanumeric and hyphens only, max 64 characters.
|
||||
fn generate_slug(description: &str) -> String {
|
||||
let slug: String = description
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
// Collapse consecutive hyphens.
|
||||
let mut collapsed = String::with_capacity(slug.len());
|
||||
let mut prev_hyphen = false;
|
||||
for c in slug.chars() {
|
||||
if c == '-' {
|
||||
if !prev_hyphen {
|
||||
collapsed.push('-');
|
||||
}
|
||||
prev_hyphen = true;
|
||||
} else {
|
||||
collapsed.push(c);
|
||||
prev_hyphen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim leading/trailing hyphens, then truncate.
|
||||
let trimmed = collapsed.trim_matches('-');
|
||||
if trimmed.len() > 64 {
|
||||
// Truncate at a hyphen boundary if possible.
|
||||
let truncated = &trimmed[..64];
|
||||
truncated.trim_end_matches('-').to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a slug is non-empty, alphanumeric + hyphens, max 64 chars.
|
||||
fn validate_slug(slug: &str) -> bool {
|
||||
!slug.is_empty()
|
||||
&& slug.len() <= 64
|
||||
&& slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !slug.starts_with('-')
|
||||
&& !slug.ends_with('-')
|
||||
}
|
||||
|
||||
/// Generate SKILL.toml content from task execution data.
|
||||
fn generate_skill_toml(slug: &str, description: &str, tool_calls: &[ToolCallRecord]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut toml = String::new();
|
||||
toml.push_str("[skill]\n");
|
||||
let _ = writeln!(toml, "name = {}", toml_escape(slug));
|
||||
let _ = writeln!(
|
||||
toml,
|
||||
"description = {}",
|
||||
toml_escape(&format!("Auto-generated: {description}"))
|
||||
);
|
||||
toml.push_str("version = \"0.1.0\"\n");
|
||||
toml.push_str("author = \"zeroclaw-auto\"\n");
|
||||
toml.push_str("tags = [\"auto-generated\"]\n");
|
||||
|
||||
for call in tool_calls {
|
||||
toml.push('\n');
|
||||
toml.push_str("[[tools]]\n");
|
||||
let _ = writeln!(toml, "name = {}", toml_escape(&call.name));
|
||||
let _ = writeln!(
|
||||
toml,
|
||||
"description = {}",
|
||||
toml_escape(&format!("Tool used in task: {}", call.name))
|
||||
);
|
||||
toml.push_str("kind = \"shell\"\n");
|
||||
|
||||
// Extract the command from args if available, otherwise use the tool name.
|
||||
let command = call
|
||||
.args
|
||||
.get("command")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(&call.name);
|
||||
let _ = writeln!(toml, "command = {}", toml_escape(command));
|
||||
}
|
||||
|
||||
toml
|
||||
}
|
||||
|
||||
/// Check if a skill with a similar description already exists.
|
||||
async fn is_duplicate(
|
||||
&self,
|
||||
description: &str,
|
||||
embedding_provider: &dyn EmbeddingProvider,
|
||||
) -> Result<bool> {
|
||||
let new_embedding = embedding_provider.embed_one(description).await?;
|
||||
if new_embedding.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let skills_dir = self.skills_dir();
|
||||
if !skills_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let toml_path = entry.path().join("SKILL.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&toml_path).await?;
|
||||
// Extract description from the TOML to compare.
|
||||
if let Some(desc) = extract_description_from_toml(&content) {
|
||||
let existing_embedding = embedding_provider.embed_one(&desc).await?;
|
||||
if !existing_embedding.is_empty() {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let similarity =
|
||||
f64::from(cosine_similarity(&new_embedding, &existing_embedding));
|
||||
if similarity > self.config.similarity_threshold {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Remove the oldest auto-generated skill when we exceed `max_skills`.
|
||||
async fn enforce_lru_limit(&self) -> Result<()> {
|
||||
let skills_dir = self.skills_dir();
|
||||
if !skills_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut auto_skills: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let toml_path = entry.path().join("SKILL.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&toml_path).await?;
|
||||
if content.contains("\"zeroclaw-auto\"") || content.contains("\"auto-generated\"") {
|
||||
let modified = tokio::fs::metadata(&toml_path)
|
||||
.await?
|
||||
.modified()
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
auto_skills.push((entry.path(), modified));
|
||||
}
|
||||
}
|
||||
|
||||
// If at or above the limit, remove the oldest.
|
||||
if auto_skills.len() >= self.config.max_skills {
|
||||
auto_skills.sort_by_key(|(_, modified)| *modified);
|
||||
if let Some((oldest_dir, _)) = auto_skills.first() {
|
||||
tokio::fs::remove_dir_all(oldest_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to remove oldest auto-generated skill: {}",
|
||||
oldest_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn skills_dir(&self) -> PathBuf {
|
||||
self.workspace_dir.join("skills")
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape a string for TOML value (double-quoted).
|
||||
fn toml_escape(s: &str) -> String {
|
||||
let escaped = s
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\t', "\\t");
|
||||
format!("\"{escaped}\"")
|
||||
}
|
||||
|
||||
/// Extract the description field from a SKILL.toml string.
|
||||
fn extract_description_from_toml(content: &str) -> Option<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Partial {
|
||||
skill: PartialSkill,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PartialSkill {
|
||||
description: Option<String>,
|
||||
}
|
||||
toml::from_str::<Partial>(content)
|
||||
.ok()
|
||||
.and_then(|p| p.skill.description)
|
||||
}
|
||||
|
||||
/// Extract `ToolCallRecord`s from the agent conversation history.
|
||||
///
|
||||
/// Scans assistant messages for tool call patterns (both JSON and XML formats)
|
||||
/// and returns records for each unique tool invocation.
|
||||
pub fn extract_tool_calls_from_history(
|
||||
history: &[crate::providers::ChatMessage],
|
||||
) -> Vec<ToolCallRecord> {
|
||||
let mut records = Vec::new();
|
||||
|
||||
for msg in history {
|
||||
if msg.role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try parsing as JSON (native tool_calls format).
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg.content) {
|
||||
if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for call in tool_calls {
|
||||
if let Some(function) = call.get("function") {
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let args_str = function
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("{}");
|
||||
let args = serde_json::from_str(args_str).unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
records.push(ToolCallRecord { name, args });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try XML tool call format: <tool_name>...</tool_name>
|
||||
// Simple extraction for `<shell>{"command":"..."}</shell>` style tags.
|
||||
let content = &msg.content;
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
if let Some(start) = content[pos..].find('<') {
|
||||
let abs_start = pos + start;
|
||||
if let Some(end) = content[abs_start..].find('>') {
|
||||
let tag = &content[abs_start + 1..abs_start + end];
|
||||
// Skip closing tags and meta tags.
|
||||
if tag.starts_with('/') || tag.starts_with('!') || tag.starts_with('?') {
|
||||
pos = abs_start + end + 1;
|
||||
continue;
|
||||
}
|
||||
let tag_name = tag.split_whitespace().next().unwrap_or(tag);
|
||||
let close_tag = format!("</{tag_name}>");
|
||||
if let Some(close_pos) = content[abs_start + end + 1..].find(&close_tag) {
|
||||
let inner = &content[abs_start + end + 1..abs_start + end + 1 + close_pos];
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(inner.trim()).unwrap_or_default();
|
||||
// Only add if it looks like a tool call (not HTML/formatting tags).
|
||||
if tag_name != "tool_result"
|
||||
&& tag_name != "tool_results"
|
||||
&& !tag_name.contains(':')
|
||||
&& args.is_object()
|
||||
&& !args.as_object().map_or(true, |o| o.is_empty())
|
||||
{
|
||||
records.push(ToolCallRecord {
|
||||
name: tag_name.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
pos = abs_start + end + 1 + close_pos + close_tag.len();
|
||||
} else {
|
||||
pos = abs_start + end + 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::embeddings::{EmbeddingProvider, NoopEmbedding};
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── Slug generation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slug_basic() {
|
||||
assert_eq!(
|
||||
SkillCreator::generate_slug("Deploy to production"),
|
||||
"deploy-to-production"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_special_characters() {
|
||||
assert_eq!(
|
||||
SkillCreator::generate_slug("Build & test (CI/CD) pipeline!"),
|
||||
"build-test-ci-cd-pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_max_length() {
|
||||
let long_desc = "a".repeat(100);
|
||||
let slug = SkillCreator::generate_slug(&long_desc);
|
||||
assert!(slug.len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_leading_trailing_hyphens() {
|
||||
let slug = SkillCreator::generate_slug("---hello world---");
|
||||
assert!(!slug.starts_with('-'));
|
||||
assert!(!slug.ends_with('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_consecutive_spaces() {
|
||||
assert_eq!(SkillCreator::generate_slug("hello world"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_empty_input() {
|
||||
let slug = SkillCreator::generate_slug("");
|
||||
assert!(slug.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_only_symbols() {
|
||||
let slug = SkillCreator::generate_slug("!@#$%^&*()");
|
||||
assert!(slug.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_unicode() {
|
||||
let slug = SkillCreator::generate_slug("Deploy cafe app");
|
||||
assert_eq!(slug, "deploy-cafe-app");
|
||||
}
|
||||
|
||||
// ── Slug validation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_slug_valid() {
|
||||
assert!(SkillCreator::validate_slug("deploy-to-production"));
|
||||
assert!(SkillCreator::validate_slug("a"));
|
||||
assert!(SkillCreator::validate_slug("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_slug_invalid() {
|
||||
assert!(!SkillCreator::validate_slug(""));
|
||||
assert!(!SkillCreator::validate_slug("-starts-with-hyphen"));
|
||||
assert!(!SkillCreator::validate_slug("ends-with-hyphen-"));
|
||||
assert!(!SkillCreator::validate_slug("has spaces"));
|
||||
assert!(!SkillCreator::validate_slug("has_underscores"));
|
||||
assert!(!SkillCreator::validate_slug(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
// ── TOML generation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toml_generation_valid_format() {
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
let toml_str = SkillCreator::generate_skill_toml(
|
||||
"build-and-test",
|
||||
"Build and test the project",
|
||||
&calls,
|
||||
);
|
||||
|
||||
// Should parse as valid TOML.
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&toml_str).expect("Generated TOML should be valid");
|
||||
let skill = parsed.get("skill").expect("Should have [skill] section");
|
||||
assert_eq!(
|
||||
skill.get("name").and_then(toml::Value::as_str),
|
||||
Some("build-and-test")
|
||||
);
|
||||
assert_eq!(
|
||||
skill.get("author").and_then(toml::Value::as_str),
|
||||
Some("zeroclaw-auto")
|
||||
);
|
||||
assert_eq!(
|
||||
skill.get("version").and_then(toml::Value::as_str),
|
||||
Some("0.1.0")
|
||||
);
|
||||
|
||||
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(
|
||||
tools[0].get("command").and_then(toml::Value::as_str),
|
||||
Some("cargo build")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_generation_escapes_quotes() {
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "echo \"hello\""}),
|
||||
}];
|
||||
let toml_str =
|
||||
SkillCreator::generate_skill_toml("echo-test", "Test \"quoted\" description", &calls);
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&toml_str).expect("TOML with quotes should be valid");
|
||||
let desc = parsed
|
||||
.get("skill")
|
||||
.and_then(|s| s.get("description"))
|
||||
.and_then(toml::Value::as_str)
|
||||
.unwrap();
|
||||
assert!(desc.contains("quoted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_generation_no_command_arg() {
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "memory_store".into(),
|
||||
args: serde_json::json!({"key": "foo", "value": "bar"}),
|
||||
}];
|
||||
let toml_str = SkillCreator::generate_skill_toml("memory-op", "Store to memory", &calls);
|
||||
let parsed: toml::Value = toml::from_str(&toml_str).expect("TOML should be valid");
|
||||
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
|
||||
// When no "command" arg exists, falls back to tool name.
|
||||
assert_eq!(
|
||||
tools[0].get("command").and_then(toml::Value::as_str),
|
||||
Some("memory_store")
|
||||
);
|
||||
}
|
||||
|
||||
// ── TOML description extraction ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_description_from_valid_toml() {
|
||||
let content = r#"
|
||||
[skill]
|
||||
name = "test"
|
||||
description = "Auto-generated: Build project"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
assert_eq!(
|
||||
extract_description_from_toml(content),
|
||||
Some("Auto-generated: Build project".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_description_from_invalid_toml() {
|
||||
assert_eq!(extract_description_from_toml("not valid toml {{"), None);
|
||||
}
|
||||
|
||||
// ── Deduplication ────────────────────────────────────────────
|
||||
|
||||
/// A mock embedding provider that returns deterministic embeddings.
|
||||
///
|
||||
/// The "new" description (first text embedded) always gets `[1, 0, 0]`.
|
||||
/// The "existing" skill description (second text embedded) gets a vector
|
||||
/// whose cosine similarity with `[1, 0, 0]` equals `self.similarity`.
|
||||
struct MockEmbeddingProvider {
|
||||
similarity: f32,
|
||||
call_count: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockEmbeddingProvider {
|
||||
fn new(similarity: f32) -> Self {
|
||||
Self {
|
||||
similarity,
|
||||
call_count: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for MockEmbeddingProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
3
|
||||
}
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let call = self
|
||||
.call_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if call == 0 {
|
||||
// First call: the "new" description.
|
||||
vec![1.0, 0.0, 0.0]
|
||||
} else {
|
||||
// Subsequent calls: existing skill descriptions.
|
||||
// Produce a vector with the configured cosine similarity to [1,0,0].
|
||||
vec![
|
||||
self.similarity,
|
||||
(1.0 - self.similarity * self.similarity).sqrt(),
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_skips_similar_descriptions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skills_dir = dir.path().join("skills").join("existing-skill");
|
||||
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skills_dir.join("SKILL.toml"),
|
||||
r#"
|
||||
[skill]
|
||||
name = "existing-skill"
|
||||
description = "Auto-generated: Build the project"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
// High similarity provider -> should detect as duplicate.
|
||||
let provider = MockEmbeddingProvider::new(0.95);
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config.clone());
|
||||
assert!(creator
|
||||
.is_duplicate("Build the project", &provider)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// Low similarity provider -> not a duplicate.
|
||||
let provider_low = MockEmbeddingProvider::new(0.3);
|
||||
let creator2 = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
assert!(!creator2
|
||||
.is_duplicate("Completely different task", &provider_low)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// ── LRU eviction ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_eviction_removes_oldest() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 2,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
let skills_dir = dir.path().join("skills");
|
||||
|
||||
// Create two auto-generated skills with different timestamps.
|
||||
for (i, name) in ["old-skill", "new-skill"].iter().enumerate() {
|
||||
let skill_dir = skills_dir.join(name);
|
||||
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skill_dir.join("SKILL.toml"),
|
||||
format!(
|
||||
r#"[skill]
|
||||
name = "{name}"
|
||||
description = "Auto-generated: Skill {i}"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Small delay to ensure different timestamps.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
creator.enforce_lru_limit().await.unwrap();
|
||||
|
||||
// The oldest skill should have been removed.
|
||||
assert!(!skills_dir.join("old-skill").exists());
|
||||
assert!(skills_dir.join("new-skill").exists());
|
||||
}
|
||||
|
||||
// ── End-to-end: create_from_execution ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "pwd"}),
|
||||
},
|
||||
];
|
||||
let result = creator
|
||||
.create_from_execution("List files", &calls, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_insufficient_steps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "ls"}),
|
||||
}];
|
||||
let result = creator
|
||||
.create_from_execution("List files", &calls, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
|
||||
// Use noop embedding (no deduplication).
|
||||
let noop = NoopEmbedding;
|
||||
let result = creator
|
||||
.create_from_execution("Build and test", &calls, Some(&noop))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, Some("build-and-test".into()));
|
||||
|
||||
// Verify the skill directory and TOML were created.
|
||||
let skill_dir = dir.path().join("skills").join("build-and-test");
|
||||
assert!(skill_dir.exists());
|
||||
let toml_content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(toml_content.contains("build-and-test"));
|
||||
assert!(toml_content.contains("zeroclaw-auto"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_with_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
// First, create an existing skill.
|
||||
let skills_dir = dir.path().join("skills").join("existing");
|
||||
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skills_dir.join("SKILL.toml"),
|
||||
r#"[skill]
|
||||
name = "existing"
|
||||
description = "Auto-generated: Build and test"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// High similarity provider -> should skip.
|
||||
let provider = MockEmbeddingProvider::new(0.95);
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
let result = creator
|
||||
.create_from_execution("Build and test", &calls, Some(&provider))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── Tool call extraction from history ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_from_empty_history() {
|
||||
let history = vec![];
|
||||
let records = extract_tool_calls_from_history(&history);
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_from_user_messages_only() {
|
||||
use crate::providers::ChatMessage;
|
||||
let history = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
|
||||
let records = extract_tool_calls_from_history(&history);
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
// ── Fuzz-like tests for slug ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slug_fuzz_various_inputs() {
|
||||
let inputs = [
|
||||
"",
|
||||
" ",
|
||||
"---",
|
||||
"a",
|
||||
"hello world!",
|
||||
"UPPER CASE",
|
||||
"with-hyphens-already",
|
||||
"with__underscores",
|
||||
"123 numbers 456",
|
||||
"emoji: cafe",
|
||||
&"x".repeat(200),
|
||||
"a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-0-1-2-3-4-5",
|
||||
];
|
||||
|
||||
for input in &inputs {
|
||||
let slug = SkillCreator::generate_slug(input);
|
||||
// Slug should always pass validation (or be empty for degenerate input).
|
||||
if !slug.is_empty() {
|
||||
assert!(
|
||||
SkillCreator::validate_slug(&slug),
|
||||
"Generated slug '{slug}' from '{input}' failed validation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz-like tests for TOML generation ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn toml_fuzz_various_inputs() {
|
||||
let descriptions = [
|
||||
"simple task",
|
||||
"task with \"quotes\" and \\ backslashes",
|
||||
"task with\nnewlines\r\nand tabs\there",
|
||||
"",
|
||||
&"long ".repeat(100),
|
||||
];
|
||||
|
||||
let args_variants = [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"command": "echo hello"}),
|
||||
serde_json::json!({"command": "echo \"hello world\"", "extra": 42}),
|
||||
];
|
||||
|
||||
for desc in &descriptions {
|
||||
for args in &args_variants {
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "tool1".into(),
|
||||
args: args.clone(),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "tool2".into(),
|
||||
args: args.clone(),
|
||||
},
|
||||
];
|
||||
let toml_str = SkillCreator::generate_skill_toml("test-slug", desc, &calls);
|
||||
// Must always produce valid TOML.
|
||||
let _parsed: toml::Value = toml::from_str(&toml_str)
|
||||
.unwrap_or_else(|e| panic!("Invalid TOML for desc '{desc}': {e}\n{toml_str}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use std::process::Command;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
mod audit;
|
||||
#[cfg(feature = "skill-creation")]
|
||||
pub mod creator;
|
||||
|
||||
const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
|
||||
const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
|
||||
|
||||
+61
-2
@@ -1,6 +1,8 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
use crate::cron::{
|
||||
self, deserialize_maybe_stringified, DeliveryConfig, JobType, Schedule, SessionTarget,
|
||||
};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -176,7 +178,7 @@ impl Tool for CronAddTool {
|
||||
}
|
||||
|
||||
let schedule = match args.get("schedule") {
|
||||
Some(v) => match serde_json::from_value::<Schedule>(v.clone()) {
|
||||
Some(v) => match deserialize_maybe_stringified::<Schedule>(v) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
@@ -511,6 +513,63 @@ mod tests {
|
||||
assert!(approved.success, "{:?}", approved.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_schedule_passed_as_json_string() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp).await;
|
||||
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
// Simulate the LLM double-serializing the schedule: the value arrives
|
||||
// as a JSON string containing a JSON object, rather than an object.
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo string-schedule"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
assert!(result.output.contains("next_run"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_interval_schedule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp).await;
|
||||
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"schedule": r#"{"kind":"every","every_ms":60000}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo interval"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_schedule_with_timezone() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp).await;
|
||||
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo tz-test"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_schedule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, CronJobPatch};
|
||||
use crate::cron::{self, deserialize_maybe_stringified, CronJobPatch};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -202,7 +202,7 @@ impl Tool for CronUpdateTool {
|
||||
}
|
||||
};
|
||||
|
||||
let patch = match serde_json::from_value::<CronJobPatch>(patch_val) {
|
||||
let patch = match deserialize_maybe_stringified::<CronJobPatch>(&patch_val) {
|
||||
Ok(patch) => patch,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
|
||||
+243
-4
@@ -296,8 +296,9 @@ impl Tool for DelegateTool {
|
||||
}
|
||||
|
||||
// Wrap the provider call in a timeout to prevent indefinite blocking
|
||||
let timeout_secs = agent_config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
|
||||
Duration::from_secs(timeout_secs),
|
||||
provider.chat_with_system(
|
||||
agent_config.system_prompt.as_deref(),
|
||||
&full_prompt,
|
||||
@@ -314,7 +315,7 @@ impl Tool for DelegateTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s"
|
||||
"Agent '{agent_name}' timed out after {timeout_secs}s"
|
||||
)),
|
||||
});
|
||||
}
|
||||
@@ -401,8 +402,11 @@ impl DelegateTool {
|
||||
|
||||
let noop_observer = NoopObserver;
|
||||
|
||||
let agentic_timeout_secs = agent_config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
Duration::from_secs(agentic_timeout_secs),
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
&mut history,
|
||||
@@ -422,6 +426,7 @@ impl DelegateTool {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -453,7 +458,7 @@ impl DelegateTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Agent '{agent_name}' timed out after {DELEGATE_AGENTIC_TIMEOUT_SECS}s"
|
||||
"Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
|
||||
)),
|
||||
}),
|
||||
}
|
||||
@@ -530,6 +535,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents.insert(
|
||||
@@ -544,6 +551,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents
|
||||
@@ -697,6 +706,8 @@ mod tests {
|
||||
agentic: true,
|
||||
allowed_tools,
|
||||
max_iterations,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +816,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -911,6 +924,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -946,6 +961,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -1220,4 +1237,226 @@ mod tests {
|
||||
handle.write().push(Arc::new(FakeMcpTool));
|
||||
assert_eq!(handle.read().len(), 2);
|
||||
}
|
||||
|
||||
// ── Configurable timeout tests ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_timeout_values_used_when_config_unset() {
|
||||
let config = DelegateAgentConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
};
|
||||
assert_eq!(config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS), 120);
|
||||
assert_eq!(
|
||||
config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_timeout_values_are_respected() {
|
||||
let config = DelegateAgentConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(60),
|
||||
agentic_timeout_secs: Some(600),
|
||||
};
|
||||
assert_eq!(config.timeout_secs.unwrap_or(DELEGATE_TIMEOUT_SECS), 60);
|
||||
assert_eq!(
|
||||
config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_deserialization_defaults_to_none() {
|
||||
let toml_str = r#"
|
||||
provider = "ollama"
|
||||
model = "llama3"
|
||||
"#;
|
||||
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(config.timeout_secs.is_none());
|
||||
assert!(config.agentic_timeout_secs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_deserialization_with_custom_values() {
|
||||
let toml_str = r#"
|
||||
provider = "ollama"
|
||||
model = "llama3"
|
||||
timeout_secs = 45
|
||||
agentic_timeout_secs = 900
|
||||
"#;
|
||||
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.timeout_secs, Some(45));
|
||||
assert_eq!(config.agentic_timeout_secs, Some(900));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_zero_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(0),
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("timeout_secs must be greater than 0"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_zero_agentic_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: Some(0),
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_excessive_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(7200),
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("exceeds max 3600"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_excessive_agentic_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: Some(5000),
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("exceeds max 3600"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_accepts_max_boundary_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"ok".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(3600),
|
||||
agentic_timeout_secs: Some(3600),
|
||||
},
|
||||
);
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_accepts_none_timeouts() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"ok".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +233,22 @@ impl Default for ActivatedToolSet {
|
||||
|
||||
/// Build the `<available-deferred-tools>` section for the system prompt.
|
||||
/// Lists only tool names so the LLM knows what is available without
|
||||
/// consuming context window on full schemas.
|
||||
/// consuming context window on full schemas. Includes an instruction
|
||||
/// block that tells the LLM to call `tool_search` to activate them.
|
||||
pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
|
||||
if deferred.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from("<available-deferred-tools>\n");
|
||||
let mut out = String::new();
|
||||
out.push_str("## Deferred Tools\n\n");
|
||||
out.push_str(
|
||||
"The tools listed below are available but NOT yet loaded. \
|
||||
To use any of them you MUST first call the `tool_search` tool \
|
||||
to fetch their full schemas. Use `\"select:name1,name2\"` for \
|
||||
exact tools or keywords to search. Once activated, the tools \
|
||||
become callable for the rest of the conversation.\n\n",
|
||||
);
|
||||
out.push_str("<available-deferred-tools>\n");
|
||||
for stub in &deferred.stubs {
|
||||
out.push_str(&stub.prefixed_name);
|
||||
out.push('\n');
|
||||
@@ -416,6 +426,55 @@ mod tests {
|
||||
assert!(section.contains("</available-deferred-tools>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_deferred_section_includes_tool_search_instruction() {
|
||||
let stubs = vec![make_stub("fs__read_file", "Read a file")];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
let section = build_deferred_tools_section(&set);
|
||||
assert!(
|
||||
section.contains("tool_search"),
|
||||
"deferred section must instruct the LLM to use tool_search"
|
||||
);
|
||||
assert!(
|
||||
section.contains("## Deferred Tools"),
|
||||
"deferred section must include a heading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_deferred_section_multiple_servers() {
|
||||
let stubs = vec![
|
||||
make_stub("server_a__list", "List items"),
|
||||
make_stub("server_a__create", "Create item"),
|
||||
make_stub("server_b__query", "Query records"),
|
||||
];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
let section = build_deferred_tools_section(&set);
|
||||
assert!(section.contains("server_a__list"));
|
||||
assert!(section.contains("server_a__create"));
|
||||
assert!(section.contains("server_b__query"));
|
||||
assert!(
|
||||
section.contains("tool_search"),
|
||||
"section must mention tool_search for multi-server setups"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_search_ranks_by_hits() {
|
||||
let stubs = vec![
|
||||
@@ -457,4 +516,35 @@ mod tests {
|
||||
assert!(set.get_by_name("a__one").is_some());
|
||||
assert!(set.get_by_name("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_across_multiple_servers() {
|
||||
let stubs = vec![
|
||||
make_stub("server_a__read_file", "Read a file from disk"),
|
||||
make_stub("server_b__read_config", "Read configuration from database"),
|
||||
];
|
||||
let set = DeferredMcpToolSet {
|
||||
stubs,
|
||||
registry: std::sync::Arc::new(
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(McpRegistry::connect_all(&[]))
|
||||
.unwrap(),
|
||||
),
|
||||
};
|
||||
|
||||
// "read" should match stubs from both servers
|
||||
let results = set.search("read", 10);
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
// "file" should match only server_a
|
||||
let results = set.search("file", 10);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].prefixed_name, "server_a__read_file");
|
||||
|
||||
// "config database" should rank server_b highest (2 hits)
|
||||
let results = set.search("config database", 10);
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].prefixed_name, "server_b__read_config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub mod memory_recall;
|
||||
pub mod memory_store;
|
||||
pub mod microsoft365;
|
||||
pub mod model_routing_config;
|
||||
pub mod model_switch;
|
||||
pub mod node_tool;
|
||||
pub mod notion_tool;
|
||||
pub mod pdf_read;
|
||||
@@ -119,6 +120,7 @@ pub use memory_recall::MemoryRecallTool;
|
||||
pub use memory_store::MemoryStoreTool;
|
||||
pub use microsoft365::Microsoft365Tool;
|
||||
pub use model_routing_config::ModelRoutingConfigTool;
|
||||
pub use model_switch::ModelSwitchTool;
|
||||
#[allow(unused_imports)]
|
||||
pub use node_tool::NodeTool;
|
||||
pub use notion_tool::NotionTool;
|
||||
@@ -302,6 +304,7 @@ pub fn all_tools_with_runtime(
|
||||
config.clone(),
|
||||
security.clone(),
|
||||
)),
|
||||
Arc::new(ModelSwitchTool::new(security.clone())),
|
||||
Arc::new(ProxyConfigTool::new(config.clone(), security.clone())),
|
||||
Arc::new(GitOperationsTool::new(
|
||||
security.clone(),
|
||||
@@ -914,6 +917,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -705,6 +705,8 @@ impl ModelRoutingConfigTool {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: DEFAULT_AGENT_MAX_ITERATIONS,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
});
|
||||
|
||||
next_agent.provider = provider;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::agent::loop_::get_model_switch_state;
|
||||
use crate::providers;
|
||||
use crate::security::policy::ToolOperation;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ModelSwitchTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl ModelSwitchTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ModelSwitchTool {
|
||||
fn name(&self) -> &str {
|
||||
"model_switch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Switch the AI model at runtime. Use 'get' to see current model, 'list_providers' to see available providers, 'list_models' to see models for a provider, or 'set' to switch to a different model. The switch takes effect immediately for the current conversation."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "set", "list_providers", "list_models"],
|
||||
"description": "Action to perform: get current model, set a new model, list available providers, or list models for a provider"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Provider name (e.g., 'openai', 'anthropic', 'groq', 'ollama'). Required for 'set' and 'list_models' actions."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model ID (e.g., 'gpt-4o', 'claude-sonnet-4-6'). Required for 'set' action."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("get");
|
||||
|
||||
if let Err(error) = self
|
||||
.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "model_switch")
|
||||
{
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
|
||||
match action {
|
||||
"get" => self.handle_get(),
|
||||
"set" => self.handle_set(&args),
|
||||
"list_providers" => self.handle_list_providers(),
|
||||
"list_models" => self.handle_list_models(&args),
|
||||
_ => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Unknown action: {}. Valid actions: get, set, list_providers, list_models",
|
||||
action
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelSwitchTool {
|
||||
fn handle_get(&self) -> anyhow::Result<ToolResult> {
|
||||
let switch_state = get_model_switch_state();
|
||||
let pending = switch_state.lock().unwrap().clone();
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"pending_switch": pending,
|
||||
"note": "To switch models, use action 'set' with provider and model parameters"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_set(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let provider = args.get("provider").and_then(|v| v.as_str());
|
||||
|
||||
let provider = match provider {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing 'provider' parameter for 'set' action".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let model = args.get("model").and_then(|v| v.as_str());
|
||||
|
||||
let model = match model {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing 'model' parameter for 'set' action".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the provider exists
|
||||
let known_providers = providers::list_providers();
|
||||
let provider_valid = known_providers.iter().any(|p| {
|
||||
p.name.eq_ignore_ascii_case(provider)
|
||||
|| p.aliases.iter().any(|a| a.eq_ignore_ascii_case(provider))
|
||||
});
|
||||
|
||||
if !provider_valid {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"available_providers": known_providers.iter().map(|p| p.name).collect::<Vec<_>>()
|
||||
}))?,
|
||||
error: Some(format!(
|
||||
"Unknown provider: {}. Use 'list_providers' to see available options.",
|
||||
provider
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// Set the global model switch request
|
||||
let switch_state = get_model_switch_state();
|
||||
*switch_state.lock().unwrap() = Some((provider.to_string(), model.to_string()));
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Model switch requested",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"note": "The agent will switch to this model on the next turn. Use 'get' to check pending switch."
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_providers(&self) -> anyhow::Result<ToolResult> {
|
||||
let providers_list = providers::list_providers();
|
||||
|
||||
let providers: Vec<serde_json::Value> = providers_list
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"name": p.name,
|
||||
"display_name": p.display_name,
|
||||
"aliases": p.aliases,
|
||||
"local": p.local
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"providers": providers,
|
||||
"count": providers.len(),
|
||||
"example": "Use action 'set' with provider and model to switch"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_models(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let provider = args.get("provider").and_then(|v| v.as_str());
|
||||
|
||||
let provider = match provider {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"Missing 'provider' parameter for 'list_models' action".to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Return common models for known providers
|
||||
let models = match provider.to_lowercase().as_str() {
|
||||
"openai" => vec![
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4",
|
||||
"gpt-3.5-turbo",
|
||||
],
|
||||
"anthropic" => vec![
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
"claude-3-haiku",
|
||||
],
|
||||
"openrouter" => vec![
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"openai/gpt-4o",
|
||||
"google/gemini-pro",
|
||||
"meta-llama/llama-3-70b-instruct",
|
||||
],
|
||||
"groq" => vec![
|
||||
"llama-3.3-70b-versatile",
|
||||
"mixtral-8x7b-32768",
|
||||
"llama-3.1-70b-speculative",
|
||||
],
|
||||
"ollama" => vec!["llama3", "llama3.1", "mistral", "codellama", "phi3"],
|
||||
"deepseek" => vec!["deepseek-chat", "deepseek-coder"],
|
||||
"mistral" => vec![
|
||||
"mistral-large-latest",
|
||||
"mistral-small-latest",
|
||||
"mistral-nemo",
|
||||
],
|
||||
"google" | "gemini" => vec!["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"xai" | "grok" => vec!["grok-2", "grok-2-vision", "grok-beta"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"provider": provider,
|
||||
"models": [],
|
||||
"note": "No common models listed for this provider. Check provider documentation for available models."
|
||||
}))?,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"provider": provider,
|
||||
"models": models,
|
||||
"example": "Use action 'set' with this provider and a model ID to switch"
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -566,6 +566,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents.insert(
|
||||
@@ -580,6 +582,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents
|
||||
|
||||
@@ -281,4 +281,88 @@ mod tests {
|
||||
// Tool should now be activated
|
||||
assert!(activated.lock().unwrap().is_activated("fs__read"));
|
||||
}
|
||||
|
||||
/// Verify tool_search works with stubs from multiple MCP servers,
|
||||
/// simulating a daemon-mode setup where several servers are deferred.
|
||||
#[tokio::test]
|
||||
async fn multiple_servers_stubs_all_searchable() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let stubs = vec![
|
||||
make_stub("server_a__list_files", "List files on server A"),
|
||||
make_stub("server_a__read_file", "Read file on server A"),
|
||||
make_stub("server_b__query_db", "Query database on server B"),
|
||||
make_stub("server_b__insert_row", "Insert row on server B"),
|
||||
];
|
||||
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
||||
|
||||
// Search should find tools across both servers
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "file"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("server_a__list_files"));
|
||||
assert!(result.output.contains("server_a__read_file"));
|
||||
|
||||
// Server B tools should also be searchable
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "database query"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("server_b__query_db"));
|
||||
}
|
||||
|
||||
/// Verify select mode activates tools and they stay activated across calls,
|
||||
/// matching the daemon-mode pattern where a single ActivatedToolSet persists.
|
||||
#[tokio::test]
|
||||
async fn select_activates_and_persists_across_calls() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let stubs = vec![
|
||||
make_stub("srv__tool_a", "Tool A"),
|
||||
make_stub("srv__tool_b", "Tool B"),
|
||||
];
|
||||
let tool = ToolSearchTool::new(make_deferred_set(stubs).await, Arc::clone(&activated));
|
||||
|
||||
// Activate tool_a
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "select:srv__tool_a"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(activated.lock().unwrap().is_activated("srv__tool_a"));
|
||||
assert!(!activated.lock().unwrap().is_activated("srv__tool_b"));
|
||||
|
||||
// Activate tool_b in a separate call
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"query": "select:srv__tool_b"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
// Both should remain activated
|
||||
let guard = activated.lock().unwrap();
|
||||
assert!(guard.is_activated("srv__tool_a"));
|
||||
assert!(guard.is_activated("srv__tool_b"));
|
||||
assert_eq!(guard.tool_specs().len(), 2);
|
||||
}
|
||||
|
||||
/// Verify re-activating an already-activated tool does not duplicate it.
|
||||
#[tokio::test]
|
||||
async fn reactivation_is_idempotent() {
|
||||
let activated = Arc::new(Mutex::new(ActivatedToolSet::new()));
|
||||
let tool = ToolSearchTool::new(
|
||||
make_deferred_set(vec![make_stub("srv__tool", "A tool")]).await,
|
||||
Arc::clone(&activated),
|
||||
);
|
||||
|
||||
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
||||
.await
|
||||
.unwrap();
|
||||
tool.execute(serde_json::json!({"query": "select:srv__tool"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(activated.lock().unwrap().tool_specs().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# English tool descriptions (default locale)
|
||||
#
|
||||
# Each key under [tools] matches the tool's name() return value.
|
||||
# Values are the human-readable descriptions shown in system prompts.
|
||||
|
||||
[tools]
|
||||
backup = "Create, list, verify, and restore workspace backups"
|
||||
browser = "Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions."
|
||||
browser_delegate = "Delegate browser-based tasks to a browser-capable CLI for interacting with web applications like Teams, Outlook, Jira, Confluence"
|
||||
browser_open = "Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
|
||||
cloud_ops = "Cloud transformation advisory tool. Analyzes IaC plans, assesses migration paths, reviews costs, and checks architecture against Well-Architected Framework pillars. Read-only: does not create or modify cloud resources."
|
||||
cloud_patterns = "Cloud pattern library. Given a workload description, suggests applicable cloud-native architectural patterns (containerization, serverless, database modernization, etc.)."
|
||||
composio = "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to see available actions (includes parameter names). action='execute' with action_name/tool_slug and params to run an action. If you are unsure of the exact params, pass 'text' instead with a natural-language description of what you want (Composio will resolve the correct parameters via NLP). action='list_accounts' or action='connected_accounts' to list OAuth-connected accounts. action='connect' with app/auth_config_id to get OAuth URL. connected_account_id is auto-resolved when omitted."
|
||||
content_search = "Search file contents by regex pattern within the workspace. Supports ripgrep (rg) with grep fallback. Output modes: 'content' (matching lines with context), 'files_with_matches' (file paths only), 'count' (match counts per file). Example: pattern='fn main', include='*.rs', output_mode='content'."
|
||||
cron_add = """Create a scheduled cron job (shell or agent) with cron/at/every schedules. Use job_type='agent' with a prompt to run the AI agent on schedule. To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix), set delivery={"mode":"announce","channel":"discord","to":"<channel_id_or_chat_id>"}. This is the preferred tool for sending scheduled/delayed messages to users via channels."""
|
||||
cron_list = "List all scheduled cron jobs"
|
||||
cron_remove = "Remove a cron job by id"
|
||||
cron_run = "Force-run a cron job immediately and record run history"
|
||||
cron_runs = "List recent run history for a cron job"
|
||||
cron_update = "Patch an existing cron job (schedule, command, prompt, enabled, delivery, model, etc.)"
|
||||
data_management = "Workspace data retention, purge, and storage statistics"
|
||||
delegate = "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single prompt by default; with agentic=true it can iterate with a filtered tool-call loop."
|
||||
file_edit = "Edit a file by replacing an exact string match with new content"
|
||||
file_read = "Read file contents with line numbers. Supports partial reading via offset and limit. Extracts text from PDF; other binary files are read with lossy UTF-8 conversion."
|
||||
file_write = "Write contents to a file in the workspace"
|
||||
git_operations = "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
|
||||
glob_search = "Search for files matching a glob pattern within the workspace. Returns a sorted list of matching file paths relative to the workspace root. Examples: '**/*.rs' (all Rust files), 'src/**/mod.rs' (all mod.rs in src)."
|
||||
google_workspace = "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) via the gws CLI. Requires gws to be installed and authenticated."
|
||||
hardware_board_info = "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'."
|
||||
hardware_memory_map = "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets."
|
||||
hardware_memory_read = "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)."
|
||||
http_request = "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits."
|
||||
image_info = "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data."
|
||||
knowledge = "Manage a knowledge graph of architecture decisions, solution patterns, lessons learned, and experts. Actions: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats."
|
||||
linkedin = "Manage LinkedIn: create posts, list your posts, comment, react, delete posts, view engagement, get profile info, and read the configured content strategy. Requires LINKEDIN_* credentials in .env file."
|
||||
memory_forget = "Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed."
|
||||
memory_recall = "Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance."
|
||||
memory_store = "Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context, or a custom category name."
|
||||
microsoft365 = "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, OneDrive files, and SharePoint search via Microsoft Graph API"
|
||||
model_routing_config = "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles"
|
||||
notion = "Interact with Notion: query databases, read/create/update pages, and search the workspace."
|
||||
pdf_read = "Extract plain text from a PDF file in the workspace. Returns all readable text. Image-only or encrypted PDFs return an empty result. Requires the 'rag-pdf' build feature."
|
||||
project_intel = "Project delivery intelligence: generate status reports, detect risks, draft client updates, summarize sprints, and estimate effort. Read-only analysis tool."
|
||||
proxy_config = "Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
|
||||
pushover = "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file."
|
||||
schedule = """Manage scheduled shell-only tasks. Actions: create/add/once/list/get/cancel/remove/pause/resume. WARNING: This tool creates shell jobs whose output is only logged, NOT delivered to any channel. To send a scheduled message to Discord/Telegram/Slack/Matrix, use the cron_add tool with job_type='agent' and a delivery config like {"mode":"announce","channel":"discord","to":"<channel_id>"}."""
|
||||
screenshot = "Capture a screenshot of the current screen. Returns the file path and base64-encoded PNG data."
|
||||
security_ops = "Security operations tool for managed cybersecurity services. Actions: triage_alert (classify/prioritize alerts), run_playbook (execute incident response steps), parse_vulnerability (parse scan results), generate_report (create security posture reports), list_playbooks (list available playbooks), alert_stats (summarize alert metrics)."
|
||||
shell = "Execute a shell command in the workspace directory"
|
||||
sop_advance = "Report the result of the current SOP step and advance to the next step. Provide the run_id, whether the step succeeded or failed, and a brief output summary."
|
||||
sop_approve = "Approve a pending SOP step that is waiting for operator approval. Returns the step instruction to execute. Use sop_status to see which runs are waiting."
|
||||
sop_execute = "Manually trigger a Standard Operating Procedure (SOP) by name. Returns the run ID and first step instruction. Use sop_list to see available SOPs."
|
||||
sop_list = "List all loaded Standard Operating Procedures (SOPs) with their triggers, priority, step count, and active run count. Optionally filter by name or priority."
|
||||
sop_status = "Query SOP execution status. Provide run_id for a specific run, or sop_name to list runs for that SOP. With no arguments, shows all active runs."
|
||||
swarm = "Orchestrate a swarm of agents to collaboratively handle a task. Supports sequential (pipeline), parallel (fan-out/fan-in), and router (LLM-selected) strategies."
|
||||
tool_search = """Fetch full schema definitions for deferred MCP tools so they can be called. Use "select:name1,name2" for exact match or keywords to search."""
|
||||
web_fetch = "Fetch a web page and return its content as clean plain text. HTML pages are automatically converted to readable text. JSON and plain text responses are returned as-is. Only GET requests; follows redirects. Security: allowlist-only domains, no local/private hosts."
|
||||
web_search_tool = "Search the web for information. Returns relevant search results with titles, URLs, and descriptions. Use this to find current information, news, or research topics."
|
||||
workspace = "Manage multi-client workspaces. Subcommands: list, switch, create, info, export. Each workspace provides isolated memory, audit, secrets, and tool restrictions."
|
||||
@@ -0,0 +1,60 @@
|
||||
# 中文工具描述 (简体中文)
|
||||
#
|
||||
# [tools] 下的每个键对应工具的 name() 返回值。
|
||||
# 值是显示在系统提示中的人类可读描述。
|
||||
# 缺少的键将回退到英文 (en.toml) 描述。
|
||||
|
||||
[tools]
|
||||
backup = "创建、列出、验证和恢复工作区备份"
|
||||
browser = "基于可插拔后端(agent-browser、rust-native、computer_use)的网页/浏览器自动化。支持 DOM 操作以及通过 computer-use 辅助工具进行的可选系统级操作(mouse_move、mouse_click、mouse_drag、key_type、key_press、screen_capture)。使用 'snapshot' 将交互元素映射到引用(@e1、@e2)。对 open 操作强制执行 browser.allowed_domains。"
|
||||
browser_delegate = "将基于浏览器的任务委派给具有浏览器功能的 CLI,用于与 Teams、Outlook、Jira、Confluence 等 Web 应用交互"
|
||||
browser_open = "在系统浏览器中打开经批准的 HTTPS URL。安全约束:仅允许列表域名,禁止本地/私有主机,禁止抓取。"
|
||||
cloud_ops = "云转型咨询工具。分析 IaC 计划、评估迁移路径、审查成本,并根据良好架构框架支柱检查架构。只读:不创建或修改云资源。"
|
||||
cloud_patterns = "云模式库。根据工作负载描述,建议适用的云原生架构模式(容器化、无服务器、数据库现代化等)。"
|
||||
composio = "通过 Composio 在 1000 多个应用上执行操作(Gmail、Notion、GitHub、Slack 等)。使用 action='list' 查看可用操作(包含参数名称)。使用 action='execute' 配合 action_name/tool_slug 和 params 运行操作。如果不确定具体参数,可传入 'text' 并用自然语言描述需求(Composio 将通过 NLP 解析正确参数)。使用 action='list_accounts' 或 action='connected_accounts' 列出 OAuth 已连接账户。使用 action='connect' 配合 app/auth_config_id 获取 OAuth URL。省略时自动解析 connected_account_id。"
|
||||
content_search = "在工作区内按正则表达式搜索文件内容。支持 ripgrep (rg),可回退到 grep。输出模式:'content'(带上下文的匹配行)、'files_with_matches'(仅文件路径)、'count'(每个文件的匹配计数)。"
|
||||
cron_add = "创建带有 cron/at/every 计划的定时任务(shell 或 agent)。使用 job_type='agent' 配合 prompt 按计划运行 AI 代理。要将输出发送到频道(Discord、Telegram、Slack、Mattermost、Matrix),请设置 delivery 配置。这是通过频道向用户发送定时/延迟消息的首选工具。"
|
||||
cron_list = "列出所有已计划的 cron 任务"
|
||||
cron_remove = "按 ID 删除 cron 任务"
|
||||
cron_run = "立即强制运行 cron 任务并记录运行历史"
|
||||
cron_runs = "列出 cron 任务的最近运行历史"
|
||||
cron_update = "修改现有 cron 任务(计划、命令、提示、启用状态、投递配置、模型等)"
|
||||
data_management = "工作区数据保留、清理和存储统计"
|
||||
delegate = "将子任务委派给专用代理。适用场景:任务受益于不同模型(如快速摘要、深度推理、代码生成)。子代理默认运行单个提示;设置 agentic=true 后可通过过滤的工具调用循环进行迭代。"
|
||||
file_edit = "通过替换精确匹配的字符串来编辑文件"
|
||||
file_read = "读取带行号的文件内容。支持通过 offset 和 limit 进行部分读取。可从 PDF 提取文本;其他二进制文件使用有损 UTF-8 转换读取。"
|
||||
file_write = "将内容写入工作区中的文件"
|
||||
git_operations = "执行结构化的 Git 操作(status、diff、log、branch、commit、add、checkout、stash)。提供解析后的 JSON 输出,并与安全策略集成以实现自主控制。"
|
||||
glob_search = "在工作区内搜索匹配 glob 模式的文件。返回相对于工作区根目录的排序文件路径列表。示例:'**/*.rs'(所有 Rust 文件)、'src/**/mod.rs'(src 中所有 mod.rs)。"
|
||||
google_workspace = "与 Google Workspace 服务(Drive、Gmail、Calendar、Sheets、Docs 等)交互。通过 gws CLI 操作,需要 gws 已安装并认证。"
|
||||
hardware_board_info = "返回已连接硬件的完整板卡信息(芯片、架构、内存映射)。适用场景:用户询问板卡信息、连接的硬件、芯片信息等。"
|
||||
hardware_memory_map = "返回已连接硬件的内存映射(Flash 和 RAM 地址范围)。适用场景:用户询问内存地址、地址空间或可读地址。返回数据手册中的 Flash/RAM 范围。"
|
||||
hardware_memory_read = "通过 USB 从 Nucleo 读取实际内存/寄存器值。适用场景:用户要求读取寄存器值、读取内存地址、转储内存等。返回十六进制转储。需要 Nucleo 通过 USB 连接并启用 probe 功能。"
|
||||
http_request = "向外部 API 发送 HTTP 请求。支持 GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS 方法。安全约束:仅允许列表域名,禁止本地/私有主机,可配置超时和响应大小限制。"
|
||||
image_info = "读取图片文件元数据(格式、尺寸、大小),可选返回 base64 编码数据。"
|
||||
knowledge = "管理架构决策、解决方案模式、经验教训和专家的知识图谱。操作:capture、search、relate、suggest、expert_find、lessons_extract、graph_stats。"
|
||||
linkedin = "管理 LinkedIn:创建帖子、列出帖子、评论、点赞、删除帖子、查看互动数据、获取个人资料信息,以及阅读配置的内容策略。需要在 .env 文件中配置 LINKEDIN_* 凭据。"
|
||||
memory_forget = "按键删除记忆。用于删除过时事实或敏感数据。返回记忆是否被找到并删除。"
|
||||
memory_recall = "在长期记忆中搜索相关事实、偏好或上下文。返回按相关性排名的评分结果。"
|
||||
memory_store = "在长期记忆中存储事实、偏好或笔记。使用类别 'core' 存储永久事实,'daily' 存储会话笔记,'conversation' 存储聊天上下文,或使用自定义类别名称。"
|
||||
microsoft365 = "Microsoft 365 集成:通过 Microsoft Graph API 管理 Outlook 邮件、Teams 消息、日历事件、OneDrive 文件和 SharePoint 搜索"
|
||||
model_routing_config = "管理默认模型设置、基于场景的提供商/模型路由、分类规则和委派子代理配置"
|
||||
notion = "与 Notion 交互:查询数据库、读取/创建/更新页面、搜索工作区。"
|
||||
pdf_read = "从工作区中的 PDF 文件提取纯文本。返回所有可读文本。仅图片或加密的 PDF 返回空结果。需要 'rag-pdf' 构建功能。"
|
||||
project_intel = "项目交付智能:生成状态报告、检测风险、起草客户更新、总结冲刺、估算工作量。只读分析工具。"
|
||||
proxy_config = "管理 ZeroClaw 代理设置(范围:environment | zeroclaw | services),包括运行时和进程环境应用"
|
||||
pushover = "向设备发送 Pushover 通知。需要在 .env 文件中配置 PUSHOVER_TOKEN 和 PUSHOVER_USER_KEY。"
|
||||
schedule = "管理仅限 shell 的定时任务。操作:create/add/once/list/get/cancel/remove/pause/resume。警告:此工具创建的 shell 任务输出仅记录日志,不会发送到任何频道。要向 Discord/Telegram/Slack/Matrix 发送定时消息,请使用 cron_add 工具。"
|
||||
screenshot = "捕获当前屏幕截图。返回文件路径和 base64 编码的 PNG 数据。"
|
||||
security_ops = "托管网络安全服务的安全运营工具。操作:triage_alert(分类/优先级排序警报)、run_playbook(执行事件响应步骤)、parse_vulnerability(解析扫描结果)、generate_report(创建安全态势报告)、list_playbooks(列出可用剧本)、alert_stats(汇总警报指标)。"
|
||||
shell = "在工作区目录中执行 shell 命令"
|
||||
sop_advance = "报告当前 SOP 步骤的结果并前进到下一步。提供 run_id、步骤是否成功或失败,以及简短的输出摘要。"
|
||||
sop_approve = "批准等待操作员批准的待处理 SOP 步骤。返回要执行的步骤指令。使用 sop_status 查看哪些运行正在等待。"
|
||||
sop_execute = "按名称手动触发标准操作程序 (SOP)。返回运行 ID 和第一步指令。使用 sop_list 查看可用 SOP。"
|
||||
sop_list = "列出所有已加载的标准操作程序 (SOP),包括触发器、优先级、步骤数和活跃运行数。可按名称或优先级筛选。"
|
||||
sop_status = "查询 SOP 执行状态。提供 run_id 查看特定运行,或提供 sop_name 列出该 SOP 的所有运行。无参数时显示所有活跃运行。"
|
||||
swarm = "编排代理群以协作处理任务。支持顺序(管道)、并行(扇出/扇入)和路由器(LLM 选择)策略。"
|
||||
tool_search = "获取延迟 MCP 工具的完整 schema 定义以便调用。使用 \"select:name1,name2\" 精确匹配或关键词搜索。"
|
||||
web_fetch = "获取网页并以纯文本形式返回内容。HTML 页面自动转换为可读文本。JSON 和纯文本响应按原样返回。仅 GET 请求;跟随重定向。安全:仅允许列表域名,禁止本地/私有主机。"
|
||||
web_search_tool = "搜索网络获取信息。返回包含标题、URL 和描述的相关搜索结果。用于查找当前信息、新闻或研究主题。"
|
||||
workspace = "管理多客户端工作区。子命令:list、switch、create、info、export。每个工作区提供隔离的记忆、审计、密钥和工具限制。"
|
||||
Reference in New Issue
Block a user