#[cfg(any(feature = "acp", feature = "acp-http"))]
use std::path::PathBuf;
#[cfg(feature = "acp")]
use parking_lot::RwLock;
#[cfg(feature = "acp")]
use crate::agent_setup;
#[cfg(any(feature = "acp", feature = "acp-http"))]
use crate::bootstrap::{AppBuilder, create_mcp_registry};
#[cfg(feature = "acp")]
use zeph_core::agent::Agent;
#[cfg(feature = "acp")]
use zeph_core::channel::Channel;
#[cfg(feature = "acp")]
use zeph_tools::ErasedToolExecutor;
#[cfg(feature = "acp")]
fn resolve_runtime_path(path: &std::path::Path, cwd: &std::path::Path) -> std::path::PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
#[cfg(feature = "acp")]
fn log_acp_runtime_paths(config: &zeph_core::config::Config, config_path: &std::path::Path) {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let logging_file = if config.logging.file.is_empty() {
None
} else {
Some(resolve_runtime_path(
std::path::Path::new(&config.logging.file),
&cwd,
))
};
let sqlite_path = resolve_runtime_path(std::path::Path::new(&config.memory.sqlite_path), &cwd);
let debug_output_dir = resolve_runtime_path(config.debug.output_dir.as_path(), &cwd);
let skill_paths: Vec<std::path::PathBuf> = config
.skills
.paths
.iter()
.map(|p| resolve_runtime_path(std::path::Path::new(p), &cwd))
.collect();
let permission_file = config
.acp
.permission_file
.as_ref()
.map(|p| resolve_runtime_path(p.as_path(), &cwd));
tracing::info!(
cwd = %cwd.display(),
config_path = %config_path.display(),
logging_file = logging_file
.as_ref()
.map_or_else(|| "<disabled>".to_owned(), |p| p.display().to_string()),
sqlite_path = %sqlite_path.display(),
debug_output_dir = %debug_output_dir.display(),
permission_file = permission_file
.as_ref()
.map_or_else(|| "<none>".to_owned(), |p| p.display().to_string()),
skill_paths = ?skill_paths,
"ACP startup runtime paths"
);
}
#[cfg(any(feature = "session", feature = "acp"))]
pub(crate) struct SharedCore {
pub(crate) provider: zeph_llm::any::AnyProvider,
pub(crate) embedding_provider: zeph_llm::any::AnyProvider,
pub(crate) registry: std::sync::Arc<parking_lot::RwLock<zeph_skills::registry::SkillRegistry>>,
pub(crate) matcher: Option<zeph_skills::matcher::SkillMatcherBackend>,
pub(crate) memory: std::sync::Arc<zeph_memory::semantic::SemanticMemory>,
pub(crate) budget_tokens: usize,
}
#[cfg(any(feature = "session", feature = "acp"))]
pub(crate) async fn build_shared_core(
app: &crate::bootstrap::AppBuilder,
supervisor: &zeph_common::TaskSupervisor,
) -> anyhow::Result<SharedCore> {
let (provider, _status_tx, _status_rx) = app.build_provider().await?;
let embedding_provider = crate::bootstrap::create_embedding_provider(app.config(), &provider);
let budget_tokens = app.auto_budget_tokens(&provider);
let registry = std::sync::Arc::new(parking_lot::RwLock::new(app.build_registry()));
let memory = std::sync::Arc::new(app.build_memory(&provider, supervisor).await?);
let all_meta_owned: Vec<zeph_skills::loader::SkillMeta> =
registry.read().all_meta().into_iter().cloned().collect();
let all_meta_refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta_owned.iter().collect();
let matcher = app
.build_skill_matcher(&embedding_provider, &all_meta_refs, &memory)
.await;
Ok(SharedCore {
provider,
embedding_provider,
registry,
matcher,
memory,
budget_tokens,
})
}
#[cfg(feature = "acp")]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct SharedAgentDeps {
provider: zeph_llm::any::AnyProvider,
embedding_provider: zeph_llm::any::AnyProvider,
registry: std::sync::Arc<RwLock<zeph_skills::registry::SkillRegistry>>,
matcher: Option<zeph_skills::matcher::SkillMatcherBackend>,
max_active_skills: usize,
skill_disambiguation_threshold: f32,
skill_two_stage_matching: bool,
skill_confusability_threshold: f32,
skill_generation_provider: String,
skill_disambiguate_provider: String,
semantic_scan: bool,
semantic_scan_provider: String,
tool_executor: std::sync::Arc<dyn zeph_tools::ErasedToolExecutor>,
permission_policy: zeph_tools::PermissionPolicy,
policy_enforcer: Option<std::sync::Arc<zeph_tools::PolicyEnforcer>>,
adversarial_policy_validator: Option<std::sync::Arc<zeph_tools::PolicyValidator>>,
adversarial_policy_llm_client: Option<std::sync::Arc<dyn zeph_tools::PolicyLlmClient>>,
skill_paths: Vec<PathBuf>,
pub(crate) memory: std::sync::Arc<zeph_memory::semantic::SemanticMemory>,
history_limit: u32,
recall_limit: usize,
summarization_threshold: usize,
skill_reload_tx: tokio::sync::broadcast::Sender<zeph_skills::watcher::SkillEvent>,
config_reload_tx: tokio::sync::broadcast::Sender<zeph_core::config_watcher::ConfigEvent>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
config_path: PathBuf,
mcp_tools: Vec<zeph_mcp::McpTool>,
mcp_registry: Option<zeph_mcp::McpToolRegistry>,
mcp_manager: std::sync::Arc<zeph_mcp::McpManager>,
mcp_shared_tools: std::sync::Arc<RwLock<Vec<zeph_mcp::McpTool>>>,
mcp_config: zeph_core::config::McpConfig,
summary_provider: Option<zeph_llm::any::AnyProvider>,
judge_provider: Option<zeph_llm::any::AnyProvider>,
feedback_classifier: Option<zeph_llm::classifier::llm::LlmClassifier>,
#[cfg(feature = "classifiers")]
classifiers_config: zeph_core::config::ClassifiersConfig,
#[cfg(feature = "classifiers")]
pii_filter_enabled: bool,
causal_ipi_config: zeph_sanitizer::causal_ipi::CausalIpiConfig,
causal_provider: Option<zeph_llm::any::AnyProvider>,
nli_config: zeph_sanitizer::nli::NliConfig,
nli_provider: Option<zeph_llm::any::AnyProvider>,
secret_registry: Option<std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
vigil_config: zeph_config::VigilConfig,
probe_provider: Option<zeph_llm::any::AnyProvider>,
planner_provider: Option<zeph_llm::any::AnyProvider>,
verify_provider: Option<zeph_llm::any::AnyProvider>,
orchestrator_provider: Option<zeph_llm::any::AnyProvider>,
predicate_provider: Option<zeph_llm::any::AnyProvider>,
quarantine_provider: Option<(zeph_llm::any::AnyProvider, zeph_sanitizer::QuarantineConfig)>,
guardrail_provider: Option<(
zeph_llm::any::AnyProvider,
zeph_sanitizer::guardrail::GuardrailConfig,
)>,
audit_logger: Option<std::sync::Arc<zeph_tools::AuditLogger>>,
session_config: zeph_core::AgentSessionConfig,
session_persistence_config: zeph_config::SessionConfig,
resume_condenser: zeph_session::LlmCondenser,
resume_token_counter: std::sync::Arc<zeph_agent_context::memory_backend::TokenCounterAdapter>,
provider_pool: Vec<zeph_core::config::ProviderEntry>,
provider_config_snapshot: zeph_core::ProviderConfigSnapshot,
focus_config: zeph_core::config::FocusConfig,
sidequest_config: zeph_core::config::SidequestConfig,
trajectory_config: zeph_core::config::TrajectoryConfig,
category_config: zeph_core::config::CategoryConfig,
tool_filter_config: zeph_core::config::ToolFilterConfig,
hooks_config: zeph_core::config::HooksConfig,
acp_agent_name: String,
acp_agent_version: String,
acp_max_sessions: usize,
acp_session_idle_timeout_secs: u64,
acp_permission_file: Option<std::path::PathBuf>,
acp_available_models: std::sync::Arc<RwLock<Vec<String>>>,
acp_auth_bearer_token: Option<String>,
acp_discovery_enabled: bool,
acp_title_max_chars: usize,
acp_max_history: usize,
acp_log_file: Option<String>,
sqlite_path: String,
#[cfg(feature = "acp")]
acp_provider_factory: Option<zeph_acp::ProviderFactory>,
acp_provider_names: Vec<(String, zeph_acp::LlmProtocol)>,
acp_project_rules: Vec<PathBuf>,
acp_additional_directories: Vec<zeph_core::config::AdditionalDir>,
acp_auth_methods: Vec<zeph_core::config::AcpAuthMethod>,
acp_message_ids_enabled: bool,
acp_timeouts: zeph_config::AcpTimeoutsConfig,
acp_model_config: zeph_config::AcpModelConfigConfig,
plugin_dirs_supplier: std::sync::Arc<dyn Fn() -> Vec<PathBuf> + Send + Sync>,
startup_shell_overlay: zeph_core::ShellOverlaySnapshot,
shell_policy_handle: zeph_tools::ShellPolicyHandle,
#[cfg(feature = "scheduler")]
scheduler_executor: Option<std::sync::Arc<crate::scheduler_executor::SchedulerExecutor>>,
#[cfg(feature = "scheduler")]
scheduler_update_tx: Option<tokio::sync::broadcast::Sender<String>>,
#[cfg(feature = "scheduler")]
scheduler_custom_tx: Option<tokio::sync::broadcast::Sender<String>>,
}
#[cfg(feature = "acp")]
fn broadcast_to_mpsc<T: Clone + Send + 'static>(
mut brx: tokio::sync::broadcast::Receiver<T>,
cancel: zeph_memory::CancellationToken,
) -> tokio::sync::mpsc::Receiver<T> {
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
loop {
tokio::select! {
() = cancel.cancelled() => break,
result = brx.recv() => {
match result {
Ok(item) => {
if tx.send(item).await.is_err() {
break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "broadcast_to_mpsc: lagged, some reload events dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
}
});
rx
}
#[cfg(feature = "acp")]
pub(crate) struct PrebuiltAcpCore {
pub(crate) core: SharedCore,
pub(crate) supervisor: std::sync::Arc<zeph_common::TaskSupervisor>,
}
#[cfg(feature = "acp")]
#[allow(clippy::too_many_lines)]
async fn build_acp_deps(
app: &AppBuilder,
prebuilt_core: Option<PrebuiltAcpCore>,
prebuilt_mcp_manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
) -> anyhow::Result<(SharedAgentDeps, Box<dyn std::any::Any>)> {
log_acp_runtime_paths(app.config(), app.config_path());
let embed_model = app.embedding_model();
let (
SharedCore {
provider,
embedding_provider,
registry,
matcher,
memory,
budget_tokens,
},
acp_mem_supervisor,
) = if let Some(p) = prebuilt_core {
(p.core, p.supervisor)
} else {
let acp_mem_cancel = tokio_util::sync::CancellationToken::new();
let acp_mem_supervisor =
std::sync::Arc::new(zeph_common::TaskSupervisor::new(acp_mem_cancel));
let core = build_shared_core(app, &acp_mem_supervisor).await?;
(core, acp_mem_supervisor)
};
{
let sqlite = memory.sqlite().clone();
let retention_secs = app
.config()
.tools
.overflow
.retention_days
.saturating_mul(86_400);
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some((sqlite, retention_secs))));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "overflow_cleanup",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let args = cell.lock().take();
async move {
if let Some((sqlite, retention_secs)) = args {
match sqlite.cleanup_overflow(retention_secs).await {
Ok(n) if n > 0 => {
tracing::info!("cleaned up {n} stale overflow entries");
}
Ok(_) => {}
Err(e) => tracing::warn!("overflow cleanup failed: {e}"),
}
} else {
tracing::warn!("overflow_cleanup factory called more than once");
}
}
},
});
}
let config = app.config();
let filter_registry = if config.tools.filters.enabled {
zeph_tools::OutputFilterRegistry::default_filters(&config.tools.filters)
} else {
zeph_tools::OutputFilterRegistry::new(false)
};
let permission_policy =
zeph_tools::build_permission_policy(&config.tools, config.security.autonomy_level);
let mut shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell)
.with_permissions(permission_policy.clone())
.with_output_filters(filter_registry)
.with_task_supervisor((*acp_mem_supervisor).clone());
if config.tools.sandbox.enabled {
let denied_present = !config.tools.sandbox.denied_domains.is_empty();
match zeph_tools::sandbox::build_sandbox_with_policy(
config.tools.sandbox.strict,
config.tools.sandbox.fail_if_unavailable,
denied_present,
) {
Ok(backend) => {
let name = backend.name();
let policy = crate::agent_setup::sandbox_policy_from_config(&config.tools.sandbox);
shell_executor = shell_executor.with_sandbox(std::sync::Arc::from(backend), policy);
tracing::info!(backend = name, "OS sandbox enabled (acp)");
}
Err(e) if config.tools.sandbox.strict || config.tools.sandbox.fail_if_unavailable => {
panic!("sandbox initialization failed: {e}");
}
Err(e) => {
tracing::warn!("OS sandbox unavailable, running without isolation: {e}");
}
}
}
let mut scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape)
.with_egress_config(config.tools.egress.clone());
if config.tools.egress.enabled {
let (egress_tx, egress_rx) = tokio::sync::mpsc::channel(256);
let dropped = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
scrape_executor = scrape_executor.with_egress_tx(egress_tx, dropped);
{
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some(egress_rx)));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "egress_drain",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let rx = cell.lock().take();
async move {
if let Some(rx) = rx {
agent_setup::drain_egress_events(rx, None).await;
} else {
tracing::warn!("egress_drain factory called more than once");
}
}
},
});
}
}
let mut acp_audit_logger: Option<std::sync::Arc<zeph_tools::AuditLogger>> = None;
if config.tools.audit.enabled
&& let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit, false).await
{
let logger = std::sync::Arc::new(logger);
shell_executor = shell_executor.with_audit(std::sync::Arc::clone(&logger));
scrape_executor = scrape_executor.with_audit(std::sync::Arc::clone(&logger));
acp_audit_logger = Some(logger);
}
let file_executor = zeph_tools::FileExecutor::new(
config
.tools
.shell
.allowed_paths
.iter()
.map(PathBuf::from)
.collect(),
);
let mcp_manager = if let Some(m) = prebuilt_mcp_manager {
m
} else {
let builder =
crate::bootstrap::create_mcp_manager_with_vault(config, false, app.age_vault_arc());
let builder =
crate::bootstrap::wire_trust_calibration(builder, config, Some(memory.sqlite().pool()))
.await;
std::sync::Arc::new(builder)
};
let (mcp_tools, _mcp_outcomes) = mcp_manager.connect_all().await;
let mcp_shared_tools = std::sync::Arc::new(RwLock::new(mcp_tools.clone()));
let mcp_executor =
zeph_mcp::McpToolExecutor::new(mcp_manager.clone(), mcp_shared_tools.clone());
let shell_policy_handle = shell_executor.policy_handle();
let diagnostics_executor = crate::agent_setup::build_diagnostics_executor(config);
let base_executor = crate::agent_setup::build_base_executor_chain(
file_executor,
shell_executor,
scrape_executor,
diagnostics_executor,
);
let index_provider = crate::bootstrap::resolve_index_embed_provider(config, provider.clone());
let inner_executor: std::sync::Arc<dyn zeph_tools::ErasedToolExecutor> = {
let base: std::sync::Arc<dyn zeph_tools::ErasedToolExecutor> = std::sync::Arc::new(
zeph_tools::CompositeExecutor::new(base_executor, mcp_executor),
);
if let Some(search_executor) = crate::agent_setup::build_search_code_executor(
config,
app.qdrant_ops().cloned(),
index_provider,
memory.sqlite().pool().clone(),
Some(std::sync::Arc::clone(&mcp_manager)),
) {
std::sync::Arc::new(zeph_tools::CompositeExecutor::new(
zeph_tools::DynExecutor(base),
search_executor,
))
} else {
base
}
};
let tool_executor = inner_executor;
let (adversarial_policy_validator, adversarial_policy_llm_client) = if config
.tools
.adversarial_policy
.enabled
{
let adv_cfg = &config.tools.adversarial_policy;
let policies: Vec<String> = if let Some(ref path) = adv_cfg.policy_file {
let path_owned = path.clone();
let load_result =
tokio::task::spawn_blocking(move || -> Result<Vec<String>, std::io::Error> {
let p = std::path::Path::new(&path_owned);
let canonical = std::fs::canonicalize(p)?;
let canonical_base = std::env::current_dir().and_then(std::fs::canonicalize)?;
if !canonical.starts_with(&canonical_base) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"adversarial policy file escapes project root",
));
}
let content = std::fs::read_to_string(&canonical)?;
Ok(zeph_tools::parse_policy_lines(&content))
})
.await
.unwrap_or_else(|e| Err(std::io::Error::other(e)));
match load_result {
Ok(lines) => lines,
Err(e) => {
tracing::error!(
path = %path,
"adversarial policy: failed to load policy file: {e}"
);
vec![]
}
}
} else {
vec![]
};
if policies.is_empty() {
tracing::warn!("adversarial policy enabled but no policies loaded; gate is a no-op");
}
let validator = std::sync::Arc::new(zeph_tools::PolicyValidator::new(
policies,
std::time::Duration::from_millis(adv_cfg.timeout_ms),
adv_cfg.fail_open,
adv_cfg.exempt_tools.clone(),
));
let policy_provider = if adv_cfg.policy_provider.is_empty() {
provider.clone()
} else {
match crate::bootstrap::create_named_provider(adv_cfg.policy_provider.as_str(), config)
{
Ok(p) => p,
Err(e) => {
tracing::warn!(
provider = %adv_cfg.policy_provider,
error = %e,
"adversarial policy provider resolution failed, using primary"
);
provider.clone()
}
}
};
let llm_client: std::sync::Arc<dyn zeph_tools::PolicyLlmClient> =
std::sync::Arc::new(agent_setup::AdversarialPolicyLlmAdapter {
provider: policy_provider,
});
(Some(validator), Some(llm_client))
} else {
(None, None)
};
let effective_policy =
if config.tools.authorization.enabled && !config.tools.authorization.rules.is_empty() {
let mut merged = config.tools.policy.clone();
merged
.rules
.extend(config.tools.authorization.rules.clone());
merged.enabled = true;
merged
} else {
config.tools.policy.clone()
};
let policy_enforcer = if effective_policy.enabled {
match zeph_tools::PolicyEnforcer::compile(&effective_policy) {
Ok(enforcer) => Some(std::sync::Arc::new(enforcer)),
Err(e) => {
tracing::error!("failed to compile policy rules, policy enforcement disabled: {e}");
None
}
}
} else {
None
};
let mcp_registry = create_mcp_registry(
config,
&provider,
&mcp_tools,
&embed_model,
app.qdrant_ops(),
)
.await;
let summary_provider = app.build_summary_provider();
let skill_paths = app.skill_paths_for_registry();
let plugin_dirs_supplier = app.plugin_dirs_supplier();
let acp_project_rules = collect_project_rules(&skill_paths);
let crate::bootstrap::WatcherBundle {
skill_watcher,
skill_reload_rx: mpsc_skill_rx,
config_watcher,
config_reload_rx: mpsc_config_rx,
} = app.build_watchers(&acp_mem_supervisor);
let config_path_owned = app.config_path().to_owned();
let (_, shutdown_rx) = AppBuilder::build_shutdown();
let broadcast_cap = config.acp.broadcast_capacity.max(1);
let (skill_reload_tx, _) = tokio::sync::broadcast::channel(broadcast_cap);
let (config_reload_tx, _) = tokio::sync::broadcast::channel(broadcast_cap);
{
let skill_tx = skill_reload_tx.clone();
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some(mpsc_skill_rx)));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "skill_reload_fwd",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let rx = cell.lock().take();
let tx = skill_tx.clone();
async move {
if let Some(mut rx) = rx {
while let Some(ev) = rx.recv().await {
let _ = tx.send(ev);
}
} else {
tracing::warn!("skill_reload_fwd factory called more than once");
}
}
},
});
}
{
let cfg_tx = config_reload_tx.clone();
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some(mpsc_config_rx)));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "config_reload_fwd",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let rx = cell.lock().take();
let tx = cfg_tx.clone();
async move {
if let Some(mut rx) = rx {
while let Some(ev) = rx.recv().await {
let _ = tx.send(ev);
}
} else {
tracing::warn!("config_reload_fwd factory called more than once");
}
}
},
});
}
#[cfg(feature = "scheduler")]
let (scheduler_executor, scheduler_update_tx, scheduler_custom_tx) = {
let exp_deps = {
use std::sync::Arc;
if config.experiments.enabled && config.experiments.schedule.enabled {
let p = provider.clone();
Some((Arc::new(p), Some(Arc::clone(&memory))))
} else {
None
}
};
let five_signal = memory.five_signal_runtime();
match crate::scheduler::init_scheduler(
config,
shutdown_rx.clone(),
exp_deps,
five_signal,
Some(&acp_mem_supervisor),
)
.await
{
Some(result) => {
let exec = std::sync::Arc::new(result.executor);
let custom_rx = result.custom_rx;
let (ctx, _) = tokio::sync::broadcast::channel::<String>(broadcast_cap);
let ctx_clone = ctx.clone();
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some(custom_rx)));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "sched_custom_fwd",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let rx = cell.lock().take();
let tx = ctx_clone.clone();
async move {
if let Some(mut rx) = rx {
while let Some(ev) = rx.recv().await {
let _ = tx.send(ev);
}
} else {
tracing::warn!("sched_custom_fwd factory called more than once");
}
}
},
});
let update_tx = if let Some(update_rx) = result.update_rx {
let (utx, _) = tokio::sync::broadcast::channel::<String>(broadcast_cap);
let utx_clone = utx.clone();
let cell = std::sync::Arc::new(parking_lot::Mutex::new(Some(update_rx)));
acp_mem_supervisor.spawn(zeph_common::task_supervisor::TaskDescriptor {
name: "sched_update_fwd",
restart: zeph_common::task_supervisor::RestartPolicy::RunOnce,
factory: move || {
let rx = cell.lock().take();
let tx = utx_clone.clone();
async move {
if let Some(mut rx) = rx {
while let Some(ev) = rx.recv().await {
let _ = tx.send(ev);
}
} else {
tracing::warn!(
"sched_update_fwd factory called more than once"
);
}
}
},
});
Some(utx)
} else {
None
};
let (update_tx, custom_tx) = (update_tx, Some(ctx));
(Some(exec), update_tx, custom_tx)
}
None => (None, None, None),
}
};
let session_config = zeph_core::AgentSessionConfig::from_config(config, budget_tokens);
let (resume_condenser_built, resume_token_counter_built) =
zeph_core::provider_factory::build_resume_condenser(config, &provider);
let feedback_classifier = app.build_feedback_classifier(&provider);
let provider_config_snapshot = agent_setup::build_provider_config_snapshot(config);
let deps = SharedAgentDeps {
provider,
embedding_provider,
registry,
matcher,
max_active_skills: config.skills.max_active_skills.get(),
skill_disambiguation_threshold: config.skills.disambiguation_threshold,
skill_two_stage_matching: config.skills.two_stage_matching,
skill_confusability_threshold: config.skills.confusability_threshold,
skill_generation_provider: config.skills.generation_provider.as_str().to_owned(),
skill_disambiguate_provider: config.skills.disambiguate_provider.as_str().to_owned(),
semantic_scan: config.skills.semantic_scan,
semantic_scan_provider: config.skills.semantic_scan_provider.as_str().to_owned(),
tool_executor,
permission_policy,
policy_enforcer,
adversarial_policy_validator,
adversarial_policy_llm_client,
skill_paths,
skill_reload_tx,
config_reload_tx,
memory,
history_limit: config.memory.history_limit,
recall_limit: config.memory.semantic.recall_limit,
summarization_threshold: config.memory.summarization_threshold,
shutdown_rx,
config_path: config_path_owned,
mcp_tools,
mcp_registry,
mcp_manager,
mcp_shared_tools,
mcp_config: config.mcp.clone(),
summary_provider,
judge_provider: app.build_judge_provider(),
feedback_classifier,
#[cfg(feature = "classifiers")]
classifiers_config: config.classifiers.clone(),
#[cfg(feature = "classifiers")]
pii_filter_enabled: config.security.pii_filter.enabled,
causal_ipi_config: config.security.causal_ipi.clone(),
causal_provider: config
.security
.causal_ipi
.provider
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|name| match crate::bootstrap::create_named_provider(name, config) {
Ok(p) => {
tracing::info!(provider = %name, "causal IPI dedicated provider configured (acp)");
Some(p)
}
Err(e) => {
tracing::warn!(
provider = %name,
error = %e,
"causal IPI provider resolution failed, falling back to primary (acp)"
);
None
}
}),
nli_config: config.security.content_isolation.nli.clone(),
nli_provider: config
.security
.content_isolation
.nli
.provider
.as_non_empty()
.and_then(|name| match crate::bootstrap::create_named_provider(name, config) {
Ok(p) => {
tracing::info!(provider = %name, "NLI dedicated provider configured (acp)");
Some(p)
}
Err(e) => {
tracing::warn!(
provider = %name,
error = %e,
"NLI provider resolution failed, falling back to primary (acp)"
);
None
}
}),
secret_registry: app.secret_registry(),
vigil_config: config.security.vigil.clone(),
probe_provider: app.build_probe_provider(),
planner_provider: app.build_planner_provider(),
verify_provider: app.build_verify_provider(),
orchestrator_provider: app.build_orchestrator_provider(),
predicate_provider: app.build_predicate_provider(),
quarantine_provider: app.build_quarantine_provider(),
guardrail_provider: app.build_guardrail_provider(),
audit_logger: acp_audit_logger,
hooks_config: config.hooks.clone(),
session_config,
session_persistence_config: config.session.clone(),
resume_condenser: resume_condenser_built,
resume_token_counter: resume_token_counter_built,
provider_pool: config.llm.providers.clone(),
provider_config_snapshot,
focus_config: config.agent.focus.clone(),
sidequest_config: config.memory.sidequest.clone(),
trajectory_config: config.memory.trajectory.clone(),
category_config: config.memory.category.clone(),
tool_filter_config: config.agent.tool_filter.clone(),
acp_agent_name: config.acp.agent_name.clone(),
acp_agent_version: config.acp.agent_version.clone(),
acp_max_sessions: config.acp.max_sessions,
acp_session_idle_timeout_secs: config.acp.session_idle_timeout_secs,
acp_permission_file: config.acp.permission_file.clone(),
acp_available_models: std::sync::Arc::new(RwLock::new(
if config.acp.available_models.is_empty() {
discover_models_from_config(config).await
} else {
config.acp.available_models.clone()
},
)),
acp_auth_bearer_token: config.acp.auth_token.clone(),
acp_discovery_enabled: config.acp.discovery_enabled,
acp_title_max_chars: config.memory.sessions.title_max_chars,
acp_max_history: config.memory.sessions.max_history,
acp_log_file: if config.logging.file.is_empty() {
None
} else {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Some(
resolve_runtime_path(std::path::Path::new(&config.logging.file), &cwd)
.display()
.to_string(),
)
},
sqlite_path: crate::db_url::resolve_db_url(config).to_owned(),
acp_provider_factory: Some(build_acp_provider_factory(config, app.secret_registry())),
acp_provider_names: acp_provider_names(config),
acp_project_rules,
acp_additional_directories: config.acp.additional_directories.clone(),
acp_auth_methods: config.acp.auth_methods.clone(),
acp_message_ids_enabled: config.acp.message_ids_enabled,
acp_timeouts: config.acp.timeouts.clone(),
acp_model_config: config.acp.model_config.clone(),
plugin_dirs_supplier: std::sync::Arc::new(plugin_dirs_supplier),
#[cfg(feature = "scheduler")]
scheduler_executor,
#[cfg(feature = "scheduler")]
scheduler_update_tx,
#[cfg(feature = "scheduler")]
scheduler_custom_tx,
startup_shell_overlay: {
let mut blocked = config.tools.shell.blocked_commands.clone();
blocked.sort();
let mut allowed = config.tools.shell.allowed_commands.clone();
allowed.sort();
zeph_core::ShellOverlaySnapshot { blocked, allowed }
},
shell_policy_handle,
};
let keepalive: Box<dyn std::any::Any> = Box::new((skill_watcher, config_watcher));
Ok((deps, keepalive))
}
#[cfg(feature = "acp")]
const SESSION_LOCK_DEGRADED_MESSAGE: &str =
"Session persistence unavailable: another process already holds this session's write lock.";
#[cfg(feature = "acp")]
async fn notify_lock_degraded(
status_notifier: Option<&zeph_acp::SessionStatusNotifier>,
channel: &mut zeph_core::channel::LoopbackChannel,
) {
if let Some(notifier) = status_notifier {
notifier.notify_status_nowait(SESSION_LOCK_DEGRADED_MESSAGE);
} else {
let _ = channel.send_status(SESSION_LOCK_DEGRADED_MESSAGE).await;
}
}
#[cfg(feature = "acp")]
async fn open_session_log_or_notify_locked(
session_path: &std::path::Path,
status_notifier: Option<&zeph_acp::SessionStatusNotifier>,
channel: &mut zeph_core::channel::LoopbackChannel,
) -> Option<std::sync::Arc<zeph_session::SessionEventLog>> {
match zeph_session::SessionEventLog::open_exclusive(session_path).await {
Ok(log) => Some(std::sync::Arc::new(log)),
Err(zeph_session::SessionError::AlreadyLocked(lock_path)) => {
tracing::error!(
lock_path,
"failed to open session event log for ACP session: another process \
already holds this session's write lock; session persistence disabled \
for this session"
);
notify_lock_degraded(status_notifier, channel).await;
None
}
Err(e) => {
tracing::warn!(error = %e, "failed to open session event log for ACP session; session persistence disabled for this session");
None
}
}
}
#[cfg(feature = "acp")]
#[allow(clippy::too_many_lines)]
async fn spawn_acp_agent(
d: std::sync::Arc<SharedAgentDeps>,
mut channel: zeph_core::channel::LoopbackChannel,
acp_ctx: Option<zeph_acp::AcpContext>,
session_ctx: zeph_acp::SessionContext,
) {
use std::sync::Arc;
let provider = d.provider.clone();
let registry = Arc::clone(&d.registry);
let matcher = d.matcher.clone();
let max_active_skills = d.max_active_skills;
let skill_disambiguation_threshold = d.skill_disambiguation_threshold;
let skill_two_stage_matching = d.skill_two_stage_matching;
let skill_confusability_threshold = d.skill_confusability_threshold;
let skill_generation_provider = d.skill_generation_provider.clone();
let skill_disambiguate_provider = d.skill_disambiguate_provider.clone();
let semantic_scan = d.semantic_scan;
let semantic_scan_provider = d.semantic_scan_provider.clone();
let tool_executor = Arc::clone(&d.tool_executor);
let permission_policy = d.permission_policy.clone();
let skill_paths = d.skill_paths.clone();
let plugin_dirs_supplier = Arc::clone(&d.plugin_dirs_supplier);
let memory = Arc::clone(&d.memory);
let history_limit = d.history_limit;
let recall_limit = d.recall_limit;
let summarization_threshold = d.summarization_threshold;
let shutdown_rx = d.shutdown_rx.clone();
let config_path = d.config_path.clone();
let mcp_tools = d.mcp_tools.clone();
let mcp_registry = d.mcp_registry.clone();
let mcp_manager = Arc::clone(&d.mcp_manager);
let mcp_shared_tools = Arc::clone(&d.mcp_shared_tools);
let mcp_config = d.mcp_config.clone();
let summary_provider = d.summary_provider.clone();
let judge_provider = d.judge_provider.clone();
let feedback_classifier = d.feedback_classifier.clone();
#[cfg(feature = "classifiers")]
let classifiers_config = d.classifiers_config.clone();
#[cfg(feature = "classifiers")]
let pii_filter_enabled = d.pii_filter_enabled;
let causal_ipi_config = d.causal_ipi_config.clone();
let causal_provider = d.causal_provider.clone();
let nli_config = d.nli_config.clone();
let nli_provider = d.nli_provider.clone();
let secret_registry = d.secret_registry.clone();
let vigil_config = d.vigil_config.clone();
let probe_provider = d.probe_provider.clone();
let planner_provider = d.planner_provider.clone();
let verify_provider = d.verify_provider.clone();
let orchestrator_provider = d.orchestrator_provider.clone();
let predicate_provider = d.predicate_provider.clone();
let quarantine_provider = d.quarantine_provider.clone();
let guardrail_provider = d.guardrail_provider.clone();
let session_config = d.session_config.clone();
let session_persistence_config = d.session_persistence_config.clone();
let provider_pool = d.provider_pool.clone();
let provider_config_snapshot = d.provider_config_snapshot.clone();
let managed_skills_dir = crate::bootstrap::managed_skills_dir();
let skill_reload_tx = d.skill_reload_tx.clone();
let config_reload_tx = d.config_reload_tx.clone();
#[cfg(feature = "scheduler")]
let scheduler_executor = d.scheduler_executor.as_ref().map(std::sync::Arc::clone);
#[cfg(feature = "scheduler")]
let scheduler_update_tx = d.scheduler_update_tx.clone();
#[cfg(feature = "scheduler")]
let scheduler_custom_tx = d.scheduler_custom_tx.clone();
let hooks_config = d.hooks_config.clone();
let tool_filter_config = d.tool_filter_config.clone();
let status_notifier = acp_ctx.as_ref().map(|ctx| ctx.status_notifier.clone());
let adapter_cancel = zeph_memory::CancellationToken::new();
let reload_rx = broadcast_to_mpsc(skill_reload_tx.subscribe(), adapter_cancel.clone());
let config_reload_rx = broadcast_to_mpsc(config_reload_tx.subscribe(), adapter_cancel.clone());
#[cfg(feature = "scheduler")]
let scheduler_update_rx = scheduler_update_tx
.as_ref()
.map(|tx| broadcast_to_mpsc(tx.subscribe(), adapter_cancel.clone()));
#[cfg(feature = "scheduler")]
let scheduler_custom_rx = scheduler_custom_tx
.as_ref()
.map(|tx| broadcast_to_mpsc(tx.subscribe(), adapter_cancel.clone()));
let debug_config = session_config.debug_config.clone();
let memory_validation_config = session_config.security.memory_validation.clone();
let memory_executor = zeph_core::memory_tools::MemoryToolExecutor::with_validator(
Arc::clone(&memory),
session_ctx
.conversation_id
.unwrap_or(zeph_memory::ConversationId(0)),
zeph_sanitizer::memory_validation::MemoryWriteValidator::new(memory_validation_config),
);
let overflow_executor = {
let mut ex =
zeph_core::overflow_tools::OverflowToolExecutor::new(Arc::new(memory.sqlite().clone()));
if let Some(cid) = session_ctx.conversation_id {
ex = ex.with_conversation(cid.0);
}
ex
};
let skill_loader_executor = zeph_core::SkillLoaderExecutor::new(Arc::clone(®istry));
let (base_composite, cancel_signal, provider_override, parent_tool_use_id): (
Arc<dyn ErasedToolExecutor>,
_,
_,
_,
) = if let Some(ctx) = acp_ctx {
let cancel_signal = Arc::clone(&ctx.cancel_signal);
let provider_override = Arc::clone(&ctx.provider_override);
let parent_tool_use_id = ctx.parent_tool_use_id.clone();
let adapter_cancel_clone = adapter_cancel.clone();
let cancel_signal_clone = Arc::clone(&cancel_signal);
tokio::spawn(async move {
cancel_signal_clone.notified().await;
adapter_cancel_clone.cancel();
});
let mut base: Arc<dyn ErasedToolExecutor> = Arc::clone(&tool_executor) as Arc<_>;
if let Some(fs) = ctx.file_executor {
let filtered = zeph_tools::ToolFilter::new(
zeph_tools::DynExecutor(base),
&["read", "write", "glob"],
);
base = Arc::new(zeph_tools::CompositeExecutor::new(fs, filtered));
}
if let Some(shell) = ctx.shell_executor {
base = Arc::new(zeph_tools::CompositeExecutor::new(
shell,
zeph_tools::DynExecutor(base),
));
}
base = Arc::new(zeph_tools::CompositeExecutor::new(
skill_loader_executor,
zeph_tools::CompositeExecutor::new(
memory_executor,
zeph_tools::CompositeExecutor::new(
overflow_executor,
zeph_tools::DynExecutor(base),
),
),
));
(
base,
Some(cancel_signal),
Some(provider_override),
parent_tool_use_id,
)
} else {
let base: Arc<dyn ErasedToolExecutor> = Arc::new(zeph_tools::CompositeExecutor::new(
skill_loader_executor,
zeph_tools::CompositeExecutor::new(
memory_executor,
zeph_tools::CompositeExecutor::new(
overflow_executor,
zeph_tools::DynExecutor(Arc::clone(&tool_executor) as Arc<_>),
),
),
));
(base, None, None, None)
};
let (trust_gated, mcp_ids_handle) = crate::agent_setup::apply_common_tool_gating(
zeph_tools::DynExecutor(base_composite),
&permission_policy,
);
crate::agent_setup::register_mcp_tool_ids(&mcp_ids_handle, &mcp_tools);
let adversarial_gated: zeph_tools::DynExecutor = if let (Some(validator), Some(llm_client)) = (
d.adversarial_policy_validator.as_ref(),
d.adversarial_policy_llm_client.as_ref(),
) {
let mut gate = zeph_tools::AdversarialPolicyGateExecutor::new(
trust_gated,
Arc::clone(validator),
Arc::clone(llm_client),
);
if let Some(ref audit) = d.audit_logger {
gate = gate.with_audit(Arc::clone(audit));
}
zeph_tools::DynExecutor(Arc::new(gate))
} else {
trust_gated
};
let tool_executor: zeph_tools::DynExecutor = if let Some(enforcer) = d.policy_enforcer.as_ref()
{
let policy_context = Arc::new(RwLock::new(zeph_tools::PolicyContext {
trust_level: zeph_common::SkillTrustLevel::Trusted,
env: std::env::vars().collect(),
}));
let gate = zeph_tools::PolicyGateExecutor::new(
adversarial_gated,
Arc::clone(enforcer),
policy_context,
);
zeph_tools::DynExecutor(Arc::new(gate))
} else {
adversarial_gated
};
let mut acp_session_sink = None;
let mut preloaded_messages: Vec<zeph_llm::provider::Message> = Vec::new();
if session_persistence_config.enabled {
let sid = zeph_common::SessionId::new(session_ctx.session_id.to_string());
let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
if let Err(e) = store.create(sid.as_str()).await {
tracing::warn!(error = %e, session_id = %sid, "failed to create session-store row for ACP session");
}
let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
let session_path = zeph_session::session_dir(&data_dir, sid.as_str());
let log = if let Some(cid) = session_ctx.conversation_id {
match zeph_agent_persistence::hydrate_and_condense(
&session_path,
&store,
sid.as_str(),
cid,
&memory,
None,
&d.resume_condenser,
d.resume_token_counter.as_ref(),
d.session_config.budget_tokens,
)
.await
{
Ok(hydrated) => {
preloaded_messages = hydrated.messages;
Some(hydrated.log)
}
Err(zeph_agent_persistence::PersistenceError::Session(
zeph_session::SessionError::AlreadyLocked(lock_path),
)) => {
tracing::error!(
lock_path,
"session hydration failed: another process already holds this session's \
write lock; session persistence disabled for this session"
);
notify_lock_degraded(status_notifier.as_ref(), &mut channel).await;
None
}
Err(e) => {
tracing::warn!(error = %e, "session hydration failed; session persistence disabled for this session");
None
}
}
} else {
open_session_log_or_notify_locked(&session_path, status_notifier.as_ref(), &mut channel)
.await
};
if let Some(log) = log {
acp_session_sink = Some(Arc::new(zeph_agent_persistence::SessionSink::new(
log, store, sid,
)));
}
}
let mut agent = Box::pin(
Agent::new_with_registry_arc(
provider.clone(),
d.embedding_provider.clone(),
channel,
Arc::clone(®istry),
matcher,
max_active_skills,
tool_executor,
)
.apply_session_config(session_config)
.with_skill_matching_config(
skill_disambiguation_threshold,
skill_two_stage_matching,
skill_confusability_threshold,
)
.with_skill_provider_names(skill_generation_provider, skill_disambiguate_provider)
.with_semantic_scan(semantic_scan, semantic_scan_provider)
.with_working_dir(session_ctx.working_dir.clone())
.with_skill_reload(skill_paths, reload_rx)
.with_plugin_dirs_supplier(move || plugin_dirs_supplier())
.with_managed_skills_dir(managed_skills_dir)
.with_shutdown(shutdown_rx)
.with_config_reload(config_path, config_reload_rx)
.with_plugins_dir(
crate::bootstrap::plugins_dir(),
d.startup_shell_overlay.clone(),
)
.with_shell_policy_handle(d.shell_policy_handle.clone())
.with_mcp(
mcp_tools,
mcp_registry,
Some(Arc::clone(&mcp_manager)),
&mcp_config,
)
.with_mcp_shared_tools(mcp_shared_tools)
.with_focus_and_sidequest_config(d.focus_config.clone(), d.sidequest_config.clone())
.with_trajectory_and_category_config(d.trajectory_config.clone(), d.category_config.clone())
.with_provider_pool(provider_pool, provider_config_snapshot)
.with_embedding_provider(d.embedding_provider.clone())
.maybe_init_tool_schema_filter(tool_filter_config, provider.clone()),
)
.await;
agent = agent.with_acp_session(true);
if let Some(ref logger) = d.audit_logger {
agent = agent.with_audit_logger(std::sync::Arc::clone(logger));
}
#[cfg(feature = "scheduler")]
{
if let Some(rx) = scheduler_update_rx {
agent = agent.with_update_notifications(rx);
}
if let Some(rx) = scheduler_custom_rx {
agent = agent.with_custom_task_rx(rx);
}
if let Some(sched_exec) = scheduler_executor {
agent = agent
.add_tool_executor(crate::scheduler_executor::DynSchedulerExecutor(sched_exec));
}
}
if let Some(cid) = session_ctx.conversation_id {
agent = agent.with_memory(
Arc::clone(&memory),
cid,
history_limit,
recall_limit,
summarization_threshold,
);
}
if !preloaded_messages.is_empty() {
agent = agent.with_preloaded_messages(preloaded_messages);
}
if let Some(sink) = acp_session_sink {
agent = agent
.with_session_sink(Some(sink))
.with_session_persistence_config(Some(session_persistence_config.clone()));
}
if let Some(signal) = cancel_signal {
agent = agent.with_cancel_signal(signal);
}
if let Some(slot) = provider_override {
agent = agent.with_provider_override(slot);
}
if let Some(parent_id) = parent_tool_use_id {
agent = agent.with_parent_tool_use_id(parent_id);
}
if let Some(sp) = summary_provider {
agent = agent.with_summary_provider(sp);
}
if let Some(jp) = judge_provider {
agent = agent.with_judge_provider(jp);
}
if let Some(fc) = feedback_classifier {
agent = agent.with_llm_classifier(fc);
}
if let Some(pp) = probe_provider {
agent = agent.with_probe_provider(pp);
}
if let Some(pp) = planner_provider {
agent = agent.with_planner_provider(pp);
}
if let Some(vp) = verify_provider {
agent = agent.with_verify_provider(vp);
}
if let Some(op) = orchestrator_provider {
agent = agent.with_orchestrator_provider(op);
}
if let Some(pp) = predicate_provider {
agent = agent.with_predicate_provider(pp);
}
agent = agent_setup::apply_quarantine_provider(agent, quarantine_provider);
{
agent = agent_setup::apply_guardrail(agent, guardrail_provider);
}
#[cfg(feature = "classifiers")]
{
agent = agent_setup::apply_injection_classifier_with_cfg(agent, &classifiers_config);
if classifiers_config.enabled {
agent = agent.with_enforcement_mode(classifiers_config.enforcement_mode);
}
agent = agent_setup::apply_three_class_classifier_with_cfg(agent, &classifiers_config);
agent = agent_setup::apply_pii_classifier_with_cfg(agent, &classifiers_config);
agent = agent_setup::apply_pii_ner_classifier_with_cfg(
agent,
&classifiers_config,
pii_filter_enabled,
);
}
agent = agent_setup::apply_causal_analyzer_with_cfg(
agent,
provider.clone(),
causal_provider,
&causal_ipi_config,
secret_registry.as_ref(),
);
agent = agent_setup::apply_nli_sanitizer_with_cfg(
agent,
provider.clone(),
nli_provider,
&nli_config,
secret_registry.as_ref(),
);
agent = agent_setup::apply_secret_masking(agent, secret_registry);
agent = agent_setup::apply_vigil(agent, &vigil_config);
if debug_config.enabled {
let session_dump_dir = debug_config
.output_dir
.join(session_ctx.session_id.to_string());
agent =
agent_setup::apply_debug_dumper(agent, session_dump_dir.as_path(), debug_config.format)
.0;
}
agent = agent.with_hooks_config(&hooks_config);
agent = agent.with_mcp_tool_ids_handle(mcp_ids_handle);
drop(d);
if let Err(e) = agent.load_history().await {
tracing::error!("failed to load agent history: {e:#}");
}
if let Err(e) = Box::pin(agent.run()).await {
tracing::error!("ACP agent loop error: {e:#}");
}
agent.shutdown().await;
adapter_cancel.cancel();
}
#[cfg(feature = "acp")]
async fn discover_models_from_config(config: &zeph_core::config::Config) -> Vec<String> {
use zeph_llm::model_cache::ModelCache;
async fn expand_from_cache(slug: &str, fallback: &str) -> Vec<String> {
let cache = ModelCache::for_slug(slug);
if !cache.is_stale_async().await
&& let Ok(Some(entries)) = cache.load_async().await
&& !entries.is_empty()
{
return entries
.into_iter()
.map(|m| format!("{slug}:{}", m.id))
.collect();
}
vec![format!("{slug}:{fallback}")]
}
let mut models: Vec<String> = Vec::new();
for entry in &config.llm.providers {
let slug = entry.provider_type.as_str();
let fallback = entry.model.as_deref().unwrap_or("unknown");
models.extend(expand_from_cache(slug, fallback).await);
}
models.dedup();
models
}
#[cfg(feature = "acp")]
async fn warm_model_caches(
provider: zeph_llm::any::AnyProvider,
available_models: std::sync::Arc<RwLock<Vec<String>>>,
) {
use zeph_llm::model_cache::ModelCache;
let provider_count = {
let models = available_models.read();
models
.iter()
.filter_map(|k| k.split_once(':').map(|(slug, _)| slug))
.collect::<std::collections::HashSet<_>>()
.len()
};
tracing::info!(
providers = provider_count,
"warming model caches in background"
);
let fetch = async move {
match provider.list_models_remote().await {
Ok(models) => tracing::info!(models = models.len(), "model cache fetch completed"),
Err(e) => {
tracing::info!(error = %e, "model cache warm-up failed; keeping fallback list");
}
}
};
if tokio::time::timeout(std::time::Duration::from_secs(5), fetch)
.await
.is_err()
{
tracing::info!("model cache warm-up timed out; keeping fallback list");
return;
}
let slugs: Vec<String> = {
let models = available_models.read();
models
.iter()
.filter_map(|k| k.split_once(':').map(|(s, _)| s.to_owned()))
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect()
};
for slug in slugs {
let cache = ModelCache::for_slug(&slug);
if cache.is_stale_async().await {
tracing::info!(provider = %slug, "model cache still stale after warm-up");
continue;
}
if let Ok(Some(entries)) = cache.load_async().await
&& !entries.is_empty()
{
let new_keys: Vec<String> = entries
.into_iter()
.map(|m| format!("{slug}:{}", m.id))
.collect();
let count = new_keys.len();
let mut models = available_models.write();
models.retain(|k| !k.starts_with(&format!("{slug}:")));
models.extend(new_keys);
models.dedup();
tracing::info!(provider = %slug, models = count, "model cache ready");
}
}
let total_models = available_models.read().len();
tracing::info!(models = total_models, "model cache warming finished");
}
#[cfg(feature = "acp")]
#[allow(clippy::too_many_lines)]
fn build_acp_provider_factory(
config: &zeph_core::config::Config,
secret_registry: Option<std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
) -> zeph_acp::ProviderFactory {
#[derive(Clone)]
enum ProviderSnapshot {
Ollama {
base_url: String,
embed: String,
},
Claude {
api_key: String,
max_tokens: u32,
},
OpenAi {
api_key: String,
base_url: String,
max_tokens: u32,
embed: Option<String>,
reasoning_effort: Option<String>,
},
Compatible {
api_key: String,
base_url: String,
max_tokens: u32,
embed: Option<String>,
name: String,
},
}
let mut snapshots: Vec<ProviderSnapshot> = Vec::new();
for entry in &config.llm.providers {
let name = entry.effective_name();
match entry.provider_type {
zeph_core::config::ProviderKind::Ollama => {
snapshots.push(ProviderSnapshot::Ollama {
base_url: entry
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_owned()),
embed: config.llm.embedding_model.clone(),
});
}
zeph_core::config::ProviderKind::Claude => {
if let Some(ref secret) = config.secrets.claude_api_key {
snapshots.push(ProviderSnapshot::Claude {
api_key: secret.expose().to_owned(),
max_tokens: entry.max_tokens.unwrap_or(4096),
});
}
}
zeph_core::config::ProviderKind::OpenAi => {
if let Some(ref secret) = config.secrets.openai_api_key {
snapshots.push(ProviderSnapshot::OpenAi {
api_key: secret.expose().to_owned(),
base_url: entry
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1".to_owned()),
max_tokens: entry.max_tokens.unwrap_or(4096),
embed: entry.embedding_model.clone(),
reasoning_effort: entry.reasoning_effort.clone(),
});
}
}
zeph_core::config::ProviderKind::Compatible => {
let secret = entry
.api_key
.as_deref()
.map(std::borrow::ToOwned::to_owned)
.or_else(|| {
config
.secrets
.compatible_api_keys
.get(&name)
.map(|s| s.expose().to_owned())
});
if let Some(api_key) = secret {
snapshots.push(ProviderSnapshot::Compatible {
api_key,
base_url: entry.base_url.clone().unwrap_or_default(),
max_tokens: entry.max_tokens.unwrap_or(4096),
embed: entry.embedding_model.clone(),
name,
});
}
}
_ => {}
}
}
let masker: Option<std::sync::Arc<dyn zeph_llm::masking::OutboundMasker>> =
secret_registry.map(|r| r as std::sync::Arc<dyn zeph_llm::masking::OutboundMasker>);
let snapshots = std::sync::Arc::new(snapshots);
std::sync::Arc::new(move |key: &str| {
let wrap = |p: zeph_llm::any::AnyProvider| -> zeph_llm::any::AnyProvider {
match &masker {
Some(m) => p.masked(std::sync::Arc::clone(m)),
None => p,
}
};
let (provider_name, model) = key.split_once(':')?;
let model = model.to_owned();
for snapshot in snapshots.as_ref() {
match snapshot {
ProviderSnapshot::Ollama {
base_url, embed, ..
} if provider_name == "ollama" => {
let mut p = zeph_llm::ollama::OllamaProvider::new(
base_url,
model.clone(),
embed.clone(),
);
p.set_context_window(0);
return Some(wrap(zeph_llm::any::AnyProvider::Ollama(p)));
}
ProviderSnapshot::Claude {
api_key,
max_tokens,
} if provider_name == "claude" => {
return Some(wrap(zeph_llm::any::AnyProvider::Claude(
zeph_llm::claude::ClaudeProvider::new(
api_key.clone(),
model.clone(),
*max_tokens,
),
)));
}
ProviderSnapshot::OpenAi {
api_key,
base_url,
max_tokens,
embed,
reasoning_effort,
} if provider_name == "openai" => {
return Some(wrap(zeph_llm::any::AnyProvider::OpenAi(
zeph_llm::openai::OpenAiProvider::new(zeph_llm::openai::OpenAiConfig {
api_key: api_key.clone(),
base_url: base_url.clone(),
model: model.clone(),
max_tokens: *max_tokens,
embedding_model: embed.clone(),
reasoning_effort: reasoning_effort.clone(),
context_window: None,
completion_tokens_param: None,
}),
)));
}
ProviderSnapshot::Compatible {
api_key,
base_url,
max_tokens,
embed,
name,
} if provider_name == name => {
return Some(wrap(zeph_llm::any::AnyProvider::Compatible(
zeph_llm::compatible::CompatibleProvider::new(
zeph_llm::compatible::CompatibleConfig {
provider_name: name.clone(),
api_key: api_key.clone(),
base_url: base_url.clone(),
model: model.clone(),
max_tokens: *max_tokens,
embedding_model: embed.clone(),
completion_tokens_param: None,
},
),
)));
}
_ => {}
}
}
None
})
}
#[cfg(feature = "acp")]
fn acp_provider_names(config: &zeph_core::config::Config) -> Vec<(String, zeph_acp::LlmProtocol)> {
config
.llm
.providers
.iter()
.map(|entry| {
let protocol = match entry.provider_type {
zeph_core::config::ProviderKind::Claude => zeph_acp::LlmProtocol::Anthropic,
zeph_core::config::ProviderKind::OpenAi
| zeph_core::config::ProviderKind::Compatible => zeph_acp::LlmProtocol::OpenAi,
other => zeph_acp::LlmProtocol::Other(other.as_str().to_owned()),
};
(entry.effective_name(), protocol)
})
.collect()
}
#[cfg(feature = "acp")]
fn collect_project_rules(skill_paths: &[PathBuf]) -> Vec<PathBuf> {
let mut rules = Vec::new();
let rules_dir = std::path::Path::new(".claude/rules");
if rules_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(rules_dir)
{
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "md"))
.collect();
paths.sort();
rules.extend(paths);
}
for sp in skill_paths {
if sp.is_file() {
rules.push(sp.clone());
}
}
rules
}
#[cfg(feature = "acp")]
pub(crate) async fn run_acp_server(
config_path: Option<&std::path::Path>,
vault_backend: Option<&str>,
vault_key: Option<&std::path::Path>,
vault_path: Option<&std::path::Path>,
cli_additional_dirs: Vec<std::path::PathBuf>,
cli_auth_methods: Vec<String>,
cli_message_ids: Option<bool>,
) -> anyhow::Result<()> {
use std::sync::Arc;
let app = AppBuilder::new(config_path, vault_backend, vault_key, vault_path).await?;
let (mut deps, _keepalive) = Box::pin(build_acp_deps(&app, None, None)).await?;
let available_models = std::sync::Arc::clone(&deps.acp_available_models);
let provider = deps.provider.clone();
warm_model_caches(provider, available_models).await;
let effective_additional_dirs = if cli_additional_dirs.is_empty() {
deps.acp_additional_directories.clone()
} else {
cli_additional_dirs
.into_iter()
.map(|p| {
zeph_core::config::AdditionalDir::parse(p.clone()).map_err(|e| {
anyhow::anyhow!("invalid --acp-additional-dir {}: {e}", p.display())
})
})
.collect::<anyhow::Result<Vec<_>>>()?
};
let effective_auth_methods = if cli_auth_methods.is_empty() {
let methods = deps.acp_auth_methods.clone();
anyhow::ensure!(
!methods.is_empty(),
"acp.auth_methods must not be empty; set at least one method (e.g. \"agent\")"
);
methods
} else {
let methods: Vec<_> = cli_auth_methods
.iter()
.map(|m| match m.as_str() {
"agent" => Ok(zeph_core::config::AcpAuthMethod::Agent),
other => Err(anyhow::anyhow!(
"unknown --acp-auth-method {other:?}; accepted values: agent"
)),
})
.collect::<anyhow::Result<Vec<_>>>()?;
anyhow::ensure!(
!methods.is_empty(),
"--acp-auth-method list must not be empty after parsing"
);
methods
};
let effective_message_ids = cli_message_ids.unwrap_or(deps.acp_message_ids_enabled);
let mcp_manager_for_acp = Arc::clone(&deps.mcp_manager);
let server_config = zeph_acp::AcpServerConfig {
agent_name: deps.acp_agent_name.clone(),
agent_version: deps.acp_agent_version.clone(),
max_sessions: deps.acp_max_sessions,
session_idle_timeout_secs: deps.acp_session_idle_timeout_secs,
permission_file: deps.acp_permission_file.clone(),
provider_factory: deps.acp_provider_factory.take(),
available_models: std::sync::Arc::clone(&deps.acp_available_models),
provider_names: deps.acp_provider_names.clone(),
mcp_manager: Some(mcp_manager_for_acp),
auth_bearer_token: deps.acp_auth_bearer_token.clone(),
discovery_enabled: deps.acp_discovery_enabled,
terminal_timeout_secs: deps.acp_timeouts.terminal_secs,
project_rules: deps.acp_project_rules.clone(),
title_max_chars: deps.acp_title_max_chars,
max_history: deps.acp_max_history,
sqlite_path: Some(deps.sqlite_path.clone()),
session_data_dir: deps
.session_persistence_config
.enabled
.then(|| std::path::PathBuf::from(&deps.session_persistence_config.data_dir)),
ready_notification: Some(zeph_acp::transport::ReadyNotification {
version: deps.acp_agent_version.clone(),
pid: std::process::id(),
log_file: deps.acp_log_file.clone(),
}),
additional_directories: effective_additional_dirs,
auth_methods: effective_auth_methods,
message_ids_enabled: effective_message_ids,
timeouts: deps.acp_timeouts.clone(),
model_config: deps.acp_model_config.clone(),
};
let shared = Arc::new(deps);
let spawner: zeph_acp::AgentSpawner = Arc::new(move |channel, acp_ctx, session_ctx| {
let shared = Arc::clone(&shared);
Box::pin(spawn_acp_agent(shared, channel, acp_ctx, session_ctx))
});
zeph_acp::serve_stdio(spawner, server_config).await?;
Ok(())
}
#[cfg(feature = "acp-http")]
pub(crate) async fn run_acp_http_server(
config_path: Option<&std::path::Path>,
vault_backend: Option<&str>,
vault_key: Option<&std::path::Path>,
vault_path: Option<&std::path::Path>,
bind_override: Option<&str>,
auth_token_override: Option<String>,
) -> anyhow::Result<()> {
use std::sync::Arc;
use tokio::sync::RwLock;
let app = AppBuilder::new(config_path, vault_backend, vault_key, vault_path).await?;
log_acp_runtime_paths(app.config(), app.config_path());
let bind_addr = bind_override.map_or_else(|| app.config().acp.http_bind.clone(), str::to_owned);
let auth_bearer_token = auth_token_override.or(app.config().acp.auth_token.clone());
let mcp_manager_for_acp = Arc::new(crate::bootstrap::create_mcp_manager_with_vault(
app.config(),
false,
app.age_vault_arc(),
));
let server_config = zeph_acp::AcpServerConfig {
agent_name: app.config().acp.agent_name.clone(),
agent_version: app.config().acp.agent_version.clone(),
max_sessions: app.config().acp.max_sessions,
session_idle_timeout_secs: app.config().acp.session_idle_timeout_secs,
permission_file: app.config().acp.permission_file.clone(),
provider_factory: Some(build_acp_provider_factory(
app.config(),
app.secret_registry(),
)),
available_models: std::sync::Arc::new(parking_lot::RwLock::new(
if app.config().acp.available_models.is_empty() {
discover_models_from_config(app.config()).await
} else {
app.config().acp.available_models.clone()
},
)),
provider_names: acp_provider_names(app.config()),
mcp_manager: Some(Arc::clone(&mcp_manager_for_acp)),
auth_bearer_token,
discovery_enabled: app.config().acp.discovery_enabled,
terminal_timeout_secs: app.config().acp.timeouts.terminal_secs,
project_rules: collect_project_rules(&app.skill_paths_for_registry()),
title_max_chars: app.config().memory.sessions.title_max_chars,
max_history: app.config().memory.sessions.max_history,
sqlite_path: Some(crate::db_url::resolve_db_url(app.config()).to_owned()),
session_data_dir: app
.config()
.session
.enabled
.then(|| std::path::PathBuf::from(&app.config().session.data_dir)),
ready_notification: None,
additional_directories: app.config().acp.additional_directories.clone(),
auth_methods: app.config().acp.auth_methods.clone(),
message_ids_enabled: app.config().acp.message_ids_enabled,
timeouts: app.config().acp.timeouts.clone(),
model_config: app.config().acp.model_config.clone(),
};
let shared_deps: Arc<RwLock<Option<Arc<SharedAgentDeps>>>> = Arc::new(RwLock::new(None));
let shared_deps_for_spawner = Arc::clone(&shared_deps);
let spawner: zeph_acp::SendAgentSpawner = Arc::new(move |channel, acp_ctx, session_ctx| {
let shared_deps = Arc::clone(&shared_deps_for_spawner);
Box::pin(async move {
let maybe_shared = shared_deps.read().await.clone();
let Some(shared) = maybe_shared else {
tracing::warn!("ACP request received before runtime became ready");
return;
};
Box::pin(spawn_acp_agent(shared, channel, acp_ctx, session_ctx)).await;
})
});
let mut state = zeph_acp::AcpHttpState::new(spawner, server_config);
match zeph_memory::store::SqliteStore::new(crate::db_url::resolve_db_url(app.config())).await {
Ok(store) => state = state.with_store(store),
Err(e) => tracing::warn!(error = %e, "failed to open SQLite for HTTP session endpoints"),
}
let router = zeph_acp::acp_router(state.clone());
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
tracing::info!("ACP HTTP server listening on {bind_addr}");
let server_task = tokio::spawn(async move { ::axum::serve(listener, router).await });
let (deps, _keepalive) =
match Box::pin(build_acp_deps(&app, None, Some(mcp_manager_for_acp))).await {
Ok(result) => result,
Err(err) => {
server_task.abort();
return Err(err);
}
};
let available_models = std::sync::Arc::clone(&deps.acp_available_models);
let provider = deps.provider.clone();
warm_model_caches(provider, available_models).await;
*shared_deps.write().await = Some(Arc::new(deps));
state.mark_ready();
state.start_reaper();
tracing::info!("ACP server ready");
server_task.await??;
Ok(())
}
#[cfg(all(feature = "acp-http", feature = "session"))]
pub(crate) async fn build_combined_deps(
app: &AppBuilder,
supervisor: &std::sync::Arc<zeph_common::TaskSupervisor>,
) -> anyhow::Result<(
crate::serve::deps::ServeAgentDeps,
SharedAgentDeps,
Box<dyn std::any::Any>,
)> {
let core = build_shared_core(app, supervisor).await?;
let serve_deps = crate::serve::deps::assemble_serve_deps(app, &core, supervisor).await?;
let prebuilt_core = PrebuiltAcpCore {
core,
supervisor: std::sync::Arc::clone(supervisor),
};
let (acp_deps, keepalive) = Box::pin(build_acp_deps(app, Some(prebuilt_core), None)).await?;
Ok((serve_deps, acp_deps, keepalive))
}
#[cfg(all(feature = "acp-http", feature = "session"))]
pub(crate) fn acp_http_server_config(deps: &mut SharedAgentDeps) -> zeph_acp::AcpServerConfig {
zeph_acp::AcpServerConfig {
agent_name: deps.acp_agent_name.clone(),
agent_version: deps.acp_agent_version.clone(),
max_sessions: deps.acp_max_sessions,
session_idle_timeout_secs: deps.acp_session_idle_timeout_secs,
permission_file: deps.acp_permission_file.clone(),
provider_factory: deps.acp_provider_factory.take(),
available_models: std::sync::Arc::clone(&deps.acp_available_models),
provider_names: deps.acp_provider_names.clone(),
mcp_manager: Some(std::sync::Arc::clone(&deps.mcp_manager)),
auth_bearer_token: deps.acp_auth_bearer_token.clone(),
discovery_enabled: deps.acp_discovery_enabled,
terminal_timeout_secs: deps.acp_timeouts.terminal_secs,
project_rules: deps.acp_project_rules.clone(),
title_max_chars: deps.acp_title_max_chars,
max_history: deps.acp_max_history,
sqlite_path: Some(deps.sqlite_path.clone()),
session_data_dir: deps
.session_persistence_config
.enabled
.then(|| std::path::PathBuf::from(&deps.session_persistence_config.data_dir)),
ready_notification: None,
additional_directories: deps.acp_additional_directories.clone(),
auth_methods: deps.acp_auth_methods.clone(),
message_ids_enabled: deps.acp_message_ids_enabled,
timeouts: deps.acp_timeouts.clone(),
model_config: deps.acp_model_config.clone(),
}
}
#[cfg(all(feature = "acp-http", feature = "session"))]
pub(crate) async fn acp_http_ready_spawner(
deps: std::sync::Arc<SharedAgentDeps>,
) -> zeph_acp::SendAgentSpawner {
let available_models = std::sync::Arc::clone(&deps.acp_available_models);
let provider = deps.provider.clone();
warm_model_caches(provider, available_models).await;
std::sync::Arc::new(move |channel, acp_ctx, session_ctx| {
let shared = std::sync::Arc::clone(&deps);
Box::pin(spawn_acp_agent(shared, channel, acp_ctx, session_ctx))
})
}
#[cfg(feature = "acp")]
pub(crate) fn print_acp_manifest() {
let manifest = serde_json::json!({
"name": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
"transport": "stdio",
"command": [env!("CARGO_PKG_NAME"), "--acp"],
"capabilities": ["prompt", "cancel", "load_session", "set_session_mode", "config_options", "ext_methods"],
"description": "Zeph AI Agent",
"readiness": {
"notification": {
"method": "zeph/ready",
"params": {
"version": env!("CARGO_PKG_VERSION"),
"pid": "<process-id>",
"log_file": "<configured-log-file>"
}
},
"http": {
"health_endpoint": "/health",
"statuses": [200, 503]
}
}
});
println!(
"{}",
serde_json::to_string_pretty(&manifest).unwrap_or_default()
);
}
#[cfg(all(test, feature = "acp"))]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;
use zeph_tools::executor::ToolExecutor;
fn make_rules_dir(dir: &std::path::Path, files: &[&str]) {
let rules = dir.join(".claude").join("rules");
fs::create_dir_all(&rules).unwrap();
for name in files {
fs::write(rules.join(name), b"").unwrap();
}
}
#[test]
#[serial]
fn collect_project_rules_empty_skill_paths_no_rules_dir() {
let tmp = TempDir::new().unwrap();
let orig = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = collect_project_rules(&[]);
std::env::set_current_dir(orig).unwrap();
assert!(result.is_empty());
}
#[test]
#[serial]
fn collect_project_rules_picks_md_files_from_rules_dir() {
let tmp = TempDir::new().unwrap();
make_rules_dir(tmp.path(), &["rust-code.md", "testing.md", "notes.txt"]);
let orig = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = collect_project_rules(&[]);
std::env::set_current_dir(orig).unwrap();
assert_eq!(result.len(), 2);
let names: Vec<_> = result
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"rust-code.md".to_owned()));
assert!(names.contains(&"testing.md".to_owned()));
assert!(!names.contains(&"notes.txt".to_owned()));
}
#[test]
#[serial]
fn collect_project_rules_includes_skill_files() {
let tmp = TempDir::new().unwrap();
let skill_file = tmp.path().join("my-skill.md");
fs::write(&skill_file, b"").unwrap();
let skill_dir = tmp.path().join("skills-dir");
fs::create_dir_all(&skill_dir).unwrap();
let orig = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = collect_project_rules(&[skill_file.clone(), skill_dir]);
std::env::set_current_dir(orig).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0], skill_file);
}
#[tokio::test]
async fn diagnostics_tool_call_dispatches_through_acp_composite_chain() {
let config = zeph_core::config::Config::default();
let file_executor = zeph_tools::FileExecutor::new(vec![]);
let shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell);
let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape);
let diagnostics_executor = crate::agent_setup::build_diagnostics_executor(&config);
let base_executor = crate::agent_setup::build_base_executor_chain(
file_executor,
shell_executor,
scrape_executor,
diagnostics_executor,
);
let policy =
zeph_tools::PermissionPolicy::default().with_autonomy(zeph_tools::AutonomyLevel::Full);
let base_executor = zeph_tools::TrustGateExecutor::new(base_executor, policy);
let outside = std::env::temp_dir();
let mut params = serde_json::Map::new();
params.insert(
"path".into(),
serde_json::Value::String(outside.display().to_string()),
);
let call = zeph_tools::ToolCall {
tool_id: "diagnostics".into(),
params,
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = base_executor.execute_tool_call(&call).await;
assert!(
matches!(result, Err(zeph_tools::ToolError::SandboxViolation { .. })),
"expected SandboxViolation from DiagnosticsExecutor, got {result:?}"
);
}
#[tokio::test]
async fn diagnostics_requires_confirmation_in_acp_composite_chain() {
let config = zeph_core::config::Config::default();
let file_executor = zeph_tools::FileExecutor::new(vec![]);
let shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell);
let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape);
let diagnostics_executor = crate::agent_setup::build_diagnostics_executor(&config);
let base_executor = crate::agent_setup::build_base_executor_chain(
file_executor,
shell_executor,
scrape_executor,
diagnostics_executor,
);
let base_executor = zeph_tools::TrustGateExecutor::new(
base_executor,
zeph_tools::PermissionPolicy::default(),
);
let call = zeph_tools::ToolCall {
tool_id: "diagnostics".into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = base_executor.execute_tool_call(&call).await;
assert!(
matches!(
result,
Err(zeph_tools::ToolError::ConfirmationRequired { .. })
),
"expected ConfirmationRequired for diagnostics under Supervised autonomy, got {result:?}"
);
}
#[derive(Debug)]
struct AcpTaggedMock(&'static str);
impl zeph_tools::executor::ToolExecutor for AcpTaggedMock {
async fn execute(
&self,
_response: &str,
) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
Ok(None)
}
async fn execute_tool_call(
&self,
call: &zeph_tools::ToolCall,
) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
if call.tool_id != self.0 {
return Ok(None);
}
Ok(Some(zeph_tools::ToolOutput {
tool_name: call.tool_id.clone(),
summary: "ok".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
fn acp_test_call(tool_id: &str) -> zeph_tools::ToolCall {
zeph_tools::ToolCall {
tool_id: tool_id.into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
}
}
#[tokio::test]
async fn quarantine_blocks_memory_and_mcp_in_acp_composite_chain() {
let mcp_tool = zeph_mcp::McpTool {
server_id: "mcp".to_owned(),
name: "write_file".to_owned(),
description: String::new(),
input_schema: serde_json::Value::Null,
output_schema: None,
security_meta: zeph_mcp::tool::ToolSecurityMeta::default(),
};
let mcp_tool_id = mcp_tool.sanitized_id();
assert_eq!(mcp_tool_id, "mcp_write_file");
let base_tool = zeph_tools::CompositeExecutor::new(
AcpTaggedMock("read"),
AcpTaggedMock("mcp_write_file"),
);
let inner_executor =
zeph_tools::DynExecutor(std::sync::Arc::new(zeph_tools::CompositeExecutor::new(
AcpTaggedMock("load_skill"),
zeph_tools::CompositeExecutor::new(
AcpTaggedMock("memory_save"),
zeph_tools::CompositeExecutor::new(AcpTaggedMock("overflow_flush"), base_tool),
),
)));
let (gated, mcp_ids_handle) = crate::agent_setup::apply_common_tool_gating(
inner_executor,
&zeph_tools::PermissionPolicy::default(),
);
crate::agent_setup::register_mcp_tool_ids(&mcp_ids_handle, std::slice::from_ref(&mcp_tool));
zeph_tools::executor::ToolExecutor::set_effective_trust(
&gated,
zeph_common::SkillTrustLevel::Quarantined,
);
let memory_result = gated.execute_tool_call(&acp_test_call("memory_save")).await;
assert!(
matches!(memory_result, Err(zeph_tools::ToolError::Blocked { .. })),
"memory_save must be denied under Quarantine, got {memory_result:?}"
);
let mcp_result = gated.execute_tool_call(&acp_test_call(&mcp_tool_id)).await;
assert!(
matches!(mcp_result, Err(zeph_tools::ToolError::Blocked { .. })),
"MCP-sourced tool must be denied under Quarantine, got {mcp_result:?}"
);
let skill_load_result = gated.execute_tool_call(&acp_test_call("load_skill")).await;
assert!(
matches!(
skill_load_result,
Err(zeph_tools::ToolError::Blocked { .. })
),
"load_skill must be denied under Quarantine, got {skill_load_result:?}"
);
let read_result = gated.execute_tool_call(&acp_test_call("read")).await;
assert!(
read_result.is_ok(),
"readonly native tool must remain reachable under Quarantine, got {read_result:?}"
);
}
#[tokio::test]
async fn policy_gate_denies_tool_in_acp_composite_chain() {
let config = zeph_core::config::Config::default();
let file_executor = zeph_tools::FileExecutor::new(vec![]);
let shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell);
let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape);
let diagnostics_executor = crate::agent_setup::build_diagnostics_executor(&config);
let base_executor = crate::agent_setup::build_base_executor_chain(
file_executor,
shell_executor,
scrape_executor,
diagnostics_executor,
);
let policy =
zeph_tools::PermissionPolicy::default().with_autonomy(zeph_tools::AutonomyLevel::Full);
let base_executor = zeph_tools::TrustGateExecutor::new(base_executor, policy);
let policy_config = zeph_tools::PolicyConfig {
enabled: true,
default_effect: zeph_tools::DefaultEffect::Allow,
rules: vec![zeph_tools::PolicyRuleConfig {
effect: zeph_tools::PolicyEffect::Deny,
tool: "diagnostics".into(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
..Default::default()
};
let enforcer = zeph_tools::PolicyEnforcer::compile(&policy_config).unwrap();
let policy_context = std::sync::Arc::new(RwLock::new(zeph_tools::PolicyContext {
trust_level: zeph_common::SkillTrustLevel::Trusted,
env: std::collections::HashMap::new(),
}));
let gated = zeph_tools::PolicyGateExecutor::new(
base_executor,
std::sync::Arc::new(enforcer),
policy_context,
);
let call = zeph_tools::ToolCall {
tool_id: "diagnostics".into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = gated.execute_tool_call(&call).await;
assert!(
matches!(result, Err(zeph_tools::ToolError::Blocked { .. })),
"expected Blocked from PolicyGateExecutor deny rule, got {result:?}"
);
}
#[tokio::test]
async fn adversarial_policy_gate_denies_tool_in_acp_composite_chain() {
struct AlwaysDenyLlm;
impl zeph_tools::PolicyLlmClient for AlwaysDenyLlm {
fn chat<'a>(
&'a self,
_messages: &'a [zeph_tools::PolicyMessage],
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>,
> {
Box::pin(async move { Ok("DENY: test policy".to_owned()) })
}
}
let config = zeph_core::config::Config::default();
let file_executor = zeph_tools::FileExecutor::new(vec![]);
let shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell);
let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape);
let diagnostics_executor = crate::agent_setup::build_diagnostics_executor(&config);
let base_executor = crate::agent_setup::build_base_executor_chain(
file_executor,
shell_executor,
scrape_executor,
diagnostics_executor,
);
let policy =
zeph_tools::PermissionPolicy::default().with_autonomy(zeph_tools::AutonomyLevel::Full);
let base_executor = zeph_tools::TrustGateExecutor::new(base_executor, policy);
let validator = std::sync::Arc::new(zeph_tools::PolicyValidator::new(
vec!["never allow diagnostics".to_owned()],
std::time::Duration::from_millis(500),
false,
vec![],
));
let llm_client: std::sync::Arc<dyn zeph_tools::PolicyLlmClient> =
std::sync::Arc::new(AlwaysDenyLlm);
let gated =
zeph_tools::AdversarialPolicyGateExecutor::new(base_executor, validator, llm_client);
let call = zeph_tools::ToolCall {
tool_id: "diagnostics".into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = gated.execute_tool_call(&call).await;
assert!(
matches!(result, Err(zeph_tools::ToolError::Blocked { .. })),
"expected Blocked from AdversarialPolicyGateExecutor deny decision, got {result:?}"
);
}
#[tokio::test]
async fn policy_and_quarantine_trust_gate_both_enforce_in_acp_composite_chain() {
use zeph_tools::executor::ToolExecutor;
let mcp_tool = zeph_mcp::McpTool {
server_id: "mcp".to_owned(),
name: "write_file".to_owned(),
description: String::new(),
input_schema: serde_json::Value::Null,
output_schema: None,
security_meta: zeph_mcp::tool::ToolSecurityMeta::default(),
};
let base_tool = zeph_tools::CompositeExecutor::new(
AcpTaggedMock("read"),
AcpTaggedMock("mcp_write_file"),
);
let inner_executor =
zeph_tools::DynExecutor(std::sync::Arc::new(zeph_tools::CompositeExecutor::new(
AcpTaggedMock("load_skill"),
zeph_tools::CompositeExecutor::new(
AcpTaggedMock("memory_save"),
zeph_tools::CompositeExecutor::new(AcpTaggedMock("overflow_flush"), base_tool),
),
)));
let (trust_gated, mcp_ids_handle) = crate::agent_setup::apply_common_tool_gating(
inner_executor,
&zeph_tools::PermissionPolicy::default(),
);
crate::agent_setup::register_mcp_tool_ids(&mcp_ids_handle, std::slice::from_ref(&mcp_tool));
zeph_tools::ToolExecutor::set_effective_trust(
&trust_gated,
zeph_common::SkillTrustLevel::Quarantined,
);
let policy_config = zeph_tools::PolicyConfig {
enabled: true,
default_effect: zeph_tools::DefaultEffect::Allow,
rules: vec![zeph_tools::PolicyRuleConfig {
effect: zeph_tools::PolicyEffect::Deny,
tool: "overflow_flush".into(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
}],
..Default::default()
};
let enforcer = zeph_tools::PolicyEnforcer::compile(&policy_config).unwrap();
let policy_context = std::sync::Arc::new(RwLock::new(zeph_tools::PolicyContext {
trust_level: zeph_common::SkillTrustLevel::Trusted,
env: std::collections::HashMap::new(),
}));
let gated = zeph_tools::PolicyGateExecutor::new(
trust_gated,
std::sync::Arc::new(enforcer),
policy_context,
);
let policy_denied = gated
.execute_tool_call(&acp_test_call("overflow_flush"))
.await;
assert!(
matches!(policy_denied, Err(zeph_tools::ToolError::Blocked { .. })),
"expected Blocked from PolicyGateExecutor's own deny rule, got {policy_denied:?}"
);
let quarantine_denied = gated.execute_tool_call(&acp_test_call("load_skill")).await;
assert!(
matches!(
quarantine_denied,
Err(zeph_tools::ToolError::Blocked { .. })
),
"expected Blocked from TrustGateExecutor's Quarantine enforcement, got {quarantine_denied:?}"
);
let allowed = gated.execute_tool_call(&acp_test_call("read")).await;
assert!(
allowed.is_ok(),
"expected read to dispatch normally through the merged gate stack, got {allowed:?}"
);
}
#[derive(Debug)]
struct AcpNativeStandIn {
tool_id: &'static str,
}
impl ToolExecutor for AcpNativeStandIn {
async fn execute(
&self,
_response: &str,
) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
Ok(None)
}
async fn execute_tool_call(
&self,
call: &zeph_tools::ToolCall,
) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> {
if call.tool_id != self.tool_id {
return Ok(None);
}
panic!(
"AcpNativeStandIn({}) reached — gate did not intercept",
self.tool_id
);
}
}
async fn build_full_acp_session_composite_with_native_fs_shell() -> Arc<dyn ErasedToolExecutor>
{
let registry = Arc::new(RwLock::new(zeph_skills::registry::SkillRegistry::empty()));
let skill_loader_executor = zeph_core::SkillLoaderExecutor::new(Arc::clone(®istry));
let mock_provider =
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
let memory = Arc::new(
zeph_memory::semantic::SemanticMemory::new(
":memory:",
"http://127.0.0.1:1",
None,
mock_provider,
"test",
)
.await
.unwrap(),
);
let memory_executor = zeph_core::memory_tools::MemoryToolExecutor::with_validator(
Arc::clone(&memory),
zeph_memory::ConversationId(0),
zeph_sanitizer::memory_validation::MemoryWriteValidator::new(
zeph_core::config::Config::default()
.security
.memory_validation
.clone(),
),
);
let overflow_executor =
zeph_core::overflow_tools::OverflowToolExecutor::new(Arc::new(memory.sqlite().clone()));
let mut base: Arc<dyn ErasedToolExecutor> = Arc::new(zeph_tools::FileExecutor::new(vec![]));
let filtered =
zeph_tools::ToolFilter::new(zeph_tools::DynExecutor(base), &["read", "write", "glob"]);
base = Arc::new(zeph_tools::CompositeExecutor::new(
AcpNativeStandIn {
tool_id: "write_file",
},
filtered,
));
base = Arc::new(zeph_tools::CompositeExecutor::new(
AcpNativeStandIn { tool_id: "bash" },
zeph_tools::DynExecutor(base),
));
base = Arc::new(zeph_tools::CompositeExecutor::new(
skill_loader_executor,
zeph_tools::CompositeExecutor::new(
memory_executor,
zeph_tools::CompositeExecutor::new(
overflow_executor,
zeph_tools::DynExecutor(base),
),
),
));
base
}
#[tokio::test]
async fn policy_gate_denies_skill_and_memory_tools_in_full_acp_session_composite() {
let session_composite = build_full_acp_session_composite_with_native_fs_shell().await;
let policy_config = zeph_tools::PolicyConfig {
enabled: true,
default_effect: zeph_tools::DefaultEffect::Allow,
rules: ["load_skill", "memory_search", "write_file", "bash"]
.into_iter()
.map(|tool| zeph_tools::PolicyRuleConfig {
effect: zeph_tools::PolicyEffect::Deny,
tool: tool.into(),
paths: vec![],
env: vec![],
trust_level: None,
args_match: None,
capabilities: vec![],
})
.collect(),
..Default::default()
};
let enforcer = zeph_tools::PolicyEnforcer::compile(&policy_config).unwrap();
let policy_context = Arc::new(RwLock::new(zeph_tools::PolicyContext {
trust_level: zeph_common::SkillTrustLevel::Trusted,
env: std::collections::HashMap::new(),
}));
let gated = zeph_tools::PolicyGateExecutor::new(
zeph_tools::DynExecutor(session_composite),
Arc::new(enforcer),
policy_context,
);
for tool_id in ["load_skill", "memory_search", "write_file", "bash"] {
let call = zeph_tools::ToolCall {
tool_id: tool_id.into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = gated.execute_tool_call(&call).await;
assert!(
matches!(result, Err(zeph_tools::ToolError::Blocked { .. })),
"expected Blocked for {tool_id} from PolicyGateExecutor wrapping the full \
per-session composite (including ACP-native fs/shell), got {result:?}"
);
}
}
#[tokio::test]
async fn adversarial_policy_gate_denies_skill_and_memory_tools_in_full_acp_session_composite() {
struct AlwaysDenyLlm;
impl zeph_tools::PolicyLlmClient for AlwaysDenyLlm {
fn chat<'a>(
&'a self,
_messages: &'a [zeph_tools::PolicyMessage],
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>,
> {
Box::pin(async move { Ok("DENY: test policy".to_owned()) })
}
}
let session_composite = build_full_acp_session_composite_with_native_fs_shell().await;
let validator = Arc::new(zeph_tools::PolicyValidator::new(
vec!["never allow load_skill, memory_search, write_file, or bash".to_owned()],
std::time::Duration::from_millis(500),
false,
vec![],
));
let llm_client: Arc<dyn zeph_tools::PolicyLlmClient> = Arc::new(AlwaysDenyLlm);
let gated = zeph_tools::AdversarialPolicyGateExecutor::new(
zeph_tools::DynExecutor(session_composite),
validator,
llm_client,
);
for tool_id in ["load_skill", "memory_search", "write_file", "bash"] {
let call = zeph_tools::ToolCall {
tool_id: tool_id.into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
let result = gated.execute_tool_call(&call).await;
assert!(
matches!(result, Err(zeph_tools::ToolError::Blocked { .. })),
"expected Blocked for {tool_id} from AdversarialPolicyGateExecutor wrapping the \
full per-session composite (including ACP-native fs/shell), got {result:?}"
);
}
}
#[test]
fn build_acp_provider_factory_masks_when_registry_present() {
let mut config = zeph_core::config::Config::default();
config.llm.providers = vec![zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Ollama,
name: Some("ollama".into()),
model: Some("qwen3:8b".into()),
..zeph_core::config::ProviderEntry::default()
}];
let registry = std::sync::Arc::new(zeph_sanitizer::secret_mask::SecretMaskRegistry::new());
let factory = build_acp_provider_factory(&config, Some(std::sync::Arc::clone(®istry)));
let provider = factory("ollama:qwen3:8b").expect("factory must resolve a known model key");
assert!(
matches!(provider, zeph_llm::any::AnyProvider::Masked(_)),
"factory output must be wrapped when a secret registry is supplied"
);
}
#[test]
fn build_acp_provider_factory_unmasked_when_registry_absent() {
let mut config = zeph_core::config::Config::default();
config.llm.providers = vec![zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Ollama,
name: Some("ollama".into()),
model: Some("qwen3:8b".into()),
..zeph_core::config::ProviderEntry::default()
}];
let factory = build_acp_provider_factory(&config, None);
let provider = factory("ollama:qwen3:8b").expect("factory must resolve a known model key");
assert!(
!matches!(provider, zeph_llm::any::AnyProvider::Masked(_)),
"no registry supplied — factory output must be a plain passthrough"
);
}
#[test]
fn acp_provider_names_maps_known_protocols() {
let mut config = zeph_core::config::Config::default();
config.llm.providers = vec![
zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Claude,
name: Some("claude".into()),
..zeph_core::config::ProviderEntry::default()
},
zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::OpenAi,
name: Some("openai".into()),
..zeph_core::config::ProviderEntry::default()
},
zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Compatible,
name: Some("compat".into()),
..zeph_core::config::ProviderEntry::default()
},
zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Ollama,
name: Some("ollama".into()),
..zeph_core::config::ProviderEntry::default()
},
];
let names = acp_provider_names(&config);
assert_eq!(
names,
vec![
("claude".to_owned(), zeph_acp::LlmProtocol::Anthropic),
("openai".to_owned(), zeph_acp::LlmProtocol::OpenAi),
("compat".to_owned(), zeph_acp::LlmProtocol::OpenAi),
(
"ollama".to_owned(),
zeph_acp::LlmProtocol::Other("ollama".to_owned())
),
]
);
}
#[test]
fn acp_provider_names_empty_providers_returns_empty_vec() {
let config = zeph_core::config::Config::default();
assert!(acp_provider_names(&config).is_empty());
}
#[test]
#[serial]
fn collect_project_rules_mixed_sources() {
let tmp = TempDir::new().unwrap();
make_rules_dir(tmp.path(), &["branching.md"]);
let skill_file = tmp.path().join("SKILL.md");
fs::write(&skill_file, b"").unwrap();
let orig = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let result = collect_project_rules(std::slice::from_ref(&skill_file));
std::env::set_current_dir(orig).unwrap();
assert_eq!(result.len(), 2);
let names: Vec<_> = result
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"branching.md".to_owned()));
assert!(names.contains(&"SKILL.md".to_owned()));
}
#[test]
fn shared_agent_deps_has_document_and_graph_config_fields() {
let doc_cfg = zeph_core::config::DocumentConfig {
rag_enabled: true,
top_k: 7,
collection: String::new(),
chunk_size: 0,
chunk_overlap: 0,
};
assert!(doc_cfg.rag_enabled);
assert_eq!(doc_cfg.top_k, 7);
}
#[test]
fn shared_agent_deps_has_anomaly_and_orchestration_config_fields() {
let anomaly_cfg = zeph_tools::AnomalyConfig {
enabled: true,
..Default::default()
};
let orch_cfg = zeph_core::config::OrchestrationConfig {
enabled: true,
..Default::default()
};
assert!(anomaly_cfg.enabled);
assert!(orch_cfg.enabled);
}
#[cfg(all(feature = "acp-http", feature = "session"))]
#[tokio::test]
async fn build_combined_deps_wires_skill_matching_config_from_config() {
let mut config =
zeph_core::config::Config::load(std::path::Path::new("/nonexistent")).unwrap();
config.llm.providers = vec![zeph_core::config::ProviderEntry {
provider_type: zeph_core::config::ProviderKind::Ollama,
base_url: Some("http://127.0.0.1:1".to_owned()),
model: Some("test-model".to_owned()),
..Default::default()
}];
config.memory.sqlite_path = ":memory:".to_owned();
config.skills.disambiguation_threshold = 0.55;
config.skills.two_stage_matching = true;
config.skills.confusability_threshold = 0.65;
config.skills.generation_provider = zeph_common::ProviderName::new("gen-test");
config.skills.disambiguate_provider = zeph_common::ProviderName::new("disamb-test");
config.skills.semantic_scan = true;
config.skills.semantic_scan_provider = zeph_common::ProviderName::new("scan-test");
let app = crate::bootstrap::AppBuilder::for_test(config);
let cancel = tokio_util::sync::CancellationToken::new();
let supervisor = std::sync::Arc::new(zeph_common::TaskSupervisor::new(cancel));
let (serve_deps, acp_deps, _keepalive) = build_combined_deps(&app, &supervisor)
.await
.expect("build_combined_deps must succeed against a mock-provider AppBuilder");
assert!(
(serve_deps.skill_disambiguation_threshold - 0.55).abs() < f32::EPSILON,
"config.skills.disambiguation_threshold must flow into ServeAgentDeps"
);
assert!(
serve_deps.skill_two_stage_matching,
"config.skills.two_stage_matching must flow into ServeAgentDeps"
);
assert!(
(serve_deps.skill_confusability_threshold - 0.65).abs() < f32::EPSILON,
"config.skills.confusability_threshold must flow into ServeAgentDeps"
);
assert_eq!(serve_deps.skill_generation_provider, "gen-test");
assert_eq!(serve_deps.skill_disambiguate_provider, "disamb-test");
assert!(
serve_deps.semantic_scan,
"config.skills.semantic_scan must flow into ServeAgentDeps"
);
assert_eq!(serve_deps.semantic_scan_provider, "scan-test");
assert!(
(acp_deps.skill_disambiguation_threshold - 0.55).abs() < f32::EPSILON,
"config.skills.disambiguation_threshold must flow into SharedAgentDeps"
);
assert!(
acp_deps.skill_two_stage_matching,
"config.skills.two_stage_matching must flow into SharedAgentDeps"
);
assert!(
(acp_deps.skill_confusability_threshold - 0.65).abs() < f32::EPSILON,
"config.skills.confusability_threshold must flow into SharedAgentDeps"
);
assert_eq!(acp_deps.skill_generation_provider, "gen-test");
assert_eq!(acp_deps.skill_disambiguate_provider, "disamb-test");
assert!(
acp_deps.semantic_scan,
"config.skills.semantic_scan must flow into SharedAgentDeps"
);
assert_eq!(acp_deps.semantic_scan_provider, "scan-test");
}
#[tokio::test]
async fn broadcast_to_mpsc_forwards_items() {
let (btx, brx) = tokio::sync::broadcast::channel::<u32>(16);
let cancel = zeph_memory::CancellationToken::new();
let mut rx = broadcast_to_mpsc(brx, cancel.clone());
btx.send(1).unwrap();
btx.send(2).unwrap();
drop(btx);
assert_eq!(rx.recv().await, Some(1));
assert_eq!(rx.recv().await, Some(2));
assert_eq!(rx.recv().await, None);
cancel.cancel();
}
#[tokio::test]
async fn broadcast_to_mpsc_cancellation_stops_task() {
let (btx, brx) = tokio::sync::broadcast::channel::<u32>(16);
let cancel = zeph_memory::CancellationToken::new();
let mut rx = broadcast_to_mpsc(brx, cancel.clone());
cancel.cancel();
tokio::task::yield_now().await;
drop(btx);
assert_eq!(rx.recv().await, None);
}
#[tokio::test]
async fn broadcast_lag_does_not_block_direct_cancel_signal() {
let (btx, brx) = tokio::sync::broadcast::channel::<u32>(1);
let adapter_cancel = zeph_memory::CancellationToken::new();
let mut rx = broadcast_to_mpsc(brx, adapter_cancel.clone());
let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
{
let cancel_signal = std::sync::Arc::clone(&cancel_signal);
let adapter_cancel = adapter_cancel.clone();
tokio::spawn(async move {
cancel_signal.notified().await;
adapter_cancel.cancel();
});
}
btx.send(1).unwrap();
btx.send(2).unwrap();
btx.send(3).unwrap();
tokio::task::yield_now().await;
cancel_signal.notify_one();
drop(btx);
tokio::time::timeout(
std::time::Duration::from_secs(1),
adapter_cancel.cancelled(),
)
.await
.expect("direct ACP cancel signal should not be blocked by reload lag");
loop {
let next = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("adapter receiver should shut down promptly after cancel");
if next.is_none() {
break;
}
}
}
#[tokio::test]
async fn notify_lock_degraded_falls_back_to_channel_send_status_without_notifier() {
let (mut channel, mut handle) = zeph_core::channel::LoopbackChannel::pair(8);
notify_lock_degraded(None, &mut channel).await;
let event = handle
.output_rx
.recv()
.await
.expect("channel must receive a status event");
match event {
zeph_core::LoopbackEvent::Status(text) => {
assert_eq!(text, SESSION_LOCK_DEGRADED_MESSAGE);
}
other => panic!("expected LoopbackEvent::Status, got {other:?}"),
}
}
#[tokio::test]
async fn already_locked_session_log_notifies_client_proactively_without_prompt() {
let tmp = TempDir::new().unwrap();
let session_path = tmp.path().join("already-locked-session");
let _held_lock = zeph_session::SessionEventLog::open_exclusive(&session_path)
.await
.expect("first open_exclusive must succeed and hold the lock");
let (mut channel, _handle) = zeph_core::channel::LoopbackChannel::pair(8);
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel(8);
let session_id =
agent_client_protocol::schema::v1::SessionId::new("already-locked-test".to_owned());
let status_notifier = Some(zeph_acp::SessionStatusNotifier::new(
notify_tx,
session_id.clone(),
));
let log = open_session_log_or_notify_locked(
&session_path,
status_notifier.as_ref(),
&mut channel,
)
.await;
assert!(
log.is_none(),
"AlreadyLocked must degrade to no persistence, not fail session creation"
);
let (notification, _ack) = notify_rx.try_recv().expect(
"client must be notified proactively — synchronously, with no prompt drain needed",
);
assert_eq!(notification.session_id, session_id);
match notification.update {
agent_client_protocol::schema::v1::SessionUpdate::AgentThoughtChunk(chunk) => {
match chunk.content {
agent_client_protocol::schema::v1::ContentBlock::Text(t) => {
assert_eq!(t.text, SESSION_LOCK_DEGRADED_MESSAGE);
}
other => panic!("expected ContentBlock::Text, got {other:?}"),
}
}
other => panic!("expected AgentThoughtChunk, got {other:?}"),
}
}
}