Merge branch 'dev' into issue-3153-codex-mcp-config

This commit is contained in:
SimianAstronaut7 2026-03-12 00:20:12 +00:00 committed by GitHub
commit 48eea41395
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 339 additions and 48 deletions

View File

@ -181,6 +181,8 @@ jobs:
context: .
push: false
load: true
build-args: |
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: zeroclaw-release-candidate:${{ steps.meta.outputs.release_tag }}
platforms: linux/amd64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}
@ -282,7 +284,7 @@ jobs:
context: .
push: true
build-args: |
ZEROCLAW_CARGO_ALL_FEATURES=true
ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}

View File

@ -418,16 +418,21 @@ jobs:
LINKER_ENV: ${{ matrix.linker_env }}
LINKER: ${{ matrix.linker }}
USE_CROSS: ${{ matrix.use_cross }}
ZEROCLAW_RELEASE_CARGO_FEATURES: channel-matrix
run: |
BUILD_ARGS=(--profile release-fast --locked --target ${{ matrix.target }})
if [ -n "$ZEROCLAW_RELEASE_CARGO_FEATURES" ]; then
BUILD_ARGS+=(--features "$ZEROCLAW_RELEASE_CARGO_FEATURES")
fi
if [ -n "$LINKER_ENV" ] && [ -n "$LINKER" ]; then
echo "Using linker override: $LINKER_ENV=$LINKER"
export "$LINKER_ENV=$LINKER"
fi
if [ "$USE_CROSS" = "true" ]; then
echo "Using cross for MUSL target"
cross build --profile release-fast --locked --target ${{ matrix.target }}
echo "Using cross for official release build"
cross build "${BUILD_ARGS[@]}"
else
cargo build --profile release-fast --locked --target ${{ matrix.target }}
cargo build "${BUILD_ARGS[@]}"
fi
- name: Check binary size (Unix)

View File

@ -1808,7 +1808,7 @@ pub async fn run(
message: Option<String>,
provider_override: Option<String>,
model_override: Option<String>,
temperature: f64,
temperature: Option<f64>,
peripheral_overrides: Vec<String>,
interactive: bool,
) -> Result<String> {
@ -1881,6 +1881,7 @@ pub async fn run(
.as_deref()
.or(config.default_model.as_deref())
.unwrap_or("anthropic/claude-sonnet-4");
let temperature = temperature.unwrap_or(config.default_temperature);
let provider_runtime_options = providers::ProviderRuntimeOptions {
auth_profile_override: None,

View File

@ -6691,11 +6691,41 @@ impl Config {
set_runtime_proxy_config(self.proxy.clone());
}
pub async fn save(&self) -> Result<()> {
// Encrypt secrets before serialization
let mut config_to_save = self.clone();
let zeroclaw_dir = self
async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
if self
.config_path
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty())
{
return Ok(self.config_path.clone());
}
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
let (zeroclaw_dir, _workspace_dir, source) =
resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
let file_name = self
.config_path
.file_name()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
let resolved = zeroclaw_dir.join(file_name);
tracing::warn!(
path = %self.config_path.display(),
resolved = %resolved.display(),
source = source.as_str(),
"Config path missing parent directory; resolving from runtime environment"
);
Ok(resolved)
}
pub async fn save(&mut self) -> Result<()> {
// Encrypt secrets before serialization
let config_path = self.resolve_config_path_for_save().await?;
// Keep the in-memory config_path in sync so downstream reads
// (e.g. proxy_config, model_routing_config) use the resolved path.
self.config_path = config_path.clone();
let mut config_to_save = self.clone();
let zeroclaw_dir = config_path
.parent()
.context("Config path must have a parent directory")?;
let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
@ -6764,8 +6794,7 @@ impl Config {
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
let parent_dir = self
.config_path
let parent_dir = config_path
.parent()
.context("Config path must have a parent directory")?;
@ -6776,8 +6805,7 @@ impl Config {
)
})?;
let file_name = self
.config_path
let file_name = config_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("config.toml");
@ -6817,9 +6845,9 @@ impl Config {
.context("Failed to fsync temporary config file")?;
drop(temp_file);
let had_existing_config = self.config_path.exists();
let had_existing_config = config_path.exists();
if had_existing_config {
fs::copy(&self.config_path, &backup_path)
fs::copy(&config_path, &backup_path)
.await
.with_context(|| {
format!(
@ -6829,10 +6857,10 @@ impl Config {
})?;
}
if let Err(e) = fs::rename(&temp_path, &self.config_path).await {
if let Err(e) = fs::rename(&temp_path, &config_path).await {
let _ = fs::remove_file(&temp_path).await;
if had_existing_config && backup_path.exists() {
fs::copy(&backup_path, &self.config_path)
fs::copy(&backup_path, &config_path)
.await
.context("Failed to restore config backup")?;
}
@ -6842,12 +6870,12 @@ impl Config {
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
fs::set_permissions(&self.config_path, Permissions::from_mode(0o600))
fs::set_permissions(&config_path, Permissions::from_mode(0o600))
.await
.with_context(|| {
format!(
"Failed to enforce secure permissions on config file: {}",
self.config_path.display()
config_path.display()
)
})?;
}
@ -7660,7 +7688,7 @@ tool_dispatcher = "xml"
fs::create_dir_all(&dir).await.unwrap();
let config_path = dir.join("config.toml");
let config = Config {
let mut config = Config {
workspace_dir: dir.join("workspace"),
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
@ -10486,6 +10514,44 @@ default_model = "legacy-model"
);
}
#[test]
async fn save_repairs_bare_config_filename_using_runtime_resolution() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("workspace");
let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
let mut config = Config::default();
config.workspace_dir = workspace_dir;
config.config_path = PathBuf::from("config.toml");
config.default_temperature = 0.5;
config.save().await.unwrap();
assert!(resolved_config_path.exists());
assert_eq!(
config.config_path, resolved_config_path,
"save() must update config_path to the resolved path"
);
let saved = tokio::fs::read_to_string(&resolved_config_path)
.await
.unwrap();
let parsed: Config = toml::from_str(&saved).unwrap();
assert_eq!(parsed.default_temperature, 0.5);
std::env::remove_var("ZEROCLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = tokio::fs::remove_dir_all(temp_home).await;
}
#[cfg(unix)]
#[test]
async fn save_restricts_existing_world_readable_config_to_owner_only() {

View File

@ -173,7 +173,7 @@ async fn run_agent_job(
Some(prefixed_prompt),
None,
model_override,
config.default_temperature,
Some(config.default_temperature),
vec![],
false,
)

View File

@ -272,7 +272,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
Some(prompt),
None,
None,
temp,
Some(temp),
vec![],
false,
)

View File

@ -543,7 +543,7 @@ pub async fn handle_api_config_put(
};
let current_config = state.config.lock().clone();
let new_config = hydrate_config_for_save(incoming, &current_config);
let mut new_config = hydrate_config_for_save(incoming, &current_config);
if let Err(e) = new_config.validate() {
return (
@ -752,7 +752,7 @@ pub async fn handle_api_integration_credentials_put(
}
}
let updated = match apply_integration_credentials_update(&current, &id, &body.fields) {
let mut updated = match apply_integration_credentials_update(&current, &id, &body.fields) {
Ok(config) => config,
Err(error) if error.starts_with("Unknown integration id:") => {
return (

View File

@ -195,8 +195,8 @@ Examples:
model: Option<String>,
/// Temperature (0.0 - 2.0)
#[arg(short, long, default_value = "0.7", value_parser = parse_temperature)]
temperature: f64,
#[arg(short, long, value_parser = parse_temperature)]
temperature: Option<f64>,
/// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
#[arg(long)]
@ -2235,6 +2235,44 @@ mod tests {
);
}
#[test]
fn agent_cli_does_not_force_temperature_override_when_flag_is_absent() {
let cli = Cli::try_parse_from(["zeroclaw", "agent", "--provider", "openrouter", "-m", "hi"])
.expect("agent invocation should parse without temperature");
match cli.command {
Commands::Agent { temperature, .. } => {
assert_eq!(
temperature, None,
"temperature should stay unset so config.default_temperature is preserved"
);
}
other => panic!("expected agent command, got {other:?}"),
}
}
#[test]
fn agent_cli_parses_explicit_temperature_override() {
let cli = Cli::try_parse_from([
"zeroclaw",
"agent",
"--provider",
"openrouter",
"-m",
"hi",
"--temperature",
"1.1",
])
.expect("agent invocation should parse explicit temperature");
match cli.command {
Commands::Agent { temperature, .. } => {
assert_eq!(temperature, Some(1.1));
}
other => panic!("expected agent command, got {other:?}"),
}
}
#[test]
fn gateway_cli_accepts_new_pairing_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"])

View File

@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
// ── Build config ──
// Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime
let config = Config {
let mut config = Config {
workspace_dir: workspace_dir.clone(),
config_path: config_path.clone(),
api_key: if api_key.is_empty() {
@ -487,7 +487,7 @@ async fn run_quick_setup_with_home(
// Create memory config based on backend choice
let memory_config = memory_config_defaults_for_backend(&memory_backend_name);
let config = Config {
let mut config = Config {
workspace_dir: workspace_dir.clone(),
config_path: config_path.clone(),
api_key: credential_override.map(|c| {

View File

@ -6,6 +6,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
use crate::providers::{self, ChatMessage, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
use crate::tools::SharedToolRegistry;
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
@ -36,7 +37,7 @@ pub struct DelegateTool {
/// Depth at which this tool instance lives in the delegation chain.
depth: u32,
/// Parent tool registry for agentic sub-agents.
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
parent_tools: SharedToolRegistry,
/// Inherited multimodal handling config for sub-agent loops.
multimodal_config: crate::config::MultimodalConfig,
/// Optional typed coordination bus used to trace delegate lifecycle events.
@ -72,7 +73,7 @@ impl DelegateTool {
fallback_credential,
provider_runtime_options,
depth: 0,
parent_tools: Arc::new(Vec::new()),
parent_tools: crate::tools::new_shared_tool_registry(),
multimodal_config: crate::config::MultimodalConfig::default(),
coordination_bus,
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
@ -111,7 +112,7 @@ impl DelegateTool {
fallback_credential,
provider_runtime_options,
depth,
parent_tools: Arc::new(Vec::new()),
parent_tools: crate::tools::new_shared_tool_registry(),
multimodal_config: crate::config::MultimodalConfig::default(),
coordination_bus,
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
@ -119,7 +120,7 @@ impl DelegateTool {
}
/// Attach parent tools used to build sub-agent allowlist registries.
pub fn with_parent_tools(mut self, parent_tools: Arc<Vec<Arc<dyn Tool>>>) -> Self {
pub fn with_parent_tools(mut self, parent_tools: SharedToolRegistry) -> Self {
self.parent_tools = parent_tools;
self
}
@ -461,9 +462,13 @@ impl DelegateTool {
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect::<std::collections::HashSet<_>>();
let sub_tools: Vec<Box<dyn Tool>> = self
let parent_tools = self
.parent_tools
.lock()
.map(|tools| tools.clone())
.unwrap_or_default();
let sub_tools: Vec<Box<dyn Tool>> = parent_tools
.iter()
.filter(|tool| allowed.contains(tool.name()))
.filter(|tool| tool.name() != "delegate")
@ -967,6 +972,12 @@ mod tests {
}
}
fn shared_parent_tools(tools: Vec<Arc<dyn Tool>>) -> SharedToolRegistry {
let shared = crate::tools::new_shared_tool_registry();
crate::tools::sync_shared_tool_registry(&shared, &tools);
shared
}
#[test]
fn name_and_schema() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
@ -1278,7 +1289,7 @@ mod tests {
);
let tool = DelegateTool::new(agents, None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let result = tool
.execute(json!({"agent": "agentic", "prompt": "test"}))
.await
@ -1296,7 +1307,7 @@ mod tests {
async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
Arc::new(vec![
shared_parent_tools(vec![
Arc::new(EchoTool),
Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
]),
@ -1313,11 +1324,33 @@ mod tests {
assert!(result.output.contains("done"));
}
#[tokio::test]
async fn execute_agentic_reads_late_bound_parent_tools() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let parent_tools = crate::tools::new_shared_tool_registry();
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(parent_tools.clone());
crate::tools::sync_shared_tool_registry(
&parent_tools,
&[Arc::new(EchoTool) as Arc<dyn Tool>],
);
let provider = OneToolThenFinalProvider;
let result = tool
.execute_agentic("agentic", &config, &provider, "run", 0.2)
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("done"));
}
#[tokio::test]
async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
let config = agentic_config(vec!["delegate".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
Arc::new(vec![Arc::new(DelegateTool::new(
shared_parent_tools(vec![Arc::new(DelegateTool::new(
HashMap::new(),
None,
test_security(),
@ -1342,7 +1375,7 @@ mod tests {
async fn execute_agentic_respects_max_iterations() {
let config = agentic_config(vec!["echo_tool".to_string()], 2);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let provider = InfiniteToolCallProvider;
let result = tool
@ -1362,7 +1395,7 @@ mod tests {
async fn execute_agentic_propagates_provider_errors() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
.with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let provider = FailingProvider;
let result = tool

View File

@ -126,7 +126,7 @@ use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct ArcDelegatingTool {
@ -162,6 +162,21 @@ fn boxed_registry_from_arcs(tools: Vec<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
}
pub(crate) type SharedToolRegistry = Arc<Mutex<Vec<Arc<dyn Tool>>>>;
pub(crate) fn new_shared_tool_registry() -> SharedToolRegistry {
Arc::new(Mutex::new(Vec::new()))
}
pub(crate) fn sync_shared_tool_registry(
shared_registry: &SharedToolRegistry,
tools: &[Arc<dyn Tool>],
) {
if let Ok(mut guard) = shared_registry.lock() {
*guard = tools.to_vec();
}
}
/// Create the default tool registry
pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
default_tools_with_runtime(security, Arc::new(NativeRuntime::new()))
@ -417,6 +432,7 @@ pub fn all_tools_with_runtime(
}
// Add delegation and sub-agent orchestration tools when agents are configured
let mut shared_parent_tools = None;
if !agents.is_empty() {
let delegate_agents: HashMap<String, DelegateAgentConfig> = agents
.iter()
@ -442,7 +458,8 @@ pub fn all_tools_with_runtime(
max_tokens_override: None,
model_support_vision: root_config.model_support_vision,
};
let parent_tools = Arc::new(tool_arcs.clone());
let parent_tools = new_shared_tool_registry();
shared_parent_tools = Some(parent_tools.clone());
let mut delegate_tool = DelegateTool::new_with_options(
delegate_agents.clone(),
delegate_fallback_credential.clone(),
@ -536,6 +553,10 @@ pub fn all_tools_with_runtime(
}
}
if let Some(shared_registry) = shared_parent_tools.as_ref() {
sync_shared_tool_registry(shared_registry, &tool_arcs);
}
boxed_registry_from_arcs(tool_arcs)
}

View File

@ -921,7 +921,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()

View File

@ -450,7 +450,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()

View File

@ -11,6 +11,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
use crate::providers::{self, ChatMessage, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
use crate::tools::SharedToolRegistry;
use async_trait::async_trait;
use chrono::Utc;
use serde_json::json;
@ -32,7 +33,7 @@ pub struct SubAgentSpawnTool {
fallback_credential: Option<String>,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc<SubAgentRegistry>,
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
parent_tools: SharedToolRegistry,
multimodal_config: crate::config::MultimodalConfig,
}
@ -44,7 +45,7 @@ impl SubAgentSpawnTool {
security: Arc<SecurityPolicy>,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc<SubAgentRegistry>,
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
parent_tools: SharedToolRegistry,
multimodal_config: crate::config::MultimodalConfig,
) -> Self {
Self {
@ -395,7 +396,7 @@ async fn run_agentic_background(
agent_config: &DelegateAgentConfig,
provider: &dyn Provider,
full_prompt: &str,
parent_tools: &[Arc<dyn Tool>],
parent_tools: &SharedToolRegistry,
multimodal_config: &crate::config::MultimodalConfig,
) -> anyhow::Result<ToolResult> {
if agent_config.allowed_tools.is_empty() {
@ -414,6 +415,10 @@ async fn run_agentic_background(
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect::<std::collections::HashSet<_>>();
let parent_tools = parent_tools
.lock()
.map(|tools| tools.clone())
.unwrap_or_default();
let sub_tools: Vec<Box<dyn Tool>> = parent_tools
.iter()
@ -530,6 +535,100 @@ mod tests {
agents
}
#[derive(Default)]
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo_tool"
}
fn description(&self) -> &str {
"Echoes the `value` argument."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"value": {"type": "string"}
},
"required": ["value"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let value = args
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
Ok(ToolResult {
success: true,
output: format!("echo:{value}"),
error: None,
})
}
}
struct OneToolThenFinalProvider;
#[async_trait]
impl Provider for OneToolThenFinalProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
request: crate::providers::ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<crate::providers::ChatResponse> {
let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
if has_tool_message {
Ok(crate::providers::ChatResponse {
text: Some("done".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
})
} else {
Ok(crate::providers::ChatResponse {
text: None,
tool_calls: vec![crate::providers::ToolCall {
id: "call_1".to_string(),
name: "echo_tool".to_string(),
arguments: "{\"value\":\"ping\"}".to_string(),
}],
usage: None,
reasoning_content: None,
})
}
}
}
fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "model-test".to_string(),
system_prompt: Some("You are agentic.".to_string()),
api_key: Some("delegate-test-credential".to_string()),
temperature: Some(0.2),
max_depth: 3,
agentic: true,
allowed_tools,
max_iterations,
}
}
fn make_tool(
agents: HashMap<String, DelegateAgentConfig>,
security: Arc<SecurityPolicy>,
@ -540,7 +639,7 @@ mod tests {
security,
providers::ProviderRuntimeOptions::default(),
Arc::new(SubAgentRegistry::new()),
Arc::new(Vec::new()),
crate::tools::new_shared_tool_registry(),
crate::config::MultimodalConfig::default(),
)
}
@ -705,7 +804,7 @@ mod tests {
test_security(),
providers::ProviderRuntimeOptions::default(),
registry,
Arc::new(Vec::new()),
crate::tools::new_shared_tool_registry(),
crate::config::MultimodalConfig::default(),
);
@ -726,4 +825,30 @@ mod tests {
.unwrap();
assert!(desc.contains("researcher"));
}
#[tokio::test]
async fn run_agentic_background_reads_late_bound_parent_tools() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let parent_tools = crate::tools::new_shared_tool_registry();
let provider = OneToolThenFinalProvider;
crate::tools::sync_shared_tool_registry(
&parent_tools,
&[Arc::new(EchoTool) as Arc<dyn Tool>],
);
let result = run_agentic_background(
"agentic",
&config,
&provider,
"run",
&parent_tools,
&crate::config::MultimodalConfig::default(),
)
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("done"));
}
}