merge: resolve conflicts with master, include channel-lark in RELEASE_CARGO_FEATURES
Add channel-lark (merged to master separately) to RELEASE_CARGO_FEATURES env var. Keep the DRY env-var approach and remove stale Docker build-args.
This commit is contained in:
commit
3ce7f2345e
@ -74,4 +74,4 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix,channel-lark,memory-postgres --target ${{ matrix.target }}
|
||||
|
||||
16
.github/workflows/pub-aur.yml
vendored
16
.github/workflows/pub-aur.yml
vendored
@ -134,15 +134,27 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set up SSH key — normalize line endings and ensure trailing newline
|
||||
mkdir -p ~/.ssh
|
||||
echo "$AUR_SSH_KEY" > ~/.ssh/aur
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "$AUR_SSH_KEY" | tr -d '\r' > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
cat >> ~/.ssh/config <<SSH_CONFIG
|
||||
|
||||
cat > ~/.ssh/config <<'SSH_CONFIG'
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur
|
||||
User aur
|
||||
StrictHostKeyChecking accept-new
|
||||
SSH_CONFIG
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
# Verify key is valid and print fingerprint for debugging
|
||||
echo "::group::SSH key diagnostics"
|
||||
ssh-keygen -l -f ~/.ssh/aur || { echo "::error::AUR_SSH_KEY is not a valid SSH private key"; exit 1; }
|
||||
echo "::endgroup::"
|
||||
|
||||
# Test SSH connectivity before attempting clone
|
||||
ssh -T -o BatchMode=yes -o ConnectTimeout=10 aur@aur.archlinux.org 2>&1 || true
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"
|
||||
|
||||
2
.github/workflows/release-beta-on-push.yml
vendored
2
.github/workflows/release-beta-on-push.yml
vendored
@ -16,7 +16,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
RELEASE_CARGO_FEATURES: channel-matrix,memory-postgres
|
||||
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
|
||||
|
||||
jobs:
|
||||
version:
|
||||
|
||||
2
.github/workflows/release-stable-manual.yml
vendored
2
.github/workflows/release-stable-manual.yml
vendored
@ -20,7 +20,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
RELEASE_CARGO_FEATURES: channel-matrix,memory-postgres
|
||||
RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -9164,7 +9164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-imap",
|
||||
|
||||
@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
authors = ["theonlyhennygod"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@ -12,7 +12,7 @@ RUN npm run build
|
||||
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@ -27,7 +27,7 @@ RUN npm run build
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
ARG ZEROCLAW_CARGO_FEATURES="memory-postgres"
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@ -16,6 +16,7 @@
|
||||
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
|
||||
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
|
||||
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
|
||||
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
|
||||
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
|
||||
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>
|
||||
|
||||
40
install.sh
40
install.sh
@ -448,46 +448,32 @@ bool_to_word() {
|
||||
fi
|
||||
}
|
||||
|
||||
guided_input_stream() {
|
||||
# Some constrained containers report interactive stdin (-t 0) but deny
|
||||
# opening /dev/stdin directly. Probe readability before selecting it.
|
||||
if [[ -t 0 ]] && (: </dev/stdin) 2>/dev/null; then
|
||||
echo "/dev/stdin"
|
||||
guided_open_input() {
|
||||
# Use stdin directly when it is an interactive terminal (e.g. SSH into LXC).
|
||||
# Subshell probing of /dev/stdin fails in some constrained containers even
|
||||
# when FD 0 is perfectly usable, so skip the probe and trust -t 0.
|
||||
if [[ -t 0 ]]; then
|
||||
GUIDED_FD=0
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -t 0 ]] && (: </proc/self/fd/0) 2>/dev/null; then
|
||||
echo "/proc/self/fd/0"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if (: </dev/tty) 2>/dev/null; then
|
||||
echo "/dev/tty"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
# Non-interactive stdin: try to open /dev/tty as an explicit fd.
|
||||
exec {GUIDED_FD}</dev/tty 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
guided_read() {
|
||||
local __target_var="$1"
|
||||
local __prompt="$2"
|
||||
local __silent="${3:-false}"
|
||||
local __input_source=""
|
||||
local __value=""
|
||||
|
||||
if ! __input_source="$(guided_input_stream)"; then
|
||||
return 1
|
||||
fi
|
||||
[[ -n "${GUIDED_FD:-}" ]] || guided_open_input || return 1
|
||||
|
||||
if [[ "$__silent" == true ]]; then
|
||||
if ! read -r -s -p "$__prompt" __value <"$__input_source"; then
|
||||
return 1
|
||||
fi
|
||||
read -r -s -u "$GUIDED_FD" -p "$__prompt" __value || return 1
|
||||
echo
|
||||
else
|
||||
if ! read -r -p "$__prompt" __value <"$__input_source"; then
|
||||
return 1
|
||||
fi
|
||||
read -r -u "$GUIDED_FD" -p "$__prompt" __value || return 1
|
||||
fi
|
||||
|
||||
printf -v "$__target_var" '%s' "$__value"
|
||||
@ -708,7 +694,7 @@ prompt_model() {
|
||||
run_guided_installer() {
|
||||
local os_name="$1"
|
||||
|
||||
if ! guided_input_stream >/dev/null; then
|
||||
if ! guided_open_input >/dev/null; then
|
||||
error "guided installer requires an interactive terminal."
|
||||
error "Run from a terminal, or pass --no-guided with explicit flags."
|
||||
exit 1
|
||||
|
||||
@ -8,7 +8,7 @@ use crate::providers::{
|
||||
self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
|
||||
};
|
||||
use crate::runtime;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use crate::tools::{self, Tool};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
@ -2181,8 +2181,10 @@ pub(crate) async fn agent_turn(
|
||||
temperature: f64,
|
||||
silent: bool,
|
||||
channel_name: &str,
|
||||
channel_reply_target: Option<&str>,
|
||||
multimodal_config: &crate::config::MultimodalConfig,
|
||||
max_tool_iterations: usize,
|
||||
approval: Option<&ApprovalManager>,
|
||||
excluded_tools: &[String],
|
||||
dedup_exempt_tools: &[String],
|
||||
activated_tools: Option<&std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
|
||||
@ -2197,8 +2199,9 @@ pub(crate) async fn agent_turn(
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
None,
|
||||
approval,
|
||||
channel_name,
|
||||
channel_reply_target,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
None,
|
||||
@ -2212,6 +2215,100 @@ pub(crate) async fn agent_turn(
|
||||
.await
|
||||
}
|
||||
|
||||
fn maybe_inject_channel_delivery_defaults(
|
||||
tool_name: &str,
|
||||
tool_args: &mut serde_json::Value,
|
||||
channel_name: &str,
|
||||
channel_reply_target: Option<&str>,
|
||||
) {
|
||||
if tool_name != "cron_add" {
|
||||
return;
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
channel_name,
|
||||
"telegram" | "discord" | "slack" | "mattermost" | "matrix"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(reply_target) = channel_reply_target
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(args) = tool_args.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let is_agent_job = args
|
||||
.get("job_type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent"))
|
||||
|| args
|
||||
.get("prompt")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|prompt| !prompt.trim().is_empty());
|
||||
if !is_agent_job {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_delivery = || {
|
||||
serde_json::json!({
|
||||
"mode": "announce",
|
||||
"channel": channel_name,
|
||||
"to": reply_target,
|
||||
})
|
||||
};
|
||||
|
||||
match args.get_mut("delivery") {
|
||||
None => {
|
||||
args.insert("delivery".to_string(), default_delivery());
|
||||
}
|
||||
Some(serde_json::Value::Null) => {
|
||||
*args.get_mut("delivery").expect("delivery key exists") = default_delivery();
|
||||
}
|
||||
Some(serde_json::Value::Object(delivery)) => {
|
||||
if delivery
|
||||
.get("mode")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|mode| mode.eq_ignore_ascii_case("none"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
delivery
|
||||
.entry("mode".to_string())
|
||||
.or_insert_with(|| serde_json::Value::String("announce".to_string()));
|
||||
|
||||
let needs_channel = delivery
|
||||
.get("channel")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_none_or(|value| value.trim().is_empty());
|
||||
if needs_channel {
|
||||
delivery.insert(
|
||||
"channel".to_string(),
|
||||
serde_json::Value::String(channel_name.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let needs_target = delivery
|
||||
.get("to")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_none_or(|value| value.trim().is_empty());
|
||||
if needs_target {
|
||||
delivery.insert(
|
||||
"to".to_string(),
|
||||
serde_json::Value::String(reply_target.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_one_tool(
|
||||
call_name: &str,
|
||||
call_arguments: serde_json::Value,
|
||||
@ -2405,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
silent: bool,
|
||||
approval: Option<&ApprovalManager>,
|
||||
channel_name: &str,
|
||||
channel_reply_target: Option<&str>,
|
||||
multimodal_config: &crate::config::MultimodalConfig,
|
||||
max_tool_iterations: usize,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
@ -2815,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
}
|
||||
|
||||
maybe_inject_channel_delivery_defaults(
|
||||
&tool_name,
|
||||
&mut tool_args,
|
||||
channel_name,
|
||||
channel_reply_target,
|
||||
);
|
||||
|
||||
// ── Approval hook ────────────────────────────────
|
||||
if let Some(mgr) = approval {
|
||||
if mgr.needs_approval(&tool_name) {
|
||||
@ -3466,6 +3571,7 @@ pub async fn run(
|
||||
bootstrap_max_chars,
|
||||
native_tools,
|
||||
config.skills.prompt_injection_mode,
|
||||
config.autonomy.level,
|
||||
);
|
||||
|
||||
// Append structured tool-use instructions with schemas (only for non-native providers)
|
||||
@ -3556,6 +3662,7 @@ pub async fn run(
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
None,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
@ -3782,6 +3889,7 @@ pub async fn run(
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
None,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
@ -3894,6 +4002,7 @@ pub async fn process_message(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy);
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
|
||||
&config.memory,
|
||||
&config.embedding_routes,
|
||||
@ -4085,6 +4194,16 @@ pub async fn process_message(
|
||||
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
|
||||
));
|
||||
}
|
||||
|
||||
// Filter out tools excluded for non-CLI channels (gateway counts as non-CLI).
|
||||
// Skip when autonomy is `Full` — full-autonomy agents keep all tools.
|
||||
if config.autonomy.level != AutonomyLevel::Full {
|
||||
let excluded = &config.autonomy.non_cli_excluded_tools;
|
||||
if !excluded.is_empty() {
|
||||
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
|
||||
}
|
||||
}
|
||||
|
||||
let bootstrap_max_chars = if config.agent.compact_context {
|
||||
Some(6000)
|
||||
} else {
|
||||
@ -4100,6 +4219,7 @@ pub async fn process_message(
|
||||
bootstrap_max_chars,
|
||||
native_tools,
|
||||
config.skills.prompt_injection_mode,
|
||||
config.autonomy.level,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs)));
|
||||
@ -4133,8 +4253,11 @@ pub async fn process_message(
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&enriched),
|
||||
];
|
||||
let excluded_tools =
|
||||
let mut excluded_tools =
|
||||
compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message);
|
||||
if config.autonomy.level != AutonomyLevel::Full {
|
||||
excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned());
|
||||
}
|
||||
|
||||
agent_turn(
|
||||
provider.as_ref(),
|
||||
@ -4146,8 +4269,10 @@ pub async fn process_message(
|
||||
config.default_temperature,
|
||||
true,
|
||||
"daemon",
|
||||
None,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
Some(&approval_manager),
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle_pm.as_ref(),
|
||||
@ -4463,6 +4588,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingArgsTool {
|
||||
name: String,
|
||||
recorded_args: Arc<Mutex<Vec<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingArgsTool {
|
||||
fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
recorded_args,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RecordingArgsTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Records tool arguments for regression tests"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string" },
|
||||
"schedule": { "type": "object" },
|
||||
"delivery": { "type": "object" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<crate::tools::ToolResult> {
|
||||
self.recorded_args
|
||||
.lock()
|
||||
.expect("recorded args lock should be valid")
|
||||
.push(args.clone());
|
||||
Ok(crate::tools::ToolResult {
|
||||
success: true,
|
||||
output: args.to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DelayTool {
|
||||
name: String,
|
||||
delay_ms: u64,
|
||||
@ -4601,6 +4777,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
3,
|
||||
None,
|
||||
@ -4650,6 +4827,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&multimodal,
|
||||
3,
|
||||
None,
|
||||
@ -4693,6 +4871,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
3,
|
||||
None,
|
||||
@ -4822,6 +5001,7 @@ mod tests {
|
||||
true,
|
||||
Some(&approval_mgr),
|
||||
"telegram",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -4859,6 +5039,122 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let recorded_args = Arc::new(Mutex::new(Vec::new()));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
|
||||
"cron_add",
|
||||
Arc::clone(&recorded_args),
|
||||
))];
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("schedule a reminder"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"telegram",
|
||||
Some("chat-42"),
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("cron_add delivery defaults should be injected");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
|
||||
let recorded = recorded_args
|
||||
.lock()
|
||||
.expect("recorded args lock should be valid");
|
||||
let delivery = recorded[0]["delivery"].clone();
|
||||
assert_eq!(
|
||||
delivery,
|
||||
serde_json::json!({
|
||||
"mode": "announce",
|
||||
"channel": "telegram",
|
||||
"to": "chat-42",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let recorded_args = Arc::new(Mutex::new(Vec::new()));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(RecordingArgsTool::new(
|
||||
"cron_add",
|
||||
Arc::clone(&recorded_args),
|
||||
))];
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("schedule a quiet cron job"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"telegram",
|
||||
Some("chat-42"),
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("explicit delivery mode should be preserved");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
|
||||
let recorded = recorded_args
|
||||
.lock()
|
||||
.expect("recorded args lock should be valid");
|
||||
assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
@ -4894,6 +5190,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -4962,6 +5259,7 @@ mod tests {
|
||||
true,
|
||||
Some(&approval_mgr),
|
||||
"telegram",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -5021,6 +5319,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -5100,6 +5399,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -5156,6 +5456,7 @@ mod tests {
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
@ -5228,8 +5529,10 @@ mod tests {
|
||||
0.0,
|
||||
true,
|
||||
"daemon",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
Some(&activated),
|
||||
@ -6672,6 +6975,7 @@ Let me check the result."#;
|
||||
None, // no bootstrap_max_chars
|
||||
true, // native_tools
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
crate::security::AutonomyLevel::default(),
|
||||
);
|
||||
|
||||
// Must contain zero XML protocol artifacts
|
||||
@ -7117,6 +7421,7 @@ Let me check the result."#;
|
||||
true,
|
||||
None,
|
||||
"telegram",
|
||||
None,
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
|
||||
@ -98,7 +98,7 @@ use crate::observability::traits::{ObserverEvent, ObserverMetric};
|
||||
use crate::observability::{self, runtime_trace, Observer};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::runtime;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use crate::tools::{self, Tool};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::{Context, Result};
|
||||
@ -328,6 +328,7 @@ struct ChannelRuntimeContext {
|
||||
multimodal: crate::config::MultimodalConfig,
|
||||
hooks: Option<Arc<crate::hooks::HookRunner>>,
|
||||
non_cli_excluded_tools: Arc<Vec<String>>,
|
||||
autonomy_level: AutonomyLevel,
|
||||
tool_call_dedup_exempt: Arc<Vec<String>>,
|
||||
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
|
||||
query_classification: crate::config::QueryClassificationConfig,
|
||||
@ -2239,12 +2240,15 @@ async fn process_channel_message(
|
||||
true,
|
||||
Some(&*ctx.approval_manager),
|
||||
msg.channel.as_str(),
|
||||
Some(msg.reply_target.as_str()),
|
||||
&ctx.multimodal,
|
||||
ctx.max_tool_iterations,
|
||||
Some(cancellation_token.clone()),
|
||||
delta_tx,
|
||||
ctx.hooks.as_deref(),
|
||||
if msg.channel == "cli" {
|
||||
if msg.channel == "cli"
|
||||
|| ctx.autonomy_level == AutonomyLevel::Full
|
||||
{
|
||||
&[]
|
||||
} else {
|
||||
ctx.non_cli_excluded_tools.as_ref()
|
||||
@ -2785,6 +2789,7 @@ pub fn build_system_prompt(
|
||||
bootstrap_max_chars,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
AutonomyLevel::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -2797,6 +2802,7 @@ pub fn build_system_prompt_with_mode(
|
||||
bootstrap_max_chars: Option<usize>,
|
||||
native_tools: bool,
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
|
||||
autonomy_level: AutonomyLevel,
|
||||
) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut prompt = String::with_capacity(8192);
|
||||
@ -2862,13 +2868,18 @@ pub fn build_system_prompt_with_mode(
|
||||
|
||||
// ── 2. Safety ───────────────────────────────────────────────
|
||||
prompt.push_str("## Safety\n\n");
|
||||
prompt.push_str(
|
||||
"- Do not exfiltrate private data.\n\
|
||||
- Do not run destructive commands without asking.\n\
|
||||
- Do not bypass oversight or approval mechanisms.\n\
|
||||
- Prefer `trash` over `rm` (recoverable beats gone forever).\n\
|
||||
- When in doubt, ask before acting externally.\n\n",
|
||||
);
|
||||
prompt.push_str("- Do not exfiltrate private data.\n");
|
||||
if autonomy_level != AutonomyLevel::Full {
|
||||
prompt.push_str(
|
||||
"- Do not run destructive commands without asking.\n\
|
||||
- Do not bypass oversight or approval mechanisms.\n",
|
||||
);
|
||||
}
|
||||
prompt.push_str("- Prefer `trash` over `rm` (recoverable beats gone forever).\n");
|
||||
if autonomy_level != AutonomyLevel::Full {
|
||||
prompt.push_str("- When in doubt, ask before acting externally.\n");
|
||||
}
|
||||
prompt.push('\n');
|
||||
|
||||
// ── 3. Skills (full or compact, based on config) ─────────────
|
||||
if !skills.is_empty() {
|
||||
@ -4006,8 +4017,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
|
||||
// Filter out tools excluded for non-CLI channels so the system prompt
|
||||
// does not advertise them for channel-driven runs.
|
||||
// Skip this filter when autonomy is `Full` — full-autonomy agents keep
|
||||
// all tools available regardless of channel.
|
||||
let excluded = &config.autonomy.non_cli_excluded_tools;
|
||||
if !excluded.is_empty() {
|
||||
if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full {
|
||||
tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
|
||||
}
|
||||
|
||||
@ -4026,6 +4039,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
bootstrap_max_chars,
|
||||
native_tools,
|
||||
config.skills.prompt_injection_mode,
|
||||
config.autonomy.level,
|
||||
);
|
||||
if !native_tools {
|
||||
system_prompt.push_str(&build_tool_instructions(
|
||||
@ -4186,6 +4200,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
None
|
||||
},
|
||||
non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
|
||||
autonomy_level: config.autonomy.level,
|
||||
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
|
||||
model_routes: Arc::new(config.model_routes.clone()),
|
||||
query_classification: config.query_classification.clone(),
|
||||
@ -4490,6 +4505,7 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -4599,6 +4615,7 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -4664,6 +4681,7 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -4748,6 +4766,7 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5280,6 +5299,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
slack: false,
|
||||
},
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
@ -5353,6 +5373,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
slack: false,
|
||||
},
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
@ -5442,6 +5463,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5514,6 +5536,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5596,6 +5619,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5699,6 +5723,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5783,6 +5808,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5882,6 +5908,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -5966,6 +5993,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6040,6 +6068,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6225,6 +6254,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6318,6 +6348,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6429,6 +6460,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
|
||||
@ -6531,6 +6563,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6618,6 +6651,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6690,6 +6724,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -6956,6 +6991,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
None,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Compact,
|
||||
AutonomyLevel::default(),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("<available_skills>"), "missing skills XML");
|
||||
@ -7078,6 +7114,65 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_autonomy_omits_approval_instructions() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt_with_mode(
|
||||
ws.path(),
|
||||
"model",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
AutonomyLevel::Full,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!prompt.contains("without asking"),
|
||||
"full autonomy prompt must not tell the model to ask before acting"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("ask before acting externally"),
|
||||
"full autonomy prompt must not contain ask-before-acting instruction"
|
||||
);
|
||||
// Core safety rules should still be present
|
||||
assert!(
|
||||
prompt.contains("Do not exfiltrate private data"),
|
||||
"data exfiltration guard must remain"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Prefer `trash` over `rm`"),
|
||||
"trash-over-rm hint must remain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervised_autonomy_includes_approval_instructions() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt_with_mode(
|
||||
ws.path(),
|
||||
"model",
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
AutonomyLevel::Supervised,
|
||||
);
|
||||
|
||||
assert!(
|
||||
prompt.contains("without asking"),
|
||||
"supervised prompt must include ask-before-acting instruction"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("ask before acting externally"),
|
||||
"supervised prompt must include ask-before-acting instruction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_notify_observer_truncates_utf8_arguments_safely() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
@ -7320,6 +7415,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -7418,6 +7514,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -7516,6 +7613,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -8078,6 +8176,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -8157,6 +8256,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
@ -8310,6 +8410,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(model_routes),
|
||||
query_classification: classification_config,
|
||||
@ -8413,6 +8514,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(model_routes),
|
||||
query_classification: classification_config,
|
||||
@ -8508,6 +8610,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(model_routes),
|
||||
query_classification: classification_config,
|
||||
@ -8623,6 +8726,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(model_routes),
|
||||
query_classification: classification_config,
|
||||
|
||||
@ -137,7 +137,12 @@ pub struct Config {
|
||||
pub cloud_ops: CloudOpsConfig,
|
||||
|
||||
/// Conversational AI agent builder configuration (`[conversational_ai]`).
|
||||
#[serde(default)]
|
||||
///
|
||||
/// Experimental / future feature — not yet wired into the agent runtime.
|
||||
/// Omitted from generated config files when disabled (the default).
|
||||
/// Existing configs that already contain this section will continue to
|
||||
/// deserialize correctly thanks to `#[serde(default)]`.
|
||||
#[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
|
||||
pub conversational_ai: ConversationalAiConfig,
|
||||
|
||||
/// Managed cybersecurity service configuration (`[security_ops]`).
|
||||
@ -4045,7 +4050,8 @@ pub struct ClassificationRule {
|
||||
pub struct HeartbeatConfig {
|
||||
/// Enable periodic heartbeat pings. Default: `false`.
|
||||
pub enabled: bool,
|
||||
/// Interval in minutes between heartbeat pings. Default: `30`.
|
||||
/// Interval in minutes between heartbeat pings. Default: `5`.
|
||||
#[serde(default = "default_heartbeat_interval")]
|
||||
pub interval_minutes: u32,
|
||||
/// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
|
||||
/// executes only when the LLM decides there is work to do. Saves API cost
|
||||
@ -4089,6 +4095,10 @@ pub struct HeartbeatConfig {
|
||||
pub max_run_history: u32,
|
||||
}
|
||||
|
||||
fn default_heartbeat_interval() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_two_phase() -> bool {
|
||||
true
|
||||
}
|
||||
@ -4109,7 +4119,7 @@ impl Default for HeartbeatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
interval_minutes: 30,
|
||||
interval_minutes: default_heartbeat_interval(),
|
||||
two_phase: true,
|
||||
message: None,
|
||||
target: None,
|
||||
@ -4133,6 +4143,15 @@ pub struct CronConfig {
|
||||
/// Enable the cron subsystem. Default: `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Run all overdue jobs at scheduler startup. Default: `true`.
|
||||
///
|
||||
/// When the machine boots late or the daemon restarts, jobs whose
|
||||
/// `next_run` is in the past are considered "missed". With this
|
||||
/// option enabled the scheduler fires them once before entering
|
||||
/// the normal polling loop. Disable if you prefer missed jobs to
|
||||
/// simply wait for their next scheduled occurrence.
|
||||
#[serde(default = "default_true")]
|
||||
pub catch_up_on_startup: bool,
|
||||
/// Maximum number of historical cron run records to retain. Default: `50`.
|
||||
#[serde(default = "default_max_run_history")]
|
||||
pub max_run_history: u32,
|
||||
@ -4146,6 +4165,7 @@ impl Default for CronConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
catch_up_on_startup: true,
|
||||
max_run_history: default_max_run_history(),
|
||||
}
|
||||
}
|
||||
@ -5872,8 +5892,8 @@ fn default_conversational_ai_timeout_secs() -> u64 {
|
||||
|
||||
/// Conversational AI agent builder configuration (`[conversational_ai]` section).
|
||||
///
|
||||
/// Controls language detection, escalation behavior, conversation limits, and
|
||||
/// analytics for conversational agent workflows. Disabled by default.
|
||||
/// **Status: Reserved for future use.** This configuration is parsed but not yet
|
||||
/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ConversationalAiConfig {
|
||||
/// Enable conversational AI features. Default: false.
|
||||
@ -5905,6 +5925,17 @@ pub struct ConversationalAiConfig {
|
||||
pub knowledge_base_tool: Option<String>,
|
||||
}
|
||||
|
||||
impl ConversationalAiConfig {
|
||||
/// Returns `true` when the feature is disabled (the default).
|
||||
///
|
||||
/// Used by `#[serde(skip_serializing_if)]` to omit the entire
|
||||
/// `[conversational_ai]` section from newly-generated config files,
|
||||
/// avoiding user confusion over an undocumented / experimental section.
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
!self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConversationalAiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@ -7728,6 +7759,13 @@ impl Config {
|
||||
}
|
||||
|
||||
set_runtime_proxy_config(self.proxy.clone());
|
||||
|
||||
if self.conversational_ai.enabled {
|
||||
tracing::warn!(
|
||||
"conversational_ai.enabled = true but conversational AI features are not yet \
|
||||
implemented; this section is reserved for future use and will be ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
|
||||
@ -8335,7 +8373,7 @@ mod tests {
|
||||
async fn heartbeat_config_default() {
|
||||
let h = HeartbeatConfig::default();
|
||||
assert!(!h.enabled);
|
||||
assert_eq!(h.interval_minutes, 30);
|
||||
assert_eq!(h.interval_minutes, 5);
|
||||
assert!(h.message.is_none());
|
||||
assert!(h.target.is_none());
|
||||
assert!(h.to.is_none());
|
||||
@ -8369,11 +8407,13 @@ recipient = "42"
|
||||
async fn cron_config_serde_roundtrip() {
|
||||
let c = CronConfig {
|
||||
enabled: false,
|
||||
catch_up_on_startup: false,
|
||||
max_run_history: 100,
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.enabled);
|
||||
assert!(!parsed.catch_up_on_startup);
|
||||
assert_eq!(parsed.max_run_history, 100);
|
||||
}
|
||||
|
||||
@ -8387,6 +8427,7 @@ default_temperature = 0.7
|
||||
|
||||
let parsed: Config = toml::from_str(toml_str).unwrap();
|
||||
assert!(parsed.cron.enabled);
|
||||
assert!(parsed.cron.catch_up_on_startup);
|
||||
assert_eq!(parsed.cron.max_run_history, 50);
|
||||
}
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ pub use schedule::{
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
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,
|
||||
add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run,
|
||||
record_run, remove_job, reschedule_after_run, update_job,
|
||||
};
|
||||
pub use types::{
|
||||
deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType,
|
||||
|
||||
@ -6,8 +6,9 @@ use crate::channels::{
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{
|
||||
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
|
||||
update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget,
|
||||
all_overdue_jobs, due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job,
|
||||
reschedule_after_run, update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule,
|
||||
SessionTarget,
|
||||
};
|
||||
use crate::security::SecurityPolicy;
|
||||
use anyhow::Result;
|
||||
@ -33,6 +34,18 @@ pub async fn run(config: Config) -> Result<()> {
|
||||
|
||||
crate::health::mark_component_ok(SCHEDULER_COMPONENT);
|
||||
|
||||
// ── Startup catch-up: run ALL overdue jobs before entering the
|
||||
// normal polling loop. The regular loop is capped by `max_tasks`,
|
||||
// which could leave some overdue jobs waiting across many cycles
|
||||
// if the machine was off for a while. The catch-up phase fetches
|
||||
// without the `max_tasks` limit so every missed job fires once.
|
||||
// Controlled by `[cron] catch_up_on_startup` (default: true).
|
||||
if config.cron.catch_up_on_startup {
|
||||
catch_up_overdue_jobs(&config, &security).await;
|
||||
} else {
|
||||
tracing::info!("Scheduler startup: catch-up disabled by config");
|
||||
}
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Keep scheduler liveness fresh even when there are no due jobs.
|
||||
@ -51,6 +64,35 @@ pub async fn run(config: Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch **all** overdue jobs (ignoring `max_tasks`) and execute them.
|
||||
///
|
||||
/// Called once at scheduler startup so that jobs missed during downtime
|
||||
/// (e.g. late boot, daemon restart) are caught up immediately.
|
||||
async fn catch_up_overdue_jobs(config: &Config, security: &Arc<SecurityPolicy>) {
|
||||
let now = Utc::now();
|
||||
let jobs = match all_overdue_jobs(config, now) {
|
||||
Ok(jobs) => jobs,
|
||||
Err(e) => {
|
||||
tracing::warn!("Startup catch-up query failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if jobs.is_empty() {
|
||||
tracing::info!("Scheduler startup: no overdue jobs to catch up");
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
count = jobs.len(),
|
||||
"Scheduler startup: catching up overdue jobs"
|
||||
);
|
||||
|
||||
process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await;
|
||||
|
||||
tracing::info!("Scheduler startup: catch-up complete");
|
||||
}
|
||||
|
||||
pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) {
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
Box::pin(execute_job_with_retry(config, &security, job)).await
|
||||
@ -506,18 +548,12 @@ async fn run_job_command_with_timeout(
|
||||
);
|
||||
}
|
||||
|
||||
let child = match Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(&job.command)
|
||||
.current_dir(&config.workspace_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => return (false, format!("spawn error: {e}")),
|
||||
let child = match build_cron_shell_command(&job.command, &config.workspace_dir) {
|
||||
Ok(mut cmd) => match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => return (false, format!("spawn error: {e}")),
|
||||
},
|
||||
Err(e) => return (false, format!("shell setup error: {e}")),
|
||||
};
|
||||
|
||||
match time::timeout(timeout, child.wait_with_output()).await {
|
||||
@ -540,6 +576,35 @@ async fn run_job_command_with_timeout(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a shell `Command` for cron job execution.
|
||||
///
|
||||
/// Uses `sh -c <command>` (non-login shell). On Windows, ZeroClaw users
|
||||
/// typically have Git Bash installed which provides `sh` in PATH, and
|
||||
/// cron commands are written with Unix shell syntax. The previous `-lc`
|
||||
/// (login shell) flag was dropped: login shells load the full user
|
||||
/// profile on every invocation which is slow and may cause side effects.
|
||||
///
|
||||
/// The command is configured with:
|
||||
/// - `current_dir` set to the workspace
|
||||
/// - `stdin` piped to `/dev/null` (no interactive input)
|
||||
/// - `stdout` and `stderr` piped for capture
|
||||
/// - `kill_on_drop(true)` for safe timeout handling
|
||||
fn build_cron_shell_command(
|
||||
command: &str,
|
||||
workspace_dir: &std::path::Path,
|
||||
) -> anyhow::Result<Command> {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(workspace_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -1152,4 +1217,50 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("matrix delivery channel requires `channel-matrix` feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cron_shell_command_uses_sh_non_login() {
|
||||
let workspace = std::env::temp_dir();
|
||||
let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap();
|
||||
let debug = format!("{cmd:?}");
|
||||
assert!(debug.contains("echo cron-test"));
|
||||
assert!(debug.contains("\"sh\""), "should use sh: {debug}");
|
||||
// Must NOT use login shell (-l) — login shells load full profile
|
||||
// and are slow/unpredictable for cron jobs.
|
||||
assert!(
|
||||
!debug.contains("\"-lc\""),
|
||||
"must not use login shell: {debug}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_cron_shell_command_executes_successfully() {
|
||||
let workspace = std::env::temp_dir();
|
||||
let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap();
|
||||
let output = cmd.output().await.unwrap();
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("cron-ok"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp).await;
|
||||
config.scheduler.max_tasks = 1; // limit normal polling to 1
|
||||
|
||||
// Create 3 jobs with "every minute" schedule
|
||||
for i in 0..3 {
|
||||
let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap();
|
||||
}
|
||||
|
||||
// Verify normal due_jobs is limited to max_tasks=1
|
||||
let far_future = Utc::now() + ChronoDuration::days(1);
|
||||
let due = cron::due_jobs(&config, far_future).unwrap();
|
||||
assert_eq!(due.len(), 1, "due_jobs must respect max_tasks");
|
||||
|
||||
// all_overdue_jobs ignores the limit
|
||||
let overdue = cron::all_overdue_jobs(&config, far_future).unwrap();
|
||||
assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all");
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,6 +188,34 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Return **all** enabled overdue jobs without the `max_tasks` limit.
|
||||
///
|
||||
/// Used by the scheduler startup catch-up to ensure every missed job is
|
||||
/// executed at least once after a period of downtime (late boot, daemon
|
||||
/// restart, etc.).
|
||||
pub fn all_overdue_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
|
||||
with_connection(config, |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
|
||||
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
|
||||
FROM cron_jobs
|
||||
WHERE enabled = 1 AND next_run <= ?1
|
||||
ORDER BY next_run ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![now.to_rfc3339()], map_cron_job_row)?;
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
for row in rows {
|
||||
match row {
|
||||
Ok(job) => jobs.push(job),
|
||||
Err(e) => tracing::warn!("Skipping cron job with unparseable row data: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(jobs)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<CronJob> {
|
||||
let mut job = get_job(config, job_id)?;
|
||||
let mut schedule_changed = false;
|
||||
@ -704,6 +732,46 @@ mod tests {
|
||||
assert_eq!(due.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_overdue_jobs_ignores_max_tasks_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.scheduler.max_tasks = 2;
|
||||
|
||||
let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap();
|
||||
let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap();
|
||||
let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap();
|
||||
|
||||
let far_future = Utc::now() + ChronoDuration::days(365);
|
||||
// due_jobs respects the limit
|
||||
let due = due_jobs(&config, far_future).unwrap();
|
||||
assert_eq!(due.len(), 2);
|
||||
// all_overdue_jobs returns everything
|
||||
let overdue = all_overdue_jobs(&config, far_future).unwrap();
|
||||
assert_eq!(overdue.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_overdue_jobs_excludes_disabled_jobs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
|
||||
let job = add_job(&config, "* * * * *", "echo disabled").unwrap();
|
||||
let _ = update_job(
|
||||
&config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
enabled: Some(false),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let far_future = Utc::now() + ChronoDuration::days(365);
|
||||
let overdue = all_overdue_jobs(&config, far_future).unwrap();
|
||||
assert!(overdue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_persists_last_status_and_last_run() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@ -315,7 +315,10 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
|
||||
|
||||
// ── Phase 1: LLM decision (two-phase mode) ──────────────
|
||||
let tasks_to_run = if two_phase {
|
||||
let decision_prompt = HeartbeatEngine::build_decision_prompt(&tasks);
|
||||
let decision_prompt = format!(
|
||||
"[Heartbeat Task | decision] {}",
|
||||
HeartbeatEngine::build_decision_prompt(&tasks),
|
||||
);
|
||||
match Box::pin(crate::agent::run(
|
||||
config.clone(),
|
||||
Some(decision_prompt),
|
||||
|
||||
@ -357,6 +357,65 @@ pub async fn handle_api_cron_delete(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/cron/settings — return cron subsystem settings
|
||||
pub async fn handle_api_cron_settings_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let config = state.config.lock().clone();
|
||||
Json(serde_json::json!({
|
||||
"enabled": config.cron.enabled,
|
||||
"catch_up_on_startup": config.cron.catch_up_on_startup,
|
||||
"max_run_history": config.cron.max_run_history,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// PATCH /api/cron/settings — update cron subsystem settings
|
||||
pub async fn handle_api_cron_settings_patch(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let mut config = state.config.lock().clone();
|
||||
|
||||
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
|
||||
config.cron.enabled = v;
|
||||
}
|
||||
if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) {
|
||||
config.cron.catch_up_on_startup = v;
|
||||
}
|
||||
if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) {
|
||||
config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX);
|
||||
}
|
||||
|
||||
if let Err(e) = config.save().await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to save config: {e}")})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
*state.config.lock() = config.clone();
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"enabled": config.cron.enabled,
|
||||
"catch_up_on_startup": config.cron.catch_up_on_startup,
|
||||
"max_run_history": config.cron.max_run_history,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /api/integrations — list all integrations with status
|
||||
pub async fn handle_api_integrations(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@ -766,6 +766,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/tools", get(api::handle_api_tools))
|
||||
.route("/api/cron", get(api::handle_api_cron_list))
|
||||
.route("/api/cron", post(api::handle_api_cron_add))
|
||||
.route(
|
||||
"/api/cron/settings",
|
||||
get(api::handle_api_cron_settings_get).patch(api::handle_api_cron_settings_patch),
|
||||
)
|
||||
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
|
||||
.route("/api/cron/{id}/runs", get(api::handle_api_cron_runs))
|
||||
.route("/api/integrations", get(api::handle_api_integrations))
|
||||
|
||||
@ -101,6 +101,7 @@ pub fn should_skip_autosave_content(content: &str) -> bool {
|
||||
|
||||
let lowered = normalized.to_ascii_lowercase();
|
||||
lowered.starts_with("[cron:")
|
||||
|| lowered.starts_with("[heartbeat task")
|
||||
|| lowered.starts_with("[distilled_")
|
||||
|| lowered.contains("distilled_index_sig:")
|
||||
}
|
||||
@ -471,6 +472,12 @@ mod tests {
|
||||
assert!(should_skip_autosave_content(
|
||||
"[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123"
|
||||
));
|
||||
assert!(should_skip_autosave_content(
|
||||
"[Heartbeat Task | decision] Should I run tasks?"
|
||||
));
|
||||
assert!(should_skip_autosave_content(
|
||||
"[Heartbeat Task | high] Execute scheduled patrol"
|
||||
));
|
||||
assert!(!should_skip_autosave_content(
|
||||
"User prefers concise answers."
|
||||
));
|
||||
|
||||
@ -463,6 +463,47 @@ fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
|
||||
(config_dir.clone(), config_dir.join("workspace"))
|
||||
}
|
||||
|
||||
fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> {
|
||||
let exe = exe.to_string_lossy();
|
||||
if exe == "/opt/homebrew/bin/zeroclaw"
|
||||
|| exe.starts_with("/opt/homebrew/Cellar/zeroclaw/")
|
||||
|| exe.starts_with("/opt/homebrew/opt/zeroclaw/")
|
||||
{
|
||||
return Some("/opt/homebrew");
|
||||
}
|
||||
|
||||
if exe == "/usr/local/bin/zeroclaw"
|
||||
|| exe.starts_with("/usr/local/Cellar/zeroclaw/")
|
||||
|| exe.starts_with("/usr/local/opt/zeroclaw/")
|
||||
{
|
||||
return Some("/usr/local");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn quick_setup_homebrew_service_note(
|
||||
config_path: &Path,
|
||||
workspace_dir: &Path,
|
||||
exe: &Path,
|
||||
) -> Option<String> {
|
||||
let prefix = homebrew_prefix_for_exe(exe)?;
|
||||
let service_root = Path::new(prefix).join("var").join("zeroclaw");
|
||||
let service_config = service_root.join("config.toml");
|
||||
let service_workspace = service_root.join("workspace");
|
||||
|
||||
if config_path == service_config || workspace_dir == service_workspace {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run ZeroClaw as a service, copy or link this workspace first.",
|
||||
service_workspace.display(),
|
||||
service_config.display(),
|
||||
config_path.display(),
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn run_quick_setup_with_home(
|
||||
credential_override: Option<&str>,
|
||||
@ -650,6 +691,16 @@ async fn run_quick_setup_with_home(
|
||||
style("Config saved:").white().bold(),
|
||||
style(config_path.display()).green()
|
||||
);
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(note) =
|
||||
quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe)
|
||||
{
|
||||
println!();
|
||||
println!(" {}", style(note).yellow());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!(" {}", style("Next steps:").white().bold());
|
||||
if credential_override.is_none() {
|
||||
@ -6066,6 +6117,52 @@ mod tests {
|
||||
assert_eq!(config.config_path, expected_config_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homebrew_prefix_for_exe_detects_supported_layouts() {
|
||||
assert_eq!(
|
||||
homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")),
|
||||
Some("/opt/homebrew")
|
||||
);
|
||||
assert_eq!(
|
||||
homebrew_prefix_for_exe(Path::new(
|
||||
"/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw",
|
||||
)),
|
||||
Some("/opt/homebrew")
|
||||
);
|
||||
assert_eq!(
|
||||
homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")),
|
||||
Some("/usr/local")
|
||||
);
|
||||
assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_setup_homebrew_service_note_mentions_service_workspace() {
|
||||
let note = quick_setup_homebrew_service_note(
|
||||
Path::new("/Users/alix/.zeroclaw/config.toml"),
|
||||
Path::new("/Users/alix/.zeroclaw/workspace"),
|
||||
Path::new("/opt/homebrew/bin/zeroclaw"),
|
||||
)
|
||||
.expect("homebrew installs should emit a service workspace note");
|
||||
|
||||
assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace"));
|
||||
assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml"));
|
||||
assert!(note.contains("/Users/alix/.zeroclaw/config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_setup_homebrew_service_note_skips_matching_service_layout() {
|
||||
let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml");
|
||||
let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace");
|
||||
|
||||
assert!(quick_setup_homebrew_service_note(
|
||||
service_config,
|
||||
service_workspace,
|
||||
Path::new("/opt/homebrew/bin/zeroclaw"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ── scaffold_workspace: basic file creation ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -211,9 +211,9 @@ impl AnthropicProvider {
|
||||
text.len() > 3072
|
||||
}
|
||||
|
||||
/// Cache conversations with more than 4 messages (excluding system)
|
||||
/// Cache conversations with more than 1 non-system message (i.e. after first exchange)
|
||||
fn should_cache_conversation(messages: &[ChatMessage]) -> bool {
|
||||
messages.iter().filter(|m| m.role != "system").count() > 4
|
||||
messages.iter().filter(|m| m.role != "system").count() > 1
|
||||
}
|
||||
|
||||
/// Apply cache control to the last message content block
|
||||
@ -447,17 +447,13 @@ impl AnthropicProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert system text to SystemPrompt with cache control if large
|
||||
// Always use Blocks format with cache_control for system prompts
|
||||
let system_prompt = system_text.map(|text| {
|
||||
if Self::should_cache_system(&text) {
|
||||
SystemPrompt::Blocks(vec![SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text,
|
||||
cache_control: Some(CacheControl::ephemeral()),
|
||||
}])
|
||||
} else {
|
||||
SystemPrompt::String(text)
|
||||
}
|
||||
SystemPrompt::Blocks(vec![SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text,
|
||||
cache_control: Some(CacheControl::ephemeral()),
|
||||
}])
|
||||
});
|
||||
|
||||
(system_prompt, native_messages)
|
||||
@ -1063,12 +1059,8 @@ mod tests {
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "Hi".to_string(),
|
||||
},
|
||||
];
|
||||
// Only 2 non-system messages
|
||||
// Only 1 non-system message — should not cache
|
||||
assert!(!AnthropicProvider::should_cache_conversation(&messages));
|
||||
}
|
||||
|
||||
@ -1078,8 +1070,8 @@ mod tests {
|
||||
role: "system".to_string(),
|
||||
content: "System prompt".to_string(),
|
||||
}];
|
||||
// Add 5 non-system messages
|
||||
for i in 0..5 {
|
||||
// Add 3 non-system messages
|
||||
for i in 0..3 {
|
||||
messages.push(ChatMessage {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
content: format!("Message {i}"),
|
||||
@ -1090,21 +1082,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn should_cache_conversation_boundary() {
|
||||
let mut messages = vec![];
|
||||
// Add exactly 4 non-system messages
|
||||
for i in 0..4 {
|
||||
messages.push(ChatMessage {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
content: format!("Message {i}"),
|
||||
});
|
||||
}
|
||||
let messages = vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
}];
|
||||
// Exactly 1 non-system message — should not cache
|
||||
assert!(!AnthropicProvider::should_cache_conversation(&messages));
|
||||
|
||||
// Add one more to cross boundary
|
||||
messages.push(ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "One more".to_string(),
|
||||
});
|
||||
// Add one more to cross boundary (>1)
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "Hi".to_string(),
|
||||
},
|
||||
];
|
||||
assert!(AnthropicProvider::should_cache_conversation(&messages));
|
||||
}
|
||||
|
||||
@ -1217,7 +1212,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_messages_small_system_prompt() {
|
||||
fn convert_messages_small_system_prompt_uses_blocks_with_cache() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: "Short system prompt".to_string(),
|
||||
@ -1226,10 +1221,17 @@ mod tests {
|
||||
let (system_prompt, _) = AnthropicProvider::convert_messages(&messages);
|
||||
|
||||
match system_prompt.unwrap() {
|
||||
SystemPrompt::String(s) => {
|
||||
assert_eq!(s, "Short system prompt");
|
||||
SystemPrompt::Blocks(blocks) => {
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].text, "Short system prompt");
|
||||
assert!(
|
||||
blocks[0].cache_control.is_some(),
|
||||
"Small system prompts should have cache_control"
|
||||
);
|
||||
}
|
||||
SystemPrompt::String(_) => {
|
||||
panic!("Expected Blocks variant with cache_control for small prompt")
|
||||
}
|
||||
SystemPrompt::Blocks(_) => panic!("Expected String variant for small prompt"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1254,12 +1256,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compatibility_native_chat_request() {
|
||||
// Test that requests without cache_control serialize identically to old format
|
||||
fn native_chat_request_with_blocks_system() {
|
||||
// System prompts now always use Blocks format with cache_control
|
||||
let req = NativeChatRequest {
|
||||
model: "claude-3-opus".to_string(),
|
||||
max_tokens: 4096,
|
||||
system: Some(SystemPrompt::String("System".to_string())),
|
||||
system: Some(SystemPrompt::Blocks(vec![SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: "System".to_string(),
|
||||
cache_control: Some(CacheControl::ephemeral()),
|
||||
}])),
|
||||
messages: vec![NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
@ -1272,8 +1278,11 @@ mod tests {
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("cache_control"));
|
||||
assert!(json.contains(r#""system":"System""#));
|
||||
assert!(json.contains("System"));
|
||||
assert!(
|
||||
json.contains(r#""cache_control":{"type":"ephemeral"}"#),
|
||||
"System prompt should include cache_control"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -1119,7 +1119,13 @@ fn create_provider_with_url_and_options(
|
||||
)?))
|
||||
}
|
||||
// ── Primary providers (custom implementations) ───────
|
||||
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
|
||||
"openrouter" => {
|
||||
let mut p = openrouter::OpenRouterProvider::new(key);
|
||||
if let Some(t) = options.provider_timeout_secs {
|
||||
p = p.with_timeout_secs(t);
|
||||
}
|
||||
Ok(Box::new(p))
|
||||
}
|
||||
"anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))),
|
||||
"openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))),
|
||||
// Ollama uses api_url for custom base URL (e.g. remote Ollama instance)
|
||||
|
||||
@ -4,12 +4,14 @@ use crate::providers::traits::{
|
||||
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use anyhow::Context as _;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct OpenRouterProvider {
|
||||
credential: Option<String>,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@ -149,9 +151,16 @@ impl OpenRouterProvider {
|
||||
pub fn new(credential: Option<&str>) -> Self {
|
||||
Self {
|
||||
credential: credential.map(ToString::to_string),
|
||||
timeout_secs: 120,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the HTTP request timeout for LLM API calls.
|
||||
pub fn with_timeout_secs(mut self, secs: u64) -> Self {
|
||||
self.timeout_secs = secs;
|
||||
self
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
@ -296,7 +305,11 @@ impl OpenRouterProvider {
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
|
||||
crate::config::build_runtime_proxy_client_with_timeouts(
|
||||
"provider.openrouter",
|
||||
self.timeout_secs,
|
||||
10,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -368,7 +381,13 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let text = response.text().await?;
|
||||
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
|
||||
format!(
|
||||
"OpenRouter: failed to decode response body: {}",
|
||||
&text[..text.len().min(500)]
|
||||
)
|
||||
})?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
@ -415,7 +434,13 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let text = response.text().await?;
|
||||
let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| {
|
||||
format!(
|
||||
"OpenRouter: failed to decode response body: {}",
|
||||
&text[..text.len().min(500)]
|
||||
)
|
||||
})?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
@ -460,7 +485,14 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let text = response.text().await?;
|
||||
let native_response: NativeChatResponse =
|
||||
serde_json::from_str(&text).with_context(|| {
|
||||
format!(
|
||||
"OpenRouter: failed to decode response body: {}",
|
||||
&text[..text.len().min(500)]
|
||||
)
|
||||
})?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
@ -552,7 +584,14 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let text = response.text().await?;
|
||||
let native_response: NativeChatResponse =
|
||||
serde_json::from_str(&text).with_context(|| {
|
||||
format!(
|
||||
"OpenRouter: failed to decode response body: {}",
|
||||
&text[..text.len().min(500)]
|
||||
)
|
||||
})?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
@ -1017,4 +1056,20 @@ mod tests {
|
||||
assert!(json.contains("reasoning_content"));
|
||||
assert!(json.contains("thinking..."));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// timeout_secs configuration tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn default_timeout_is_120() {
|
||||
let provider = OpenRouterProvider::new(Some("key"));
|
||||
assert_eq!(provider.timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_timeout_secs_overrides_default() {
|
||||
let provider = OpenRouterProvider::new(Some("key")).with_timeout_secs(300);
|
||||
assert_eq!(provider.timeout_secs, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tool schema validation errors are NOT non-retryable — the provider's
|
||||
// built-in fallback in compatible.rs can recover by switching to
|
||||
// prompt-guided tool instructions.
|
||||
if is_tool_schema_error(err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4xx errors are generally non-retryable (bad request, auth failure, etc.),
|
||||
// except 429 (rate-limit — transient) and 408 (timeout — worth retrying).
|
||||
if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
|
||||
@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|
||||
|| msg_lower.contains("invalid"))
|
||||
}
|
||||
|
||||
/// Check if an error is a tool schema validation failure (e.g. Groq returning
|
||||
/// "tool call validation failed: attempted to call tool '...' which was not in request").
|
||||
/// These errors should NOT be classified as non-retryable because the provider's
|
||||
/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`)
|
||||
/// can recover by switching to prompt-guided tool instructions.
|
||||
pub fn is_tool_schema_error(err: &anyhow::Error) -> bool {
|
||||
let lower = err.to_string().to_lowercase();
|
||||
let hints = [
|
||||
"tool call validation failed",
|
||||
"was not in request",
|
||||
"not found in tool list",
|
||||
"invalid_tool_call",
|
||||
];
|
||||
hints.iter().any(|hint| lower.contains(hint))
|
||||
}
|
||||
|
||||
fn is_context_window_exceeded(err: &anyhow::Error) -> bool {
|
||||
let lower = err.to_string().to_lowercase();
|
||||
let hints = [
|
||||
@ -2189,4 +2212,55 @@ mod tests {
|
||||
// Should have been called twice: once with full messages, once with truncated
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
// ── Tool schema error detection tests ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_schema_error_detects_groq_validation_failure() {
|
||||
let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#;
|
||||
let err = anyhow::anyhow!("{}", msg);
|
||||
assert!(is_tool_schema_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_error_detects_not_in_request() {
|
||||
let err = anyhow::anyhow!("tool 'search' was not in request");
|
||||
assert!(is_tool_schema_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_error_detects_not_found_in_tool_list() {
|
||||
let err = anyhow::anyhow!("function 'foo' not found in tool list");
|
||||
assert!(is_tool_schema_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_error_detects_invalid_tool_call() {
|
||||
let err = anyhow::anyhow!("invalid_tool_call: no matching function");
|
||||
assert!(is_tool_schema_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_error_ignores_unrelated_errors() {
|
||||
let err = anyhow::anyhow!("invalid api key");
|
||||
assert!(!is_tool_schema_error(&err));
|
||||
|
||||
let err = anyhow::anyhow!("model not found");
|
||||
assert!(!is_tool_schema_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_returns_false_for_tool_schema_400() {
|
||||
// A 400 error with tool schema validation text should NOT be non-retryable.
|
||||
let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request";
|
||||
let err = anyhow::anyhow!("{}", msg);
|
||||
assert!(!is_non_retryable(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_returns_true_for_other_400_errors() {
|
||||
// A regular 400 error (e.g. invalid API key) should still be non-retryable.
|
||||
let err = anyhow::anyhow!("400 Bad Request: invalid api key provided");
|
||||
assert!(is_non_retryable(&err));
|
||||
}
|
||||
}
|
||||
|
||||
@ -409,13 +409,43 @@ fn has_shell_shebang(path: &Path) -> bool {
|
||||
return false;
|
||||
};
|
||||
let prefix = &content[..content.len().min(128)];
|
||||
let shebang = String::from_utf8_lossy(prefix).to_ascii_lowercase();
|
||||
shebang.starts_with("#!")
|
||||
&& (shebang.contains("sh")
|
||||
|| shebang.contains("bash")
|
||||
|| shebang.contains("zsh")
|
||||
|| shebang.contains("pwsh")
|
||||
|| shebang.contains("powershell"))
|
||||
let shebang_line = String::from_utf8_lossy(prefix)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let Some(interpreter) = shebang_interpreter(&shebang_line) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
interpreter,
|
||||
"sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell"
|
||||
)
|
||||
}
|
||||
|
||||
fn shebang_interpreter(line: &str) -> Option<&str> {
|
||||
let shebang = line.strip_prefix("#!")?.trim();
|
||||
if shebang.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = shebang.split_whitespace();
|
||||
let first = parts.next()?;
|
||||
let first_basename = Path::new(first).file_name()?.to_str()?;
|
||||
|
||||
if first_basename == "env" {
|
||||
for part in parts {
|
||||
if part.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
return Path::new(part).file_name()?.to_str();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(first_basename)
|
||||
}
|
||||
|
||||
fn extract_markdown_links(content: &str) -> Vec<String> {
|
||||
@ -586,6 +616,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_allows_python_shebang_file_when_early_text_contains_sh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join("python-helper");
|
||||
let scripts_dir = skill_dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
|
||||
std::fs::write(
|
||||
scripts_dir.join("helper.py"),
|
||||
"#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = audit_skill_directory(&skill_dir).unwrap();
|
||||
assert!(
|
||||
!report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.contains("script-like files are blocked")),
|
||||
"{:#?}",
|
||||
report.findings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rejects_markdown_escape_links() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@ -418,6 +418,7 @@ impl DelegateTool {
|
||||
true,
|
||||
None,
|
||||
"delegate",
|
||||
None,
|
||||
&self.multimodal_config,
|
||||
agent_config.max_iterations,
|
||||
None,
|
||||
|
||||
@ -146,7 +146,7 @@ pub use workspace_tool::WorkspaceTool;
|
||||
use crate::config::{Config, DelegateAgentConfig};
|
||||
use crate::memory::Memory;
|
||||
use crate::runtime::{NativeRuntime, RuntimeAdapter};
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::security::{create_sandbox, SecurityPolicy};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
@ -283,8 +283,13 @@ pub fn all_tools_with_runtime(
|
||||
root_config: &crate::config::Config,
|
||||
) -> (Vec<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
|
||||
let has_shell_access = runtime.has_shell_access();
|
||||
let sandbox = create_sandbox(&root_config.security);
|
||||
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
|
||||
Arc::new(ShellTool::new(security.clone(), runtime)),
|
||||
Arc::new(ShellTool::new_with_sandbox(
|
||||
security.clone(),
|
||||
runtime,
|
||||
sandbox,
|
||||
)),
|
||||
Arc::new(FileReadTool::new(security.clone())),
|
||||
Arc::new(FileWriteTool::new(security.clone())),
|
||||
Arc::new(FileEditTool::new(security.clone())),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::runtime::RuntimeAdapter;
|
||||
use crate::security::traits::Sandbox;
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@ -44,11 +45,28 @@ const SAFE_ENV_VARS: &[&str] = &[
|
||||
pub struct ShellTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
|
||||
Self { security, runtime }
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
sandbox: Arc::new(crate::security::NoopSandbox),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_sandbox(
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
sandbox: Arc<dyn Sandbox>,
|
||||
) -> Self {
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
sandbox,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,6 +187,14 @@ impl Tool for ShellTool {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Apply sandbox wrapping before execution.
|
||||
// The Sandbox trait operates on std::process::Command, so use as_std_mut()
|
||||
// to get a mutable reference to the underlying command.
|
||||
self.sandbox
|
||||
.wrap_command(cmd.as_std_mut())
|
||||
.map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?;
|
||||
|
||||
cmd.env_clear();
|
||||
|
||||
for var in collect_allowed_shell_env_vars(&self.security) {
|
||||
@ -690,4 +716,59 @@ mod tests {
|
||||
|| r2.error.as_deref().unwrap_or("").contains("budget")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sandbox integration tests ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn shell_tool_can_be_constructed_with_sandbox() {
|
||||
use crate::security::NoopSandbox;
|
||||
|
||||
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
|
||||
let tool = ShellTool::new_with_sandbox(
|
||||
test_security(AutonomyLevel::Supervised),
|
||||
test_runtime(),
|
||||
sandbox,
|
||||
);
|
||||
assert_eq!(tool.name(), "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_sandbox_does_not_modify_command() {
|
||||
use crate::security::NoopSandbox;
|
||||
|
||||
let sandbox = NoopSandbox;
|
||||
let mut cmd = std::process::Command::new("echo");
|
||||
cmd.arg("hello");
|
||||
|
||||
let program_before = cmd.get_program().to_os_string();
|
||||
let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
|
||||
|
||||
sandbox
|
||||
.wrap_command(&mut cmd)
|
||||
.expect("wrap_command should succeed");
|
||||
|
||||
assert_eq!(cmd.get_program(), program_before);
|
||||
assert_eq!(
|
||||
cmd.get_args().map(|a| a.to_os_string()).collect::<Vec<_>>(),
|
||||
args_before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_executes_with_sandbox() {
|
||||
use crate::security::NoopSandbox;
|
||||
|
||||
let sandbox: Arc<dyn Sandbox> = Arc::new(NoopSandbox);
|
||||
let tool = ShellTool::new_with_sandbox(
|
||||
test_security(AutonomyLevel::Supervised),
|
||||
test_runtime(),
|
||||
sandbox,
|
||||
);
|
||||
let result = tool
|
||||
.execute(json!({"command": "echo sandbox_test"}))
|
||||
.await
|
||||
.expect("command with sandbox should succeed");
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("sandbox_test"));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
web/dist/logo.png
vendored
Normal file
BIN
web/dist/logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@ -193,6 +193,25 @@ export function getCronRuns(
|
||||
).then((data) => unwrapField(data, 'runs'));
|
||||
}
|
||||
|
||||
export interface CronSettings {
|
||||
enabled: boolean;
|
||||
catch_up_on_startup: boolean;
|
||||
max_run_history: number;
|
||||
}
|
||||
|
||||
export function getCronSettings(): Promise<CronSettings> {
|
||||
return apiFetch<CronSettings>('/api/cron/settings');
|
||||
}
|
||||
|
||||
export function patchCronSettings(
|
||||
patch: Partial<CronSettings>,
|
||||
): Promise<CronSettings> {
|
||||
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -12,7 +12,15 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import type { CronJob, CronRun } from '@/types/api';
|
||||
import { getCronJobs, addCronJob, deleteCronJob, getCronRuns } from '@/lib/api';
|
||||
import {
|
||||
getCronJobs,
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
getCronRuns,
|
||||
getCronSettings,
|
||||
patchCronSettings,
|
||||
} from '@/lib/api';
|
||||
import type { CronSettings } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
@ -143,6 +151,8 @@ export default function Cron() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [expandedJob, setExpandedJob] = useState<string | null>(null);
|
||||
const [settings, setSettings] = useState<CronSettings | null>(null);
|
||||
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
@ -159,8 +169,28 @@ export default function Cron() {
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const fetchSettings = () => {
|
||||
getCronSettings().then(setSettings).catch(() => {});
|
||||
};
|
||||
|
||||
const toggleCatchUp = async () => {
|
||||
if (!settings) return;
|
||||
setTogglingCatchUp(true);
|
||||
try {
|
||||
const updated = await patchCronSettings({
|
||||
catch_up_on_startup: !settings.catch_up_on_startup,
|
||||
});
|
||||
setSettings(updated);
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} finally {
|
||||
setTogglingCatchUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
@ -250,6 +280,37 @@ export default function Cron() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Catch-up toggle */}
|
||||
{settings && (
|
||||
<div className="glass-card px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-white">
|
||||
Catch up missed jobs on startup
|
||||
</span>
|
||||
<p className="text-xs text-[#556080] mt-0.5">
|
||||
Run all overdue jobs when ZeroClaw starts after downtime
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleCatchUp}
|
||||
disabled={togglingCatchUp}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
|
||||
settings.catch_up_on_startup
|
||||
? 'bg-[#0080ff]'
|
||||
: 'bg-[#1a1a3e]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
|
||||
settings.catch_up_on_startup
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Job Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user