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