mod collect;
mod secrets;
mod spawn;
mod worktree;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use zeph_common::task_supervisor::BlockingHandle;
use zeph_common::{SkillTrustLevel, TaskSupervisor};
use zeph_config::{ContentIsolationConfig, McpServerConfig};
use zeph_llm::provider::Message;
use crate::def::{PermissionMode, SubAgentDef};
use crate::durable::DurableResolverSeat;
use crate::error::SubAgentError;
use crate::fleet::SharedFleetRegistry;
use crate::grants::{GrantedSecret, PermissionGrants, SecretRequest};
use crate::state::SubAgentState;
#[derive(Default)]
pub struct SpawnContext {
pub parent_messages: Vec<Message>,
pub parent_cancel: Option<CancellationToken>,
pub parent_provider_name: Option<String>,
pub spawn_depth: u32,
pub mcp_tool_names: Vec<String>,
pub seed_trajectory_score: Option<f32>,
pub content_isolation: ContentIsolationConfig,
pub orchestrator_name: Option<String>,
pub orchestrator_role: Option<String>,
pub session_mcp_servers: Vec<McpServerConfig>,
pub max_trust_level: Option<SkillTrustLevel>,
pub inherited_tool_allowlist: Option<HashSet<String>>,
pub durable_resolver: Option<DurableResolverSeat>,
pub network_denied: bool,
}
#[derive(Debug, Clone)]
pub struct SubAgentStatus {
pub state: SubAgentState,
pub last_message: Option<String>,
pub turns_used: u32,
pub started_at: Instant,
}
pub struct SubAgentHandle {
pub id: String,
pub def: SubAgentDef,
pub task_id: String,
pub state: SubAgentState,
pub join_handle: Option<BlockingHandle<Result<String, SubAgentError>>>,
pub cancel: CancellationToken,
pub status_rx: watch::Receiver<SubAgentStatus>,
pub grants: PermissionGrants,
pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
pub secret_tx: mpsc::Sender<Option<GrantedSecret>>,
pub started_at_str: String,
pub transcript_dir: Option<PathBuf>,
pub mcp_tool_names: Vec<String>,
}
impl SubAgentHandle {
#[cfg(test)]
pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
let initial_status = SubAgentStatus {
state: SubAgentState::Working,
last_message: None,
turns_used: 0,
started_at: Instant::now(),
};
let (status_tx, status_rx) = watch::channel(initial_status);
drop(status_tx);
let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
drop(pending_secret_rx_tx);
let (secret_tx, _) = mpsc::channel(1);
let id_str = id.into();
Self {
task_id: id_str.clone(),
id: id_str,
def,
state: SubAgentState::Working,
join_handle: None,
cancel: CancellationToken::new(),
status_rx,
grants: PermissionGrants::default(),
pending_secret_rx,
secret_tx,
started_at_str: String::new(),
transcript_dir: None,
mcp_tool_names: Vec::new(),
}
}
}
impl std::fmt::Debug for SubAgentHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubAgentHandle")
.field("id", &self.id)
.field("task_id", &self.task_id)
.field("state", &self.state)
.field("def_name", &self.def.name)
.finish_non_exhaustive()
}
}
impl Drop for SubAgentHandle {
fn drop(&mut self) {
self.cancel.cancel();
if !self.grants.is_empty_grants() {
tracing::warn!(
id = %self.id,
"SubAgentHandle dropped without explicit cleanup — revoking grants"
);
}
self.grants.revoke_all();
}
}
pub struct SubAgentManager {
definitions: Vec<SubAgentDef>,
agents: HashMap<String, SubAgentHandle>,
max_concurrent: usize,
reserved_slots: usize,
stop_hooks: Vec<super::hooks::HookDef>,
transcript_dir: Option<PathBuf>,
transcript_max_files: usize,
fleet_registry: Option<SharedFleetRegistry>,
hook_tasks: JoinSet<()>,
max_hook_tasks: usize,
worktree_manager: Option<Arc<zeph_worktree::DefaultWorktreeManager>>,
cwd_lock: Arc<tokio::sync::Mutex<()>>,
task_supervisor: Option<TaskSupervisor>,
}
impl std::fmt::Debug for SubAgentManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubAgentManager")
.field("definitions_count", &self.definitions.len())
.field("active_agents", &self.agents.len())
.field("max_concurrent", &self.max_concurrent)
.field("reserved_slots", &self.reserved_slots)
.field("stop_hooks_count", &self.stop_hooks.len())
.field("transcript_dir", &self.transcript_dir)
.field("transcript_max_files", &self.transcript_max_files)
.field("fleet_registry", &self.fleet_registry.is_some())
.field("hook_tasks_len", &self.hook_tasks.len())
.field("max_hook_tasks", &self.max_hook_tasks)
.field("worktree_manager", &self.worktree_manager.is_some())
.field("cwd_lock", &"<Mutex>")
.field("task_supervisor", &self.task_supervisor.is_some())
.finish()
}
}
impl SubAgentManager {
#[must_use]
pub fn new(max_concurrent: usize) -> Self {
Self {
definitions: Vec::new(),
agents: HashMap::new(),
max_concurrent,
reserved_slots: 0,
stop_hooks: Vec::new(),
transcript_dir: None,
transcript_max_files: 50,
fleet_registry: None,
hook_tasks: JoinSet::new(),
max_hook_tasks: 64,
worktree_manager: None,
cwd_lock: Arc::new(tokio::sync::Mutex::new(())),
task_supervisor: None,
}
}
pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
self.task_supervisor = Some(supervisor);
}
pub fn set_worktree_manager(&mut self, wm: Arc<zeph_worktree::DefaultWorktreeManager>) {
self.worktree_manager = Some(wm);
}
#[must_use]
pub fn worktree_manager(&self) -> Option<&Arc<zeph_worktree::DefaultWorktreeManager>> {
self.worktree_manager.as_ref()
}
fn spawn_hook_task<F>(&mut self, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
while self.hook_tasks.try_join_next().is_some() {}
if self.hook_tasks.len() >= self.max_hook_tasks {
tracing::warn!(
limit = self.max_hook_tasks,
"hook task limit reached — dropping fire-and-forget task"
);
return;
}
self.hook_tasks.spawn(future);
}
pub(crate) fn spawn_agent_task<F, Fut, T, E>(
&self,
name: Arc<str>,
factory: F,
) -> BlockingHandle<Result<T, E>>
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
T: Send + 'static,
E: Send + 'static,
{
if let Some(ref sup) = self.task_supervisor {
sup.spawn_oneshot_classified(name, factory, Result::is_ok)
} else {
let local = TaskSupervisor::new(CancellationToken::new());
local.spawn_oneshot_classified(name, factory, Result::is_ok)
}
}
pub fn reserve_slots(&mut self, n: usize) {
self.reserved_slots = self.reserved_slots.saturating_add(n);
}
pub fn release_reservation(&mut self, n: usize) {
self.reserved_slots = self.reserved_slots.saturating_sub(n);
}
pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
self.transcript_dir = dir;
self.transcript_max_files = max_files;
}
pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
self.stop_hooks = hooks;
}
pub fn set_fleet_registry(&mut self, registry: SharedFleetRegistry) {
self.fleet_registry = Some(registry);
}
pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
let defs = SubAgentDef::load_all(dirs)?;
let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
match std::fs::canonicalize(user_dir) {
Ok(canonical_user) => dirs
.iter()
.filter_map(|d| std::fs::canonicalize(d).ok())
.any(|d| d == canonical_user),
Err(e) => {
tracing::warn!(
dir = %user_dir.display(),
error = %e,
"could not canonicalize user agents dir, treating as non-user-level"
);
false
}
}
});
if loads_user_dir {
for def in &defs {
if def.permissions.permission_mode != PermissionMode::Default {
return Err(SubAgentError::Invalid(format!(
"sub-agent '{}': non-default permission_mode is not allowed for \
user-level definitions (~/.zeph/agents/)",
def.name
)));
}
}
}
self.definitions = defs;
tracing::info!(
count = self.definitions.len(),
"sub-agent definitions loaded"
);
Ok(())
}
#[tracing::instrument(name = "subagent.manager.load_definitions_with_sources", skip_all)]
pub async fn load_definitions_with_sources(
&mut self,
ordered_paths: &[PathBuf],
cli_agents: &[PathBuf],
config_user_dir: Option<&PathBuf>,
extra_dirs: &[PathBuf],
) -> Result<(), SubAgentError> {
let ordered = ordered_paths.to_vec();
let cli = cli_agents.to_vec();
let user_dir = config_user_dir.cloned();
let extra = extra_dirs.to_vec();
let defs = tokio::task::spawn_blocking(move || {
SubAgentDef::load_all_with_sources(&ordered, &cli, user_dir.as_ref(), &extra)
})
.await
.map_err(|e| SubAgentError::TaskPanic(format!("load_definitions_with_sources: {e}")))?;
self.definitions = defs?;
tracing::info!(
count = self.definitions.len(),
"sub-agent definitions loaded"
);
Ok(())
}
#[must_use]
pub fn definitions(&self) -> &[SubAgentDef] {
&self.definitions
}
pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
&mut self.definitions
}
pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
self.agents.insert(id, handle);
}
}
#[cfg(test)]
mod tests;