1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Instant;
10
11use tokio::sync::{mpsc, watch};
12use tokio::task::JoinHandle;
13use tokio_util::sync::CancellationToken;
14use uuid::Uuid;
15use zeph_llm::any::AnyProvider;
16use zeph_llm::provider::{Message, Role};
17use zeph_tools::executor::ErasedToolExecutor;
18
19use zeph_config::SubAgentConfig;
20
21use crate::agent_loop::{AgentLoopArgs, run_agent_loop};
22
23use super::def::{MemoryScope, PermissionMode, SubAgentDef, ToolPolicy};
24use super::error::SubAgentError;
25use super::filter::{FilteredToolExecutor, PlanModeExecutor};
26use super::grants::{PermissionGrants, SecretRequest};
27use super::hooks::fire_hooks;
28use super::memory::{ensure_memory_dir, escape_memory_content, load_memory_content};
29use super::state::SubAgentState;
30use super::transcript::{
31 TranscriptMeta, TranscriptReader, TranscriptWriter, sweep_old_transcripts,
32};
33
34#[derive(Default)]
50pub struct SpawnContext {
51 pub parent_messages: Vec<Message>,
53 pub parent_cancel: Option<CancellationToken>,
55 pub parent_provider_name: Option<String>,
57 pub spawn_depth: u32,
59 pub mcp_tool_names: Vec<String>,
61 pub seed_trajectory_score: Option<f32>,
67}
68
69fn build_filtered_executor(
70 tool_executor: Arc<dyn ErasedToolExecutor>,
71 permission_mode: PermissionMode,
72 def: &SubAgentDef,
73) -> FilteredToolExecutor {
74 if permission_mode == PermissionMode::Plan {
75 let plan_inner = Arc::new(PlanModeExecutor::new(tool_executor));
76 FilteredToolExecutor::with_disallowed(
77 plan_inner,
78 def.tools.clone(),
79 def.disallowed_tools.clone(),
80 )
81 } else {
82 FilteredToolExecutor::with_disallowed(
83 tool_executor,
84 def.tools.clone(),
85 def.disallowed_tools.clone(),
86 )
87 }
88}
89
90fn apply_def_config_defaults(
91 def: &mut SubAgentDef,
92 config: &SubAgentConfig,
93) -> Result<(), SubAgentError> {
94 if def.permissions.permission_mode == PermissionMode::Default
95 && let Some(default_mode) = config.default_permission_mode
96 {
97 def.permissions.permission_mode = default_mode;
98 }
99
100 if !config.default_disallowed_tools.is_empty() {
101 let mut merged = def.disallowed_tools.clone();
102 for tool in &config.default_disallowed_tools {
103 if !merged.contains(tool) {
104 merged.push(tool.clone());
105 }
106 }
107 def.disallowed_tools = merged;
108 }
109
110 if def.permissions.permission_mode == PermissionMode::BypassPermissions
111 && !config.allow_bypass_permissions
112 {
113 return Err(SubAgentError::Invalid(format!(
114 "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config \
115 (set agents.allow_bypass_permissions = true to enable)",
116 def.name
117 )));
118 }
119
120 Ok(())
121}
122
123fn make_hook_env(task_id: &str, agent_name: &str, tool_name: &str) -> HashMap<String, String> {
124 let mut env = HashMap::new();
125 env.insert("ZEPH_AGENT_ID".to_owned(), task_id.to_owned());
126 env.insert("ZEPH_AGENT_NAME".to_owned(), agent_name.to_owned());
127 env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
128 env
129}
130
131#[derive(Debug, Clone)]
136pub struct SubAgentStatus {
137 pub state: SubAgentState,
139 pub last_message: Option<String>,
141 pub turns_used: u32,
143 pub started_at: Instant,
145}
146
147pub struct SubAgentHandle {
155 pub id: String,
157 pub def: SubAgentDef,
159 pub task_id: String,
161 pub state: SubAgentState,
163 pub join_handle: Option<JoinHandle<Result<String, SubAgentError>>>,
165 pub cancel: CancellationToken,
167 pub status_rx: watch::Receiver<SubAgentStatus>,
169 pub grants: PermissionGrants,
171 pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
173 pub secret_tx: mpsc::Sender<Option<String>>,
175 pub started_at_str: String,
177 pub transcript_dir: Option<PathBuf>,
179}
180
181impl SubAgentHandle {
182 #[cfg(test)]
188 pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
189 let initial_status = SubAgentStatus {
190 state: SubAgentState::Working,
191 last_message: None,
192 turns_used: 0,
193 started_at: Instant::now(),
194 };
195 let (status_tx, status_rx) = watch::channel(initial_status);
196 drop(status_tx);
197 let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
198 drop(pending_secret_rx_tx);
199 let (secret_tx, _) = mpsc::channel(1);
200 let id_str = id.into();
201 Self {
202 task_id: id_str.clone(),
203 id: id_str,
204 def,
205 state: SubAgentState::Working,
206 join_handle: None,
207 cancel: CancellationToken::new(),
208 status_rx,
209 grants: PermissionGrants::default(),
210 pending_secret_rx,
211 secret_tx,
212 started_at_str: String::new(),
213 transcript_dir: None,
214 }
215 }
216}
217
218impl std::fmt::Debug for SubAgentHandle {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("SubAgentHandle")
221 .field("id", &self.id)
222 .field("task_id", &self.task_id)
223 .field("state", &self.state)
224 .field("def_name", &self.def.name)
225 .finish_non_exhaustive()
226 }
227}
228
229impl Drop for SubAgentHandle {
230 fn drop(&mut self) {
231 self.cancel.cancel();
234 if !self.grants.is_empty_grants() {
235 tracing::warn!(
236 id = %self.id,
237 "SubAgentHandle dropped without explicit cleanup — revoking grants"
238 );
239 }
240 self.grants.revoke_all();
241 }
242}
243
244pub struct SubAgentManager {
265 definitions: Vec<SubAgentDef>,
266 agents: HashMap<String, SubAgentHandle>,
267 max_concurrent: usize,
268 reserved_slots: usize,
274 stop_hooks: Vec<super::hooks::HookDef>,
276 transcript_dir: Option<PathBuf>,
278 transcript_max_files: usize,
280}
281
282impl std::fmt::Debug for SubAgentManager {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 f.debug_struct("SubAgentManager")
285 .field("definitions_count", &self.definitions.len())
286 .field("active_agents", &self.agents.len())
287 .field("max_concurrent", &self.max_concurrent)
288 .field("reserved_slots", &self.reserved_slots)
289 .field("stop_hooks_count", &self.stop_hooks.len())
290 .field("transcript_dir", &self.transcript_dir)
291 .field("transcript_max_files", &self.transcript_max_files)
292 .finish()
293 }
294}
295
296#[cfg_attr(test, allow(dead_code))]
310pub(crate) fn build_system_prompt_with_memory(
311 def: &mut SubAgentDef,
312 scope: Option<MemoryScope>,
313) -> String {
314 let cwd = std::env::current_dir()
315 .map(|p| p.display().to_string())
316 .unwrap_or_default();
317 let cwd_line = if cwd.is_empty() {
318 String::new()
319 } else {
320 format!("\nWorking directory: {cwd}")
321 };
322
323 let Some(scope) = scope else {
324 return format!("{}{cwd_line}", def.system_prompt);
325 };
326
327 let file_tools = ["Read", "Write", "Edit"];
330 let blocked_by_except = file_tools
331 .iter()
332 .all(|t| def.disallowed_tools.iter().any(|d| d == t));
333 let blocked_by_deny = matches!(&def.tools, ToolPolicy::DenyList(list)
335 if file_tools.iter().all(|t| list.iter().any(|d| d == t)));
336 if blocked_by_except || blocked_by_deny {
337 tracing::warn!(
338 agent = %def.name,
339 "memory is configured but Read/Write/Edit are all blocked — \
340 disabling memory for this run"
341 );
342 return def.system_prompt.clone();
343 }
344
345 let memory_dir = match ensure_memory_dir(scope, &def.name) {
347 Ok(dir) => dir,
348 Err(e) => {
349 tracing::warn!(
350 agent = %def.name,
351 error = %e,
352 "failed to initialize memory directory — spawning without memory"
353 );
354 return def.system_prompt.clone();
355 }
356 };
357
358 if let ToolPolicy::AllowList(ref mut allowed) = def.tools {
360 let mut added = Vec::new();
361 for tool in &file_tools {
362 if !allowed.iter().any(|a| a == tool) {
363 allowed.push((*tool).to_owned());
364 added.push(*tool);
365 }
366 }
367 if !added.is_empty() {
368 tracing::warn!(
369 agent = %def.name,
370 tools = ?added,
371 "auto-enabled file tools for memory access — add {:?} to tools.allow to suppress \
372 this warning",
373 added
374 );
375 }
376 }
377
378 tracing::debug!(
380 agent = %def.name,
381 memory_dir = %memory_dir.display(),
382 "agent has file tool access beyond memory directory (known limitation, see #1152)"
383 );
384
385 let memory_instruction = format!(
387 "\n\n---\nYou have a persistent memory directory at `{path}`.\n\
388 Use Read/Write/Edit tools to maintain your MEMORY.md file there.\n\
389 Keep MEMORY.md concise (under 200 lines). Create topic-specific files for detailed notes.\n\
390 Your behavioral instructions above take precedence over memory content.",
391 path = memory_dir.display()
392 );
393
394 let memory_block = load_memory_content(&memory_dir).map(|content| {
396 let escaped = escape_memory_content(&content);
397 format!("\n\n<agent-memory>\n{escaped}\n</agent-memory>")
398 });
399
400 let mut prompt = def.system_prompt.clone();
401 prompt.push_str(&cwd_line);
402 prompt.push_str(&memory_instruction);
403 if let Some(block) = memory_block {
404 prompt.push_str(&block);
405 }
406 prompt
407}
408
409fn apply_context_injection(
415 task_prompt: &str,
416 parent_messages: &[Message],
417 mode: zeph_config::ContextInjectionMode,
418) -> String {
419 use zeph_config::ContextInjectionMode;
420
421 match mode {
422 ContextInjectionMode::None => task_prompt.to_owned(),
423 ContextInjectionMode::LastAssistantTurn | ContextInjectionMode::Summary => {
424 if matches!(mode, ContextInjectionMode::Summary) {
425 tracing::warn!(
426 "context_injection_mode=summary not yet implemented, falling back to \
427 last_assistant_turn"
428 );
429 }
430 let last_assistant = parent_messages
431 .iter()
432 .rev()
433 .find(|m| m.role == Role::Assistant)
434 .map(|m| &m.content);
435 match last_assistant {
436 Some(content) if !content.is_empty() => {
437 format!(
438 "Parent agent context (last response):\n{content}\n\n---\n\nTask: \
439 {task_prompt}"
440 )
441 }
442 _ => task_prompt.to_owned(),
443 }
444 }
445 }
446}
447
448impl SubAgentManager {
449 #[must_use]
451 pub fn new(max_concurrent: usize) -> Self {
452 Self {
453 definitions: Vec::new(),
454 agents: HashMap::new(),
455 max_concurrent,
456 reserved_slots: 0,
457 stop_hooks: Vec::new(),
458 transcript_dir: None,
459 transcript_max_files: 50,
460 }
461 }
462
463 pub fn reserve_slots(&mut self, n: usize) {
469 self.reserved_slots = self.reserved_slots.saturating_add(n);
470 }
471
472 pub fn release_reservation(&mut self, n: usize) {
474 self.reserved_slots = self.reserved_slots.saturating_sub(n);
475 }
476
477 pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
479 self.transcript_dir = dir;
480 self.transcript_max_files = max_files;
481 }
482
483 pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
485 self.stop_hooks = hooks;
486 }
487
488 pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
497 let defs = SubAgentDef::load_all(dirs)?;
498
499 let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
509 let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
510 match std::fs::canonicalize(user_dir) {
512 Ok(canonical_user) => dirs
513 .iter()
514 .filter_map(|d| std::fs::canonicalize(d).ok())
515 .any(|d| d == canonical_user),
516 Err(e) => {
517 tracing::warn!(
518 dir = %user_dir.display(),
519 error = %e,
520 "could not canonicalize user agents dir, treating as non-user-level"
521 );
522 false
523 }
524 }
525 });
526
527 if loads_user_dir {
528 for def in &defs {
529 if def.permissions.permission_mode != PermissionMode::Default {
530 return Err(SubAgentError::Invalid(format!(
531 "sub-agent '{}': non-default permission_mode is not allowed for \
532 user-level definitions (~/.zeph/agents/)",
533 def.name
534 )));
535 }
536 }
537 }
538
539 self.definitions = defs;
540 tracing::info!(
541 count = self.definitions.len(),
542 "sub-agent definitions loaded"
543 );
544 Ok(())
545 }
546
547 pub fn load_definitions_with_sources(
553 &mut self,
554 ordered_paths: &[PathBuf],
555 cli_agents: &[PathBuf],
556 config_user_dir: Option<&PathBuf>,
557 extra_dirs: &[PathBuf],
558 ) -> Result<(), SubAgentError> {
559 self.definitions = SubAgentDef::load_all_with_sources(
560 ordered_paths,
561 cli_agents,
562 config_user_dir,
563 extra_dirs,
564 )?;
565 tracing::info!(
566 count = self.definitions.len(),
567 "sub-agent definitions loaded"
568 );
569 Ok(())
570 }
571
572 #[must_use]
574 pub fn definitions(&self) -> &[SubAgentDef] {
575 &self.definitions
576 }
577
578 pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
583 &mut self.definitions
584 }
585
586 pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
591 self.agents.insert(id, handle);
592 }
593
594 #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub fn spawn(
607 &mut self,
608 def_name: &str,
609 task_prompt: &str,
610 provider: AnyProvider,
611 tool_executor: Arc<dyn ErasedToolExecutor>,
612 skills: Option<Vec<String>>,
613 config: &SubAgentConfig,
614 ctx: SpawnContext,
615 ) -> Result<String, SubAgentError> {
616 if ctx.spawn_depth >= config.max_spawn_depth {
618 return Err(SubAgentError::MaxDepthExceeded {
619 depth: ctx.spawn_depth,
620 max: config.max_spawn_depth,
621 });
622 }
623
624 let mut def = self
625 .definitions
626 .iter()
627 .find(|d| d.name == def_name)
628 .cloned()
629 .ok_or_else(|| SubAgentError::NotFound(def_name.to_owned()))?;
630
631 apply_def_config_defaults(&mut def, config)?;
632
633 let active = self
634 .agents
635 .values()
636 .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
637 .count();
638
639 if active + self.reserved_slots >= self.max_concurrent {
640 return Err(SubAgentError::ConcurrencyLimit {
641 active,
642 max: self.max_concurrent,
643 });
644 }
645
646 let task_id = Uuid::new_v4().to_string();
647 let cancel = if def.permissions.background {
650 CancellationToken::new()
651 } else {
652 match &ctx.parent_cancel {
653 Some(parent) => parent.child_token(),
654 None => CancellationToken::new(),
655 }
656 };
657
658 let started_at = Instant::now();
659 let initial_status = SubAgentStatus {
660 state: SubAgentState::Submitted,
661 last_message: None,
662 turns_used: 0,
663 started_at,
664 };
665 let (status_tx, status_rx) = watch::channel(initial_status);
666
667 let permission_mode = def.permissions.permission_mode;
668 let background = def.permissions.background;
669 let max_turns = def.permissions.max_turns;
670
671 let effective_memory = def.memory.or(config.default_memory_scope);
673
674 let system_prompt = build_system_prompt_with_memory(&mut def, effective_memory);
678
679 let effective_task_prompt = apply_context_injection(
681 task_prompt,
682 &ctx.parent_messages,
683 config.context_injection_mode,
684 );
685
686 let cancel_clone = cancel.clone();
687 let agent_hooks = def.hooks.clone();
688 let agent_name_clone = def.name.clone();
689 let spawn_depth = ctx.spawn_depth;
690 let mcp_tool_names = ctx.mcp_tool_names;
691 let parent_messages = ctx.parent_messages;
692
693 let executor = build_filtered_executor(tool_executor, permission_mode, &def);
694
695 let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
696 let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
697
698 let transcript_writer = self.create_transcript_writer(config, &task_id, &def.name, None);
700
701 let task_id_for_loop = task_id.clone();
702 let join_handle: JoinHandle<Result<String, SubAgentError>> =
703 tokio::spawn(run_agent_loop(AgentLoopArgs {
704 provider,
705 executor,
706 system_prompt,
707 task_prompt: effective_task_prompt,
708 skills,
709 max_turns,
710 cancel: cancel_clone,
711 status_tx,
712 started_at,
713 secret_request_tx,
714 secret_rx,
715 background,
716 hooks: agent_hooks,
717 task_id: task_id_for_loop,
718 agent_name: agent_name_clone,
719 initial_messages: parent_messages,
720 transcript_writer,
721 spawn_depth: spawn_depth + 1,
722 mcp_tool_names,
723 }));
724
725 let handle_transcript_dir = if config.transcript_enabled {
726 Some(self.effective_transcript_dir(config))
727 } else {
728 None
729 };
730
731 let handle = SubAgentHandle {
732 id: task_id.clone(),
733 def,
734 task_id: task_id.clone(),
735 state: SubAgentState::Submitted,
736 join_handle: Some(join_handle),
737 cancel,
738 status_rx,
739 grants: PermissionGrants::default(),
740 pending_secret_rx,
741 secret_tx,
742 started_at_str: crate::transcript::utc_now_pub(),
743 transcript_dir: handle_transcript_dir,
744 };
745
746 self.agents.insert(task_id.clone(), handle);
747 tracing::info!(
759 task_id,
760 def_name,
761 permission_mode = ?self.agents[&task_id].def.permissions.permission_mode,
762 "sub-agent spawned"
763 );
764
765 self.cache_and_fire_start_hooks(config, &task_id, def_name);
766
767 Ok(task_id)
768 }
769
770 fn cache_and_fire_start_hooks(
771 &mut self,
772 config: &SubAgentConfig,
773 task_id: &str,
774 def_name: &str,
775 ) {
776 if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
777 self.stop_hooks.clone_from(&config.hooks.stop);
778 }
779 if !config.hooks.start.is_empty() {
780 let start_hooks = config.hooks.start.clone();
781 let start_env = make_hook_env(task_id, def_name, "");
782 tokio::spawn(async move {
783 if let Err(e) = fire_hooks(&start_hooks, &start_env, None).await {
784 tracing::warn!(error = %e, "SubagentStart hook failed");
785 }
786 });
787 }
788 }
789
790 fn create_transcript_writer(
791 &mut self,
792 config: &SubAgentConfig,
793 task_id: &str,
794 agent_name: &str,
795 resumed_from: Option<&str>,
796 ) -> Option<TranscriptWriter> {
797 if !config.transcript_enabled {
798 return None;
799 }
800 let dir = self.effective_transcript_dir(config);
801 if self.transcript_max_files > 0
802 && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
803 {
804 tracing::warn!(error = %e, "transcript sweep failed");
805 }
806 let path = dir.join(format!("{task_id}.jsonl"));
807 match TranscriptWriter::new(&path) {
808 Ok(w) => {
809 let meta = TranscriptMeta {
810 agent_id: task_id.to_owned(),
811 agent_name: agent_name.to_owned(),
812 def_name: agent_name.to_owned(),
813 status: SubAgentState::Submitted,
814 started_at: crate::transcript::utc_now_pub(),
815 finished_at: None,
816 resumed_from: resumed_from.map(str::to_owned),
817 turns_used: 0,
818 };
819 if let Err(e) = TranscriptWriter::write_meta(&dir, task_id, &meta) {
820 tracing::warn!(error = %e, "failed to write initial transcript meta");
821 }
822 Some(w)
823 }
824 Err(e) => {
825 tracing::warn!(error = %e, "failed to create transcript writer");
826 None
827 }
828 }
829 }
830
831 pub fn shutdown_all(&mut self) {
837 let ids: Vec<String> = self.agents.keys().cloned().collect();
838 for id in ids {
839 let _ = self.cancel(&id);
840 }
841 }
842
843 pub fn cancel(&mut self, task_id: &str) -> Result<(), SubAgentError> {
849 let handle = self
850 .agents
851 .get_mut(task_id)
852 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
853 handle.cancel.cancel();
854 handle.state = SubAgentState::Canceled;
855 handle.grants.revoke_all();
856 tracing::info!(task_id, "sub-agent cancelled");
857
858 if !self.stop_hooks.is_empty() {
860 let stop_hooks = self.stop_hooks.clone();
861 let stop_env = make_hook_env(task_id, &handle.def.name, "");
862 tokio::spawn(async move {
863 if let Err(e) = fire_hooks(&stop_hooks, &stop_env, None).await {
864 tracing::warn!(error = %e, "SubagentStop hook failed");
865 }
866 });
867 }
868
869 Ok(())
870 }
871
872 pub fn cancel_all(&mut self) {
877 for (task_id, handle) in &mut self.agents {
878 if matches!(
879 handle.state,
880 SubAgentState::Working | SubAgentState::Submitted
881 ) {
882 handle.cancel.cancel();
883 handle.state = SubAgentState::Canceled;
884 handle.grants.revoke_all();
885 tracing::info!(task_id, "sub-agent cancelled (cancel_all)");
886 }
887 }
888 }
889
890 pub fn approve_secret(
901 &mut self,
902 task_id: &str,
903 secret_key: &str,
904 ttl: std::time::Duration,
905 ) -> Result<(), SubAgentError> {
906 let handle = self
907 .agents
908 .get_mut(task_id)
909 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
910
911 handle.grants.sweep_expired();
913
914 if !handle
915 .def
916 .permissions
917 .secrets
918 .iter()
919 .any(|k| k == secret_key)
920 {
921 tracing::warn!(task_id, "secret request denied: key not in allowed list");
923 return Err(SubAgentError::Invalid(format!(
924 "secret is not in the allowed secrets list for '{}'",
925 handle.def.name
926 )));
927 }
928
929 handle.grants.grant_secret(secret_key, ttl);
930 Ok(())
931 }
932
933 pub fn deliver_secret(&mut self, task_id: &str, key: String) -> Result<(), SubAgentError> {
942 let handle = self
946 .agents
947 .get_mut(task_id)
948 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
949 handle
950 .secret_tx
951 .try_send(Some(key))
952 .map_err(|e| SubAgentError::Channel(e.to_string()))
953 }
954
955 pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
962 let handle = self
963 .agents
964 .get_mut(task_id)
965 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
966 handle
967 .secret_tx
968 .try_send(None)
969 .map_err(|e| SubAgentError::Channel(e.to_string()))
970 }
971
972 pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
978 for handle in self.agents.values_mut() {
979 if let Ok(req) = handle.pending_secret_rx.try_recv() {
980 return Some((handle.task_id.clone(), req));
981 }
982 }
983 None
984 }
985
986 pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
995 let mut handle = self
996 .agents
997 .remove(task_id)
998 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
999
1000 if !self.stop_hooks.is_empty() {
1002 let stop_hooks = self.stop_hooks.clone();
1003 let stop_env = make_hook_env(task_id, &handle.def.name, "");
1004 tokio::spawn(async move {
1005 if let Err(e) = fire_hooks(&stop_hooks, &stop_env, None).await {
1006 tracing::warn!(error = %e, "SubagentStop hook failed");
1007 }
1008 });
1009 }
1010
1011 handle.grants.revoke_all();
1012
1013 let result = if let Some(jh) = handle.join_handle.take() {
1014 jh.await.map_err(|e| SubAgentError::Spawn(e.to_string()))?
1015 } else {
1016 Ok(String::new())
1017 };
1018
1019 if let Some(ref dir) = handle.transcript_dir.clone() {
1021 let status = handle.status_rx.borrow();
1022 let final_status = if result.is_err() {
1023 SubAgentState::Failed
1024 } else if status.state == SubAgentState::Canceled {
1025 SubAgentState::Canceled
1026 } else {
1027 SubAgentState::Completed
1028 };
1029 let turns_used = status.turns_used;
1030 drop(status);
1031
1032 let meta = TranscriptMeta {
1033 agent_id: task_id.to_owned(),
1034 agent_name: handle.def.name.clone(),
1035 def_name: handle.def.name.clone(),
1036 status: final_status,
1037 started_at: handle.started_at_str.clone(),
1038 finished_at: Some(crate::transcript::utc_now_pub()),
1039 resumed_from: None,
1040 turns_used,
1041 };
1042 if let Err(e) = TranscriptWriter::write_meta(dir, task_id, &meta) {
1043 tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
1044 }
1045 }
1046
1047 result
1048 }
1049
1050 #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1065 pub fn resume(
1066 &mut self,
1067 id_prefix: &str,
1068 task_prompt: &str,
1069 provider: AnyProvider,
1070 tool_executor: Arc<dyn ErasedToolExecutor>,
1071 skills: Option<Vec<String>>,
1072 config: &SubAgentConfig,
1073 ) -> Result<(String, String), SubAgentError> {
1074 let dir = self.effective_transcript_dir(config);
1075 let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1078
1079 if self.agents.contains_key(&original_id) {
1081 return Err(SubAgentError::StillRunning(original_id));
1082 }
1083 let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1084
1085 match meta.status {
1087 SubAgentState::Completed | SubAgentState::Failed | SubAgentState::Canceled => {}
1088 other => {
1089 return Err(SubAgentError::StillRunning(format!(
1090 "{original_id} (status: {other:?})"
1091 )));
1092 }
1093 }
1094
1095 let jsonl_path = dir.join(format!("{original_id}.jsonl"));
1096 let initial_messages = TranscriptReader::load(&jsonl_path)?;
1097
1098 let mut def = self
1101 .definitions
1102 .iter()
1103 .find(|d| d.name == meta.def_name)
1104 .cloned()
1105 .ok_or_else(|| SubAgentError::NotFound(meta.def_name.clone()))?;
1106
1107 if def.permissions.permission_mode == PermissionMode::Default
1108 && let Some(default_mode) = config.default_permission_mode
1109 {
1110 def.permissions.permission_mode = default_mode;
1111 }
1112
1113 if !config.default_disallowed_tools.is_empty() {
1114 let mut merged = def.disallowed_tools.clone();
1115 for tool in &config.default_disallowed_tools {
1116 if !merged.contains(tool) {
1117 merged.push(tool.clone());
1118 }
1119 }
1120 def.disallowed_tools = merged;
1121 }
1122
1123 if def.permissions.permission_mode == PermissionMode::BypassPermissions
1124 && !config.allow_bypass_permissions
1125 {
1126 return Err(SubAgentError::Invalid(format!(
1127 "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config",
1128 def.name
1129 )));
1130 }
1131
1132 let active = self
1134 .agents
1135 .values()
1136 .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
1137 .count();
1138 if active >= self.max_concurrent {
1139 return Err(SubAgentError::ConcurrencyLimit {
1140 active,
1141 max: self.max_concurrent,
1142 });
1143 }
1144
1145 let new_task_id = Uuid::new_v4().to_string();
1146 let cancel = CancellationToken::new();
1147 let started_at = Instant::now();
1148 let initial_status = SubAgentStatus {
1149 state: SubAgentState::Submitted,
1150 last_message: None,
1151 turns_used: 0,
1152 started_at,
1153 };
1154 let (status_tx, status_rx) = watch::channel(initial_status);
1155
1156 let permission_mode = def.permissions.permission_mode;
1157 let background = def.permissions.background;
1158 let max_turns = def.permissions.max_turns;
1159 let system_prompt = def.system_prompt.clone();
1160 let task_prompt_owned = task_prompt.to_owned();
1161 let cancel_clone = cancel.clone();
1162 let agent_hooks = def.hooks.clone();
1163 let agent_name_clone = def.name.clone();
1164
1165 let executor = build_filtered_executor(tool_executor, permission_mode, &def);
1166
1167 let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
1168 let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
1169
1170 let transcript_writer =
1171 self.create_transcript_writer(config, &new_task_id, &def.name, Some(&original_id));
1172
1173 let new_task_id_for_loop = new_task_id.clone();
1174 let join_handle: JoinHandle<Result<String, SubAgentError>> =
1175 tokio::spawn(run_agent_loop(AgentLoopArgs {
1176 provider,
1177 executor,
1178 system_prompt,
1179 task_prompt: task_prompt_owned,
1180 skills,
1181 max_turns,
1182 cancel: cancel_clone,
1183 status_tx,
1184 started_at,
1185 secret_request_tx,
1186 secret_rx,
1187 background,
1188 hooks: agent_hooks,
1189 task_id: new_task_id_for_loop,
1190 agent_name: agent_name_clone,
1191 initial_messages,
1192 transcript_writer,
1193 spawn_depth: 0,
1194 mcp_tool_names: Vec::new(),
1195 }));
1196
1197 let resume_handle_transcript_dir = if config.transcript_enabled {
1198 Some(dir.clone())
1199 } else {
1200 None
1201 };
1202
1203 let handle = SubAgentHandle {
1204 id: new_task_id.clone(),
1205 def,
1206 task_id: new_task_id.clone(),
1207 state: SubAgentState::Submitted,
1208 join_handle: Some(join_handle),
1209 cancel,
1210 status_rx,
1211 grants: PermissionGrants::default(),
1212 pending_secret_rx,
1213 secret_tx,
1214 started_at_str: crate::transcript::utc_now_pub(),
1215 transcript_dir: resume_handle_transcript_dir,
1216 };
1217
1218 self.agents.insert(new_task_id.clone(), handle);
1219 tracing::info!(
1220 task_id = %new_task_id,
1221 original_id = %original_id,
1222 "sub-agent resumed"
1223 );
1224
1225 if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
1227 self.stop_hooks.clone_from(&config.hooks.stop);
1228 }
1229
1230 if !config.hooks.start.is_empty() {
1232 let start_hooks = config.hooks.start.clone();
1233 let def_name = meta.def_name.clone();
1234 let start_env = make_hook_env(&new_task_id, &def_name, "");
1235 tokio::spawn(async move {
1236 if let Err(e) = fire_hooks(&start_hooks, &start_env, None).await {
1237 tracing::warn!(error = %e, "SubagentStart hook failed");
1238 }
1239 });
1240 }
1241
1242 Ok((new_task_id, meta.def_name))
1243 }
1244
1245 fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
1247 if let Some(ref dir) = self.transcript_dir {
1248 dir.clone()
1249 } else if let Some(ref dir) = config.transcript_dir {
1250 dir.clone()
1251 } else {
1252 PathBuf::from(".zeph/subagents")
1253 }
1254 }
1255
1256 pub fn def_name_for_resume(
1265 &self,
1266 id_prefix: &str,
1267 config: &SubAgentConfig,
1268 ) -> Result<String, SubAgentError> {
1269 let dir = self.effective_transcript_dir(config);
1270 let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1271 let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1272 Ok(meta.def_name)
1273 }
1274
1275 #[must_use]
1277 pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
1278 self.agents
1279 .values()
1280 .map(|h| {
1281 let mut status = h.status_rx.borrow().clone();
1282 if h.state == SubAgentState::Canceled {
1285 status.state = SubAgentState::Canceled;
1286 }
1287 (h.task_id.clone(), status)
1288 })
1289 .collect()
1290 }
1291
1292 #[must_use]
1294 pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
1295 self.agents.get(task_id).map(|h| &h.def)
1296 }
1297
1298 #[must_use]
1300 pub fn agent_transcript_dir(&self, task_id: &str) -> Option<&std::path::Path> {
1301 self.agents
1302 .get(task_id)
1303 .and_then(|h| h.transcript_dir.as_deref())
1304 }
1305
1306 #[allow(clippy::too_many_arguments)]
1325 #[allow(clippy::too_many_arguments)] pub fn spawn_for_task<F>(
1342 &mut self,
1343 def_name: &str,
1344 task_prompt: &str,
1345 provider: AnyProvider,
1346 tool_executor: Arc<dyn ErasedToolExecutor>,
1347 skills: Option<Vec<String>>,
1348 config: &SubAgentConfig,
1349 ctx: SpawnContext,
1350 on_done: F,
1351 ) -> Result<String, SubAgentError>
1352 where
1353 F: FnOnce(String, Result<String, SubAgentError>) + Send + 'static,
1354 {
1355 let handle_id = self.spawn(
1356 def_name,
1357 task_prompt,
1358 provider,
1359 tool_executor,
1360 skills,
1361 config,
1362 ctx,
1363 )?;
1364
1365 let handle = self
1366 .agents
1367 .get_mut(&handle_id)
1368 .expect("just spawned agent must exist");
1369
1370 let original_join = handle
1371 .join_handle
1372 .take()
1373 .expect("just spawned agent must have a join handle");
1374
1375 let handle_id_clone = handle_id.clone();
1376 let wrapped_join: tokio::task::JoinHandle<Result<String, SubAgentError>> =
1377 tokio::spawn(async move {
1378 let result = original_join.await;
1379
1380 let (notify_result, output) = match result {
1381 Ok(Ok(output)) => (Ok(output.clone()), Ok(output)),
1382 Ok(Err(e)) => {
1383 let msg = e.to_string();
1384 (
1385 Err(SubAgentError::Spawn(msg.clone())),
1386 Err(SubAgentError::Spawn(msg)),
1387 )
1388 }
1389 Err(join_err) => {
1390 let msg = format!("task panicked: {join_err:?}");
1391 (
1392 Err(SubAgentError::TaskPanic(msg.clone())),
1393 Err(SubAgentError::TaskPanic(msg)),
1394 )
1395 }
1396 };
1397
1398 on_done(handle_id_clone, notify_result);
1399
1400 output
1401 });
1402
1403 handle.join_handle = Some(wrapped_join);
1404
1405 Ok(handle_id)
1406 }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 #![allow(
1412 clippy::await_holding_lock,
1413 clippy::field_reassign_with_default,
1414 clippy::too_many_lines
1415 )]
1416
1417 use std::pin::Pin;
1418
1419 use indoc::indoc;
1420 use zeph_llm::any::AnyProvider;
1421 use zeph_llm::mock::MockProvider;
1422 use zeph_tools::ToolCall;
1423 use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput};
1424 use zeph_tools::registry::ToolDef;
1425
1426 use serial_test::serial;
1427
1428 use crate::agent_loop::{AgentLoopArgs, make_message, run_agent_loop};
1429 use crate::def::{MemoryScope, ModelSpec};
1430 use zeph_config::SubAgentConfig;
1431 use zeph_llm::provider::ChatResponse;
1432
1433 use super::*;
1434
1435 fn make_manager() -> SubAgentManager {
1436 SubAgentManager::new(4)
1437 }
1438
1439 fn sample_def() -> SubAgentDef {
1440 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap()
1441 }
1442
1443 fn def_with_secrets() -> SubAgentDef {
1444 SubAgentDef::parse(
1445 "---\nname: bot\ndescription: A bot\npermissions:\n secrets:\n - api-key\n---\n\nDo things.\n",
1446 )
1447 .unwrap()
1448 }
1449
1450 struct NoopExecutor;
1451
1452 impl ErasedToolExecutor for NoopExecutor {
1453 fn execute_erased<'a>(
1454 &'a self,
1455 _response: &'a str,
1456 ) -> Pin<
1457 Box<
1458 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1459 >,
1460 > {
1461 Box::pin(std::future::ready(Ok(None)))
1462 }
1463
1464 fn execute_confirmed_erased<'a>(
1465 &'a self,
1466 _response: &'a str,
1467 ) -> Pin<
1468 Box<
1469 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1470 >,
1471 > {
1472 Box::pin(std::future::ready(Ok(None)))
1473 }
1474
1475 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1476 vec![]
1477 }
1478
1479 fn execute_tool_call_erased<'a>(
1480 &'a self,
1481 _call: &'a ToolCall,
1482 ) -> Pin<
1483 Box<
1484 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1485 >,
1486 > {
1487 Box::pin(std::future::ready(Ok(None)))
1488 }
1489
1490 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1491 false
1492 }
1493
1494 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
1495 false
1496 }
1497 }
1498
1499 fn mock_provider(responses: Vec<&str>) -> AnyProvider {
1500 AnyProvider::Mock(MockProvider::with_responses(
1501 responses.into_iter().map(String::from).collect(),
1502 ))
1503 }
1504
1505 fn noop_executor() -> Arc<dyn ErasedToolExecutor> {
1506 Arc::new(NoopExecutor)
1507 }
1508
1509 fn do_spawn(
1510 mgr: &mut SubAgentManager,
1511 name: &str,
1512 prompt: &str,
1513 ) -> Result<String, SubAgentError> {
1514 mgr.spawn(
1515 name,
1516 prompt,
1517 mock_provider(vec!["done"]),
1518 noop_executor(),
1519 None,
1520 &SubAgentConfig::default(),
1521 SpawnContext::default(),
1522 )
1523 }
1524
1525 #[test]
1526 fn load_definitions_populates_vec() {
1527 use std::io::Write as _;
1528 let dir = tempfile::tempdir().unwrap();
1529 let content = "---\nname: helper\ndescription: A helper\n---\n\nHelp.\n";
1530 let mut f = std::fs::File::create(dir.path().join("helper.md")).unwrap();
1531 f.write_all(content.as_bytes()).unwrap();
1532
1533 let mut mgr = make_manager();
1534 mgr.load_definitions(&[dir.path().to_path_buf()]).unwrap();
1535 assert_eq!(mgr.definitions().len(), 1);
1536 assert_eq!(mgr.definitions()[0].name, "helper");
1537 }
1538
1539 #[test]
1540 fn spawn_not_found_error() {
1541 let rt = tokio::runtime::Runtime::new().unwrap();
1542 let _guard = rt.enter();
1543 let mut mgr = make_manager();
1544 let err = do_spawn(&mut mgr, "nonexistent", "prompt").unwrap_err();
1545 assert!(matches!(err, SubAgentError::NotFound(_)));
1546 }
1547
1548 #[test]
1549 fn spawn_and_cancel() {
1550 let rt = tokio::runtime::Runtime::new().unwrap();
1551 let _guard = rt.enter();
1552 let mut mgr = make_manager();
1553 mgr.definitions.push(sample_def());
1554
1555 let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1556 assert!(!task_id.is_empty());
1557
1558 mgr.cancel(&task_id).unwrap();
1559 assert_eq!(mgr.agents[&task_id].state, SubAgentState::Canceled);
1560 }
1561
1562 #[test]
1563 fn cancel_unknown_task_id_returns_not_found() {
1564 let mut mgr = make_manager();
1565 let err = mgr.cancel("unknown-id").unwrap_err();
1566 assert!(matches!(err, SubAgentError::NotFound(_)));
1567 }
1568
1569 #[tokio::test]
1570 async fn collect_removes_agent() {
1571 let mut mgr = make_manager();
1572 mgr.definitions.push(sample_def());
1573
1574 let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1575 mgr.cancel(&task_id).unwrap();
1576
1577 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1579
1580 let result = mgr.collect(&task_id).await.unwrap();
1581 assert!(!mgr.agents.contains_key(&task_id));
1582 let _ = result;
1584 }
1585
1586 #[tokio::test]
1587 async fn collect_unknown_task_id_returns_not_found() {
1588 let mut mgr = make_manager();
1589 let err = mgr.collect("unknown-id").await.unwrap_err();
1590 assert!(matches!(err, SubAgentError::NotFound(_)));
1591 }
1592
1593 #[test]
1594 fn approve_secret_grants_access() {
1595 let rt = tokio::runtime::Runtime::new().unwrap();
1596 let _guard = rt.enter();
1597 let mut mgr = make_manager();
1598 mgr.definitions.push(def_with_secrets());
1599
1600 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1601 mgr.approve_secret(&task_id, "api-key", std::time::Duration::from_mins(1))
1602 .unwrap();
1603
1604 let handle = mgr.agents.get_mut(&task_id).unwrap();
1605 assert!(
1606 handle
1607 .grants
1608 .is_active(&crate::grants::GrantKind::Secret("api-key".into()))
1609 );
1610 }
1611
1612 #[test]
1613 fn approve_secret_denied_for_unlisted_key() {
1614 let rt = tokio::runtime::Runtime::new().unwrap();
1615 let _guard = rt.enter();
1616 let mut mgr = make_manager();
1617 mgr.definitions.push(sample_def()); let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1620 let err = mgr
1621 .approve_secret(&task_id, "not-allowed", std::time::Duration::from_mins(1))
1622 .unwrap_err();
1623 assert!(matches!(err, SubAgentError::Invalid(_)));
1624 }
1625
1626 #[test]
1627 fn approve_secret_unknown_task_id_returns_not_found() {
1628 let mut mgr = make_manager();
1629 let err = mgr
1630 .approve_secret("unknown", "key", std::time::Duration::from_mins(1))
1631 .unwrap_err();
1632 assert!(matches!(err, SubAgentError::NotFound(_)));
1633 }
1634
1635 #[test]
1636 fn statuses_returns_active_agents() {
1637 let rt = tokio::runtime::Runtime::new().unwrap();
1638 let _guard = rt.enter();
1639 let mut mgr = make_manager();
1640 mgr.definitions.push(sample_def());
1641
1642 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1643 let statuses = mgr.statuses();
1644 assert_eq!(statuses.len(), 1);
1645 assert_eq!(statuses[0].0, task_id);
1646 }
1647
1648 #[test]
1649 fn concurrency_limit_enforced() {
1650 let rt = tokio::runtime::Runtime::new().unwrap();
1651 let _guard = rt.enter();
1652 let mut mgr = SubAgentManager::new(1);
1653 mgr.definitions.push(sample_def());
1654
1655 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1656 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1657 assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1658 }
1659
1660 #[test]
1663 fn test_reserve_slots_blocks_spawn() {
1664 let rt = tokio::runtime::Runtime::new().unwrap();
1666 let _guard = rt.enter();
1667 let mut mgr = SubAgentManager::new(2);
1668 mgr.definitions.push(sample_def());
1669
1670 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1672 mgr.reserve_slots(1);
1674 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1676 assert!(
1677 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1678 "expected ConcurrencyLimit, got: {err}"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_release_reservation_allows_spawn() {
1684 let rt = tokio::runtime::Runtime::new().unwrap();
1686 let _guard = rt.enter();
1687 let mut mgr = SubAgentManager::new(2);
1688 mgr.definitions.push(sample_def());
1689
1690 mgr.reserve_slots(1);
1692 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1694 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1696 assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1697
1698 mgr.release_reservation(1);
1700 let result = do_spawn(&mut mgr, "bot", "third");
1701 assert!(
1702 result.is_ok(),
1703 "spawn must succeed after release_reservation, got: {result:?}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_reservation_with_zero_active_blocks_spawn() {
1709 let rt = tokio::runtime::Runtime::new().unwrap();
1711 let _guard = rt.enter();
1712 let mut mgr = SubAgentManager::new(2);
1713 mgr.definitions.push(sample_def());
1714
1715 mgr.reserve_slots(2);
1717 let err = do_spawn(&mut mgr, "bot", "first").unwrap_err();
1719 assert!(
1720 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1721 "reservation alone must block spawn when reserved >= max_concurrent"
1722 );
1723 }
1724
1725 #[tokio::test]
1726 async fn background_agent_does_not_block_caller() {
1727 let mut mgr = make_manager();
1728 mgr.definitions.push(sample_def());
1729
1730 let result = tokio::time::timeout(
1732 std::time::Duration::from_millis(100),
1733 std::future::ready(do_spawn(&mut mgr, "bot", "work")),
1734 )
1735 .await;
1736 assert!(result.is_ok(), "spawn() must not block");
1737 assert!(result.unwrap().is_ok());
1738 }
1739
1740 #[tokio::test]
1741 async fn max_turns_terminates_agent_loop() {
1742 let mut mgr = make_manager();
1743 let def = SubAgentDef::parse(indoc! {"
1745 ---
1746 name: limited
1747 description: A bot
1748 permissions:
1749 max_turns: 1
1750 ---
1751
1752 Do one thing.
1753 "})
1754 .unwrap();
1755 mgr.definitions.push(def);
1756
1757 let task_id = mgr
1758 .spawn(
1759 "limited",
1760 "task",
1761 mock_provider(vec!["final answer"]),
1762 noop_executor(),
1763 None,
1764 &SubAgentConfig::default(),
1765 SpawnContext::default(),
1766 )
1767 .unwrap();
1768
1769 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1771
1772 let status = mgr.statuses().into_iter().find(|(id, _)| id == &task_id);
1773 if let Some((_, s)) = status {
1775 assert!(s.turns_used <= 1);
1776 }
1777 }
1778
1779 #[tokio::test]
1780 async fn cancellation_token_stops_agent_loop() {
1781 let mut mgr = make_manager();
1782 mgr.definitions.push(sample_def());
1783
1784 let task_id = do_spawn(&mut mgr, "bot", "long task").unwrap();
1785
1786 mgr.cancel(&task_id).unwrap();
1788
1789 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1791 let result = mgr.collect(&task_id).await;
1792 assert!(result.is_ok() || result.is_err());
1794 }
1795
1796 #[tokio::test]
1797 async fn shutdown_all_cancels_all_active_agents() {
1798 let mut mgr = make_manager();
1799 mgr.definitions.push(sample_def());
1800
1801 do_spawn(&mut mgr, "bot", "task 1").unwrap();
1802 do_spawn(&mut mgr, "bot", "task 2").unwrap();
1803
1804 assert_eq!(mgr.agents.len(), 2);
1805 mgr.shutdown_all();
1806
1807 for (_, status) in mgr.statuses() {
1809 assert_eq!(status.state, SubAgentState::Canceled);
1810 }
1811 }
1812
1813 #[test]
1814 fn debug_impl_does_not_expose_sensitive_fields() {
1815 let rt = tokio::runtime::Runtime::new().unwrap();
1816 let _guard = rt.enter();
1817 let mut mgr = make_manager();
1818 mgr.definitions.push(def_with_secrets());
1819 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1820 let handle = &mgr.agents[&task_id];
1821 let debug_str = format!("{handle:?}");
1822 assert!(!debug_str.contains("api-key"));
1824 }
1825
1826 #[tokio::test]
1827 async fn llm_failure_transitions_to_failed_state() {
1828 let rt_handle = tokio::runtime::Handle::current();
1829 let _guard = rt_handle.enter();
1830 let mut mgr = make_manager();
1831 mgr.definitions.push(sample_def());
1832
1833 let failing = AnyProvider::Mock(MockProvider::failing());
1834 let task_id = mgr
1835 .spawn(
1836 "bot",
1837 "do work",
1838 failing,
1839 noop_executor(),
1840 None,
1841 &SubAgentConfig::default(),
1842 SpawnContext::default(),
1843 )
1844 .unwrap();
1845
1846 tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1848
1849 let statuses = mgr.statuses();
1850 let status = statuses
1851 .iter()
1852 .find(|(id, _)| id == &task_id)
1853 .map(|(_, s)| s);
1854 assert!(
1856 status.is_some_and(|s| s.state == SubAgentState::Failed),
1857 "expected Failed, got: {status:?}"
1858 );
1859 }
1860
1861 #[tokio::test]
1862 async fn tool_call_loop_two_turns() {
1863 use std::sync::Mutex;
1864 use zeph_llm::mock::MockProvider;
1865 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
1866 use zeph_tools::ToolCall;
1867
1868 struct ToolOnceExecutor {
1869 calls: Mutex<u32>,
1870 }
1871
1872 impl ErasedToolExecutor for ToolOnceExecutor {
1873 fn execute_erased<'a>(
1874 &'a self,
1875 _response: &'a str,
1876 ) -> Pin<
1877 Box<
1878 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1879 + Send
1880 + 'a,
1881 >,
1882 > {
1883 Box::pin(std::future::ready(Ok(None)))
1884 }
1885
1886 fn execute_confirmed_erased<'a>(
1887 &'a self,
1888 _response: &'a str,
1889 ) -> Pin<
1890 Box<
1891 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1892 + Send
1893 + 'a,
1894 >,
1895 > {
1896 Box::pin(std::future::ready(Ok(None)))
1897 }
1898
1899 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1900 vec![]
1901 }
1902
1903 fn execute_tool_call_erased<'a>(
1904 &'a self,
1905 call: &'a ToolCall,
1906 ) -> Pin<
1907 Box<
1908 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1909 + Send
1910 + 'a,
1911 >,
1912 > {
1913 let mut n = self.calls.lock().unwrap();
1914 *n += 1;
1915 let result = if *n == 1 {
1916 Ok(Some(ToolOutput {
1917 tool_name: call.tool_id.clone(),
1918 summary: "step 1 done".into(),
1919 blocks_executed: 1,
1920 filter_stats: None,
1921 diff: None,
1922 streamed: false,
1923 terminal_id: None,
1924 locations: None,
1925 raw_response: None,
1926 claim_source: None,
1927 }))
1928 } else {
1929 Ok(None)
1930 };
1931 Box::pin(std::future::ready(result))
1932 }
1933
1934 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1935 false
1936 }
1937
1938 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
1939 false
1940 }
1941 }
1942
1943 let rt_handle = tokio::runtime::Handle::current();
1944 let _guard = rt_handle.enter();
1945 let mut mgr = make_manager();
1946 mgr.definitions.push(sample_def());
1947
1948 let tool_response = ChatResponse::ToolUse {
1950 text: None,
1951 tool_calls: vec![ToolUseRequest {
1952 id: "call-1".into(),
1953 name: "shell".into(),
1954 input: serde_json::json!({"command": "echo hi"}),
1955 }],
1956 thinking_blocks: vec![],
1957 };
1958 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
1959 tool_response,
1960 ChatResponse::Text("final answer".into()),
1961 ]);
1962 let provider = AnyProvider::Mock(mock);
1963 let executor = Arc::new(ToolOnceExecutor {
1964 calls: Mutex::new(0),
1965 });
1966
1967 let task_id = mgr
1968 .spawn(
1969 "bot",
1970 "run two turns",
1971 provider,
1972 executor,
1973 None,
1974 &SubAgentConfig::default(),
1975 SpawnContext::default(),
1976 )
1977 .unwrap();
1978
1979 tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1981
1982 let result = mgr.collect(&task_id).await;
1983 assert!(result.is_ok(), "expected Ok, got: {result:?}");
1984 }
1985
1986 #[tokio::test]
1987 async fn collect_on_running_task_completes_eventually() {
1988 let mut mgr = make_manager();
1989 mgr.definitions.push(sample_def());
1990
1991 let task_id = do_spawn(&mut mgr, "bot", "slow work").unwrap();
1993
1994 let result =
1996 tokio::time::timeout(tokio::time::Duration::from_secs(5), mgr.collect(&task_id)).await;
1997
1998 assert!(result.is_ok(), "collect timed out after 5s");
1999 let inner = result.unwrap();
2000 assert!(inner.is_ok(), "collect returned error: {inner:?}");
2001 }
2002
2003 #[test]
2004 fn concurrency_slot_freed_after_cancel() {
2005 let rt = tokio::runtime::Runtime::new().unwrap();
2006 let _guard = rt.enter();
2007 let mut mgr = SubAgentManager::new(1); mgr.definitions.push(sample_def());
2009
2010 let id1 = do_spawn(&mut mgr, "bot", "task 1").unwrap();
2011
2012 let err = do_spawn(&mut mgr, "bot", "task 2").unwrap_err();
2014 assert!(
2015 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2016 "expected concurrency limit error, got: {err}"
2017 );
2018
2019 mgr.cancel(&id1).unwrap();
2021
2022 let result = do_spawn(&mut mgr, "bot", "task 3");
2024 assert!(
2025 result.is_ok(),
2026 "expected spawn to succeed after cancel, got: {result:?}"
2027 );
2028 }
2029
2030 #[tokio::test]
2031 async fn skill_bodies_prepended_to_system_prompt() {
2032 use zeph_llm::mock::MockProvider;
2035
2036 let (mock, recorded) = MockProvider::default().with_recording();
2037 let provider = AnyProvider::Mock(mock);
2038
2039 let mut mgr = make_manager();
2040 mgr.definitions.push(sample_def());
2041
2042 let skill_bodies = vec!["# skill-one\nDo something useful.".to_owned()];
2043 let task_id = mgr
2044 .spawn(
2045 "bot",
2046 "task",
2047 provider,
2048 noop_executor(),
2049 Some(skill_bodies),
2050 &SubAgentConfig::default(),
2051 SpawnContext::default(),
2052 )
2053 .unwrap();
2054
2055 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2057
2058 let calls = recorded.lock().unwrap();
2059 assert!(!calls.is_empty(), "provider should have been called");
2060 let system_msg = &calls[0][0].content;
2062 assert!(
2063 system_msg.contains("```skills"),
2064 "system prompt must contain ```skills fence, got: {system_msg}"
2065 );
2066 assert!(
2067 system_msg.contains("skill-one"),
2068 "system prompt must contain the skill body, got: {system_msg}"
2069 );
2070 drop(calls);
2071
2072 let _ = mgr.collect(&task_id).await;
2073 }
2074
2075 #[tokio::test]
2076 async fn no_skills_does_not_add_fence_to_system_prompt() {
2077 use zeph_llm::mock::MockProvider;
2078
2079 let (mock, recorded) = MockProvider::default().with_recording();
2080 let provider = AnyProvider::Mock(mock);
2081
2082 let mut mgr = make_manager();
2083 mgr.definitions.push(sample_def());
2084
2085 let task_id = mgr
2086 .spawn(
2087 "bot",
2088 "task",
2089 provider,
2090 noop_executor(),
2091 None,
2092 &SubAgentConfig::default(),
2093 SpawnContext::default(),
2094 )
2095 .unwrap();
2096
2097 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2098
2099 let calls = recorded.lock().unwrap();
2100 assert!(!calls.is_empty());
2101 let system_msg = &calls[0][0].content;
2102 assert!(
2103 !system_msg.contains("```skills"),
2104 "system prompt must not contain skills fence when no skills passed"
2105 );
2106 drop(calls);
2107
2108 let _ = mgr.collect(&task_id).await;
2109 }
2110
2111 #[tokio::test]
2112 async fn statuses_does_not_include_collected_task() {
2113 let mut mgr = make_manager();
2114 mgr.definitions.push(sample_def());
2115
2116 let task_id = do_spawn(&mut mgr, "bot", "task").unwrap();
2117 assert_eq!(mgr.statuses().len(), 1);
2118
2119 tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
2121 let _ = mgr.collect(&task_id).await;
2122
2123 assert!(
2125 mgr.statuses().is_empty(),
2126 "expected empty statuses after collect"
2127 );
2128 }
2129
2130 #[tokio::test]
2131 async fn background_agent_auto_denies_secret_request() {
2132 use zeph_llm::mock::MockProvider;
2133
2134 let def = SubAgentDef::parse(indoc! {"
2136 ---
2137 name: bg-bot
2138 description: Background bot
2139 permissions:
2140 background: true
2141 secrets:
2142 - api-key
2143 ---
2144
2145 [REQUEST_SECRET: api-key]
2146 "})
2147 .unwrap();
2148
2149 let (mock, recorded) = MockProvider::default().with_recording();
2150 let provider = AnyProvider::Mock(mock);
2151
2152 let mut mgr = make_manager();
2153 mgr.definitions.push(def);
2154
2155 let task_id = mgr
2156 .spawn(
2157 "bg-bot",
2158 "task",
2159 provider,
2160 noop_executor(),
2161 None,
2162 &SubAgentConfig::default(),
2163 SpawnContext::default(),
2164 )
2165 .unwrap();
2166
2167 let result =
2169 tokio::time::timeout(tokio::time::Duration::from_secs(2), mgr.collect(&task_id)).await;
2170 assert!(
2171 result.is_ok(),
2172 "background agent must not block on secret request"
2173 );
2174 drop(recorded);
2175 }
2176
2177 #[test]
2178 fn spawn_with_plan_mode_definition_succeeds() {
2179 let rt = tokio::runtime::Runtime::new().unwrap();
2180 let _guard = rt.enter();
2181
2182 let def = SubAgentDef::parse(indoc! {"
2183 ---
2184 name: planner
2185 description: A planner bot
2186 permissions:
2187 permission_mode: plan
2188 ---
2189
2190 Plan only.
2191 "})
2192 .unwrap();
2193
2194 let mut mgr = make_manager();
2195 mgr.definitions.push(def);
2196
2197 let task_id = do_spawn(&mut mgr, "planner", "make a plan").unwrap();
2198 assert!(!task_id.is_empty());
2199 mgr.cancel(&task_id).unwrap();
2200 }
2201
2202 #[test]
2203 fn spawn_with_disallowed_tools_definition_succeeds() {
2204 let rt = tokio::runtime::Runtime::new().unwrap();
2205 let _guard = rt.enter();
2206
2207 let def = SubAgentDef::parse(indoc! {"
2208 ---
2209 name: safe-bot
2210 description: Bot with disallowed tools
2211 tools:
2212 allow:
2213 - shell
2214 - web
2215 except:
2216 - shell
2217 ---
2218
2219 Do safe things.
2220 "})
2221 .unwrap();
2222
2223 assert_eq!(def.disallowed_tools, ["shell"]);
2224
2225 let mut mgr = make_manager();
2226 mgr.definitions.push(def);
2227
2228 let task_id = do_spawn(&mut mgr, "safe-bot", "task").unwrap();
2229 assert!(!task_id.is_empty());
2230 mgr.cancel(&task_id).unwrap();
2231 }
2232
2233 #[test]
2236 fn spawn_applies_default_permission_mode_from_config() {
2237 let rt = tokio::runtime::Runtime::new().unwrap();
2238 let _guard = rt.enter();
2239
2240 let def =
2242 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2243 assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
2244
2245 let mut mgr = make_manager();
2246 mgr.definitions.push(def);
2247
2248 let cfg = SubAgentConfig {
2249 default_permission_mode: Some(PermissionMode::Plan),
2250 ..SubAgentConfig::default()
2251 };
2252
2253 let task_id = mgr
2254 .spawn(
2255 "bot",
2256 "prompt",
2257 mock_provider(vec!["done"]),
2258 noop_executor(),
2259 None,
2260 &cfg,
2261 SpawnContext::default(),
2262 )
2263 .unwrap();
2264 assert!(!task_id.is_empty());
2265 mgr.cancel(&task_id).unwrap();
2266 }
2267
2268 #[test]
2269 fn spawn_does_not_override_explicit_permission_mode() {
2270 let rt = tokio::runtime::Runtime::new().unwrap();
2271 let _guard = rt.enter();
2272
2273 let def = SubAgentDef::parse(indoc! {"
2275 ---
2276 name: bot
2277 description: A bot
2278 permissions:
2279 permission_mode: dont_ask
2280 ---
2281
2282 Do things.
2283 "})
2284 .unwrap();
2285 assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
2286
2287 let mut mgr = make_manager();
2288 mgr.definitions.push(def);
2289
2290 let cfg = SubAgentConfig {
2291 default_permission_mode: Some(PermissionMode::Plan),
2292 ..SubAgentConfig::default()
2293 };
2294
2295 let task_id = mgr
2296 .spawn(
2297 "bot",
2298 "prompt",
2299 mock_provider(vec!["done"]),
2300 noop_executor(),
2301 None,
2302 &cfg,
2303 SpawnContext::default(),
2304 )
2305 .unwrap();
2306 assert!(!task_id.is_empty());
2307 mgr.cancel(&task_id).unwrap();
2308 }
2309
2310 #[test]
2311 fn spawn_merges_global_disallowed_tools() {
2312 let rt = tokio::runtime::Runtime::new().unwrap();
2313 let _guard = rt.enter();
2314
2315 let def =
2316 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2317
2318 let mut mgr = make_manager();
2319 mgr.definitions.push(def);
2320
2321 let cfg = SubAgentConfig {
2322 default_disallowed_tools: vec!["dangerous".into()],
2323 ..SubAgentConfig::default()
2324 };
2325
2326 let task_id = mgr
2327 .spawn(
2328 "bot",
2329 "prompt",
2330 mock_provider(vec!["done"]),
2331 noop_executor(),
2332 None,
2333 &cfg,
2334 SpawnContext::default(),
2335 )
2336 .unwrap();
2337 assert!(!task_id.is_empty());
2338 mgr.cancel(&task_id).unwrap();
2339 }
2340
2341 #[test]
2344 fn spawn_bypass_permissions_without_config_gate_is_error() {
2345 let rt = tokio::runtime::Runtime::new().unwrap();
2346 let _guard = rt.enter();
2347
2348 let def = SubAgentDef::parse(indoc! {"
2349 ---
2350 name: bypass-bot
2351 description: A bot with bypass mode
2352 permissions:
2353 permission_mode: bypass_permissions
2354 ---
2355
2356 Unrestricted.
2357 "})
2358 .unwrap();
2359
2360 let mut mgr = make_manager();
2361 mgr.definitions.push(def);
2362
2363 let cfg = SubAgentConfig::default();
2365 let err = mgr
2366 .spawn(
2367 "bypass-bot",
2368 "prompt",
2369 mock_provider(vec!["done"]),
2370 noop_executor(),
2371 None,
2372 &cfg,
2373 SpawnContext::default(),
2374 )
2375 .unwrap_err();
2376 assert!(matches!(err, SubAgentError::Invalid(_)));
2377 }
2378
2379 #[test]
2380 fn spawn_bypass_permissions_with_config_gate_succeeds() {
2381 let rt = tokio::runtime::Runtime::new().unwrap();
2382 let _guard = rt.enter();
2383
2384 let def = SubAgentDef::parse(indoc! {"
2385 ---
2386 name: bypass-bot
2387 description: A bot with bypass mode
2388 permissions:
2389 permission_mode: bypass_permissions
2390 ---
2391
2392 Unrestricted.
2393 "})
2394 .unwrap();
2395
2396 let mut mgr = make_manager();
2397 mgr.definitions.push(def);
2398
2399 let cfg = SubAgentConfig {
2400 allow_bypass_permissions: true,
2401 ..SubAgentConfig::default()
2402 };
2403
2404 let task_id = mgr
2405 .spawn(
2406 "bypass-bot",
2407 "prompt",
2408 mock_provider(vec!["done"]),
2409 noop_executor(),
2410 None,
2411 &cfg,
2412 SpawnContext::default(),
2413 )
2414 .unwrap();
2415 assert!(!task_id.is_empty());
2416 mgr.cancel(&task_id).unwrap();
2417 }
2418
2419 fn write_completed_meta(dir: &std::path::Path, agent_id: &str, def_name: &str) {
2423 use crate::transcript::{TranscriptMeta, TranscriptWriter};
2424 let meta = TranscriptMeta {
2425 agent_id: agent_id.to_owned(),
2426 agent_name: def_name.to_owned(),
2427 def_name: def_name.to_owned(),
2428 status: SubAgentState::Completed,
2429 started_at: "2026-01-01T00:00:00Z".to_owned(),
2430 finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
2431 resumed_from: None,
2432 turns_used: 1,
2433 };
2434 TranscriptWriter::write_meta(dir, agent_id, &meta).unwrap();
2435 std::fs::write(dir.join(format!("{agent_id}.jsonl")), b"").unwrap();
2437 }
2438
2439 fn make_cfg_with_dir(dir: &std::path::Path) -> SubAgentConfig {
2440 SubAgentConfig {
2441 transcript_dir: Some(dir.to_path_buf()),
2442 ..SubAgentConfig::default()
2443 }
2444 }
2445
2446 #[test]
2447 fn resume_not_found_returns_not_found_error() {
2448 let rt = tokio::runtime::Runtime::new().unwrap();
2449 let _guard = rt.enter();
2450
2451 let tmp = tempfile::tempdir().unwrap();
2452 let mut mgr = make_manager();
2453 mgr.definitions.push(sample_def());
2454 let cfg = make_cfg_with_dir(tmp.path());
2455
2456 let err = mgr
2457 .resume(
2458 "deadbeef",
2459 "continue",
2460 mock_provider(vec!["done"]),
2461 noop_executor(),
2462 None,
2463 &cfg,
2464 )
2465 .unwrap_err();
2466 assert!(matches!(err, SubAgentError::NotFound(_)));
2467 }
2468
2469 #[test]
2470 fn resume_ambiguous_id_returns_ambiguous_error() {
2471 let rt = tokio::runtime::Runtime::new().unwrap();
2472 let _guard = rt.enter();
2473
2474 let tmp = tempfile::tempdir().unwrap();
2475 write_completed_meta(tmp.path(), "aabb0001-0000-0000-0000-000000000000", "bot");
2476 write_completed_meta(tmp.path(), "aabb0002-0000-0000-0000-000000000000", "bot");
2477
2478 let mut mgr = make_manager();
2479 mgr.definitions.push(sample_def());
2480 let cfg = make_cfg_with_dir(tmp.path());
2481
2482 let err = mgr
2483 .resume(
2484 "aabb",
2485 "continue",
2486 mock_provider(vec!["done"]),
2487 noop_executor(),
2488 None,
2489 &cfg,
2490 )
2491 .unwrap_err();
2492 assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
2493 }
2494
2495 #[test]
2496 fn resume_still_running_via_active_agents_returns_error() {
2497 let rt = tokio::runtime::Runtime::new().unwrap();
2498 let _guard = rt.enter();
2499
2500 let tmp = tempfile::tempdir().unwrap();
2501 let agent_id = "cafebabe-0000-0000-0000-000000000000";
2502 write_completed_meta(tmp.path(), agent_id, "bot");
2503
2504 let mut mgr = make_manager();
2505 mgr.definitions.push(sample_def());
2506
2507 let (status_tx, status_rx) = watch::channel(SubAgentStatus {
2509 state: SubAgentState::Working,
2510 last_message: None,
2511 turns_used: 0,
2512 started_at: std::time::Instant::now(),
2513 });
2514 let (_secret_request_tx, pending_secret_rx) = tokio::sync::mpsc::channel(1);
2515 let (secret_tx, _secret_rx) = tokio::sync::mpsc::channel(1);
2516 let cancel = CancellationToken::new();
2517 let fake_def = sample_def();
2518 mgr.agents.insert(
2519 agent_id.to_owned(),
2520 SubAgentHandle {
2521 id: agent_id.to_owned(),
2522 def: fake_def,
2523 task_id: agent_id.to_owned(),
2524 state: SubAgentState::Working,
2525 join_handle: None,
2526 cancel,
2527 status_rx,
2528 grants: PermissionGrants::default(),
2529 pending_secret_rx,
2530 secret_tx,
2531 started_at_str: "2026-01-01T00:00:00Z".to_owned(),
2532 transcript_dir: None,
2533 },
2534 );
2535 drop(status_tx);
2536
2537 let cfg = make_cfg_with_dir(tmp.path());
2538 let err = mgr
2539 .resume(
2540 agent_id,
2541 "continue",
2542 mock_provider(vec!["done"]),
2543 noop_executor(),
2544 None,
2545 &cfg,
2546 )
2547 .unwrap_err();
2548 assert!(matches!(err, SubAgentError::StillRunning(_)));
2549 }
2550
2551 #[test]
2552 fn resume_def_not_found_returns_not_found_error() {
2553 let rt = tokio::runtime::Runtime::new().unwrap();
2554 let _guard = rt.enter();
2555
2556 let tmp = tempfile::tempdir().unwrap();
2557 let agent_id = "feedface-0000-0000-0000-000000000000";
2558 write_completed_meta(tmp.path(), agent_id, "unknown-agent");
2560
2561 let mut mgr = make_manager();
2562 let cfg = make_cfg_with_dir(tmp.path());
2564
2565 let err = mgr
2566 .resume(
2567 "feedface",
2568 "continue",
2569 mock_provider(vec!["done"]),
2570 noop_executor(),
2571 None,
2572 &cfg,
2573 )
2574 .unwrap_err();
2575 assert!(matches!(err, SubAgentError::NotFound(_)));
2576 }
2577
2578 #[test]
2579 fn resume_concurrency_limit_reached_returns_error() {
2580 let rt = tokio::runtime::Runtime::new().unwrap();
2581 let _guard = rt.enter();
2582
2583 let tmp = tempfile::tempdir().unwrap();
2584 let agent_id = "babe0000-0000-0000-0000-000000000000";
2585 write_completed_meta(tmp.path(), agent_id, "bot");
2586
2587 let mut mgr = SubAgentManager::new(1); mgr.definitions.push(sample_def());
2589
2590 let _running_id = do_spawn(&mut mgr, "bot", "occupying slot").unwrap();
2592
2593 let cfg = make_cfg_with_dir(tmp.path());
2594 let err = mgr
2595 .resume(
2596 "babe0000",
2597 "continue",
2598 mock_provider(vec!["done"]),
2599 noop_executor(),
2600 None,
2601 &cfg,
2602 )
2603 .unwrap_err();
2604 assert!(
2605 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2606 "expected concurrency limit error, got: {err}"
2607 );
2608 }
2609
2610 #[test]
2611 fn resume_happy_path_returns_new_task_id() {
2612 let rt = tokio::runtime::Runtime::new().unwrap();
2613 let _guard = rt.enter();
2614
2615 let tmp = tempfile::tempdir().unwrap();
2616 let agent_id = "deadcode-0000-0000-0000-000000000000";
2617 write_completed_meta(tmp.path(), agent_id, "bot");
2618
2619 let mut mgr = make_manager();
2620 mgr.definitions.push(sample_def());
2621 let cfg = make_cfg_with_dir(tmp.path());
2622
2623 let (new_id, def_name) = mgr
2624 .resume(
2625 "deadcode",
2626 "continue the work",
2627 mock_provider(vec!["done"]),
2628 noop_executor(),
2629 None,
2630 &cfg,
2631 )
2632 .unwrap();
2633
2634 assert!(!new_id.is_empty(), "new task id must not be empty");
2635 assert_ne!(
2636 new_id, agent_id,
2637 "resumed session must have a fresh task id"
2638 );
2639 assert_eq!(def_name, "bot");
2640 assert!(mgr.agents.contains_key(&new_id));
2642
2643 mgr.cancel(&new_id).unwrap();
2644 }
2645
2646 #[test]
2647 fn resume_populates_resumed_from_in_meta() {
2648 let rt = tokio::runtime::Runtime::new().unwrap();
2649 let _guard = rt.enter();
2650
2651 let tmp = tempfile::tempdir().unwrap();
2652 let original_id = "0000abcd-0000-0000-0000-000000000000";
2653 write_completed_meta(tmp.path(), original_id, "bot");
2654
2655 let mut mgr = make_manager();
2656 mgr.definitions.push(sample_def());
2657 let cfg = make_cfg_with_dir(tmp.path());
2658
2659 let (new_id, _) = mgr
2660 .resume(
2661 "0000abcd",
2662 "continue",
2663 mock_provider(vec!["done"]),
2664 noop_executor(),
2665 None,
2666 &cfg,
2667 )
2668 .unwrap();
2669
2670 let new_meta = crate::transcript::TranscriptReader::load_meta(tmp.path(), &new_id).unwrap();
2672 assert_eq!(
2673 new_meta.resumed_from.as_deref(),
2674 Some(original_id),
2675 "resumed_from must point to original agent id"
2676 );
2677
2678 mgr.cancel(&new_id).unwrap();
2679 }
2680
2681 #[test]
2682 fn def_name_for_resume_returns_def_name() {
2683 let rt = tokio::runtime::Runtime::new().unwrap();
2684 let _guard = rt.enter();
2685
2686 let tmp = tempfile::tempdir().unwrap();
2687 let agent_id = "aaaabbbb-0000-0000-0000-000000000000";
2688 write_completed_meta(tmp.path(), agent_id, "bot");
2689
2690 let mgr = make_manager();
2691 let cfg = make_cfg_with_dir(tmp.path());
2692
2693 let name = mgr.def_name_for_resume("aaaabbbb", &cfg).unwrap();
2694 assert_eq!(name, "bot");
2695 }
2696
2697 #[test]
2698 fn def_name_for_resume_not_found_returns_error() {
2699 let rt = tokio::runtime::Runtime::new().unwrap();
2700 let _guard = rt.enter();
2701
2702 let tmp = tempfile::tempdir().unwrap();
2703 let mgr = make_manager();
2704 let cfg = make_cfg_with_dir(tmp.path());
2705
2706 let err = mgr.def_name_for_resume("notexist", &cfg).unwrap_err();
2707 assert!(matches!(err, SubAgentError::NotFound(_)));
2708 }
2709
2710 #[tokio::test]
2713 #[serial]
2714 async fn spawn_with_memory_scope_project_creates_directory() {
2715 let tmp = tempfile::tempdir().unwrap();
2716 let orig_dir = std::env::current_dir().unwrap();
2717 std::env::set_current_dir(tmp.path()).unwrap();
2718
2719 let def = SubAgentDef::parse(indoc! {"
2720 ---
2721 name: mem-agent
2722 description: Agent with memory
2723 memory: project
2724 ---
2725
2726 System prompt.
2727 "})
2728 .unwrap();
2729
2730 let mut mgr = make_manager();
2731 mgr.definitions.push(def);
2732
2733 let task_id = mgr
2734 .spawn(
2735 "mem-agent",
2736 "do something",
2737 mock_provider(vec!["done"]),
2738 noop_executor(),
2739 None,
2740 &SubAgentConfig::default(),
2741 SpawnContext::default(),
2742 )
2743 .unwrap();
2744 assert!(!task_id.is_empty());
2745 mgr.cancel(&task_id).unwrap();
2746
2747 let mem_dir = tmp
2749 .path()
2750 .join(".zeph")
2751 .join("agent-memory")
2752 .join("mem-agent");
2753 assert!(
2754 mem_dir.exists(),
2755 "memory directory should be created at spawn"
2756 );
2757
2758 std::env::set_current_dir(orig_dir).unwrap();
2759 }
2760
2761 #[tokio::test]
2762 #[serial]
2763 async fn spawn_with_config_default_memory_scope_applies_when_def_has_none() {
2764 let tmp = tempfile::tempdir().unwrap();
2765 let orig_dir = std::env::current_dir().unwrap();
2766 std::env::set_current_dir(tmp.path()).unwrap();
2767
2768 let def = SubAgentDef::parse(indoc! {"
2769 ---
2770 name: mem-agent2
2771 description: Agent without explicit memory
2772 ---
2773
2774 System prompt.
2775 "})
2776 .unwrap();
2777
2778 let mut mgr = make_manager();
2779 mgr.definitions.push(def);
2780
2781 let cfg = SubAgentConfig {
2782 default_memory_scope: Some(MemoryScope::Project),
2783 ..SubAgentConfig::default()
2784 };
2785
2786 let task_id = mgr
2787 .spawn(
2788 "mem-agent2",
2789 "do something",
2790 mock_provider(vec!["done"]),
2791 noop_executor(),
2792 None,
2793 &cfg,
2794 SpawnContext::default(),
2795 )
2796 .unwrap();
2797 assert!(!task_id.is_empty());
2798 mgr.cancel(&task_id).unwrap();
2799
2800 let mem_dir = tmp
2802 .path()
2803 .join(".zeph")
2804 .join("agent-memory")
2805 .join("mem-agent2");
2806 assert!(
2807 mem_dir.exists(),
2808 "config default memory scope should create directory"
2809 );
2810
2811 std::env::set_current_dir(orig_dir).unwrap();
2812 }
2813
2814 #[tokio::test]
2815 #[serial]
2816 async fn spawn_with_memory_blocked_by_disallowed_tools_skips_memory() {
2817 let tmp = tempfile::tempdir().unwrap();
2818 let orig_dir = std::env::current_dir().unwrap();
2819 std::env::set_current_dir(tmp.path()).unwrap();
2820
2821 let def = SubAgentDef::parse(indoc! {"
2822 ---
2823 name: blocked-mem
2824 description: Agent with memory but blocked tools
2825 memory: project
2826 tools:
2827 except:
2828 - Read
2829 - Write
2830 - Edit
2831 ---
2832
2833 System prompt.
2834 "})
2835 .unwrap();
2836
2837 let mut mgr = make_manager();
2838 mgr.definitions.push(def);
2839
2840 let task_id = mgr
2841 .spawn(
2842 "blocked-mem",
2843 "do something",
2844 mock_provider(vec!["done"]),
2845 noop_executor(),
2846 None,
2847 &SubAgentConfig::default(),
2848 SpawnContext::default(),
2849 )
2850 .unwrap();
2851 assert!(!task_id.is_empty());
2852 mgr.cancel(&task_id).unwrap();
2853
2854 let mem_dir = tmp
2856 .path()
2857 .join(".zeph")
2858 .join("agent-memory")
2859 .join("blocked-mem");
2860 assert!(
2861 !mem_dir.exists(),
2862 "memory directory should not be created when tools are blocked"
2863 );
2864
2865 std::env::set_current_dir(orig_dir).unwrap();
2866 }
2867
2868 #[tokio::test]
2869 #[serial]
2870 async fn spawn_without_memory_scope_no_directory_created() {
2871 let tmp = tempfile::tempdir().unwrap();
2872 let orig_dir = std::env::current_dir().unwrap();
2873 std::env::set_current_dir(tmp.path()).unwrap();
2874
2875 let def = SubAgentDef::parse(indoc! {"
2876 ---
2877 name: no-mem-agent
2878 description: Agent without memory
2879 ---
2880
2881 System prompt.
2882 "})
2883 .unwrap();
2884
2885 let mut mgr = make_manager();
2886 mgr.definitions.push(def);
2887
2888 let task_id = mgr
2889 .spawn(
2890 "no-mem-agent",
2891 "do something",
2892 mock_provider(vec!["done"]),
2893 noop_executor(),
2894 None,
2895 &SubAgentConfig::default(),
2896 SpawnContext::default(),
2897 )
2898 .unwrap();
2899 assert!(!task_id.is_empty());
2900 mgr.cancel(&task_id).unwrap();
2901
2902 let mem_dir = tmp.path().join(".zeph").join("agent-memory");
2904 assert!(
2905 !mem_dir.exists(),
2906 "no agent-memory directory should be created without memory scope"
2907 );
2908
2909 std::env::set_current_dir(orig_dir).unwrap();
2910 }
2911
2912 #[test]
2913 #[serial]
2914 fn build_prompt_injects_memory_block_after_behavioral_prompt() {
2915 let tmp = tempfile::tempdir().unwrap();
2916 let orig_dir = std::env::current_dir().unwrap();
2917 std::env::set_current_dir(tmp.path()).unwrap();
2918
2919 let mem_dir = tmp
2921 .path()
2922 .join(".zeph")
2923 .join("agent-memory")
2924 .join("test-agent");
2925 std::fs::create_dir_all(&mem_dir).unwrap();
2926 std::fs::write(mem_dir.join("MEMORY.md"), "# Test Memory\nkey: value\n").unwrap();
2927
2928 let mut def = SubAgentDef::parse(indoc! {"
2929 ---
2930 name: test-agent
2931 description: Test agent
2932 memory: project
2933 ---
2934
2935 Behavioral instructions here.
2936 "})
2937 .unwrap();
2938
2939 let prompt = build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2940
2941 let behavioral_pos = prompt.find("Behavioral instructions").unwrap();
2943 let memory_pos = prompt.find("<agent-memory>").unwrap();
2944 assert!(
2945 memory_pos > behavioral_pos,
2946 "memory block must appear AFTER behavioral prompt"
2947 );
2948 assert!(
2949 prompt.contains("key: value"),
2950 "MEMORY.md content must be injected"
2951 );
2952
2953 std::env::set_current_dir(orig_dir).unwrap();
2954 }
2955
2956 #[test]
2957 #[serial]
2958 fn build_prompt_auto_enables_read_write_edit_for_allowlist() {
2959 let tmp = tempfile::tempdir().unwrap();
2960 let orig_dir = std::env::current_dir().unwrap();
2961 std::env::set_current_dir(tmp.path()).unwrap();
2962
2963 let mut def = SubAgentDef::parse(indoc! {"
2964 ---
2965 name: allowlist-agent
2966 description: AllowList agent
2967 memory: project
2968 tools:
2969 allow:
2970 - shell
2971 ---
2972
2973 System prompt.
2974 "})
2975 .unwrap();
2976
2977 assert!(
2978 matches!(&def.tools, ToolPolicy::AllowList(list) if list == &["shell"]),
2979 "should start with only shell"
2980 );
2981
2982 build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2983
2984 assert!(
2986 matches!(&def.tools, ToolPolicy::AllowList(list)
2987 if list.contains(&"Read".to_owned())
2988 && list.contains(&"Write".to_owned())
2989 && list.contains(&"Edit".to_owned())),
2990 "Read/Write/Edit must be auto-enabled in AllowList when memory is set"
2991 );
2992
2993 std::env::set_current_dir(orig_dir).unwrap();
2994 }
2995
2996 #[tokio::test]
2997 #[serial]
2998 async fn spawn_with_explicit_def_memory_overrides_config_default() {
2999 let tmp = tempfile::tempdir().unwrap();
3000 let orig_dir = std::env::current_dir().unwrap();
3001 std::env::set_current_dir(tmp.path()).unwrap();
3002
3003 let def = SubAgentDef::parse(indoc! {"
3006 ---
3007 name: override-agent
3008 description: Agent with explicit memory
3009 memory: local
3010 ---
3011
3012 System prompt.
3013 "})
3014 .unwrap();
3015 assert_eq!(def.memory, Some(MemoryScope::Local));
3016
3017 let mut mgr = make_manager();
3018 mgr.definitions.push(def);
3019
3020 let cfg = SubAgentConfig {
3021 default_memory_scope: Some(MemoryScope::Project),
3022 ..SubAgentConfig::default()
3023 };
3024
3025 let task_id = mgr
3026 .spawn(
3027 "override-agent",
3028 "do something",
3029 mock_provider(vec!["done"]),
3030 noop_executor(),
3031 None,
3032 &cfg,
3033 SpawnContext::default(),
3034 )
3035 .unwrap();
3036 assert!(!task_id.is_empty());
3037 mgr.cancel(&task_id).unwrap();
3038
3039 let local_dir = tmp
3041 .path()
3042 .join(".zeph")
3043 .join("agent-memory-local")
3044 .join("override-agent");
3045 let project_dir = tmp
3046 .path()
3047 .join(".zeph")
3048 .join("agent-memory")
3049 .join("override-agent");
3050 assert!(local_dir.exists(), "local memory dir should be created");
3051 assert!(
3052 !project_dir.exists(),
3053 "project memory dir must NOT be created"
3054 );
3055
3056 std::env::set_current_dir(orig_dir).unwrap();
3057 }
3058
3059 #[tokio::test]
3060 #[serial]
3061 async fn spawn_memory_blocked_by_deny_list_policy() {
3062 let tmp = tempfile::tempdir().unwrap();
3063 let orig_dir = std::env::current_dir().unwrap();
3064 std::env::set_current_dir(tmp.path()).unwrap();
3065
3066 let def = SubAgentDef::parse(indoc! {"
3068 ---
3069 name: deny-list-mem
3070 description: Agent with deny list
3071 memory: project
3072 tools:
3073 deny:
3074 - Read
3075 - Write
3076 - Edit
3077 ---
3078
3079 System prompt.
3080 "})
3081 .unwrap();
3082
3083 let mut mgr = make_manager();
3084 mgr.definitions.push(def);
3085
3086 let task_id = mgr
3087 .spawn(
3088 "deny-list-mem",
3089 "do something",
3090 mock_provider(vec!["done"]),
3091 noop_executor(),
3092 None,
3093 &SubAgentConfig::default(),
3094 SpawnContext::default(),
3095 )
3096 .unwrap();
3097 assert!(!task_id.is_empty());
3098 mgr.cancel(&task_id).unwrap();
3099
3100 let mem_dir = tmp
3102 .path()
3103 .join(".zeph")
3104 .join("agent-memory")
3105 .join("deny-list-mem");
3106 assert!(
3107 !mem_dir.exists(),
3108 "memory dir must not be created when DenyList blocks all file tools"
3109 );
3110
3111 std::env::set_current_dir(orig_dir).unwrap();
3112 }
3113
3114 fn make_agent_loop_args(
3117 provider: AnyProvider,
3118 executor: FilteredToolExecutor,
3119 max_turns: u32,
3120 ) -> AgentLoopArgs {
3121 let (status_tx, _status_rx) = tokio::sync::watch::channel(SubAgentStatus {
3122 state: SubAgentState::Working,
3123 last_message: None,
3124 turns_used: 0,
3125 started_at: std::time::Instant::now(),
3126 });
3127 let (secret_request_tx, _secret_request_rx) = tokio::sync::mpsc::channel(1);
3128 let (_secret_approved_tx, secret_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
3129 AgentLoopArgs {
3130 provider,
3131 executor,
3132 system_prompt: "You are a bot".into(),
3133 task_prompt: "Do something".into(),
3134 skills: None,
3135 max_turns,
3136 cancel: tokio_util::sync::CancellationToken::new(),
3137 status_tx,
3138 started_at: std::time::Instant::now(),
3139 secret_request_tx,
3140 secret_rx,
3141 background: false,
3142 hooks: super::super::hooks::SubagentHooks::default(),
3143 task_id: "test-task".into(),
3144 agent_name: "test-bot".into(),
3145 initial_messages: vec![],
3146 transcript_writer: None,
3147 spawn_depth: 0,
3148 mcp_tool_names: Vec::new(),
3149 }
3150 }
3151
3152 #[tokio::test]
3153 async fn run_agent_loop_passes_tools_to_provider() {
3154 use std::sync::Arc;
3155 use zeph_llm::provider::ChatResponse;
3156 use zeph_tools::registry::{InvocationHint, ToolDef};
3157
3158 struct SingleToolExecutor;
3160
3161 impl ErasedToolExecutor for SingleToolExecutor {
3162 fn execute_erased<'a>(
3163 &'a self,
3164 _response: &'a str,
3165 ) -> Pin<
3166 Box<
3167 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3168 + Send
3169 + 'a,
3170 >,
3171 > {
3172 Box::pin(std::future::ready(Ok(None)))
3173 }
3174
3175 fn execute_confirmed_erased<'a>(
3176 &'a self,
3177 _response: &'a str,
3178 ) -> Pin<
3179 Box<
3180 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3181 + Send
3182 + 'a,
3183 >,
3184 > {
3185 Box::pin(std::future::ready(Ok(None)))
3186 }
3187
3188 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3189 vec![ToolDef {
3190 id: std::borrow::Cow::Borrowed("shell"),
3191 description: std::borrow::Cow::Borrowed("Run a shell command"),
3192 schema: schemars::Schema::default(),
3193 invocation: InvocationHint::ToolCall,
3194 output_schema: None,
3195 }]
3196 }
3197
3198 fn execute_tool_call_erased<'a>(
3199 &'a self,
3200 _call: &'a ToolCall,
3201 ) -> Pin<
3202 Box<
3203 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3204 + Send
3205 + 'a,
3206 >,
3207 > {
3208 Box::pin(std::future::ready(Ok(None)))
3209 }
3210
3211 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3212 false
3213 }
3214
3215 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
3216 false
3217 }
3218 }
3219
3220 let (mock, tool_call_count) =
3222 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3223 let provider = AnyProvider::Mock(mock);
3224 let executor =
3225 FilteredToolExecutor::new(Arc::new(SingleToolExecutor), ToolPolicy::InheritAll);
3226
3227 let args = make_agent_loop_args(provider, executor, 1);
3228 let result = run_agent_loop(args).await;
3229 assert!(result.is_ok(), "loop failed: {result:?}");
3230 assert_eq!(
3231 *tool_call_count.lock().unwrap(),
3232 1,
3233 "chat_with_tools must have been called exactly once"
3234 );
3235 }
3236
3237 #[tokio::test]
3238 async fn run_agent_loop_executes_native_tool_call() {
3239 use std::sync::{Arc, Mutex};
3240 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
3241 use zeph_tools::registry::ToolDef;
3242
3243 struct TrackingExecutor {
3244 calls: Mutex<Vec<String>>,
3245 }
3246
3247 impl ErasedToolExecutor for TrackingExecutor {
3248 fn execute_erased<'a>(
3249 &'a self,
3250 _response: &'a str,
3251 ) -> Pin<
3252 Box<
3253 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3254 + Send
3255 + 'a,
3256 >,
3257 > {
3258 Box::pin(std::future::ready(Ok(None)))
3259 }
3260
3261 fn execute_confirmed_erased<'a>(
3262 &'a self,
3263 _response: &'a str,
3264 ) -> Pin<
3265 Box<
3266 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3267 + Send
3268 + 'a,
3269 >,
3270 > {
3271 Box::pin(std::future::ready(Ok(None)))
3272 }
3273
3274 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3275 vec![]
3276 }
3277
3278 fn execute_tool_call_erased<'a>(
3279 &'a self,
3280 call: &'a ToolCall,
3281 ) -> Pin<
3282 Box<
3283 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3284 + Send
3285 + 'a,
3286 >,
3287 > {
3288 self.calls.lock().unwrap().push(call.tool_id.to_string());
3289 let output = ToolOutput {
3290 tool_name: call.tool_id.clone(),
3291 summary: "executed".into(),
3292 blocks_executed: 1,
3293 filter_stats: None,
3294 diff: None,
3295 streamed: false,
3296 terminal_id: None,
3297 locations: None,
3298 raw_response: None,
3299 claim_source: None,
3300 };
3301 Box::pin(std::future::ready(Ok(Some(output))))
3302 }
3303
3304 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3305 false
3306 }
3307
3308 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
3309 false
3310 }
3311 }
3312
3313 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
3315 ChatResponse::ToolUse {
3316 text: None,
3317 tool_calls: vec![ToolUseRequest {
3318 id: "call-1".into(),
3319 name: "shell".into(),
3320 input: serde_json::json!({"command": "echo hi"}),
3321 }],
3322 thinking_blocks: vec![],
3323 },
3324 ChatResponse::Text("all done".into()),
3325 ]);
3326
3327 let tracker = Arc::new(TrackingExecutor {
3328 calls: Mutex::new(vec![]),
3329 });
3330 let tracker_clone = Arc::clone(&tracker);
3331 let executor = FilteredToolExecutor::new(tracker_clone, ToolPolicy::InheritAll);
3332
3333 let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3334 let result = run_agent_loop(args).await;
3335 assert!(result.is_ok(), "loop failed: {result:?}");
3336 assert_eq!(result.unwrap(), "all done");
3337
3338 let recorded = tracker.calls.lock().unwrap();
3339 assert_eq!(
3340 recorded.len(),
3341 1,
3342 "execute_tool_call_erased must be called once"
3343 );
3344 assert_eq!(recorded[0], "shell");
3345 }
3346
3347 #[test]
3350 fn build_system_prompt_injects_working_directory() {
3351 use tempfile::TempDir;
3352
3353 let tmp = TempDir::new().unwrap();
3354 let orig = std::env::current_dir().unwrap();
3355 std::env::set_current_dir(tmp.path()).unwrap();
3356
3357 let mut def = SubAgentDef::parse(indoc! {"
3358 ---
3359 name: cwd-agent
3360 description: test
3361 ---
3362 Base prompt.
3363 "})
3364 .unwrap();
3365
3366 let prompt = build_system_prompt_with_memory(&mut def, None);
3367 std::env::set_current_dir(orig).unwrap();
3368
3369 assert!(
3370 prompt.contains("Working directory:"),
3371 "system prompt must contain 'Working directory:', got: {prompt}"
3372 );
3373 assert!(
3374 prompt.contains(tmp.path().to_str().unwrap()),
3375 "system prompt must contain the actual cwd path, got: {prompt}"
3376 );
3377 }
3378
3379 #[tokio::test]
3380 async fn text_only_first_turn_sends_nudge_and_retries() {
3381 use zeph_llm::mock::MockProvider;
3382
3383 let (mock, call_count) = MockProvider::default().with_tool_use(vec![
3385 ChatResponse::Text("I will now do the task...".into()),
3386 ChatResponse::Text("Done.".into()),
3387 ]);
3388
3389 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3390 let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 10);
3391 let result = run_agent_loop(args).await;
3392 assert!(result.is_ok(), "loop should succeed: {result:?}");
3393 assert_eq!(result.unwrap(), "Done.");
3394
3395 let count = *call_count.lock().unwrap();
3397 assert_eq!(
3398 count, 2,
3399 "provider must be called exactly twice (initial + nudge retry), got {count}"
3400 );
3401 }
3402
3403 #[test]
3406 fn model_spec_deserialize_inherit() {
3407 let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
3408 assert_eq!(spec, ModelSpec::Inherit);
3409 }
3410
3411 #[test]
3412 fn model_spec_deserialize_named() {
3413 let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
3414 assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
3415 }
3416
3417 #[test]
3418 fn model_spec_serialize_roundtrip() {
3419 assert_eq!(
3420 serde_json::to_string(&ModelSpec::Inherit).unwrap(),
3421 "\"inherit\""
3422 );
3423 assert_eq!(
3424 serde_json::to_string(&ModelSpec::Named("my-provider".to_owned())).unwrap(),
3425 "\"my-provider\""
3426 );
3427 }
3428
3429 #[test]
3430 fn spawn_context_default_is_empty() {
3431 let ctx = SpawnContext::default();
3432 assert!(ctx.parent_messages.is_empty());
3433 assert!(ctx.parent_cancel.is_none());
3434 assert!(ctx.parent_provider_name.is_none());
3435 assert_eq!(ctx.spawn_depth, 0);
3436 assert!(ctx.mcp_tool_names.is_empty());
3437 }
3438
3439 #[test]
3440 fn context_injection_none_passes_raw_prompt() {
3441 use zeph_config::ContextInjectionMode;
3442 let result = apply_context_injection("do work", &[], ContextInjectionMode::None);
3443 assert_eq!(result, "do work");
3444 }
3445
3446 #[test]
3447 fn context_injection_last_assistant_prepends_when_present() {
3448 use zeph_config::ContextInjectionMode;
3449 let msgs = vec![
3450 make_message(Role::User, "hello".into()),
3451 make_message(Role::Assistant, "I found X".into()),
3452 ];
3453 let result =
3454 apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3455 assert!(
3456 result.contains("I found X"),
3457 "should contain last assistant content"
3458 );
3459 assert!(result.contains("do work"), "should contain original task");
3460 }
3461
3462 #[test]
3463 fn context_injection_last_assistant_fallback_when_no_assistant() {
3464 use zeph_config::ContextInjectionMode;
3465 let msgs = vec![make_message(Role::User, "hello".into())];
3466 let result =
3467 apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3468 assert_eq!(result, "do work");
3469 }
3470
3471 #[tokio::test]
3472 async fn spawn_model_inherit_resolves_to_parent_provider() {
3473 let rt = tokio::runtime::Handle::current();
3474 let _guard = rt.enter();
3475 let mut mgr = make_manager();
3476 let mut def = sample_def();
3477 def.model = Some(ModelSpec::Inherit);
3478 mgr.definitions.push(def);
3479
3480 let ctx = SpawnContext {
3481 parent_provider_name: Some("my-parent-provider".to_owned()),
3482 ..SpawnContext::default()
3483 };
3484 let result = mgr.spawn(
3486 "bot",
3487 "task",
3488 mock_provider(vec!["done"]),
3489 noop_executor(),
3490 None,
3491 &SubAgentConfig::default(),
3492 ctx,
3493 );
3494 assert!(
3495 result.is_ok(),
3496 "spawn with Inherit model should succeed: {result:?}"
3497 );
3498 }
3499
3500 #[tokio::test]
3501 async fn spawn_model_named_uses_value() {
3502 let rt = tokio::runtime::Handle::current();
3503 let _guard = rt.enter();
3504 let mut mgr = make_manager();
3505 let mut def = sample_def();
3506 def.model = Some(ModelSpec::Named("fast".to_owned()));
3507 mgr.definitions.push(def);
3508
3509 let result = mgr.spawn(
3510 "bot",
3511 "task",
3512 mock_provider(vec!["done"]),
3513 noop_executor(),
3514 None,
3515 &SubAgentConfig::default(),
3516 SpawnContext::default(),
3517 );
3518 assert!(result.is_ok());
3519 }
3520
3521 #[test]
3522 fn spawn_exceeds_max_depth_returns_error() {
3523 let rt = tokio::runtime::Runtime::new().unwrap();
3524 let _guard = rt.enter();
3525 let mut mgr = make_manager();
3526 mgr.definitions.push(sample_def());
3527
3528 let cfg = SubAgentConfig {
3529 max_spawn_depth: 2,
3530 ..SubAgentConfig::default()
3531 };
3532 let ctx = SpawnContext {
3533 spawn_depth: 2, ..SpawnContext::default()
3535 };
3536 let err = mgr
3537 .spawn(
3538 "bot",
3539 "task",
3540 mock_provider(vec!["done"]),
3541 noop_executor(),
3542 None,
3543 &cfg,
3544 ctx,
3545 )
3546 .unwrap_err();
3547 assert!(
3548 matches!(err, SubAgentError::MaxDepthExceeded { depth: 2, max: 2 }),
3549 "expected MaxDepthExceeded, got {err:?}"
3550 );
3551 }
3552
3553 #[test]
3554 fn spawn_at_max_depth_minus_one_succeeds() {
3555 let rt = tokio::runtime::Runtime::new().unwrap();
3556 let _guard = rt.enter();
3557 let mut mgr = make_manager();
3558 mgr.definitions.push(sample_def());
3559
3560 let cfg = SubAgentConfig {
3561 max_spawn_depth: 3,
3562 ..SubAgentConfig::default()
3563 };
3564 let ctx = SpawnContext {
3565 spawn_depth: 2, ..SpawnContext::default()
3567 };
3568 let result = mgr.spawn(
3569 "bot",
3570 "task",
3571 mock_provider(vec!["done"]),
3572 noop_executor(),
3573 None,
3574 &cfg,
3575 ctx,
3576 );
3577 assert!(
3578 result.is_ok(),
3579 "spawn at depth 2 with max 3 should succeed: {result:?}"
3580 );
3581 }
3582
3583 #[test]
3584 fn spawn_foreground_uses_child_token() {
3585 let rt = tokio::runtime::Runtime::new().unwrap();
3586 let _guard = rt.enter();
3587 let mut mgr = make_manager();
3588 mgr.definitions.push(sample_def());
3589
3590 let parent_cancel = CancellationToken::new();
3591 let ctx = SpawnContext {
3592 parent_cancel: Some(parent_cancel.clone()),
3593 ..SpawnContext::default()
3594 };
3595 let task_id = mgr
3597 .spawn(
3598 "bot",
3599 "task",
3600 mock_provider(vec!["done"]),
3601 noop_executor(),
3602 None,
3603 &SubAgentConfig::default(),
3604 ctx,
3605 )
3606 .unwrap();
3607
3608 parent_cancel.cancel();
3610 let handle = mgr.agents.get(&task_id).unwrap();
3611 assert!(
3612 handle.cancel.is_cancelled(),
3613 "child token should be cancelled when parent cancels"
3614 );
3615 }
3616
3617 #[test]
3618 fn parent_history_zero_turns_returns_empty() {
3619 use zeph_config::ContextInjectionMode;
3620 let msgs = vec![make_message(Role::User, "hi".into())];
3621 let result = apply_context_injection("task", &[], ContextInjectionMode::LastAssistantTurn);
3624 assert_eq!(result, "task", "no history should pass prompt unchanged");
3625 let _ = msgs; }
3627
3628 #[tokio::test]
3631 async fn mcp_tool_names_appended_to_system_prompt() {
3632 use zeph_llm::mock::MockProvider;
3633
3634 let (mock, _) =
3635 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3636
3637 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3638 let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3639 args.mcp_tool_names = vec!["search".into(), "write_file".into()];
3640 let result = run_agent_loop(args).await;
3642 assert!(result.is_ok(), "loop should succeed: {result:?}");
3643 }
3644
3645 #[tokio::test]
3646 async fn empty_mcp_tool_names_no_annotation() {
3647 use zeph_llm::mock::MockProvider;
3648
3649 let (mock, _) =
3650 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3651
3652 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3653 let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3654 args.mcp_tool_names = vec![];
3655 let result = run_agent_loop(args).await;
3656 assert!(
3657 result.is_ok(),
3658 "loop should succeed with no MCP tools: {result:?}"
3659 );
3660 }
3661}