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)]
600 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!(
744 task_id,
745 def_name,
746 permission_mode = ?self.agents[&task_id].def.permissions.permission_mode,
747 "sub-agent spawned"
748 );
749
750 self.cache_and_fire_start_hooks(config, &task_id, def_name);
751
752 Ok(task_id)
753 }
754
755 fn cache_and_fire_start_hooks(
756 &mut self,
757 config: &SubAgentConfig,
758 task_id: &str,
759 def_name: &str,
760 ) {
761 if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
762 self.stop_hooks.clone_from(&config.hooks.stop);
763 }
764 if !config.hooks.start.is_empty() {
765 let start_hooks = config.hooks.start.clone();
766 let start_env = make_hook_env(task_id, def_name, "");
767 tokio::spawn(async move {
768 if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
769 tracing::warn!(error = %e, "SubagentStart hook failed");
770 }
771 });
772 }
773 }
774
775 fn create_transcript_writer(
776 &mut self,
777 config: &SubAgentConfig,
778 task_id: &str,
779 agent_name: &str,
780 resumed_from: Option<&str>,
781 ) -> Option<TranscriptWriter> {
782 if !config.transcript_enabled {
783 return None;
784 }
785 let dir = self.effective_transcript_dir(config);
786 if self.transcript_max_files > 0
787 && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
788 {
789 tracing::warn!(error = %e, "transcript sweep failed");
790 }
791 let path = dir.join(format!("{task_id}.jsonl"));
792 match TranscriptWriter::new(&path) {
793 Ok(w) => {
794 let meta = TranscriptMeta {
795 agent_id: task_id.to_owned(),
796 agent_name: agent_name.to_owned(),
797 def_name: agent_name.to_owned(),
798 status: SubAgentState::Submitted,
799 started_at: crate::transcript::utc_now_pub(),
800 finished_at: None,
801 resumed_from: resumed_from.map(str::to_owned),
802 turns_used: 0,
803 };
804 if let Err(e) = TranscriptWriter::write_meta(&dir, task_id, &meta) {
805 tracing::warn!(error = %e, "failed to write initial transcript meta");
806 }
807 Some(w)
808 }
809 Err(e) => {
810 tracing::warn!(error = %e, "failed to create transcript writer");
811 None
812 }
813 }
814 }
815
816 pub fn shutdown_all(&mut self) {
822 let ids: Vec<String> = self.agents.keys().cloned().collect();
823 for id in ids {
824 let _ = self.cancel(&id);
825 }
826 }
827
828 pub fn cancel(&mut self, task_id: &str) -> Result<(), SubAgentError> {
834 let handle = self
835 .agents
836 .get_mut(task_id)
837 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
838 handle.cancel.cancel();
839 handle.state = SubAgentState::Canceled;
840 handle.grants.revoke_all();
841 tracing::info!(task_id, "sub-agent cancelled");
842
843 if !self.stop_hooks.is_empty() {
845 let stop_hooks = self.stop_hooks.clone();
846 let stop_env = make_hook_env(task_id, &handle.def.name, "");
847 tokio::spawn(async move {
848 if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
849 tracing::warn!(error = %e, "SubagentStop hook failed");
850 }
851 });
852 }
853
854 Ok(())
855 }
856
857 pub fn cancel_all(&mut self) {
862 for (task_id, handle) in &mut self.agents {
863 if matches!(
864 handle.state,
865 SubAgentState::Working | SubAgentState::Submitted
866 ) {
867 handle.cancel.cancel();
868 handle.state = SubAgentState::Canceled;
869 handle.grants.revoke_all();
870 tracing::info!(task_id, "sub-agent cancelled (cancel_all)");
871 }
872 }
873 }
874
875 pub fn approve_secret(
886 &mut self,
887 task_id: &str,
888 secret_key: &str,
889 ttl: std::time::Duration,
890 ) -> Result<(), SubAgentError> {
891 let handle = self
892 .agents
893 .get_mut(task_id)
894 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
895
896 handle.grants.sweep_expired();
898
899 if !handle
900 .def
901 .permissions
902 .secrets
903 .iter()
904 .any(|k| k == secret_key)
905 {
906 tracing::warn!(task_id, "secret request denied: key not in allowed list");
908 return Err(SubAgentError::Invalid(format!(
909 "secret is not in the allowed secrets list for '{}'",
910 handle.def.name
911 )));
912 }
913
914 handle.grants.grant_secret(secret_key, ttl);
915 Ok(())
916 }
917
918 pub fn deliver_secret(&mut self, task_id: &str, key: String) -> Result<(), SubAgentError> {
927 let handle = self
931 .agents
932 .get_mut(task_id)
933 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
934 handle
935 .secret_tx
936 .try_send(Some(key))
937 .map_err(|e| SubAgentError::Channel(e.to_string()))
938 }
939
940 pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
947 let handle = self
948 .agents
949 .get_mut(task_id)
950 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
951 handle
952 .secret_tx
953 .try_send(None)
954 .map_err(|e| SubAgentError::Channel(e.to_string()))
955 }
956
957 pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
963 for handle in self.agents.values_mut() {
964 if let Ok(req) = handle.pending_secret_rx.try_recv() {
965 return Some((handle.task_id.clone(), req));
966 }
967 }
968 None
969 }
970
971 pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
980 let mut handle = self
981 .agents
982 .remove(task_id)
983 .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
984
985 if !self.stop_hooks.is_empty() {
987 let stop_hooks = self.stop_hooks.clone();
988 let stop_env = make_hook_env(task_id, &handle.def.name, "");
989 tokio::spawn(async move {
990 if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
991 tracing::warn!(error = %e, "SubagentStop hook failed");
992 }
993 });
994 }
995
996 handle.grants.revoke_all();
997
998 let result = if let Some(jh) = handle.join_handle.take() {
999 jh.await.map_err(|e| SubAgentError::Spawn(e.to_string()))?
1000 } else {
1001 Ok(String::new())
1002 };
1003
1004 if let Some(ref dir) = handle.transcript_dir.clone() {
1006 let status = handle.status_rx.borrow();
1007 let final_status = if result.is_err() {
1008 SubAgentState::Failed
1009 } else if status.state == SubAgentState::Canceled {
1010 SubAgentState::Canceled
1011 } else {
1012 SubAgentState::Completed
1013 };
1014 let turns_used = status.turns_used;
1015 drop(status);
1016
1017 let meta = TranscriptMeta {
1018 agent_id: task_id.to_owned(),
1019 agent_name: handle.def.name.clone(),
1020 def_name: handle.def.name.clone(),
1021 status: final_status,
1022 started_at: handle.started_at_str.clone(),
1023 finished_at: Some(crate::transcript::utc_now_pub()),
1024 resumed_from: None,
1025 turns_used,
1026 };
1027 if let Err(e) = TranscriptWriter::write_meta(dir, task_id, &meta) {
1028 tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
1029 }
1030 }
1031
1032 result
1033 }
1034
1035 #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1050 pub fn resume(
1051 &mut self,
1052 id_prefix: &str,
1053 task_prompt: &str,
1054 provider: AnyProvider,
1055 tool_executor: Arc<dyn ErasedToolExecutor>,
1056 skills: Option<Vec<String>>,
1057 config: &SubAgentConfig,
1058 ) -> Result<(String, String), SubAgentError> {
1059 let dir = self.effective_transcript_dir(config);
1060 let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1063
1064 if self.agents.contains_key(&original_id) {
1066 return Err(SubAgentError::StillRunning(original_id));
1067 }
1068 let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1069
1070 match meta.status {
1072 SubAgentState::Completed | SubAgentState::Failed | SubAgentState::Canceled => {}
1073 other => {
1074 return Err(SubAgentError::StillRunning(format!(
1075 "{original_id} (status: {other:?})"
1076 )));
1077 }
1078 }
1079
1080 let jsonl_path = dir.join(format!("{original_id}.jsonl"));
1081 let initial_messages = TranscriptReader::load(&jsonl_path)?;
1082
1083 let mut def = self
1086 .definitions
1087 .iter()
1088 .find(|d| d.name == meta.def_name)
1089 .cloned()
1090 .ok_or_else(|| SubAgentError::NotFound(meta.def_name.clone()))?;
1091
1092 if def.permissions.permission_mode == PermissionMode::Default
1093 && let Some(default_mode) = config.default_permission_mode
1094 {
1095 def.permissions.permission_mode = default_mode;
1096 }
1097
1098 if !config.default_disallowed_tools.is_empty() {
1099 let mut merged = def.disallowed_tools.clone();
1100 for tool in &config.default_disallowed_tools {
1101 if !merged.contains(tool) {
1102 merged.push(tool.clone());
1103 }
1104 }
1105 def.disallowed_tools = merged;
1106 }
1107
1108 if def.permissions.permission_mode == PermissionMode::BypassPermissions
1109 && !config.allow_bypass_permissions
1110 {
1111 return Err(SubAgentError::Invalid(format!(
1112 "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config",
1113 def.name
1114 )));
1115 }
1116
1117 let active = self
1119 .agents
1120 .values()
1121 .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
1122 .count();
1123 if active >= self.max_concurrent {
1124 return Err(SubAgentError::ConcurrencyLimit {
1125 active,
1126 max: self.max_concurrent,
1127 });
1128 }
1129
1130 let new_task_id = Uuid::new_v4().to_string();
1131 let cancel = CancellationToken::new();
1132 let started_at = Instant::now();
1133 let initial_status = SubAgentStatus {
1134 state: SubAgentState::Submitted,
1135 last_message: None,
1136 turns_used: 0,
1137 started_at,
1138 };
1139 let (status_tx, status_rx) = watch::channel(initial_status);
1140
1141 let permission_mode = def.permissions.permission_mode;
1142 let background = def.permissions.background;
1143 let max_turns = def.permissions.max_turns;
1144 let system_prompt = def.system_prompt.clone();
1145 let task_prompt_owned = task_prompt.to_owned();
1146 let cancel_clone = cancel.clone();
1147 let agent_hooks = def.hooks.clone();
1148 let agent_name_clone = def.name.clone();
1149
1150 let executor = build_filtered_executor(tool_executor, permission_mode, &def);
1151
1152 let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
1153 let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
1154
1155 let transcript_writer =
1156 self.create_transcript_writer(config, &new_task_id, &def.name, Some(&original_id));
1157
1158 let new_task_id_for_loop = new_task_id.clone();
1159 let join_handle: JoinHandle<Result<String, SubAgentError>> =
1160 tokio::spawn(run_agent_loop(AgentLoopArgs {
1161 provider,
1162 executor,
1163 system_prompt,
1164 task_prompt: task_prompt_owned,
1165 skills,
1166 max_turns,
1167 cancel: cancel_clone,
1168 status_tx,
1169 started_at,
1170 secret_request_tx,
1171 secret_rx,
1172 background,
1173 hooks: agent_hooks,
1174 task_id: new_task_id_for_loop,
1175 agent_name: agent_name_clone,
1176 initial_messages,
1177 transcript_writer,
1178 spawn_depth: 0,
1179 mcp_tool_names: Vec::new(),
1180 }));
1181
1182 let resume_handle_transcript_dir = if config.transcript_enabled {
1183 Some(dir.clone())
1184 } else {
1185 None
1186 };
1187
1188 let handle = SubAgentHandle {
1189 id: new_task_id.clone(),
1190 def,
1191 task_id: new_task_id.clone(),
1192 state: SubAgentState::Submitted,
1193 join_handle: Some(join_handle),
1194 cancel,
1195 status_rx,
1196 grants: PermissionGrants::default(),
1197 pending_secret_rx,
1198 secret_tx,
1199 started_at_str: crate::transcript::utc_now_pub(),
1200 transcript_dir: resume_handle_transcript_dir,
1201 };
1202
1203 self.agents.insert(new_task_id.clone(), handle);
1204 tracing::info!(
1205 task_id = %new_task_id,
1206 original_id = %original_id,
1207 "sub-agent resumed"
1208 );
1209
1210 if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
1212 self.stop_hooks.clone_from(&config.hooks.stop);
1213 }
1214
1215 if !config.hooks.start.is_empty() {
1217 let start_hooks = config.hooks.start.clone();
1218 let def_name = meta.def_name.clone();
1219 let start_env = make_hook_env(&new_task_id, &def_name, "");
1220 tokio::spawn(async move {
1221 if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
1222 tracing::warn!(error = %e, "SubagentStart hook failed");
1223 }
1224 });
1225 }
1226
1227 Ok((new_task_id, meta.def_name))
1228 }
1229
1230 fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
1232 if let Some(ref dir) = self.transcript_dir {
1233 dir.clone()
1234 } else if let Some(ref dir) = config.transcript_dir {
1235 dir.clone()
1236 } else {
1237 PathBuf::from(".zeph/subagents")
1238 }
1239 }
1240
1241 pub fn def_name_for_resume(
1250 &self,
1251 id_prefix: &str,
1252 config: &SubAgentConfig,
1253 ) -> Result<String, SubAgentError> {
1254 let dir = self.effective_transcript_dir(config);
1255 let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1256 let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1257 Ok(meta.def_name)
1258 }
1259
1260 #[must_use]
1262 pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
1263 self.agents
1264 .values()
1265 .map(|h| {
1266 let mut status = h.status_rx.borrow().clone();
1267 if h.state == SubAgentState::Canceled {
1270 status.state = SubAgentState::Canceled;
1271 }
1272 (h.task_id.clone(), status)
1273 })
1274 .collect()
1275 }
1276
1277 #[must_use]
1279 pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
1280 self.agents.get(task_id).map(|h| &h.def)
1281 }
1282
1283 #[must_use]
1285 pub fn agent_transcript_dir(&self, task_id: &str) -> Option<&std::path::Path> {
1286 self.agents
1287 .get(task_id)
1288 .and_then(|h| h.transcript_dir.as_deref())
1289 }
1290
1291 #[allow(clippy::too_many_arguments)]
1310 #[allow(clippy::too_many_arguments)]
1324 pub fn spawn_for_task<F>(
1325 &mut self,
1326 def_name: &str,
1327 task_prompt: &str,
1328 provider: AnyProvider,
1329 tool_executor: Arc<dyn ErasedToolExecutor>,
1330 skills: Option<Vec<String>>,
1331 config: &SubAgentConfig,
1332 ctx: SpawnContext,
1333 on_done: F,
1334 ) -> Result<String, SubAgentError>
1335 where
1336 F: FnOnce(String, Result<String, SubAgentError>) + Send + 'static,
1337 {
1338 let handle_id = self.spawn(
1339 def_name,
1340 task_prompt,
1341 provider,
1342 tool_executor,
1343 skills,
1344 config,
1345 ctx,
1346 )?;
1347
1348 let handle = self
1349 .agents
1350 .get_mut(&handle_id)
1351 .expect("just spawned agent must exist");
1352
1353 let original_join = handle
1354 .join_handle
1355 .take()
1356 .expect("just spawned agent must have a join handle");
1357
1358 let handle_id_clone = handle_id.clone();
1359 let wrapped_join: tokio::task::JoinHandle<Result<String, SubAgentError>> =
1360 tokio::spawn(async move {
1361 let result = original_join.await;
1362
1363 let (notify_result, output) = match result {
1364 Ok(Ok(output)) => (Ok(output.clone()), Ok(output)),
1365 Ok(Err(e)) => {
1366 let msg = e.to_string();
1367 (
1368 Err(SubAgentError::Spawn(msg.clone())),
1369 Err(SubAgentError::Spawn(msg)),
1370 )
1371 }
1372 Err(join_err) => {
1373 let msg = format!("task panicked: {join_err:?}");
1374 (
1375 Err(SubAgentError::TaskPanic(msg.clone())),
1376 Err(SubAgentError::TaskPanic(msg)),
1377 )
1378 }
1379 };
1380
1381 on_done(handle_id_clone, notify_result);
1382
1383 output
1384 });
1385
1386 handle.join_handle = Some(wrapped_join);
1387
1388 Ok(handle_id)
1389 }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 #![allow(
1395 clippy::await_holding_lock,
1396 clippy::field_reassign_with_default,
1397 clippy::too_many_lines
1398 )]
1399
1400 use std::pin::Pin;
1401
1402 use indoc::indoc;
1403 use zeph_llm::any::AnyProvider;
1404 use zeph_llm::mock::MockProvider;
1405 use zeph_tools::ToolCall;
1406 use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput};
1407 use zeph_tools::registry::ToolDef;
1408
1409 use serial_test::serial;
1410
1411 use crate::agent_loop::{AgentLoopArgs, make_message, run_agent_loop};
1412 use crate::def::{MemoryScope, ModelSpec};
1413 use zeph_config::SubAgentConfig;
1414 use zeph_llm::provider::ChatResponse;
1415
1416 use super::*;
1417
1418 fn make_manager() -> SubAgentManager {
1419 SubAgentManager::new(4)
1420 }
1421
1422 fn sample_def() -> SubAgentDef {
1423 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap()
1424 }
1425
1426 fn def_with_secrets() -> SubAgentDef {
1427 SubAgentDef::parse(
1428 "---\nname: bot\ndescription: A bot\npermissions:\n secrets:\n - api-key\n---\n\nDo things.\n",
1429 )
1430 .unwrap()
1431 }
1432
1433 struct NoopExecutor;
1434
1435 impl ErasedToolExecutor for NoopExecutor {
1436 fn execute_erased<'a>(
1437 &'a self,
1438 _response: &'a str,
1439 ) -> Pin<
1440 Box<
1441 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1442 >,
1443 > {
1444 Box::pin(std::future::ready(Ok(None)))
1445 }
1446
1447 fn execute_confirmed_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 tool_definitions_erased(&self) -> Vec<ToolDef> {
1459 vec![]
1460 }
1461
1462 fn execute_tool_call_erased<'a>(
1463 &'a self,
1464 _call: &'a ToolCall,
1465 ) -> Pin<
1466 Box<
1467 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1468 >,
1469 > {
1470 Box::pin(std::future::ready(Ok(None)))
1471 }
1472
1473 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1474 false
1475 }
1476 }
1477
1478 fn mock_provider(responses: Vec<&str>) -> AnyProvider {
1479 AnyProvider::Mock(MockProvider::with_responses(
1480 responses.into_iter().map(String::from).collect(),
1481 ))
1482 }
1483
1484 fn noop_executor() -> Arc<dyn ErasedToolExecutor> {
1485 Arc::new(NoopExecutor)
1486 }
1487
1488 fn do_spawn(
1489 mgr: &mut SubAgentManager,
1490 name: &str,
1491 prompt: &str,
1492 ) -> Result<String, SubAgentError> {
1493 mgr.spawn(
1494 name,
1495 prompt,
1496 mock_provider(vec!["done"]),
1497 noop_executor(),
1498 None,
1499 &SubAgentConfig::default(),
1500 SpawnContext::default(),
1501 )
1502 }
1503
1504 #[test]
1505 fn load_definitions_populates_vec() {
1506 use std::io::Write as _;
1507 let dir = tempfile::tempdir().unwrap();
1508 let content = "---\nname: helper\ndescription: A helper\n---\n\nHelp.\n";
1509 let mut f = std::fs::File::create(dir.path().join("helper.md")).unwrap();
1510 f.write_all(content.as_bytes()).unwrap();
1511
1512 let mut mgr = make_manager();
1513 mgr.load_definitions(&[dir.path().to_path_buf()]).unwrap();
1514 assert_eq!(mgr.definitions().len(), 1);
1515 assert_eq!(mgr.definitions()[0].name, "helper");
1516 }
1517
1518 #[test]
1519 fn spawn_not_found_error() {
1520 let rt = tokio::runtime::Runtime::new().unwrap();
1521 let _guard = rt.enter();
1522 let mut mgr = make_manager();
1523 let err = do_spawn(&mut mgr, "nonexistent", "prompt").unwrap_err();
1524 assert!(matches!(err, SubAgentError::NotFound(_)));
1525 }
1526
1527 #[test]
1528 fn spawn_and_cancel() {
1529 let rt = tokio::runtime::Runtime::new().unwrap();
1530 let _guard = rt.enter();
1531 let mut mgr = make_manager();
1532 mgr.definitions.push(sample_def());
1533
1534 let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1535 assert!(!task_id.is_empty());
1536
1537 mgr.cancel(&task_id).unwrap();
1538 assert_eq!(mgr.agents[&task_id].state, SubAgentState::Canceled);
1539 }
1540
1541 #[test]
1542 fn cancel_unknown_task_id_returns_not_found() {
1543 let mut mgr = make_manager();
1544 let err = mgr.cancel("unknown-id").unwrap_err();
1545 assert!(matches!(err, SubAgentError::NotFound(_)));
1546 }
1547
1548 #[tokio::test]
1549 async fn collect_removes_agent() {
1550 let mut mgr = make_manager();
1551 mgr.definitions.push(sample_def());
1552
1553 let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1554 mgr.cancel(&task_id).unwrap();
1555
1556 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1558
1559 let result = mgr.collect(&task_id).await.unwrap();
1560 assert!(!mgr.agents.contains_key(&task_id));
1561 let _ = result;
1563 }
1564
1565 #[tokio::test]
1566 async fn collect_unknown_task_id_returns_not_found() {
1567 let mut mgr = make_manager();
1568 let err = mgr.collect("unknown-id").await.unwrap_err();
1569 assert!(matches!(err, SubAgentError::NotFound(_)));
1570 }
1571
1572 #[test]
1573 fn approve_secret_grants_access() {
1574 let rt = tokio::runtime::Runtime::new().unwrap();
1575 let _guard = rt.enter();
1576 let mut mgr = make_manager();
1577 mgr.definitions.push(def_with_secrets());
1578
1579 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1580 mgr.approve_secret(&task_id, "api-key", std::time::Duration::from_secs(60))
1581 .unwrap();
1582
1583 let handle = mgr.agents.get_mut(&task_id).unwrap();
1584 assert!(
1585 handle
1586 .grants
1587 .is_active(&crate::grants::GrantKind::Secret("api-key".into()))
1588 );
1589 }
1590
1591 #[test]
1592 fn approve_secret_denied_for_unlisted_key() {
1593 let rt = tokio::runtime::Runtime::new().unwrap();
1594 let _guard = rt.enter();
1595 let mut mgr = make_manager();
1596 mgr.definitions.push(sample_def()); let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1599 let err = mgr
1600 .approve_secret(&task_id, "not-allowed", std::time::Duration::from_secs(60))
1601 .unwrap_err();
1602 assert!(matches!(err, SubAgentError::Invalid(_)));
1603 }
1604
1605 #[test]
1606 fn approve_secret_unknown_task_id_returns_not_found() {
1607 let mut mgr = make_manager();
1608 let err = mgr
1609 .approve_secret("unknown", "key", std::time::Duration::from_secs(60))
1610 .unwrap_err();
1611 assert!(matches!(err, SubAgentError::NotFound(_)));
1612 }
1613
1614 #[test]
1615 fn statuses_returns_active_agents() {
1616 let rt = tokio::runtime::Runtime::new().unwrap();
1617 let _guard = rt.enter();
1618 let mut mgr = make_manager();
1619 mgr.definitions.push(sample_def());
1620
1621 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1622 let statuses = mgr.statuses();
1623 assert_eq!(statuses.len(), 1);
1624 assert_eq!(statuses[0].0, task_id);
1625 }
1626
1627 #[test]
1628 fn concurrency_limit_enforced() {
1629 let rt = tokio::runtime::Runtime::new().unwrap();
1630 let _guard = rt.enter();
1631 let mut mgr = SubAgentManager::new(1);
1632 mgr.definitions.push(sample_def());
1633
1634 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1635 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1636 assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1637 }
1638
1639 #[test]
1642 fn test_reserve_slots_blocks_spawn() {
1643 let rt = tokio::runtime::Runtime::new().unwrap();
1645 let _guard = rt.enter();
1646 let mut mgr = SubAgentManager::new(2);
1647 mgr.definitions.push(sample_def());
1648
1649 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1651 mgr.reserve_slots(1);
1653 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1655 assert!(
1656 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1657 "expected ConcurrencyLimit, got: {err}"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_release_reservation_allows_spawn() {
1663 let rt = tokio::runtime::Runtime::new().unwrap();
1665 let _guard = rt.enter();
1666 let mut mgr = SubAgentManager::new(2);
1667 mgr.definitions.push(sample_def());
1668
1669 mgr.reserve_slots(1);
1671 let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1673 let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1675 assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1676
1677 mgr.release_reservation(1);
1679 let result = do_spawn(&mut mgr, "bot", "third");
1680 assert!(
1681 result.is_ok(),
1682 "spawn must succeed after release_reservation, got: {result:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn test_reservation_with_zero_active_blocks_spawn() {
1688 let rt = tokio::runtime::Runtime::new().unwrap();
1690 let _guard = rt.enter();
1691 let mut mgr = SubAgentManager::new(2);
1692 mgr.definitions.push(sample_def());
1693
1694 mgr.reserve_slots(2);
1696 let err = do_spawn(&mut mgr, "bot", "first").unwrap_err();
1698 assert!(
1699 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1700 "reservation alone must block spawn when reserved >= max_concurrent"
1701 );
1702 }
1703
1704 #[tokio::test]
1705 async fn background_agent_does_not_block_caller() {
1706 let mut mgr = make_manager();
1707 mgr.definitions.push(sample_def());
1708
1709 let result = tokio::time::timeout(
1711 std::time::Duration::from_millis(100),
1712 std::future::ready(do_spawn(&mut mgr, "bot", "work")),
1713 )
1714 .await;
1715 assert!(result.is_ok(), "spawn() must not block");
1716 assert!(result.unwrap().is_ok());
1717 }
1718
1719 #[tokio::test]
1720 async fn max_turns_terminates_agent_loop() {
1721 let mut mgr = make_manager();
1722 let def = SubAgentDef::parse(indoc! {"
1724 ---
1725 name: limited
1726 description: A bot
1727 permissions:
1728 max_turns: 1
1729 ---
1730
1731 Do one thing.
1732 "})
1733 .unwrap();
1734 mgr.definitions.push(def);
1735
1736 let task_id = mgr
1737 .spawn(
1738 "limited",
1739 "task",
1740 mock_provider(vec!["final answer"]),
1741 noop_executor(),
1742 None,
1743 &SubAgentConfig::default(),
1744 SpawnContext::default(),
1745 )
1746 .unwrap();
1747
1748 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1750
1751 let status = mgr.statuses().into_iter().find(|(id, _)| id == &task_id);
1752 if let Some((_, s)) = status {
1754 assert!(s.turns_used <= 1);
1755 }
1756 }
1757
1758 #[tokio::test]
1759 async fn cancellation_token_stops_agent_loop() {
1760 let mut mgr = make_manager();
1761 mgr.definitions.push(sample_def());
1762
1763 let task_id = do_spawn(&mut mgr, "bot", "long task").unwrap();
1764
1765 mgr.cancel(&task_id).unwrap();
1767
1768 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1770 let result = mgr.collect(&task_id).await;
1771 assert!(result.is_ok() || result.is_err());
1773 }
1774
1775 #[tokio::test]
1776 async fn shutdown_all_cancels_all_active_agents() {
1777 let mut mgr = make_manager();
1778 mgr.definitions.push(sample_def());
1779
1780 do_spawn(&mut mgr, "bot", "task 1").unwrap();
1781 do_spawn(&mut mgr, "bot", "task 2").unwrap();
1782
1783 assert_eq!(mgr.agents.len(), 2);
1784 mgr.shutdown_all();
1785
1786 for (_, status) in mgr.statuses() {
1788 assert_eq!(status.state, SubAgentState::Canceled);
1789 }
1790 }
1791
1792 #[test]
1793 fn debug_impl_does_not_expose_sensitive_fields() {
1794 let rt = tokio::runtime::Runtime::new().unwrap();
1795 let _guard = rt.enter();
1796 let mut mgr = make_manager();
1797 mgr.definitions.push(def_with_secrets());
1798 let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1799 let handle = &mgr.agents[&task_id];
1800 let debug_str = format!("{handle:?}");
1801 assert!(!debug_str.contains("api-key"));
1803 }
1804
1805 #[tokio::test]
1806 async fn llm_failure_transitions_to_failed_state() {
1807 let rt_handle = tokio::runtime::Handle::current();
1808 let _guard = rt_handle.enter();
1809 let mut mgr = make_manager();
1810 mgr.definitions.push(sample_def());
1811
1812 let failing = AnyProvider::Mock(MockProvider::failing());
1813 let task_id = mgr
1814 .spawn(
1815 "bot",
1816 "do work",
1817 failing,
1818 noop_executor(),
1819 None,
1820 &SubAgentConfig::default(),
1821 SpawnContext::default(),
1822 )
1823 .unwrap();
1824
1825 tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1827
1828 let statuses = mgr.statuses();
1829 let status = statuses
1830 .iter()
1831 .find(|(id, _)| id == &task_id)
1832 .map(|(_, s)| s);
1833 assert!(
1835 status.is_some_and(|s| s.state == SubAgentState::Failed),
1836 "expected Failed, got: {status:?}"
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn tool_call_loop_two_turns() {
1842 use std::sync::Mutex;
1843 use zeph_llm::mock::MockProvider;
1844 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
1845 use zeph_tools::ToolCall;
1846
1847 struct ToolOnceExecutor {
1848 calls: Mutex<u32>,
1849 }
1850
1851 impl ErasedToolExecutor for ToolOnceExecutor {
1852 fn execute_erased<'a>(
1853 &'a self,
1854 _response: &'a str,
1855 ) -> Pin<
1856 Box<
1857 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1858 + Send
1859 + 'a,
1860 >,
1861 > {
1862 Box::pin(std::future::ready(Ok(None)))
1863 }
1864
1865 fn execute_confirmed_erased<'a>(
1866 &'a self,
1867 _response: &'a str,
1868 ) -> Pin<
1869 Box<
1870 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1871 + Send
1872 + 'a,
1873 >,
1874 > {
1875 Box::pin(std::future::ready(Ok(None)))
1876 }
1877
1878 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1879 vec![]
1880 }
1881
1882 fn execute_tool_call_erased<'a>(
1883 &'a self,
1884 call: &'a ToolCall,
1885 ) -> Pin<
1886 Box<
1887 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1888 + Send
1889 + 'a,
1890 >,
1891 > {
1892 let mut n = self.calls.lock().unwrap();
1893 *n += 1;
1894 let result = if *n == 1 {
1895 Ok(Some(ToolOutput {
1896 tool_name: call.tool_id.clone(),
1897 summary: "step 1 done".into(),
1898 blocks_executed: 1,
1899 filter_stats: None,
1900 diff: None,
1901 streamed: false,
1902 terminal_id: None,
1903 locations: None,
1904 raw_response: None,
1905 claim_source: None,
1906 }))
1907 } else {
1908 Ok(None)
1909 };
1910 Box::pin(std::future::ready(result))
1911 }
1912
1913 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1914 false
1915 }
1916 }
1917
1918 let rt_handle = tokio::runtime::Handle::current();
1919 let _guard = rt_handle.enter();
1920 let mut mgr = make_manager();
1921 mgr.definitions.push(sample_def());
1922
1923 let tool_response = ChatResponse::ToolUse {
1925 text: None,
1926 tool_calls: vec![ToolUseRequest {
1927 id: "call-1".into(),
1928 name: "shell".into(),
1929 input: serde_json::json!({"command": "echo hi"}),
1930 }],
1931 thinking_blocks: vec![],
1932 };
1933 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
1934 tool_response,
1935 ChatResponse::Text("final answer".into()),
1936 ]);
1937 let provider = AnyProvider::Mock(mock);
1938 let executor = Arc::new(ToolOnceExecutor {
1939 calls: Mutex::new(0),
1940 });
1941
1942 let task_id = mgr
1943 .spawn(
1944 "bot",
1945 "run two turns",
1946 provider,
1947 executor,
1948 None,
1949 &SubAgentConfig::default(),
1950 SpawnContext::default(),
1951 )
1952 .unwrap();
1953
1954 tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1956
1957 let result = mgr.collect(&task_id).await;
1958 assert!(result.is_ok(), "expected Ok, got: {result:?}");
1959 }
1960
1961 #[tokio::test]
1962 async fn collect_on_running_task_completes_eventually() {
1963 let mut mgr = make_manager();
1964 mgr.definitions.push(sample_def());
1965
1966 let task_id = do_spawn(&mut mgr, "bot", "slow work").unwrap();
1968
1969 let result =
1971 tokio::time::timeout(tokio::time::Duration::from_secs(5), mgr.collect(&task_id)).await;
1972
1973 assert!(result.is_ok(), "collect timed out after 5s");
1974 let inner = result.unwrap();
1975 assert!(inner.is_ok(), "collect returned error: {inner:?}");
1976 }
1977
1978 #[test]
1979 fn concurrency_slot_freed_after_cancel() {
1980 let rt = tokio::runtime::Runtime::new().unwrap();
1981 let _guard = rt.enter();
1982 let mut mgr = SubAgentManager::new(1); mgr.definitions.push(sample_def());
1984
1985 let id1 = do_spawn(&mut mgr, "bot", "task 1").unwrap();
1986
1987 let err = do_spawn(&mut mgr, "bot", "task 2").unwrap_err();
1989 assert!(
1990 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1991 "expected concurrency limit error, got: {err}"
1992 );
1993
1994 mgr.cancel(&id1).unwrap();
1996
1997 let result = do_spawn(&mut mgr, "bot", "task 3");
1999 assert!(
2000 result.is_ok(),
2001 "expected spawn to succeed after cancel, got: {result:?}"
2002 );
2003 }
2004
2005 #[tokio::test]
2006 async fn skill_bodies_prepended_to_system_prompt() {
2007 use zeph_llm::mock::MockProvider;
2010
2011 let (mock, recorded) = MockProvider::default().with_recording();
2012 let provider = AnyProvider::Mock(mock);
2013
2014 let mut mgr = make_manager();
2015 mgr.definitions.push(sample_def());
2016
2017 let skill_bodies = vec!["# skill-one\nDo something useful.".to_owned()];
2018 let task_id = mgr
2019 .spawn(
2020 "bot",
2021 "task",
2022 provider,
2023 noop_executor(),
2024 Some(skill_bodies),
2025 &SubAgentConfig::default(),
2026 SpawnContext::default(),
2027 )
2028 .unwrap();
2029
2030 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2032
2033 let calls = recorded.lock().unwrap();
2034 assert!(!calls.is_empty(), "provider should have been called");
2035 let system_msg = &calls[0][0].content;
2037 assert!(
2038 system_msg.contains("```skills"),
2039 "system prompt must contain ```skills fence, got: {system_msg}"
2040 );
2041 assert!(
2042 system_msg.contains("skill-one"),
2043 "system prompt must contain the skill body, got: {system_msg}"
2044 );
2045 drop(calls);
2046
2047 let _ = mgr.collect(&task_id).await;
2048 }
2049
2050 #[tokio::test]
2051 async fn no_skills_does_not_add_fence_to_system_prompt() {
2052 use zeph_llm::mock::MockProvider;
2053
2054 let (mock, recorded) = MockProvider::default().with_recording();
2055 let provider = AnyProvider::Mock(mock);
2056
2057 let mut mgr = make_manager();
2058 mgr.definitions.push(sample_def());
2059
2060 let task_id = mgr
2061 .spawn(
2062 "bot",
2063 "task",
2064 provider,
2065 noop_executor(),
2066 None,
2067 &SubAgentConfig::default(),
2068 SpawnContext::default(),
2069 )
2070 .unwrap();
2071
2072 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2073
2074 let calls = recorded.lock().unwrap();
2075 assert!(!calls.is_empty());
2076 let system_msg = &calls[0][0].content;
2077 assert!(
2078 !system_msg.contains("```skills"),
2079 "system prompt must not contain skills fence when no skills passed"
2080 );
2081 drop(calls);
2082
2083 let _ = mgr.collect(&task_id).await;
2084 }
2085
2086 #[tokio::test]
2087 async fn statuses_does_not_include_collected_task() {
2088 let mut mgr = make_manager();
2089 mgr.definitions.push(sample_def());
2090
2091 let task_id = do_spawn(&mut mgr, "bot", "task").unwrap();
2092 assert_eq!(mgr.statuses().len(), 1);
2093
2094 tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
2096 let _ = mgr.collect(&task_id).await;
2097
2098 assert!(
2100 mgr.statuses().is_empty(),
2101 "expected empty statuses after collect"
2102 );
2103 }
2104
2105 #[tokio::test]
2106 async fn background_agent_auto_denies_secret_request() {
2107 use zeph_llm::mock::MockProvider;
2108
2109 let def = SubAgentDef::parse(indoc! {"
2111 ---
2112 name: bg-bot
2113 description: Background bot
2114 permissions:
2115 background: true
2116 secrets:
2117 - api-key
2118 ---
2119
2120 [REQUEST_SECRET: api-key]
2121 "})
2122 .unwrap();
2123
2124 let (mock, recorded) = MockProvider::default().with_recording();
2125 let provider = AnyProvider::Mock(mock);
2126
2127 let mut mgr = make_manager();
2128 mgr.definitions.push(def);
2129
2130 let task_id = mgr
2131 .spawn(
2132 "bg-bot",
2133 "task",
2134 provider,
2135 noop_executor(),
2136 None,
2137 &SubAgentConfig::default(),
2138 SpawnContext::default(),
2139 )
2140 .unwrap();
2141
2142 let result =
2144 tokio::time::timeout(tokio::time::Duration::from_secs(2), mgr.collect(&task_id)).await;
2145 assert!(
2146 result.is_ok(),
2147 "background agent must not block on secret request"
2148 );
2149 drop(recorded);
2150 }
2151
2152 #[test]
2153 fn spawn_with_plan_mode_definition_succeeds() {
2154 let rt = tokio::runtime::Runtime::new().unwrap();
2155 let _guard = rt.enter();
2156
2157 let def = SubAgentDef::parse(indoc! {"
2158 ---
2159 name: planner
2160 description: A planner bot
2161 permissions:
2162 permission_mode: plan
2163 ---
2164
2165 Plan only.
2166 "})
2167 .unwrap();
2168
2169 let mut mgr = make_manager();
2170 mgr.definitions.push(def);
2171
2172 let task_id = do_spawn(&mut mgr, "planner", "make a plan").unwrap();
2173 assert!(!task_id.is_empty());
2174 mgr.cancel(&task_id).unwrap();
2175 }
2176
2177 #[test]
2178 fn spawn_with_disallowed_tools_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: safe-bot
2185 description: Bot with disallowed tools
2186 tools:
2187 allow:
2188 - shell
2189 - web
2190 except:
2191 - shell
2192 ---
2193
2194 Do safe things.
2195 "})
2196 .unwrap();
2197
2198 assert_eq!(def.disallowed_tools, ["shell"]);
2199
2200 let mut mgr = make_manager();
2201 mgr.definitions.push(def);
2202
2203 let task_id = do_spawn(&mut mgr, "safe-bot", "task").unwrap();
2204 assert!(!task_id.is_empty());
2205 mgr.cancel(&task_id).unwrap();
2206 }
2207
2208 #[test]
2211 fn spawn_applies_default_permission_mode_from_config() {
2212 let rt = tokio::runtime::Runtime::new().unwrap();
2213 let _guard = rt.enter();
2214
2215 let def =
2217 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2218 assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
2219
2220 let mut mgr = make_manager();
2221 mgr.definitions.push(def);
2222
2223 let cfg = SubAgentConfig {
2224 default_permission_mode: Some(PermissionMode::Plan),
2225 ..SubAgentConfig::default()
2226 };
2227
2228 let task_id = mgr
2229 .spawn(
2230 "bot",
2231 "prompt",
2232 mock_provider(vec!["done"]),
2233 noop_executor(),
2234 None,
2235 &cfg,
2236 SpawnContext::default(),
2237 )
2238 .unwrap();
2239 assert!(!task_id.is_empty());
2240 mgr.cancel(&task_id).unwrap();
2241 }
2242
2243 #[test]
2244 fn spawn_does_not_override_explicit_permission_mode() {
2245 let rt = tokio::runtime::Runtime::new().unwrap();
2246 let _guard = rt.enter();
2247
2248 let def = SubAgentDef::parse(indoc! {"
2250 ---
2251 name: bot
2252 description: A bot
2253 permissions:
2254 permission_mode: dont_ask
2255 ---
2256
2257 Do things.
2258 "})
2259 .unwrap();
2260 assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
2261
2262 let mut mgr = make_manager();
2263 mgr.definitions.push(def);
2264
2265 let cfg = SubAgentConfig {
2266 default_permission_mode: Some(PermissionMode::Plan),
2267 ..SubAgentConfig::default()
2268 };
2269
2270 let task_id = mgr
2271 .spawn(
2272 "bot",
2273 "prompt",
2274 mock_provider(vec!["done"]),
2275 noop_executor(),
2276 None,
2277 &cfg,
2278 SpawnContext::default(),
2279 )
2280 .unwrap();
2281 assert!(!task_id.is_empty());
2282 mgr.cancel(&task_id).unwrap();
2283 }
2284
2285 #[test]
2286 fn spawn_merges_global_disallowed_tools() {
2287 let rt = tokio::runtime::Runtime::new().unwrap();
2288 let _guard = rt.enter();
2289
2290 let def =
2291 SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2292
2293 let mut mgr = make_manager();
2294 mgr.definitions.push(def);
2295
2296 let cfg = SubAgentConfig {
2297 default_disallowed_tools: vec!["dangerous".into()],
2298 ..SubAgentConfig::default()
2299 };
2300
2301 let task_id = mgr
2302 .spawn(
2303 "bot",
2304 "prompt",
2305 mock_provider(vec!["done"]),
2306 noop_executor(),
2307 None,
2308 &cfg,
2309 SpawnContext::default(),
2310 )
2311 .unwrap();
2312 assert!(!task_id.is_empty());
2313 mgr.cancel(&task_id).unwrap();
2314 }
2315
2316 #[test]
2319 fn spawn_bypass_permissions_without_config_gate_is_error() {
2320 let rt = tokio::runtime::Runtime::new().unwrap();
2321 let _guard = rt.enter();
2322
2323 let def = SubAgentDef::parse(indoc! {"
2324 ---
2325 name: bypass-bot
2326 description: A bot with bypass mode
2327 permissions:
2328 permission_mode: bypass_permissions
2329 ---
2330
2331 Unrestricted.
2332 "})
2333 .unwrap();
2334
2335 let mut mgr = make_manager();
2336 mgr.definitions.push(def);
2337
2338 let cfg = SubAgentConfig::default();
2340 let err = mgr
2341 .spawn(
2342 "bypass-bot",
2343 "prompt",
2344 mock_provider(vec!["done"]),
2345 noop_executor(),
2346 None,
2347 &cfg,
2348 SpawnContext::default(),
2349 )
2350 .unwrap_err();
2351 assert!(matches!(err, SubAgentError::Invalid(_)));
2352 }
2353
2354 #[test]
2355 fn spawn_bypass_permissions_with_config_gate_succeeds() {
2356 let rt = tokio::runtime::Runtime::new().unwrap();
2357 let _guard = rt.enter();
2358
2359 let def = SubAgentDef::parse(indoc! {"
2360 ---
2361 name: bypass-bot
2362 description: A bot with bypass mode
2363 permissions:
2364 permission_mode: bypass_permissions
2365 ---
2366
2367 Unrestricted.
2368 "})
2369 .unwrap();
2370
2371 let mut mgr = make_manager();
2372 mgr.definitions.push(def);
2373
2374 let cfg = SubAgentConfig {
2375 allow_bypass_permissions: true,
2376 ..SubAgentConfig::default()
2377 };
2378
2379 let task_id = mgr
2380 .spawn(
2381 "bypass-bot",
2382 "prompt",
2383 mock_provider(vec!["done"]),
2384 noop_executor(),
2385 None,
2386 &cfg,
2387 SpawnContext::default(),
2388 )
2389 .unwrap();
2390 assert!(!task_id.is_empty());
2391 mgr.cancel(&task_id).unwrap();
2392 }
2393
2394 fn write_completed_meta(dir: &std::path::Path, agent_id: &str, def_name: &str) {
2398 use crate::transcript::{TranscriptMeta, TranscriptWriter};
2399 let meta = TranscriptMeta {
2400 agent_id: agent_id.to_owned(),
2401 agent_name: def_name.to_owned(),
2402 def_name: def_name.to_owned(),
2403 status: SubAgentState::Completed,
2404 started_at: "2026-01-01T00:00:00Z".to_owned(),
2405 finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
2406 resumed_from: None,
2407 turns_used: 1,
2408 };
2409 TranscriptWriter::write_meta(dir, agent_id, &meta).unwrap();
2410 std::fs::write(dir.join(format!("{agent_id}.jsonl")), b"").unwrap();
2412 }
2413
2414 fn make_cfg_with_dir(dir: &std::path::Path) -> SubAgentConfig {
2415 SubAgentConfig {
2416 transcript_dir: Some(dir.to_path_buf()),
2417 ..SubAgentConfig::default()
2418 }
2419 }
2420
2421 #[test]
2422 fn resume_not_found_returns_not_found_error() {
2423 let rt = tokio::runtime::Runtime::new().unwrap();
2424 let _guard = rt.enter();
2425
2426 let tmp = tempfile::tempdir().unwrap();
2427 let mut mgr = make_manager();
2428 mgr.definitions.push(sample_def());
2429 let cfg = make_cfg_with_dir(tmp.path());
2430
2431 let err = mgr
2432 .resume(
2433 "deadbeef",
2434 "continue",
2435 mock_provider(vec!["done"]),
2436 noop_executor(),
2437 None,
2438 &cfg,
2439 )
2440 .unwrap_err();
2441 assert!(matches!(err, SubAgentError::NotFound(_)));
2442 }
2443
2444 #[test]
2445 fn resume_ambiguous_id_returns_ambiguous_error() {
2446 let rt = tokio::runtime::Runtime::new().unwrap();
2447 let _guard = rt.enter();
2448
2449 let tmp = tempfile::tempdir().unwrap();
2450 write_completed_meta(tmp.path(), "aabb0001-0000-0000-0000-000000000000", "bot");
2451 write_completed_meta(tmp.path(), "aabb0002-0000-0000-0000-000000000000", "bot");
2452
2453 let mut mgr = make_manager();
2454 mgr.definitions.push(sample_def());
2455 let cfg = make_cfg_with_dir(tmp.path());
2456
2457 let err = mgr
2458 .resume(
2459 "aabb",
2460 "continue",
2461 mock_provider(vec!["done"]),
2462 noop_executor(),
2463 None,
2464 &cfg,
2465 )
2466 .unwrap_err();
2467 assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
2468 }
2469
2470 #[test]
2471 fn resume_still_running_via_active_agents_returns_error() {
2472 let rt = tokio::runtime::Runtime::new().unwrap();
2473 let _guard = rt.enter();
2474
2475 let tmp = tempfile::tempdir().unwrap();
2476 let agent_id = "cafebabe-0000-0000-0000-000000000000";
2477 write_completed_meta(tmp.path(), agent_id, "bot");
2478
2479 let mut mgr = make_manager();
2480 mgr.definitions.push(sample_def());
2481
2482 let (status_tx, status_rx) = watch::channel(SubAgentStatus {
2484 state: SubAgentState::Working,
2485 last_message: None,
2486 turns_used: 0,
2487 started_at: std::time::Instant::now(),
2488 });
2489 let (_secret_request_tx, pending_secret_rx) = tokio::sync::mpsc::channel(1);
2490 let (secret_tx, _secret_rx) = tokio::sync::mpsc::channel(1);
2491 let cancel = CancellationToken::new();
2492 let fake_def = sample_def();
2493 mgr.agents.insert(
2494 agent_id.to_owned(),
2495 SubAgentHandle {
2496 id: agent_id.to_owned(),
2497 def: fake_def,
2498 task_id: agent_id.to_owned(),
2499 state: SubAgentState::Working,
2500 join_handle: None,
2501 cancel,
2502 status_rx,
2503 grants: PermissionGrants::default(),
2504 pending_secret_rx,
2505 secret_tx,
2506 started_at_str: "2026-01-01T00:00:00Z".to_owned(),
2507 transcript_dir: None,
2508 },
2509 );
2510 drop(status_tx);
2511
2512 let cfg = make_cfg_with_dir(tmp.path());
2513 let err = mgr
2514 .resume(
2515 agent_id,
2516 "continue",
2517 mock_provider(vec!["done"]),
2518 noop_executor(),
2519 None,
2520 &cfg,
2521 )
2522 .unwrap_err();
2523 assert!(matches!(err, SubAgentError::StillRunning(_)));
2524 }
2525
2526 #[test]
2527 fn resume_def_not_found_returns_not_found_error() {
2528 let rt = tokio::runtime::Runtime::new().unwrap();
2529 let _guard = rt.enter();
2530
2531 let tmp = tempfile::tempdir().unwrap();
2532 let agent_id = "feedface-0000-0000-0000-000000000000";
2533 write_completed_meta(tmp.path(), agent_id, "unknown-agent");
2535
2536 let mut mgr = make_manager();
2537 let cfg = make_cfg_with_dir(tmp.path());
2539
2540 let err = mgr
2541 .resume(
2542 "feedface",
2543 "continue",
2544 mock_provider(vec!["done"]),
2545 noop_executor(),
2546 None,
2547 &cfg,
2548 )
2549 .unwrap_err();
2550 assert!(matches!(err, SubAgentError::NotFound(_)));
2551 }
2552
2553 #[test]
2554 fn resume_concurrency_limit_reached_returns_error() {
2555 let rt = tokio::runtime::Runtime::new().unwrap();
2556 let _guard = rt.enter();
2557
2558 let tmp = tempfile::tempdir().unwrap();
2559 let agent_id = "babe0000-0000-0000-0000-000000000000";
2560 write_completed_meta(tmp.path(), agent_id, "bot");
2561
2562 let mut mgr = SubAgentManager::new(1); mgr.definitions.push(sample_def());
2564
2565 let _running_id = do_spawn(&mut mgr, "bot", "occupying slot").unwrap();
2567
2568 let cfg = make_cfg_with_dir(tmp.path());
2569 let err = mgr
2570 .resume(
2571 "babe0000",
2572 "continue",
2573 mock_provider(vec!["done"]),
2574 noop_executor(),
2575 None,
2576 &cfg,
2577 )
2578 .unwrap_err();
2579 assert!(
2580 matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2581 "expected concurrency limit error, got: {err}"
2582 );
2583 }
2584
2585 #[test]
2586 fn resume_happy_path_returns_new_task_id() {
2587 let rt = tokio::runtime::Runtime::new().unwrap();
2588 let _guard = rt.enter();
2589
2590 let tmp = tempfile::tempdir().unwrap();
2591 let agent_id = "deadcode-0000-0000-0000-000000000000";
2592 write_completed_meta(tmp.path(), agent_id, "bot");
2593
2594 let mut mgr = make_manager();
2595 mgr.definitions.push(sample_def());
2596 let cfg = make_cfg_with_dir(tmp.path());
2597
2598 let (new_id, def_name) = mgr
2599 .resume(
2600 "deadcode",
2601 "continue the work",
2602 mock_provider(vec!["done"]),
2603 noop_executor(),
2604 None,
2605 &cfg,
2606 )
2607 .unwrap();
2608
2609 assert!(!new_id.is_empty(), "new task id must not be empty");
2610 assert_ne!(
2611 new_id, agent_id,
2612 "resumed session must have a fresh task id"
2613 );
2614 assert_eq!(def_name, "bot");
2615 assert!(mgr.agents.contains_key(&new_id));
2617
2618 mgr.cancel(&new_id).unwrap();
2619 }
2620
2621 #[test]
2622 fn resume_populates_resumed_from_in_meta() {
2623 let rt = tokio::runtime::Runtime::new().unwrap();
2624 let _guard = rt.enter();
2625
2626 let tmp = tempfile::tempdir().unwrap();
2627 let original_id = "0000abcd-0000-0000-0000-000000000000";
2628 write_completed_meta(tmp.path(), original_id, "bot");
2629
2630 let mut mgr = make_manager();
2631 mgr.definitions.push(sample_def());
2632 let cfg = make_cfg_with_dir(tmp.path());
2633
2634 let (new_id, _) = mgr
2635 .resume(
2636 "0000abcd",
2637 "continue",
2638 mock_provider(vec!["done"]),
2639 noop_executor(),
2640 None,
2641 &cfg,
2642 )
2643 .unwrap();
2644
2645 let new_meta = crate::transcript::TranscriptReader::load_meta(tmp.path(), &new_id).unwrap();
2647 assert_eq!(
2648 new_meta.resumed_from.as_deref(),
2649 Some(original_id),
2650 "resumed_from must point to original agent id"
2651 );
2652
2653 mgr.cancel(&new_id).unwrap();
2654 }
2655
2656 #[test]
2657 fn def_name_for_resume_returns_def_name() {
2658 let rt = tokio::runtime::Runtime::new().unwrap();
2659 let _guard = rt.enter();
2660
2661 let tmp = tempfile::tempdir().unwrap();
2662 let agent_id = "aaaabbbb-0000-0000-0000-000000000000";
2663 write_completed_meta(tmp.path(), agent_id, "bot");
2664
2665 let mgr = make_manager();
2666 let cfg = make_cfg_with_dir(tmp.path());
2667
2668 let name = mgr.def_name_for_resume("aaaabbbb", &cfg).unwrap();
2669 assert_eq!(name, "bot");
2670 }
2671
2672 #[test]
2673 fn def_name_for_resume_not_found_returns_error() {
2674 let rt = tokio::runtime::Runtime::new().unwrap();
2675 let _guard = rt.enter();
2676
2677 let tmp = tempfile::tempdir().unwrap();
2678 let mgr = make_manager();
2679 let cfg = make_cfg_with_dir(tmp.path());
2680
2681 let err = mgr.def_name_for_resume("notexist", &cfg).unwrap_err();
2682 assert!(matches!(err, SubAgentError::NotFound(_)));
2683 }
2684
2685 #[tokio::test]
2688 #[serial]
2689 async fn spawn_with_memory_scope_project_creates_directory() {
2690 let tmp = tempfile::tempdir().unwrap();
2691 let orig_dir = std::env::current_dir().unwrap();
2692 std::env::set_current_dir(tmp.path()).unwrap();
2693
2694 let def = SubAgentDef::parse(indoc! {"
2695 ---
2696 name: mem-agent
2697 description: Agent with memory
2698 memory: project
2699 ---
2700
2701 System prompt.
2702 "})
2703 .unwrap();
2704
2705 let mut mgr = make_manager();
2706 mgr.definitions.push(def);
2707
2708 let task_id = mgr
2709 .spawn(
2710 "mem-agent",
2711 "do something",
2712 mock_provider(vec!["done"]),
2713 noop_executor(),
2714 None,
2715 &SubAgentConfig::default(),
2716 SpawnContext::default(),
2717 )
2718 .unwrap();
2719 assert!(!task_id.is_empty());
2720 mgr.cancel(&task_id).unwrap();
2721
2722 let mem_dir = tmp
2724 .path()
2725 .join(".zeph")
2726 .join("agent-memory")
2727 .join("mem-agent");
2728 assert!(
2729 mem_dir.exists(),
2730 "memory directory should be created at spawn"
2731 );
2732
2733 std::env::set_current_dir(orig_dir).unwrap();
2734 }
2735
2736 #[tokio::test]
2737 #[serial]
2738 async fn spawn_with_config_default_memory_scope_applies_when_def_has_none() {
2739 let tmp = tempfile::tempdir().unwrap();
2740 let orig_dir = std::env::current_dir().unwrap();
2741 std::env::set_current_dir(tmp.path()).unwrap();
2742
2743 let def = SubAgentDef::parse(indoc! {"
2744 ---
2745 name: mem-agent2
2746 description: Agent without explicit memory
2747 ---
2748
2749 System prompt.
2750 "})
2751 .unwrap();
2752
2753 let mut mgr = make_manager();
2754 mgr.definitions.push(def);
2755
2756 let cfg = SubAgentConfig {
2757 default_memory_scope: Some(MemoryScope::Project),
2758 ..SubAgentConfig::default()
2759 };
2760
2761 let task_id = mgr
2762 .spawn(
2763 "mem-agent2",
2764 "do something",
2765 mock_provider(vec!["done"]),
2766 noop_executor(),
2767 None,
2768 &cfg,
2769 SpawnContext::default(),
2770 )
2771 .unwrap();
2772 assert!(!task_id.is_empty());
2773 mgr.cancel(&task_id).unwrap();
2774
2775 let mem_dir = tmp
2777 .path()
2778 .join(".zeph")
2779 .join("agent-memory")
2780 .join("mem-agent2");
2781 assert!(
2782 mem_dir.exists(),
2783 "config default memory scope should create directory"
2784 );
2785
2786 std::env::set_current_dir(orig_dir).unwrap();
2787 }
2788
2789 #[tokio::test]
2790 #[serial]
2791 async fn spawn_with_memory_blocked_by_disallowed_tools_skips_memory() {
2792 let tmp = tempfile::tempdir().unwrap();
2793 let orig_dir = std::env::current_dir().unwrap();
2794 std::env::set_current_dir(tmp.path()).unwrap();
2795
2796 let def = SubAgentDef::parse(indoc! {"
2797 ---
2798 name: blocked-mem
2799 description: Agent with memory but blocked tools
2800 memory: project
2801 tools:
2802 except:
2803 - Read
2804 - Write
2805 - Edit
2806 ---
2807
2808 System prompt.
2809 "})
2810 .unwrap();
2811
2812 let mut mgr = make_manager();
2813 mgr.definitions.push(def);
2814
2815 let task_id = mgr
2816 .spawn(
2817 "blocked-mem",
2818 "do something",
2819 mock_provider(vec!["done"]),
2820 noop_executor(),
2821 None,
2822 &SubAgentConfig::default(),
2823 SpawnContext::default(),
2824 )
2825 .unwrap();
2826 assert!(!task_id.is_empty());
2827 mgr.cancel(&task_id).unwrap();
2828
2829 let mem_dir = tmp
2831 .path()
2832 .join(".zeph")
2833 .join("agent-memory")
2834 .join("blocked-mem");
2835 assert!(
2836 !mem_dir.exists(),
2837 "memory directory should not be created when tools are blocked"
2838 );
2839
2840 std::env::set_current_dir(orig_dir).unwrap();
2841 }
2842
2843 #[tokio::test]
2844 #[serial]
2845 async fn spawn_without_memory_scope_no_directory_created() {
2846 let tmp = tempfile::tempdir().unwrap();
2847 let orig_dir = std::env::current_dir().unwrap();
2848 std::env::set_current_dir(tmp.path()).unwrap();
2849
2850 let def = SubAgentDef::parse(indoc! {"
2851 ---
2852 name: no-mem-agent
2853 description: Agent without memory
2854 ---
2855
2856 System prompt.
2857 "})
2858 .unwrap();
2859
2860 let mut mgr = make_manager();
2861 mgr.definitions.push(def);
2862
2863 let task_id = mgr
2864 .spawn(
2865 "no-mem-agent",
2866 "do something",
2867 mock_provider(vec!["done"]),
2868 noop_executor(),
2869 None,
2870 &SubAgentConfig::default(),
2871 SpawnContext::default(),
2872 )
2873 .unwrap();
2874 assert!(!task_id.is_empty());
2875 mgr.cancel(&task_id).unwrap();
2876
2877 let mem_dir = tmp.path().join(".zeph").join("agent-memory");
2879 assert!(
2880 !mem_dir.exists(),
2881 "no agent-memory directory should be created without memory scope"
2882 );
2883
2884 std::env::set_current_dir(orig_dir).unwrap();
2885 }
2886
2887 #[test]
2888 #[serial]
2889 fn build_prompt_injects_memory_block_after_behavioral_prompt() {
2890 let tmp = tempfile::tempdir().unwrap();
2891 let orig_dir = std::env::current_dir().unwrap();
2892 std::env::set_current_dir(tmp.path()).unwrap();
2893
2894 let mem_dir = tmp
2896 .path()
2897 .join(".zeph")
2898 .join("agent-memory")
2899 .join("test-agent");
2900 std::fs::create_dir_all(&mem_dir).unwrap();
2901 std::fs::write(mem_dir.join("MEMORY.md"), "# Test Memory\nkey: value\n").unwrap();
2902
2903 let mut def = SubAgentDef::parse(indoc! {"
2904 ---
2905 name: test-agent
2906 description: Test agent
2907 memory: project
2908 ---
2909
2910 Behavioral instructions here.
2911 "})
2912 .unwrap();
2913
2914 let prompt = build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2915
2916 let behavioral_pos = prompt.find("Behavioral instructions").unwrap();
2918 let memory_pos = prompt.find("<agent-memory>").unwrap();
2919 assert!(
2920 memory_pos > behavioral_pos,
2921 "memory block must appear AFTER behavioral prompt"
2922 );
2923 assert!(
2924 prompt.contains("key: value"),
2925 "MEMORY.md content must be injected"
2926 );
2927
2928 std::env::set_current_dir(orig_dir).unwrap();
2929 }
2930
2931 #[test]
2932 #[serial]
2933 fn build_prompt_auto_enables_read_write_edit_for_allowlist() {
2934 let tmp = tempfile::tempdir().unwrap();
2935 let orig_dir = std::env::current_dir().unwrap();
2936 std::env::set_current_dir(tmp.path()).unwrap();
2937
2938 let mut def = SubAgentDef::parse(indoc! {"
2939 ---
2940 name: allowlist-agent
2941 description: AllowList agent
2942 memory: project
2943 tools:
2944 allow:
2945 - shell
2946 ---
2947
2948 System prompt.
2949 "})
2950 .unwrap();
2951
2952 assert!(
2953 matches!(&def.tools, ToolPolicy::AllowList(list) if list == &["shell"]),
2954 "should start with only shell"
2955 );
2956
2957 build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2958
2959 assert!(
2961 matches!(&def.tools, ToolPolicy::AllowList(list)
2962 if list.contains(&"Read".to_owned())
2963 && list.contains(&"Write".to_owned())
2964 && list.contains(&"Edit".to_owned())),
2965 "Read/Write/Edit must be auto-enabled in AllowList when memory is set"
2966 );
2967
2968 std::env::set_current_dir(orig_dir).unwrap();
2969 }
2970
2971 #[tokio::test]
2972 #[serial]
2973 async fn spawn_with_explicit_def_memory_overrides_config_default() {
2974 let tmp = tempfile::tempdir().unwrap();
2975 let orig_dir = std::env::current_dir().unwrap();
2976 std::env::set_current_dir(tmp.path()).unwrap();
2977
2978 let def = SubAgentDef::parse(indoc! {"
2981 ---
2982 name: override-agent
2983 description: Agent with explicit memory
2984 memory: local
2985 ---
2986
2987 System prompt.
2988 "})
2989 .unwrap();
2990 assert_eq!(def.memory, Some(MemoryScope::Local));
2991
2992 let mut mgr = make_manager();
2993 mgr.definitions.push(def);
2994
2995 let cfg = SubAgentConfig {
2996 default_memory_scope: Some(MemoryScope::Project),
2997 ..SubAgentConfig::default()
2998 };
2999
3000 let task_id = mgr
3001 .spawn(
3002 "override-agent",
3003 "do something",
3004 mock_provider(vec!["done"]),
3005 noop_executor(),
3006 None,
3007 &cfg,
3008 SpawnContext::default(),
3009 )
3010 .unwrap();
3011 assert!(!task_id.is_empty());
3012 mgr.cancel(&task_id).unwrap();
3013
3014 let local_dir = tmp
3016 .path()
3017 .join(".zeph")
3018 .join("agent-memory-local")
3019 .join("override-agent");
3020 let project_dir = tmp
3021 .path()
3022 .join(".zeph")
3023 .join("agent-memory")
3024 .join("override-agent");
3025 assert!(local_dir.exists(), "local memory dir should be created");
3026 assert!(
3027 !project_dir.exists(),
3028 "project memory dir must NOT be created"
3029 );
3030
3031 std::env::set_current_dir(orig_dir).unwrap();
3032 }
3033
3034 #[tokio::test]
3035 #[serial]
3036 async fn spawn_memory_blocked_by_deny_list_policy() {
3037 let tmp = tempfile::tempdir().unwrap();
3038 let orig_dir = std::env::current_dir().unwrap();
3039 std::env::set_current_dir(tmp.path()).unwrap();
3040
3041 let def = SubAgentDef::parse(indoc! {"
3043 ---
3044 name: deny-list-mem
3045 description: Agent with deny list
3046 memory: project
3047 tools:
3048 deny:
3049 - Read
3050 - Write
3051 - Edit
3052 ---
3053
3054 System prompt.
3055 "})
3056 .unwrap();
3057
3058 let mut mgr = make_manager();
3059 mgr.definitions.push(def);
3060
3061 let task_id = mgr
3062 .spawn(
3063 "deny-list-mem",
3064 "do something",
3065 mock_provider(vec!["done"]),
3066 noop_executor(),
3067 None,
3068 &SubAgentConfig::default(),
3069 SpawnContext::default(),
3070 )
3071 .unwrap();
3072 assert!(!task_id.is_empty());
3073 mgr.cancel(&task_id).unwrap();
3074
3075 let mem_dir = tmp
3077 .path()
3078 .join(".zeph")
3079 .join("agent-memory")
3080 .join("deny-list-mem");
3081 assert!(
3082 !mem_dir.exists(),
3083 "memory dir must not be created when DenyList blocks all file tools"
3084 );
3085
3086 std::env::set_current_dir(orig_dir).unwrap();
3087 }
3088
3089 fn make_agent_loop_args(
3092 provider: AnyProvider,
3093 executor: FilteredToolExecutor,
3094 max_turns: u32,
3095 ) -> AgentLoopArgs {
3096 let (status_tx, _status_rx) = tokio::sync::watch::channel(SubAgentStatus {
3097 state: SubAgentState::Working,
3098 last_message: None,
3099 turns_used: 0,
3100 started_at: std::time::Instant::now(),
3101 });
3102 let (secret_request_tx, _secret_request_rx) = tokio::sync::mpsc::channel(1);
3103 let (_secret_approved_tx, secret_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
3104 AgentLoopArgs {
3105 provider,
3106 executor,
3107 system_prompt: "You are a bot".into(),
3108 task_prompt: "Do something".into(),
3109 skills: None,
3110 max_turns,
3111 cancel: tokio_util::sync::CancellationToken::new(),
3112 status_tx,
3113 started_at: std::time::Instant::now(),
3114 secret_request_tx,
3115 secret_rx,
3116 background: false,
3117 hooks: super::super::hooks::SubagentHooks::default(),
3118 task_id: "test-task".into(),
3119 agent_name: "test-bot".into(),
3120 initial_messages: vec![],
3121 transcript_writer: None,
3122 spawn_depth: 0,
3123 mcp_tool_names: Vec::new(),
3124 }
3125 }
3126
3127 #[tokio::test]
3128 async fn run_agent_loop_passes_tools_to_provider() {
3129 use std::sync::Arc;
3130 use zeph_llm::provider::ChatResponse;
3131 use zeph_tools::registry::{InvocationHint, ToolDef};
3132
3133 struct SingleToolExecutor;
3135
3136 impl ErasedToolExecutor for SingleToolExecutor {
3137 fn execute_erased<'a>(
3138 &'a self,
3139 _response: &'a str,
3140 ) -> Pin<
3141 Box<
3142 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3143 + Send
3144 + 'a,
3145 >,
3146 > {
3147 Box::pin(std::future::ready(Ok(None)))
3148 }
3149
3150 fn execute_confirmed_erased<'a>(
3151 &'a self,
3152 _response: &'a str,
3153 ) -> Pin<
3154 Box<
3155 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3156 + Send
3157 + 'a,
3158 >,
3159 > {
3160 Box::pin(std::future::ready(Ok(None)))
3161 }
3162
3163 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3164 vec![ToolDef {
3165 id: std::borrow::Cow::Borrowed("shell"),
3166 description: std::borrow::Cow::Borrowed("Run a shell command"),
3167 schema: schemars::Schema::default(),
3168 invocation: InvocationHint::ToolCall,
3169 }]
3170 }
3171
3172 fn execute_tool_call_erased<'a>(
3173 &'a self,
3174 _call: &'a ToolCall,
3175 ) -> Pin<
3176 Box<
3177 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3178 + Send
3179 + 'a,
3180 >,
3181 > {
3182 Box::pin(std::future::ready(Ok(None)))
3183 }
3184
3185 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3186 false
3187 }
3188 }
3189
3190 let (mock, tool_call_count) =
3192 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3193 let provider = AnyProvider::Mock(mock);
3194 let executor =
3195 FilteredToolExecutor::new(Arc::new(SingleToolExecutor), ToolPolicy::InheritAll);
3196
3197 let args = make_agent_loop_args(provider, executor, 1);
3198 let result = run_agent_loop(args).await;
3199 assert!(result.is_ok(), "loop failed: {result:?}");
3200 assert_eq!(
3201 *tool_call_count.lock().unwrap(),
3202 1,
3203 "chat_with_tools must have been called exactly once"
3204 );
3205 }
3206
3207 #[tokio::test]
3208 async fn run_agent_loop_executes_native_tool_call() {
3209 use std::sync::{Arc, Mutex};
3210 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
3211 use zeph_tools::registry::ToolDef;
3212
3213 struct TrackingExecutor {
3214 calls: Mutex<Vec<String>>,
3215 }
3216
3217 impl ErasedToolExecutor for TrackingExecutor {
3218 fn execute_erased<'a>(
3219 &'a self,
3220 _response: &'a str,
3221 ) -> Pin<
3222 Box<
3223 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3224 + Send
3225 + 'a,
3226 >,
3227 > {
3228 Box::pin(std::future::ready(Ok(None)))
3229 }
3230
3231 fn execute_confirmed_erased<'a>(
3232 &'a self,
3233 _response: &'a str,
3234 ) -> Pin<
3235 Box<
3236 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3237 + Send
3238 + 'a,
3239 >,
3240 > {
3241 Box::pin(std::future::ready(Ok(None)))
3242 }
3243
3244 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3245 vec![]
3246 }
3247
3248 fn execute_tool_call_erased<'a>(
3249 &'a self,
3250 call: &'a ToolCall,
3251 ) -> Pin<
3252 Box<
3253 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3254 + Send
3255 + 'a,
3256 >,
3257 > {
3258 self.calls.lock().unwrap().push(call.tool_id.to_string());
3259 let output = ToolOutput {
3260 tool_name: call.tool_id.clone(),
3261 summary: "executed".into(),
3262 blocks_executed: 1,
3263 filter_stats: None,
3264 diff: None,
3265 streamed: false,
3266 terminal_id: None,
3267 locations: None,
3268 raw_response: None,
3269 claim_source: None,
3270 };
3271 Box::pin(std::future::ready(Ok(Some(output))))
3272 }
3273
3274 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3275 false
3276 }
3277 }
3278
3279 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
3281 ChatResponse::ToolUse {
3282 text: None,
3283 tool_calls: vec![ToolUseRequest {
3284 id: "call-1".into(),
3285 name: "shell".into(),
3286 input: serde_json::json!({"command": "echo hi"}),
3287 }],
3288 thinking_blocks: vec![],
3289 },
3290 ChatResponse::Text("all done".into()),
3291 ]);
3292
3293 let tracker = Arc::new(TrackingExecutor {
3294 calls: Mutex::new(vec![]),
3295 });
3296 let tracker_clone = Arc::clone(&tracker);
3297 let executor = FilteredToolExecutor::new(tracker_clone, ToolPolicy::InheritAll);
3298
3299 let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3300 let result = run_agent_loop(args).await;
3301 assert!(result.is_ok(), "loop failed: {result:?}");
3302 assert_eq!(result.unwrap(), "all done");
3303
3304 let recorded = tracker.calls.lock().unwrap();
3305 assert_eq!(
3306 recorded.len(),
3307 1,
3308 "execute_tool_call_erased must be called once"
3309 );
3310 assert_eq!(recorded[0], "shell");
3311 }
3312
3313 #[test]
3316 fn build_system_prompt_injects_working_directory() {
3317 use tempfile::TempDir;
3318
3319 let tmp = TempDir::new().unwrap();
3320 let orig = std::env::current_dir().unwrap();
3321 std::env::set_current_dir(tmp.path()).unwrap();
3322
3323 let mut def = SubAgentDef::parse(indoc! {"
3324 ---
3325 name: cwd-agent
3326 description: test
3327 ---
3328 Base prompt.
3329 "})
3330 .unwrap();
3331
3332 let prompt = build_system_prompt_with_memory(&mut def, None);
3333 std::env::set_current_dir(orig).unwrap();
3334
3335 assert!(
3336 prompt.contains("Working directory:"),
3337 "system prompt must contain 'Working directory:', got: {prompt}"
3338 );
3339 assert!(
3340 prompt.contains(tmp.path().to_str().unwrap()),
3341 "system prompt must contain the actual cwd path, got: {prompt}"
3342 );
3343 }
3344
3345 #[tokio::test]
3346 async fn text_only_first_turn_sends_nudge_and_retries() {
3347 use zeph_llm::mock::MockProvider;
3348
3349 let (mock, call_count) = MockProvider::default().with_tool_use(vec![
3351 ChatResponse::Text("I will now do the task...".into()),
3352 ChatResponse::Text("Done.".into()),
3353 ]);
3354
3355 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3356 let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 10);
3357 let result = run_agent_loop(args).await;
3358 assert!(result.is_ok(), "loop should succeed: {result:?}");
3359 assert_eq!(result.unwrap(), "Done.");
3360
3361 let count = *call_count.lock().unwrap();
3363 assert_eq!(
3364 count, 2,
3365 "provider must be called exactly twice (initial + nudge retry), got {count}"
3366 );
3367 }
3368
3369 #[test]
3372 fn model_spec_deserialize_inherit() {
3373 let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
3374 assert_eq!(spec, ModelSpec::Inherit);
3375 }
3376
3377 #[test]
3378 fn model_spec_deserialize_named() {
3379 let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
3380 assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
3381 }
3382
3383 #[test]
3384 fn model_spec_serialize_roundtrip() {
3385 assert_eq!(
3386 serde_json::to_string(&ModelSpec::Inherit).unwrap(),
3387 "\"inherit\""
3388 );
3389 assert_eq!(
3390 serde_json::to_string(&ModelSpec::Named("my-provider".to_owned())).unwrap(),
3391 "\"my-provider\""
3392 );
3393 }
3394
3395 #[test]
3396 fn spawn_context_default_is_empty() {
3397 let ctx = SpawnContext::default();
3398 assert!(ctx.parent_messages.is_empty());
3399 assert!(ctx.parent_cancel.is_none());
3400 assert!(ctx.parent_provider_name.is_none());
3401 assert_eq!(ctx.spawn_depth, 0);
3402 assert!(ctx.mcp_tool_names.is_empty());
3403 }
3404
3405 #[test]
3406 fn context_injection_none_passes_raw_prompt() {
3407 use zeph_config::ContextInjectionMode;
3408 let result = apply_context_injection("do work", &[], ContextInjectionMode::None);
3409 assert_eq!(result, "do work");
3410 }
3411
3412 #[test]
3413 fn context_injection_last_assistant_prepends_when_present() {
3414 use zeph_config::ContextInjectionMode;
3415 let msgs = vec![
3416 make_message(Role::User, "hello".into()),
3417 make_message(Role::Assistant, "I found X".into()),
3418 ];
3419 let result =
3420 apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3421 assert!(
3422 result.contains("I found X"),
3423 "should contain last assistant content"
3424 );
3425 assert!(result.contains("do work"), "should contain original task");
3426 }
3427
3428 #[test]
3429 fn context_injection_last_assistant_fallback_when_no_assistant() {
3430 use zeph_config::ContextInjectionMode;
3431 let msgs = vec![make_message(Role::User, "hello".into())];
3432 let result =
3433 apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3434 assert_eq!(result, "do work");
3435 }
3436
3437 #[tokio::test]
3438 async fn spawn_model_inherit_resolves_to_parent_provider() {
3439 let rt = tokio::runtime::Handle::current();
3440 let _guard = rt.enter();
3441 let mut mgr = make_manager();
3442 let mut def = sample_def();
3443 def.model = Some(ModelSpec::Inherit);
3444 mgr.definitions.push(def);
3445
3446 let ctx = SpawnContext {
3447 parent_provider_name: Some("my-parent-provider".to_owned()),
3448 ..SpawnContext::default()
3449 };
3450 let result = mgr.spawn(
3452 "bot",
3453 "task",
3454 mock_provider(vec!["done"]),
3455 noop_executor(),
3456 None,
3457 &SubAgentConfig::default(),
3458 ctx,
3459 );
3460 assert!(
3461 result.is_ok(),
3462 "spawn with Inherit model should succeed: {result:?}"
3463 );
3464 }
3465
3466 #[tokio::test]
3467 async fn spawn_model_named_uses_value() {
3468 let rt = tokio::runtime::Handle::current();
3469 let _guard = rt.enter();
3470 let mut mgr = make_manager();
3471 let mut def = sample_def();
3472 def.model = Some(ModelSpec::Named("fast".to_owned()));
3473 mgr.definitions.push(def);
3474
3475 let result = mgr.spawn(
3476 "bot",
3477 "task",
3478 mock_provider(vec!["done"]),
3479 noop_executor(),
3480 None,
3481 &SubAgentConfig::default(),
3482 SpawnContext::default(),
3483 );
3484 assert!(result.is_ok());
3485 }
3486
3487 #[test]
3488 fn spawn_exceeds_max_depth_returns_error() {
3489 let rt = tokio::runtime::Runtime::new().unwrap();
3490 let _guard = rt.enter();
3491 let mut mgr = make_manager();
3492 mgr.definitions.push(sample_def());
3493
3494 let cfg = SubAgentConfig {
3495 max_spawn_depth: 2,
3496 ..SubAgentConfig::default()
3497 };
3498 let ctx = SpawnContext {
3499 spawn_depth: 2, ..SpawnContext::default()
3501 };
3502 let err = mgr
3503 .spawn(
3504 "bot",
3505 "task",
3506 mock_provider(vec!["done"]),
3507 noop_executor(),
3508 None,
3509 &cfg,
3510 ctx,
3511 )
3512 .unwrap_err();
3513 assert!(
3514 matches!(err, SubAgentError::MaxDepthExceeded { depth: 2, max: 2 }),
3515 "expected MaxDepthExceeded, got {err:?}"
3516 );
3517 }
3518
3519 #[test]
3520 fn spawn_at_max_depth_minus_one_succeeds() {
3521 let rt = tokio::runtime::Runtime::new().unwrap();
3522 let _guard = rt.enter();
3523 let mut mgr = make_manager();
3524 mgr.definitions.push(sample_def());
3525
3526 let cfg = SubAgentConfig {
3527 max_spawn_depth: 3,
3528 ..SubAgentConfig::default()
3529 };
3530 let ctx = SpawnContext {
3531 spawn_depth: 2, ..SpawnContext::default()
3533 };
3534 let result = mgr.spawn(
3535 "bot",
3536 "task",
3537 mock_provider(vec!["done"]),
3538 noop_executor(),
3539 None,
3540 &cfg,
3541 ctx,
3542 );
3543 assert!(
3544 result.is_ok(),
3545 "spawn at depth 2 with max 3 should succeed: {result:?}"
3546 );
3547 }
3548
3549 #[test]
3550 fn spawn_foreground_uses_child_token() {
3551 let rt = tokio::runtime::Runtime::new().unwrap();
3552 let _guard = rt.enter();
3553 let mut mgr = make_manager();
3554 mgr.definitions.push(sample_def());
3555
3556 let parent_cancel = CancellationToken::new();
3557 let ctx = SpawnContext {
3558 parent_cancel: Some(parent_cancel.clone()),
3559 ..SpawnContext::default()
3560 };
3561 let task_id = mgr
3563 .spawn(
3564 "bot",
3565 "task",
3566 mock_provider(vec!["done"]),
3567 noop_executor(),
3568 None,
3569 &SubAgentConfig::default(),
3570 ctx,
3571 )
3572 .unwrap();
3573
3574 parent_cancel.cancel();
3576 let handle = mgr.agents.get(&task_id).unwrap();
3577 assert!(
3578 handle.cancel.is_cancelled(),
3579 "child token should be cancelled when parent cancels"
3580 );
3581 }
3582
3583 #[test]
3584 fn parent_history_zero_turns_returns_empty() {
3585 use zeph_config::ContextInjectionMode;
3586 let msgs = vec![make_message(Role::User, "hi".into())];
3587 let result = apply_context_injection("task", &[], ContextInjectionMode::LastAssistantTurn);
3590 assert_eq!(result, "task", "no history should pass prompt unchanged");
3591 let _ = msgs; }
3593
3594 #[tokio::test]
3597 async fn mcp_tool_names_appended_to_system_prompt() {
3598 use zeph_llm::mock::MockProvider;
3599
3600 let (mock, _) =
3601 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3602
3603 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3604 let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3605 args.mcp_tool_names = vec!["search".into(), "write_file".into()];
3606 let result = run_agent_loop(args).await;
3608 assert!(result.is_ok(), "loop should succeed: {result:?}");
3609 }
3610
3611 #[tokio::test]
3612 async fn empty_mcp_tool_names_no_annotation() {
3613 use zeph_llm::mock::MockProvider;
3614
3615 let (mock, _) =
3616 MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3617
3618 let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3619 let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3620 args.mcp_tool_names = vec![];
3621 let result = run_agent_loop(args).await;
3622 assert!(
3623 result.is_ok(),
3624 "loop should succeed with no MCP tools: {result:?}"
3625 );
3626 }
3627}