fix(agent): stabilize tool workflows and routed secrets
This commit is contained in:
parent
37534fbbfe
commit
c5c82c764e
@ -219,6 +219,8 @@ probe = ["dep:probe-rs"]
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
# 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"]
|
||||
# Legacy opt-in live integration tests for removed quota tools.
|
||||
quota-tools-live = []
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
|
||||
38
build.rs
Normal file
38
build.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const PLACEHOLDER_INDEX_HTML: &str = r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ZeroClaw Dashboard Placeholder</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>ZeroClaw dashboard assets are not built</h1>
|
||||
<p>Run the web build to replace this placeholder with the real dashboard.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir =
|
||||
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing"));
|
||||
let dist_dir = manifest_dir.join("web").join("dist");
|
||||
let index_path = dist_dir.join("index.html");
|
||||
|
||||
println!("cargo:rerun-if-changed=web/dist");
|
||||
|
||||
if index_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dist_dir).expect("failed to create web/dist placeholder directory");
|
||||
fs::write(&index_path, PLACEHOLDER_INDEX_HTML)
|
||||
.expect("failed to write placeholder web/dist/index.html");
|
||||
println!(
|
||||
"cargo:warning=web/dist was missing; generated a placeholder dashboard so the Rust build can continue"
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
|
||||
use crate::config::schema::ModelPricing;
|
||||
use crate::config::Config;
|
||||
use crate::cost::{CostTracker, TokenUsage as CostTokenUsage};
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::multimodal;
|
||||
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
|
||||
@ -15,7 +17,7 @@ use anyhow::Result;
|
||||
use futures_util::StreamExt;
|
||||
use regex::{Regex, RegexSet};
|
||||
use rustyline::error::ReadlineError;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
use std::io::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
@ -194,6 +196,84 @@ tokio::task_local! {
|
||||
static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option<NonCliApprovalContext>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ToolLoopCostTrackingContext {
|
||||
tracker: Arc<CostTracker>,
|
||||
prices: Arc<HashMap<String, ModelPricing>>,
|
||||
}
|
||||
|
||||
impl ToolLoopCostTrackingContext {
|
||||
pub(crate) fn new(
|
||||
tracker: Arc<CostTracker>,
|
||||
prices: Arc<HashMap<String, ModelPricing>>,
|
||||
) -> Self {
|
||||
Self { tracker, prices }
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static TOOL_LOOP_COST_TRACKING_CONTEXT: Option<ToolLoopCostTrackingContext>;
|
||||
}
|
||||
|
||||
fn lookup_model_pricing<'a>(
|
||||
prices: &'a HashMap<String, ModelPricing>,
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
) -> Option<&'a ModelPricing> {
|
||||
prices
|
||||
.get(model)
|
||||
.or_else(|| prices.get(&format!("{provider_name}/{model}")))
|
||||
.or_else(|| {
|
||||
model
|
||||
.rsplit_once('/')
|
||||
.and_then(|(_, suffix)| prices.get(suffix))
|
||||
})
|
||||
}
|
||||
|
||||
fn record_tool_loop_cost_usage(
|
||||
provider_name: &str,
|
||||
model: &str,
|
||||
usage: &crate::providers::traits::TokenUsage,
|
||||
) -> Option<(u64, f64)> {
|
||||
let input_tokens = usage.input_tokens.unwrap_or(0);
|
||||
let output_tokens = usage.output_tokens.unwrap_or(0);
|
||||
let total_tokens = input_tokens.saturating_add(output_tokens);
|
||||
if total_tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ctx = TOOL_LOOP_COST_TRACKING_CONTEXT
|
||||
.try_with(Clone::clone)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let pricing = lookup_model_pricing(&ctx.prices, provider_name, model);
|
||||
let cost_usage = CostTokenUsage::new(
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
pricing.map_or(0.0, |entry| entry.input),
|
||||
pricing.map_or(0.0, |entry| entry.output),
|
||||
);
|
||||
|
||||
if pricing.is_none() {
|
||||
tracing::debug!(
|
||||
provider = provider_name,
|
||||
model,
|
||||
"Cost tracking recorded token usage with zero pricing because no model price was configured"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(error) = ctx.tracker.record_usage(cost_usage.clone()) {
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model,
|
||||
"Failed to record cost tracking usage: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
Some((cost_usage.total_tokens, cost_usage.cost_usd))
|
||||
}
|
||||
|
||||
/// Extract a short hint from tool call arguments for progress display.
|
||||
fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String {
|
||||
let hint = match name {
|
||||
@ -475,6 +555,31 @@ fn build_tool_unavailable_retry_prompt(tool_specs: &[crate::tools::ToolSpec]) ->
|
||||
)
|
||||
}
|
||||
|
||||
fn display_text_for_turn(
|
||||
response_text: &str,
|
||||
parsed_text: &str,
|
||||
tool_call_count: usize,
|
||||
native_tool_call_count: usize,
|
||||
) -> String {
|
||||
if tool_call_count == 0 {
|
||||
return if parsed_text.is_empty() {
|
||||
response_text.to_string()
|
||||
} else {
|
||||
parsed_text.to_string()
|
||||
};
|
||||
}
|
||||
|
||||
if !parsed_text.is_empty() {
|
||||
return parsed_text.to_string();
|
||||
}
|
||||
|
||||
if native_tool_call_count > 0 {
|
||||
return response_text.to_string();
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ToolLoopCancelled;
|
||||
|
||||
@ -734,6 +839,7 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
|
||||
approval: Option<&ApprovalManager>,
|
||||
channel_name: &str,
|
||||
non_cli_approval_context: Option<NonCliApprovalContext>,
|
||||
cost_tracking_context: Option<ToolLoopCostTrackingContext>,
|
||||
multimodal_config: &crate::config::MultimodalConfig,
|
||||
max_tool_iterations: usize,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
@ -750,23 +856,26 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
|
||||
non_cli_approval_context,
|
||||
TOOL_LOOP_REPLY_TARGET.scope(
|
||||
reply_target,
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
history,
|
||||
tools_registry,
|
||||
observer,
|
||||
provider_name,
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
approval,
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
cancellation_token,
|
||||
on_delta,
|
||||
hooks,
|
||||
excluded_tools,
|
||||
TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
|
||||
cost_tracking_context,
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
history,
|
||||
tools_registry,
|
||||
observer,
|
||||
provider_name,
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
approval,
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
cancellation_token,
|
||||
on_delta,
|
||||
hooks,
|
||||
excluded_tools,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -1019,6 +1128,11 @@ pub(crate) async fn run_tool_call_loop(
|
||||
output_tokens: resp_output_tokens,
|
||||
});
|
||||
|
||||
let _ = resp
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|usage| record_tool_loop_cost_usage(provider_name, model, usage));
|
||||
|
||||
let response_text = resp.text_or_empty().to_string();
|
||||
// First try native structured tool calls (OpenAI-format).
|
||||
// Fall back to text-based parsing (XML tags, markdown blocks,
|
||||
@ -1135,11 +1249,12 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
};
|
||||
|
||||
let display_text = if parsed_text.is_empty() {
|
||||
response_text.clone()
|
||||
} else {
|
||||
parsed_text
|
||||
};
|
||||
let display_text = display_text_for_turn(
|
||||
&response_text,
|
||||
&parsed_text,
|
||||
tool_calls.len(),
|
||||
native_tool_calls.len(),
|
||||
);
|
||||
|
||||
// ── Progress: LLM responded ─────────────────────────────
|
||||
if let Some(ref tx) = on_delta {
|
||||
@ -1845,7 +1960,7 @@ pub async fn run(
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let mut tools_registry = tools::all_tools_with_runtime(
|
||||
let (mut tool_arcs, shared_tool_registry) = tools::all_tools_with_runtime_arcs(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
runtime,
|
||||
@ -1861,12 +1976,16 @@ pub async fn run(
|
||||
&config,
|
||||
);
|
||||
|
||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||
let peripheral_tools: Vec<Arc<dyn Tool>> =
|
||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||
if !peripheral_tools.is_empty() {
|
||||
tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
|
||||
tools_registry.extend(peripheral_tools);
|
||||
tool_arcs.extend(peripheral_tools);
|
||||
if let Some(shared_registry) = shared_tool_registry.as_ref() {
|
||||
tools::sync_shared_tool_registry(shared_registry, &tool_arcs);
|
||||
}
|
||||
}
|
||||
let tools_registry = tools::boxed_registry_from_arcs(tool_arcs);
|
||||
|
||||
// ── Resolve provider ─────────────────────────────────────────
|
||||
let provider_name = provider_override
|
||||
@ -2333,7 +2452,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let mut tools_registry = tools::all_tools_with_runtime(
|
||||
let (mut tool_arcs, shared_tool_registry) = tools::all_tools_with_runtime_arcs(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
runtime,
|
||||
@ -2348,9 +2467,13 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
config.api_key.as_deref(),
|
||||
&config,
|
||||
);
|
||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||
let peripheral_tools: Vec<Arc<dyn Tool>> =
|
||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||
tools_registry.extend(peripheral_tools);
|
||||
tool_arcs.extend(peripheral_tools);
|
||||
if let Some(shared_registry) = shared_tool_registry.as_ref() {
|
||||
tools::sync_shared_tool_registry(shared_registry, &tool_arcs);
|
||||
}
|
||||
let tools_registry = tools::boxed_registry_from_arcs(tool_arcs);
|
||||
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
|
||||
let model_name = config
|
||||
@ -2508,10 +2631,11 @@ mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_scrub_credentials() {
|
||||
@ -2584,6 +2708,37 @@ mod tests {
|
||||
assert!(args.get("delivery").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_text_for_turn_hides_prompt_guided_tool_payloads() {
|
||||
let display = display_text_for_turn(
|
||||
"<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</tool_call>",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert!(display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_text_for_turn_preserves_prompt_guided_preface_text() {
|
||||
let display = display_text_for_turn(
|
||||
"Let me check.\n<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</tool_call>",
|
||||
"Let me check.",
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(display, "Let me check.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_text_for_turn_preserves_native_tool_preface_text() {
|
||||
let display = display_text_for_turn("Let me check.", "", 1, 1);
|
||||
|
||||
assert_eq!(display, "Let me check.");
|
||||
}
|
||||
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use crate::observability::NoopObserver;
|
||||
use crate::providers::router::{Route, RouterProvider};
|
||||
@ -2591,8 +2746,6 @@ mod tests {
|
||||
use crate::providers::ChatResponse;
|
||||
use crate::runtime::NativeRuntime;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy, ShellRedirectPolicy};
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct NonVisionProvider {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
@ -3472,6 +3625,7 @@ mod tests {
|
||||
reply_target: "chat-approval".to_string(),
|
||||
prompt_tx,
|
||||
}),
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -3491,6 +3645,86 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_records_cost_usage_when_tracking_context_is_scoped() {
|
||||
let provider = ScriptedProvider {
|
||||
responses: Arc::new(Mutex::new(VecDeque::from([ChatResponse {
|
||||
text: Some("done".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: Some(crate::providers::traits::TokenUsage {
|
||||
input_tokens: Some(1_200),
|
||||
output_tokens: Some(300),
|
||||
}),
|
||||
reasoning_content: None,
|
||||
}]))),
|
||||
capabilities: ProviderCapabilities::default(),
|
||||
};
|
||||
let observer = NoopObserver;
|
||||
let workspace = TempDir::new().expect("temp workspace should be created");
|
||||
let mut cost_config = crate::config::CostConfig {
|
||||
enabled: true,
|
||||
..crate::config::CostConfig::default()
|
||||
};
|
||||
cost_config.prices = HashMap::from([(
|
||||
"mock-provider/mock-model".to_string(),
|
||||
ModelPricing {
|
||||
input: 2.0,
|
||||
output: 4.0,
|
||||
},
|
||||
)]);
|
||||
let tracker = Arc::new(
|
||||
CostTracker::new(cost_config.clone(), workspace.path())
|
||||
.expect("cost tracker should initialize"),
|
||||
);
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("hello"),
|
||||
];
|
||||
|
||||
let result = run_tool_call_loop_with_non_cli_approval_context(
|
||||
&provider,
|
||||
&mut history,
|
||||
&[],
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"telegram",
|
||||
None,
|
||||
Some(ToolLoopCostTrackingContext::new(
|
||||
Arc::clone(&tracker),
|
||||
Arc::new(cost_config.prices.clone()),
|
||||
)),
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
2,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should succeed");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
|
||||
let summary = tracker
|
||||
.get_summary()
|
||||
.expect("cost summary should be readable");
|
||||
assert_eq!(summary.request_count, 1);
|
||||
assert_eq!(summary.total_tokens, 1_500);
|
||||
assert!(summary.session_cost_usd > 0.0);
|
||||
assert_eq!(
|
||||
summary
|
||||
.by_model
|
||||
.get("mock-model")
|
||||
.expect("model stats should exist")
|
||||
.total_tokens,
|
||||
1_500
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_consumes_one_time_non_cli_allow_all_token() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
|
||||
@ -72,9 +72,12 @@ pub use whatsapp_web::WhatsAppWebChannel;
|
||||
use crate::agent::loop_::{
|
||||
build_shell_policy_instructions, build_tool_instructions_from_specs,
|
||||
run_tool_call_loop_with_non_cli_approval_context, scrub_credentials, NonCliApprovalContext,
|
||||
ToolLoopCostTrackingContext,
|
||||
};
|
||||
use crate::approval::{ApprovalManager, ApprovalResponse, PendingApprovalError};
|
||||
use crate::config::schema::ModelPricing;
|
||||
use crate::config::{Config, NonCliNaturalLanguageApprovalMode};
|
||||
use crate::cost::CostTracker;
|
||||
use crate::identity;
|
||||
use crate::memory::{self, Memory};
|
||||
use crate::observability::{self, runtime_trace, Observer};
|
||||
@ -207,6 +210,12 @@ struct RuntimeConfigState {
|
||||
last_applied_stamp: Option<ConfigFileStamp>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChannelCostTrackingState {
|
||||
tracker: Arc<CostTracker>,
|
||||
prices: Arc<HashMap<String, ModelPricing>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RuntimeAutonomyPolicy {
|
||||
auto_approve: Vec<String>,
|
||||
@ -223,6 +232,11 @@ fn runtime_config_store() -> &'static Mutex<HashMap<PathBuf, RuntimeConfigState>
|
||||
STORE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn channel_cost_tracking_store() -> &'static Mutex<Option<ChannelCostTrackingState>> {
|
||||
static STORE: OnceLock<Mutex<Option<ChannelCostTrackingState>>> = OnceLock::new();
|
||||
STORE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
const SYSTEMD_STATUS_ARGS: [&str; 3] = ["--user", "is-active", "zeroclaw.service"];
|
||||
const SYSTEMD_RESTART_ARGS: [&str; 3] = ["--user", "restart", "zeroclaw.service"];
|
||||
const OPENRC_STATUS_ARGS: [&str; 2] = ["zeroclaw", "status"];
|
||||
@ -3309,6 +3323,14 @@ semantic_match={:.2} (threshold {:.2}), category={}.",
|
||||
sender: msg.sender.clone(),
|
||||
message_id: msg.id.clone(),
|
||||
};
|
||||
let cost_tracking_context = {
|
||||
let store = channel_cost_tracking_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
store
|
||||
.clone()
|
||||
.map(|state| ToolLoopCostTrackingContext::new(state.tracker, state.prices))
|
||||
};
|
||||
let llm_result = tokio::select! {
|
||||
() = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
|
||||
result = tokio::time::timeout(
|
||||
@ -3327,6 +3349,7 @@ semantic_match={:.2} (threshold {:.2}), category={}.",
|
||||
Some(ctx.approval_manager.as_ref()),
|
||||
msg.channel.as_str(),
|
||||
non_cli_approval_context,
|
||||
cost_tracking_context,
|
||||
&ctx.multimodal,
|
||||
ctx.max_tool_iterations,
|
||||
Some(cancellation_token.clone()),
|
||||
@ -5071,6 +5094,26 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
.telegram
|
||||
.as_ref()
|
||||
.is_some_and(|tg| tg.interrupt_on_new_message);
|
||||
let cost_tracking_state = if config.cost.enabled {
|
||||
match CostTracker::new(config.cost.clone(), &config.workspace_dir) {
|
||||
Ok(tracker) => Some(ChannelCostTrackingState {
|
||||
tracker: Arc::new(tracker),
|
||||
prices: Arc::new(config.cost.prices.clone()),
|
||||
}),
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to initialize channel cost tracker: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut store = channel_cost_tracking_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*store = cost_tracking_state;
|
||||
}
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name,
|
||||
@ -5120,6 +5163,13 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
{
|
||||
let mut store = channel_cost_tracking_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*store = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -9687,7 +9737,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.get("test-channel_alice")
|
||||
.expect("history should be stored for sender");
|
||||
assert_eq!(turns[0].role, "user");
|
||||
assert_eq!(turns[0].content, "hello");
|
||||
assert!(turns[0].content.ends_with("hello"));
|
||||
assert!(!turns[0].content.contains("[Memory context]"));
|
||||
}
|
||||
|
||||
@ -10511,7 +10561,7 @@ BTC is currently around $65,000 based on latest tool output."#;
|
||||
.expect("history should exist for sender");
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].role, "user");
|
||||
assert_eq!(turns[0].content, "What is WAL?");
|
||||
assert!(turns[0].content.ends_with("What is WAL?"));
|
||||
assert_eq!(turns[1].role, "assistant");
|
||||
assert_eq!(turns[1].content, "ok");
|
||||
assert!(
|
||||
|
||||
@ -1555,13 +1555,6 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
chat_id.clone()
|
||||
};
|
||||
|
||||
// Check mention_only for group messages
|
||||
// Voice messages cannot contain mentions, so skip in group chats when mention_only is set
|
||||
let is_group = Self::is_group_message(message);
|
||||
if self.mention_only && is_group {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Download and transcribe
|
||||
let file_path = match self.get_file_path(&metadata.file_id).await {
|
||||
Ok(p) => p,
|
||||
@ -3233,6 +3226,8 @@ Ensure only one `zeroclaw` process is using this bot token."
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_file(src: &Path, dst: &Path) {
|
||||
@ -5045,6 +5040,83 @@ mod tests {
|
||||
assert!(ch.voice_transcriptions.lock().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_parse_voice_message_allows_group_sender_override_and_transcribes() {
|
||||
let telegram_api = MockServer::start().await;
|
||||
let transcription_api = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/bottoken/getFile"))
|
||||
.and(query_param("file_id", "voice_file"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"ok": true,
|
||||
"result": { "file_path": "voice/file_without_ext" }
|
||||
})))
|
||||
.mount(&telegram_api)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/file/bottoken/voice/file_without_ext"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(vec![0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x03, 0x04]),
|
||||
)
|
||||
.mount(&telegram_api)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/transcribe"))
|
||||
.and(header("authorization", "Bearer test-groq-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"text": "hello from telegram voice"
|
||||
})))
|
||||
.mount(&transcription_api)
|
||||
.await;
|
||||
|
||||
let previous_api_key = std::env::var("GROQ_API_KEY").ok();
|
||||
std::env::set_var("GROQ_API_KEY", "test-groq-key");
|
||||
|
||||
let mut tc = crate::config::TranscriptionConfig::default();
|
||||
tc.enabled = true;
|
||||
tc.api_url = format!("{}/transcribe", transcription_api.uri());
|
||||
|
||||
let ch = TelegramChannel::new("token".into(), vec!["555".into()], true)
|
||||
.with_group_reply_allowed_senders(vec!["555".into()])
|
||||
.with_api_base(telegram_api.uri())
|
||||
.with_transcription(tc);
|
||||
let update = serde_json::json!({
|
||||
"message": {
|
||||
"message_id": 42,
|
||||
"voice": {
|
||||
"file_id": "voice_file",
|
||||
"duration": 4,
|
||||
"mime_type": "audio/ogg"
|
||||
},
|
||||
"from": { "id": 555, "username": "alice" },
|
||||
"chat": { "id": -100123, "type": "supergroup" }
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = ch
|
||||
.try_parse_voice_message(&update)
|
||||
.await
|
||||
.expect("voice message should be transcribed for configured sender override");
|
||||
|
||||
match previous_api_key {
|
||||
Some(value) => std::env::set_var("GROQ_API_KEY", value),
|
||||
None => std::env::remove_var("GROQ_API_KEY"),
|
||||
}
|
||||
|
||||
assert_eq!(parsed.reply_target, "-100123");
|
||||
assert_eq!(parsed.sender, "alice");
|
||||
assert_eq!(parsed.content, "[Voice] hello from telegram voice");
|
||||
assert_eq!(
|
||||
ch.voice_transcriptions
|
||||
.lock()
|
||||
.get("-100123:42")
|
||||
.cloned()
|
||||
.as_deref(),
|
||||
Some("hello from telegram voice")
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Live e2e: voice transcription via Groq Whisper + reply cache lookup
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -5858,6 +5858,16 @@ impl Config {
|
||||
&mut config.browser.computer_use.api_key,
|
||||
"config.browser.computer_use.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.web_fetch.api_key,
|
||||
"config.web_fetch.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.web_search.api_key,
|
||||
"config.web_search.api_key",
|
||||
)?;
|
||||
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
@ -5889,6 +5899,20 @@ impl Config {
|
||||
for agent in config.agents.values_mut() {
|
||||
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||||
}
|
||||
for route in &mut config.model_routes {
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut route.api_key,
|
||||
"config.model_routes.*.api_key",
|
||||
)?;
|
||||
}
|
||||
for route in &mut config.embedding_routes {
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut route.api_key,
|
||||
"config.embedding_routes.*.api_key",
|
||||
)?;
|
||||
}
|
||||
|
||||
decrypt_channel_secrets(&store, &mut config.channels_config)?;
|
||||
resolve_telegram_allowed_users_env_refs(&mut config.channels_config)?;
|
||||
@ -6727,6 +6751,16 @@ impl Config {
|
||||
&mut config_to_save.browser.computer_use.api_key,
|
||||
"config.browser.computer_use.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.web_fetch.api_key,
|
||||
"config.web_fetch.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.web_search.api_key,
|
||||
"config.web_search.api_key",
|
||||
)?;
|
||||
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
@ -6758,6 +6792,16 @@ impl Config {
|
||||
for agent in config_to_save.agents.values_mut() {
|
||||
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||||
}
|
||||
for route in &mut config_to_save.model_routes {
|
||||
encrypt_optional_secret(&store, &mut route.api_key, "config.model_routes.*.api_key")?;
|
||||
}
|
||||
for route in &mut config_to_save.embedding_routes {
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut route.api_key,
|
||||
"config.embedding_routes.*.api_key",
|
||||
)?;
|
||||
}
|
||||
|
||||
encrypt_channel_secrets(&store, &mut config_to_save.channels_config)?;
|
||||
|
||||
@ -7747,6 +7791,8 @@ tool_dispatcher = "xml"
|
||||
config.proxy.https_proxy = Some("https://user:pass@proxy.internal:8443".into());
|
||||
config.proxy.all_proxy = Some("socks5://user:pass@proxy.internal:1080".into());
|
||||
config.browser.computer_use.api_key = Some("browser-credential".into());
|
||||
config.web_fetch.api_key = Some("web-fetch-credential".into());
|
||||
config.web_search.api_key = Some("web-search-credential".into());
|
||||
config.web_search.brave_api_key = Some("brave-credential".into());
|
||||
config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
|
||||
config.reliability.api_keys = vec!["backup-credential".into()];
|
||||
@ -7754,6 +7800,20 @@ tool_dispatcher = "xml"
|
||||
"custom:https://api-a.example.com/v1".into(),
|
||||
"fallback-a-credential".into(),
|
||||
);
|
||||
config.model_routes = vec![ModelRouteConfig {
|
||||
hint: "reasoning".into(),
|
||||
provider: "openrouter".into(),
|
||||
model: "anthropic/claude-sonnet-4".into(),
|
||||
max_tokens: None,
|
||||
api_key: Some("route-credential".into()),
|
||||
}];
|
||||
config.embedding_routes = vec![EmbeddingRouteConfig {
|
||||
hint: "semantic".into(),
|
||||
provider: "openai".into(),
|
||||
model: "text-embedding-3-small".into(),
|
||||
dimensions: Some(1536),
|
||||
api_key: Some("embedding-credential".into()),
|
||||
}];
|
||||
config.gateway.paired_tokens = vec!["zc_0123456789abcdef".into()];
|
||||
config.channels_config.telegram = Some(TelegramConfig {
|
||||
bot_token: "telegram-credential".into(),
|
||||
@ -7835,6 +7895,22 @@ tool_dispatcher = "xml"
|
||||
store.decrypt(browser_encrypted).unwrap(),
|
||||
"browser-credential"
|
||||
);
|
||||
let web_fetch_encrypted = stored.web_fetch.api_key.as_deref().unwrap();
|
||||
assert!(crate::security::SecretStore::is_encrypted(
|
||||
web_fetch_encrypted
|
||||
));
|
||||
assert_eq!(
|
||||
store.decrypt(web_fetch_encrypted).unwrap(),
|
||||
"web-fetch-credential"
|
||||
);
|
||||
let web_search_api_encrypted = stored.web_search.api_key.as_deref().unwrap();
|
||||
assert!(crate::security::SecretStore::is_encrypted(
|
||||
web_search_api_encrypted
|
||||
));
|
||||
assert_eq!(
|
||||
store.decrypt(web_search_api_encrypted).unwrap(),
|
||||
"web-search-credential"
|
||||
);
|
||||
|
||||
let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
|
||||
assert!(crate::security::SecretStore::is_encrypted(
|
||||
@ -7870,6 +7946,15 @@ tool_dispatcher = "xml"
|
||||
store.decrypt(fallback_key).unwrap(),
|
||||
"fallback-a-credential"
|
||||
);
|
||||
let routed_key = stored.model_routes[0].api_key.as_deref().unwrap();
|
||||
assert!(crate::security::SecretStore::is_encrypted(routed_key));
|
||||
assert_eq!(store.decrypt(routed_key).unwrap(), "route-credential");
|
||||
let embedding_key = stored.embedding_routes[0].api_key.as_deref().unwrap();
|
||||
assert!(crate::security::SecretStore::is_encrypted(embedding_key));
|
||||
assert_eq!(
|
||||
store.decrypt(embedding_key).unwrap(),
|
||||
"embedding-credential"
|
||||
);
|
||||
|
||||
let paired_token = &stored.gateway.paired_tokens[0];
|
||||
assert!(crate::security::SecretStore::is_encrypted(paired_token));
|
||||
|
||||
@ -1096,6 +1096,12 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi
|
||||
for agent in masked.agents.values_mut() {
|
||||
mask_optional_secret(&mut agent.api_key);
|
||||
}
|
||||
for route in &mut masked.model_routes {
|
||||
mask_optional_secret(&mut route.api_key);
|
||||
}
|
||||
for route in &mut masked.embedding_routes {
|
||||
mask_optional_secret(&mut route.api_key);
|
||||
}
|
||||
|
||||
if let Some(telegram) = masked.channels_config.telegram.as_mut() {
|
||||
mask_required_secret(&mut telegram.bot_token);
|
||||
@ -1214,6 +1220,20 @@ fn restore_masked_sensitive_fields(
|
||||
restore_optional_secret(&mut agent.api_key, ¤t_agent.api_key);
|
||||
}
|
||||
}
|
||||
for (incoming_route, current_route) in incoming
|
||||
.model_routes
|
||||
.iter_mut()
|
||||
.zip(current.model_routes.iter())
|
||||
{
|
||||
restore_optional_secret(&mut incoming_route.api_key, ¤t_route.api_key);
|
||||
}
|
||||
for (incoming_route, current_route) in incoming
|
||||
.embedding_routes
|
||||
.iter_mut()
|
||||
.zip(current.embedding_routes.iter())
|
||||
{
|
||||
restore_optional_secret(&mut incoming_route.api_key, ¤t_route.api_key);
|
||||
}
|
||||
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.telegram.as_mut(),
|
||||
@ -1393,11 +1413,27 @@ mod tests {
|
||||
current.workspace_dir = std::path::PathBuf::from("/tmp/current/workspace");
|
||||
current.api_key = Some("real-key".to_string());
|
||||
current.reliability.api_keys = vec!["r1".to_string(), "r2".to_string()];
|
||||
current.model_routes = vec![crate::config::ModelRouteConfig {
|
||||
hint: "reasoning".to_string(),
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-sonnet-4".to_string(),
|
||||
max_tokens: None,
|
||||
api_key: Some("route-key".to_string()),
|
||||
}];
|
||||
current.embedding_routes = vec![crate::config::EmbeddingRouteConfig {
|
||||
hint: "semantic".to_string(),
|
||||
provider: "openai".to_string(),
|
||||
model: "text-embedding-3-small".to_string(),
|
||||
dimensions: None,
|
||||
api_key: Some("embedding-key".to_string()),
|
||||
}];
|
||||
|
||||
let mut incoming = mask_sensitive_fields(¤t);
|
||||
incoming.default_model = Some("gpt-4.1-mini".to_string());
|
||||
// Simulate UI changing only one key and keeping the first masked.
|
||||
incoming.reliability.api_keys = vec![MASKED_SECRET.to_string(), "r2-new".to_string()];
|
||||
incoming.model_routes[0].api_key = Some(MASKED_SECRET.to_string());
|
||||
incoming.embedding_routes[0].api_key = Some(MASKED_SECRET.to_string());
|
||||
|
||||
let hydrated = hydrate_config_for_save(incoming, ¤t);
|
||||
|
||||
@ -1409,6 +1445,14 @@ mod tests {
|
||||
hydrated.reliability.api_keys,
|
||||
vec!["r1".to_string(), "r2-new".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
hydrated.model_routes[0].api_key.as_deref(),
|
||||
Some("route-key")
|
||||
);
|
||||
assert_eq!(
|
||||
hydrated.embedding_routes[0].api_key.as_deref(),
|
||||
Some("embedding-key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1518,6 +1562,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_sensitive_fields_masks_route_api_keys() {
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.model_routes = vec![crate::config::ModelRouteConfig {
|
||||
hint: "reasoning".to_string(),
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-sonnet-4".to_string(),
|
||||
max_tokens: None,
|
||||
api_key: Some("route-real-key".to_string()),
|
||||
}];
|
||||
cfg.embedding_routes = vec![crate::config::EmbeddingRouteConfig {
|
||||
hint: "semantic".to_string(),
|
||||
provider: "openai".to_string(),
|
||||
model: "text-embedding-3-small".to_string(),
|
||||
dimensions: None,
|
||||
api_key: Some("embedding-real-key".to_string()),
|
||||
}];
|
||||
|
||||
let masked = mask_sensitive_fields(&cfg);
|
||||
|
||||
assert_eq!(
|
||||
masked.model_routes[0].api_key.as_deref(),
|
||||
Some(MASKED_SECRET)
|
||||
);
|
||||
assert_eq!(
|
||||
masked.embedding_routes[0].api_key.as_deref(),
|
||||
Some(MASKED_SECRET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_config_for_save_restores_wati_email_and_feishu_secrets() {
|
||||
let mut current = crate::config::Config::default();
|
||||
|
||||
@ -31,6 +31,7 @@ use crate::peripherals::traits::Peripheral;
|
||||
use crate::tools::HardwareMemoryMapTool;
|
||||
use crate::tools::Tool;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// List configured boards from config (no connection yet).
|
||||
pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> {
|
||||
@ -137,20 +138,20 @@ pub async fn handle_command(cmd: crate::PeripheralCommands, config: &Config) ->
|
||||
/// Create and connect peripherals from config, returning their tools.
|
||||
/// Returns empty vec if peripherals disabled or hardware feature off.
|
||||
#[cfg(feature = "hardware")]
|
||||
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
|
||||
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Arc<dyn Tool>>> {
|
||||
if !config.enabled || config.boards.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
|
||||
let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = Vec::new();
|
||||
|
||||
for board in &config.boards {
|
||||
// Arduino Uno Q: Bridge transport (socket to local Bridge app)
|
||||
if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q")
|
||||
{
|
||||
tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool));
|
||||
tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool));
|
||||
tools.push(Arc::new(uno_q_bridge::UnoQGpioReadTool));
|
||||
tools.push(Arc::new(uno_q_bridge::UnoQGpioWriteTool));
|
||||
tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added");
|
||||
continue;
|
||||
}
|
||||
@ -191,7 +192,7 @@ pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<B
|
||||
tools.extend(p.tools());
|
||||
if board.board == "arduino-uno" {
|
||||
if let Some(ref path) = board.path {
|
||||
tools.push(Box::new(arduino_upload::ArduinoUploadTool::new(
|
||||
tools.push(Arc::new(arduino_upload::ArduinoUploadTool::new(
|
||||
path.clone(),
|
||||
)));
|
||||
tracing::info!("Arduino upload tool added (port: {})", path);
|
||||
@ -208,18 +209,18 @@ pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<B
|
||||
// Phase B: Add hardware tools when any boards configured
|
||||
if !tools.is_empty() {
|
||||
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
|
||||
tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
|
||||
tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new(
|
||||
tools.push(Arc::new(HardwareMemoryMapTool::new(board_names.clone())));
|
||||
tools.push(Arc::new(crate::tools::HardwareBoardInfoTool::new(
|
||||
board_names.clone(),
|
||||
)));
|
||||
tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new(
|
||||
tools.push(Arc::new(crate::tools::HardwareMemoryReadTool::new(
|
||||
board_names,
|
||||
)));
|
||||
}
|
||||
|
||||
// Phase C: Add hardware_capabilities tool when any serial boards
|
||||
if !serial_transports.is_empty() {
|
||||
tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new(
|
||||
tools.push(Arc::new(capabilities_tool::HardwareCapabilitiesTool::new(
|
||||
serial_transports,
|
||||
)));
|
||||
}
|
||||
@ -229,7 +230,7 @@ pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<B
|
||||
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
|
||||
pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Arc<dyn Tool>>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
|
||||
@ -116,7 +116,6 @@ impl OpenAiCodexProvider {
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn default_zeroclaw_dir() -> PathBuf {
|
||||
|
||||
@ -518,6 +518,12 @@ pub fn build_tool_instructions_text(tools: &[ToolSpec]) -> String {
|
||||
instructions.push_str("<tool_call>\n");
|
||||
instructions.push_str(r#"{"name": "tool_name", "arguments": {"param": "value"}}"#);
|
||||
instructions.push_str("\n</tool_call>\n\n");
|
||||
instructions.push_str(
|
||||
"If you would otherwise say an action is only available in a code agent or OpenClaw, do not refuse. ",
|
||||
);
|
||||
instructions.push_str(
|
||||
"This runtime is the tool environment, so emit the matching <tool_call> payload instead.\n\n",
|
||||
);
|
||||
instructions.push_str("You may use multiple tool calls in a single response. ");
|
||||
instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
|
||||
instructions
|
||||
@ -750,6 +756,7 @@ mod tests {
|
||||
assert!(instructions.contains("Tool Use Protocol"));
|
||||
assert!(instructions.contains("<tool_call>"));
|
||||
assert!(instructions.contains("</tool_call>"));
|
||||
assert!(instructions.contains("only available in a code agent or OpenClaw"));
|
||||
|
||||
// Check for tool listings
|
||||
assert!(instructions.contains("**shell**"));
|
||||
|
||||
@ -184,7 +184,7 @@ impl Tool for CronAddTool {
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
cron::add_shell_job(&self.config, name, schedule, command)
|
||||
cron::add_shell_job_with_approval(&self.config, name, schedule, command, approved)
|
||||
}
|
||||
JobType::Agent => {
|
||||
let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
|
||||
|
||||
@ -166,10 +166,13 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let mut writable_config = config.clone();
|
||||
writable_config.autonomy.level = AutonomyLevel::Full;
|
||||
let writable_cfg = Arc::new(writable_config);
|
||||
let job = cron::add_job(&writable_cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
|
||||
|
||||
@ -211,10 +211,13 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let mut writable_config = config.clone();
|
||||
writable_config.autonomy.level = AutonomyLevel::Full;
|
||||
let writable_cfg = Arc::new(writable_config);
|
||||
let job = cron::add_job(&writable_cfg, "*/5 * * * *", "echo run-now").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo run-now").unwrap();
|
||||
let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
|
||||
@ -234,7 +237,8 @@ mod tests {
|
||||
config.autonomy.allowed_commands = vec!["touch".into()];
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "touch cron-run-approval").unwrap();
|
||||
let job =
|
||||
cron::add_job_approved(&cfg, "*/5 * * * *", "touch cron-run-approval", true).unwrap();
|
||||
let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let denied = tool.execute(json!({ "job_id": job.id })).await.unwrap();
|
||||
|
||||
@ -133,7 +133,13 @@ impl Tool for CronUpdateTool {
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
match cron::update_job(&self.config, job_id, patch) {
|
||||
let update_result = if patch.command.is_some() {
|
||||
cron::update_shell_job_with_approval(&self.config, job_id, patch, approved)
|
||||
} else {
|
||||
cron::update_job(&self.config, job_id, patch)
|
||||
};
|
||||
|
||||
match update_result {
|
||||
Ok(job) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&job)?,
|
||||
@ -228,10 +234,13 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let mut writable_config = config.clone();
|
||||
writable_config.autonomy.level = AutonomyLevel::Full;
|
||||
let writable_cfg = Arc::new(writable_config);
|
||||
let job = cron::add_job(&writable_cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool
|
||||
|
||||
@ -6,6 +6,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::security::policy::ToolOperation;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::tools::SharedToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@ -36,7 +37,7 @@ pub struct DelegateTool {
|
||||
/// Depth at which this tool instance lives in the delegation chain.
|
||||
depth: u32,
|
||||
/// Parent tool registry for agentic sub-agents.
|
||||
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
|
||||
parent_tools: SharedToolRegistry,
|
||||
/// Inherited multimodal handling config for sub-agent loops.
|
||||
multimodal_config: crate::config::MultimodalConfig,
|
||||
/// Optional typed coordination bus used to trace delegate lifecycle events.
|
||||
@ -72,7 +73,7 @@ impl DelegateTool {
|
||||
fallback_credential,
|
||||
provider_runtime_options,
|
||||
depth: 0,
|
||||
parent_tools: Arc::new(Vec::new()),
|
||||
parent_tools: crate::tools::new_shared_tool_registry(),
|
||||
multimodal_config: crate::config::MultimodalConfig::default(),
|
||||
coordination_bus,
|
||||
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
|
||||
@ -111,7 +112,7 @@ impl DelegateTool {
|
||||
fallback_credential,
|
||||
provider_runtime_options,
|
||||
depth,
|
||||
parent_tools: Arc::new(Vec::new()),
|
||||
parent_tools: crate::tools::new_shared_tool_registry(),
|
||||
multimodal_config: crate::config::MultimodalConfig::default(),
|
||||
coordination_bus,
|
||||
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
|
||||
@ -119,7 +120,7 @@ impl DelegateTool {
|
||||
}
|
||||
|
||||
/// Attach parent tools used to build sub-agent allowlist registries.
|
||||
pub fn with_parent_tools(mut self, parent_tools: Arc<Vec<Arc<dyn Tool>>>) -> Self {
|
||||
pub fn with_parent_tools(mut self, parent_tools: SharedToolRegistry) -> Self {
|
||||
self.parent_tools = parent_tools;
|
||||
self
|
||||
}
|
||||
@ -462,11 +463,20 @@ impl DelegateTool {
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
|
||||
let sub_tools: Vec<Box<dyn Tool>> = self
|
||||
let parent_tools = self
|
||||
.parent_tools
|
||||
.lock()
|
||||
.map(|tools| tools.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let sub_tools: Vec<Box<dyn Tool>> = parent_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed.contains(tool.name()))
|
||||
.filter(|tool| tool.name() != "delegate")
|
||||
.filter(|tool| {
|
||||
tool.name() != "delegate"
|
||||
&& tool.name() != "subagent_spawn"
|
||||
&& tool.name() != "subagent_manage"
|
||||
})
|
||||
.map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
|
||||
.collect();
|
||||
|
||||
@ -967,6 +977,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_parent_tools(tools: Vec<Arc<dyn Tool>>) -> SharedToolRegistry {
|
||||
let shared = crate::tools::new_shared_tool_registry();
|
||||
crate::tools::sync_shared_tool_registry(&shared, &tools);
|
||||
shared
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
@ -1278,7 +1294,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let tool = DelegateTool::new(agents, None, test_security())
|
||||
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
|
||||
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
|
||||
let result = tool
|
||||
.execute(json!({"agent": "agentic", "prompt": "test"}))
|
||||
.await
|
||||
@ -1296,7 +1312,7 @@ mod tests {
|
||||
async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
|
||||
let config = agentic_config(vec!["echo_tool".to_string()], 10);
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
|
||||
Arc::new(vec![
|
||||
shared_parent_tools(vec![
|
||||
Arc::new(EchoTool),
|
||||
Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
|
||||
]),
|
||||
@ -1313,11 +1329,33 @@ mod tests {
|
||||
assert!(result.output.contains("done"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_agentic_reads_late_bound_parent_tools() {
|
||||
let config = agentic_config(vec!["echo_tool".to_string()], 10);
|
||||
let parent_tools = crate::tools::new_shared_tool_registry();
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security())
|
||||
.with_parent_tools(parent_tools.clone());
|
||||
|
||||
crate::tools::sync_shared_tool_registry(
|
||||
&parent_tools,
|
||||
&[Arc::new(EchoTool) as Arc<dyn Tool>],
|
||||
);
|
||||
|
||||
let provider = OneToolThenFinalProvider;
|
||||
let result = tool
|
||||
.execute_agentic("agentic", &config, &provider, "run", 0.2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("done"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
|
||||
let config = agentic_config(vec!["delegate".to_string()], 10);
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
|
||||
Arc::new(vec![Arc::new(DelegateTool::new(
|
||||
shared_parent_tools(vec![Arc::new(DelegateTool::new(
|
||||
HashMap::new(),
|
||||
None,
|
||||
test_security(),
|
||||
@ -1342,7 +1380,7 @@ mod tests {
|
||||
async fn execute_agentic_respects_max_iterations() {
|
||||
let config = agentic_config(vec!["echo_tool".to_string()], 2);
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security())
|
||||
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
|
||||
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
|
||||
|
||||
let provider = InfiniteToolCallProvider;
|
||||
let result = tool
|
||||
@ -1362,7 +1400,7 @@ mod tests {
|
||||
async fn execute_agentic_propagates_provider_errors() {
|
||||
let config = agentic_config(vec!["echo_tool".to_string()], 10);
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security())
|
||||
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
|
||||
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
|
||||
|
||||
let provider = FailingProvider;
|
||||
let result = tool
|
||||
|
||||
@ -126,7 +126,7 @@ use crate::runtime::{NativeRuntime, RuntimeAdapter};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ArcDelegatingTool {
|
||||
@ -139,6 +139,21 @@ impl ArcDelegatingTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type SharedToolRegistry = Arc<Mutex<Vec<Arc<dyn Tool>>>>;
|
||||
|
||||
pub(crate) fn new_shared_tool_registry() -> SharedToolRegistry {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
pub(crate) fn sync_shared_tool_registry(
|
||||
shared_registry: &SharedToolRegistry,
|
||||
tools: &[Arc<dyn Tool>],
|
||||
) {
|
||||
if let Ok(mut guard) = shared_registry.lock() {
|
||||
*guard = tools.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ArcDelegatingTool {
|
||||
fn name(&self) -> &str {
|
||||
@ -158,7 +173,7 @@ impl Tool for ArcDelegatingTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
|
||||
pub(crate) fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
|
||||
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
|
||||
}
|
||||
|
||||
@ -244,6 +259,40 @@ pub fn all_tools_with_runtime(
|
||||
fallback_api_key: Option<&str>,
|
||||
root_config: &crate::config::Config,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
let (tool_arcs, _shared_registry) = all_tools_with_runtime_arcs(
|
||||
config,
|
||||
security,
|
||||
runtime,
|
||||
memory,
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
browser_config,
|
||||
http_config,
|
||||
web_fetch_config,
|
||||
workspace_dir,
|
||||
agents,
|
||||
fallback_api_key,
|
||||
root_config,
|
||||
);
|
||||
boxed_registry_from_arcs(tool_arcs)
|
||||
}
|
||||
|
||||
#[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
|
||||
pub(crate) fn all_tools_with_runtime_arcs(
|
||||
config: Arc<Config>,
|
||||
security: &Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
memory: Arc<dyn Memory>,
|
||||
composio_key: Option<&str>,
|
||||
composio_entity_id: Option<&str>,
|
||||
browser_config: &crate::config::BrowserConfig,
|
||||
http_config: &crate::config::HttpRequestConfig,
|
||||
web_fetch_config: &crate::config::WebFetchConfig,
|
||||
workspace_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
fallback_api_key: Option<&str>,
|
||||
root_config: &crate::config::Config,
|
||||
) -> (Vec<Arc<dyn Tool>>, Option<SharedToolRegistry>) {
|
||||
let has_shell_access = runtime.has_shell_access();
|
||||
let has_filesystem_access = runtime.has_filesystem_access();
|
||||
let zeroclaw_dir = root_config
|
||||
@ -417,6 +466,8 @@ pub fn all_tools_with_runtime(
|
||||
}
|
||||
|
||||
// Add delegation and sub-agent orchestration tools when agents are configured
|
||||
let mut shared_parent_tools = None;
|
||||
|
||||
if !agents.is_empty() {
|
||||
let delegate_agents: HashMap<String, DelegateAgentConfig> = agents
|
||||
.iter()
|
||||
@ -442,7 +493,8 @@ pub fn all_tools_with_runtime(
|
||||
max_tokens_override: None,
|
||||
model_support_vision: root_config.model_support_vision,
|
||||
};
|
||||
let parent_tools = Arc::new(tool_arcs.clone());
|
||||
let parent_tools = new_shared_tool_registry();
|
||||
shared_parent_tools = Some(parent_tools.clone());
|
||||
let mut delegate_tool = DelegateTool::new_with_options(
|
||||
delegate_agents.clone(),
|
||||
delegate_fallback_credential.clone(),
|
||||
@ -536,7 +588,11 @@ pub fn all_tools_with_runtime(
|
||||
}
|
||||
}
|
||||
|
||||
boxed_registry_from_arcs(tool_arcs)
|
||||
if let Some(shared_registry) = shared_parent_tools.as_ref() {
|
||||
sync_shared_tool_registry(shared_registry, &tool_arcs);
|
||||
}
|
||||
|
||||
(tool_arcs, shared_parent_tools)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -651,6 +707,7 @@ mod tests {
|
||||
allowed_users: vec!["*".into()],
|
||||
listen_to_bots: false,
|
||||
mention_only: false,
|
||||
group_reply: None,
|
||||
});
|
||||
|
||||
let tools = all_tools(
|
||||
|
||||
@ -11,6 +11,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::security::policy::ToolOperation;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::tools::SharedToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
@ -32,7 +33,7 @@ pub struct SubAgentSpawnTool {
|
||||
fallback_credential: Option<String>,
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions,
|
||||
registry: Arc<SubAgentRegistry>,
|
||||
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
|
||||
parent_tools: SharedToolRegistry,
|
||||
multimodal_config: crate::config::MultimodalConfig,
|
||||
}
|
||||
|
||||
@ -44,7 +45,7 @@ impl SubAgentSpawnTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions,
|
||||
registry: Arc<SubAgentRegistry>,
|
||||
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
|
||||
parent_tools: SharedToolRegistry,
|
||||
multimodal_config: crate::config::MultimodalConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
@ -395,7 +396,7 @@ async fn run_agentic_background(
|
||||
agent_config: &DelegateAgentConfig,
|
||||
provider: &dyn Provider,
|
||||
full_prompt: &str,
|
||||
parent_tools: &[Arc<dyn Tool>],
|
||||
parent_tools: &SharedToolRegistry,
|
||||
multimodal_config: &crate::config::MultimodalConfig,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
if agent_config.allowed_tools.is_empty() {
|
||||
@ -415,6 +416,11 @@ async fn run_agentic_background(
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
|
||||
let parent_tools = parent_tools
|
||||
.lock()
|
||||
.map(|tools| tools.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let sub_tools: Vec<Box<dyn Tool>> = parent_tools
|
||||
.iter()
|
||||
.filter(|tool| allowed.contains(tool.name()))
|
||||
@ -540,7 +546,7 @@ mod tests {
|
||||
security,
|
||||
providers::ProviderRuntimeOptions::default(),
|
||||
Arc::new(SubAgentRegistry::new()),
|
||||
Arc::new(Vec::new()),
|
||||
Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
crate::config::MultimodalConfig::default(),
|
||||
)
|
||||
}
|
||||
@ -705,7 +711,7 @@ mod tests {
|
||||
test_security(),
|
||||
providers::ProviderRuntimeOptions::default(),
|
||||
registry,
|
||||
Arc::new(Vec::new()),
|
||||
Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
crate::config::MultimodalConfig::default(),
|
||||
);
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
#![cfg(feature = "quota-tools-live")]
|
||||
|
||||
//! Live E2E tests for quota tools with real auth profiles.
|
||||
//!
|
||||
//! These tests require real auth-profiles.json at ~/.zeroclaw/auth-profiles.json
|
||||
|
||||
Loading…
Reference in New Issue
Block a user