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