Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d1eebad4d | |||
| 0fdd1ad490 | |||
| 86bc60fcd1 | |||
| 4837e1fe73 | |||
| 985977ae0c | |||
| 72b10f12dd | |||
| 3239f5ea07 | |||
| 3353729b01 | |||
| b6c2930a70 | |||
| 181cafff70 | |||
| d87f387111 | |||
| 7068079028 | |||
| a9b511e6ec | |||
| 65cb4fe099 |
@@ -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 }}
|
||||
|
||||
@@ -213,7 +213,7 @@ 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 }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix,channel-lark,memory-postgres
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
|
||||
|
||||
@@ -214,7 +214,7 @@ 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 }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix,channel-lark,memory-postgres
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
Generated
+1
-1
@@ -9164,7 +9164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+1
-1
@@ -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 \
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+23
-3
@@ -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;
|
||||
@@ -2183,6 +2183,7 @@ pub(crate) async fn agent_turn(
|
||||
channel_name: &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,7 +2198,7 @@ pub(crate) async fn agent_turn(
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
None,
|
||||
approval,
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
@@ -3466,6 +3467,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)
|
||||
@@ -3894,6 +3896,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 +4088,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 +4113,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 +4147,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(),
|
||||
@@ -4148,6 +4165,7 @@ pub async fn process_message(
|
||||
"daemon",
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
Some(&approval_manager),
|
||||
&excluded_tools,
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
activated_handle_pm.as_ref(),
|
||||
@@ -5230,6 +5248,7 @@ mod tests {
|
||||
"daemon",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
Some(&activated),
|
||||
@@ -6672,6 +6691,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
|
||||
|
||||
+113
-10
@@ -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,
|
||||
@@ -2244,7 +2245,9 @@ async fn process_channel_message(
|
||||
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 +2788,7 @@ pub fn build_system_prompt(
|
||||
bootstrap_max_chars,
|
||||
false,
|
||||
crate::config::SkillsPromptInjectionMode::Full,
|
||||
AutonomyLevel::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2797,6 +2801,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 +2867,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 +4016,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 +4038,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 +4199,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 +4504,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 +4614,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 +4680,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 +4765,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 +5298,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 +5372,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 +5462,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 +5535,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 +5618,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 +5722,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 +5807,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 +5907,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 +5992,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 +6067,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 +6253,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 +6347,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 +6459,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 +6562,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 +6650,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 +6723,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 +6990,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 +7113,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 +7414,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 +7513,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 +7612,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 +8175,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 +8255,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 +8409,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 +8513,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 +8609,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 +8725,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,
|
||||
|
||||
+34
-6
@@ -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,
|
||||
@@ -5872,8 +5882,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 +5915,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 +7749,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 +8363,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());
|
||||
|
||||
+4
-1
@@ -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),
|
||||
|
||||
@@ -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."
|
||||
));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user