1use std::sync::Arc;
12
13use zeph_sanitizer::secret_shape::scrub_secret_shapes;
14use zeph_tools::registry::ToolDef;
15
16use super::{Agent, error};
17use crate::channel::Channel;
18
19const LIVE_TRANSCRIPT_TAIL_LINES: usize = 20;
22
23impl<C: Channel> Agent<C> {
24 pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
32 let normalized = key.to_lowercase().replace('-', "_");
33 self.services
34 .skill
35 .available_custom_secrets
36 .get(&normalized)
37 .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
38 }
39
40 #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
49 pub async fn poll_subagents(&mut self) -> Vec<(String, String, String, bool)> {
50 let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
51 return vec![];
52 };
53
54 let finished: Vec<(String, bool)> =
55 mgr.statuses()
56 .into_iter()
57 .filter_map(|(id, status)| match status.state {
58 zeph_subagent::SubAgentState::Completed => Some((id, true)),
59 zeph_subagent::SubAgentState::Failed
60 | zeph_subagent::SubAgentState::Canceled => Some((id, false)),
61 _ => None,
62 })
63 .collect();
64
65 let mut results = vec![];
66 for (task_id, success) in finished {
67 let name = mgr.agents_def(&task_id).map_or_else(
68 || task_id[..8.min(task_id.len())].to_owned(),
69 |d| d.name.clone(),
70 );
71 match mgr.collect(&task_id).await {
72 Ok(result) => results.push((task_id, name, result, success)),
73 Err(e) => {
74 tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
75 }
76 }
77 }
78 results
79 }
80 pub(super) fn refresh_subagent_metrics(&mut self) {
87 let Some(ref mgr) = self.services.orchestration.subagent_manager else {
88 return;
89 };
90 let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
91 .statuses()
92 .into_iter()
93 .map(|(id, s)| {
94 let def = mgr.agents_def(&id);
95 crate::metrics::SubAgentMetrics {
96 name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
97 id: id.clone(),
98 state: format!("{:?}", s.state).to_lowercase(),
99 turns_used: s.turns_used,
100 max_turns: def.map_or(20, |d| d.permissions.max_turns),
101 background: def.is_some_and(|d| d.permissions.background),
102 elapsed_secs: s.started_at.elapsed().as_secs(),
103 permission_mode: def.map_or_else(String::new, |d| {
104 use zeph_subagent::def::PermissionMode;
105 match d.permissions.permission_mode {
106 PermissionMode::AcceptEdits => "accept_edits".into(),
107 PermissionMode::DontAsk => "dont_ask".into(),
108 PermissionMode::BypassPermissions => "bypass_permissions".into(),
109 PermissionMode::Plan => "plan".into(),
110 _ => String::new(),
111 }
112 }),
113 transcript_dir: mgr
114 .agent_transcript_dir(&id)
115 .map(|p| p.to_string_lossy().into_owned()),
116 live_transcript: mgr.forwarded_tail(&id, LIVE_TRANSCRIPT_TAIL_LINES),
117 }
118 })
119 .collect();
120 self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
121 }
122 pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
124 let completed = self.poll_subagents().await;
125 for (task_id, name, result, success) in completed {
126 let result = scrub_secret_shapes(&result).into_owned();
132 let notice = if result.is_empty() {
133 format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
134 } else {
135 format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
136 };
137 if let Err(e) = self.channel.send(¬ice).await {
138 tracing::warn!(error = %e, "failed to send sub-agent completion notice");
139 }
140 if let Err(e) = self
144 .channel
145 .notify_background_subagent_completed(&task_id, &name, success)
146 .await
147 {
148 tracing::warn!(error = %e, "failed to notify background sub-agent completion");
149 }
150 }
151 Ok(())
152 }
153 async fn poll_subagent_until_done(
157 &mut self,
158 task_id: &str,
159 label: &str,
160 ) -> Option<(String, bool)> {
161 use zeph_subagent::SubAgentState;
162 let result = loop {
163 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
164
165 #[allow(clippy::redundant_closure_for_method_calls)]
169 let pending = self
170 .services
171 .orchestration
172 .subagent_manager
173 .as_mut()
174 .and_then(|m| m.try_recv_secret_request());
175 if let Some((req_task_id, req)) = pending {
176 let confirm_prompt = format!(
179 "Sub-agent requests secret '{}'. Allow?",
180 crate::text::truncate_to_chars(&req.secret_key, 100)
181 );
182 let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
183 if approved {
184 let ttl = std::time::Duration::from_mins(5);
185 let key = req.secret_key.clone();
186 let resolved = self.resolve_subagent_secret(&key);
187 if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
188 if let Some(secret) = resolved {
189 if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
190 && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
191 {
192 tracing::warn!(error = %e, "sub-agent secret delivery failed");
193 let _ = mgr.deny_secret(&req_task_id);
194 }
195 } else {
196 tracing::warn!(
197 "sub-agent requested secret not resolvable from vault; denying"
198 );
199 let _ = mgr.deny_secret(&req_task_id);
200 }
201 }
202 } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
203 let _ = mgr.deny_secret(&req_task_id);
204 }
205 }
206
207 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
208 let statuses = mgr.statuses();
209 let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
210 break (format!("{label} completed (no status available)."), true);
211 };
212 match status.state {
213 SubAgentState::Completed => {
214 let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
215 break (format!("{label} completed: {msg}"), true);
216 }
217 SubAgentState::Failed => {
218 let msg = status
219 .last_message
220 .clone()
221 .unwrap_or_else(|| "unknown error".into());
222 break (format!("{label} failed: {msg}"), false);
223 }
224 SubAgentState::Canceled => {
225 break (format!("{label} was cancelled."), false);
226 }
227 _ => {
228 self.channel
229 .send_status_best_effort(&format!(
230 "{label}: turn {}/{}",
231 status.turns_used,
232 self.services
233 .orchestration
234 .subagent_manager
235 .as_ref()
236 .and_then(|m| m.agents_def(task_id))
237 .map_or(20, |d| d.permissions.max_turns)
238 ))
239 .await;
240 }
241 }
242 };
243 Some(result)
244 }
245 fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
248 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
249 let full_ids: Vec<String> = mgr
250 .statuses()
251 .into_iter()
252 .map(|(tid, _)| tid)
253 .filter(|tid| tid.starts_with(prefix))
254 .collect();
255 Some(match full_ids.as_slice() {
256 [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
257 [fid] => Ok(fid.clone()),
258 _ => Err(format!(
259 "Ambiguous id prefix '{prefix}': matches {} agents",
260 full_ids.len()
261 )),
262 })
263 }
264 fn handle_agent_list(&self) -> Option<String> {
265 use std::fmt::Write as _;
266 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
267 let spawns_line = self.format_session_spawns_line();
268 let mode_label = match mgr.delegation_mode() {
269 zeph_config::DelegationMode::Disabled => "disabled",
270 zeph_config::DelegationMode::ExplicitRequestOnly => "explicit_request_only",
271 zeph_config::DelegationMode::Proactive => "proactive",
272 _ => "unknown",
273 };
274 let defs = mgr.definitions();
275 if defs.is_empty() {
276 return Some(format!(
277 "{spawns_line}\nDelegation mode: {mode_label}\nNo sub-agent definitions found."
278 ));
279 }
280 let mut out =
281 format!("{spawns_line}\nDelegation mode: {mode_label}\nAvailable sub-agents:\n");
282 for d in defs {
283 let memory_label = match d.memory {
284 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
285 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
286 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
287 Some(_) => " [memory:unknown]",
288 None => "",
289 };
290 if let Some(ref src) = d.source {
291 let _ = writeln!(
292 out,
293 " {}{} — {} ({})",
294 d.name, memory_label, d.description, src
295 );
296 } else {
297 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
298 }
299 }
300 Some(out)
301 }
302 fn handle_agent_status(&self) -> Option<String> {
303 use std::fmt::Write as _;
304 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
305 let spawns_line = self.format_session_spawns_line();
306 let statuses = mgr.statuses();
307 if statuses.is_empty() {
308 return Some(format!("{spawns_line}\nNo active sub-agents."));
309 }
310 let mut out = format!("{spawns_line}\nActive sub-agents:\n");
311 for (id, s) in &statuses {
312 let state = format!("{:?}", s.state).to_lowercase();
313 let elapsed = s.started_at.elapsed().as_secs();
314 let _ = writeln!(
315 out,
316 " [{short}] {state} turns={t} elapsed={elapsed}s {msg}",
317 short = &id[..8.min(id.len())],
318 t = s.turns_used,
319 msg = s.last_message.as_deref().unwrap_or(""),
320 );
321 if let Some(def) = mgr.agents_def(id)
323 && let Some(scope) = def.memory
324 && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
325 {
326 let _ = writeln!(out, " memory: {}", dir.display());
327 }
328 }
329 Some(out)
330 }
331 fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
332 let full_id = match self.resolve_agent_id_prefix(id)? {
333 Ok(fid) => fid,
334 Err(msg) => return Some(msg),
335 };
336 let req = {
337 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
338 mgr.try_recv_secret_request_for(&full_id)
339 };
340 let Some(req) = req else {
341 return Some(format!(
342 "No pending secret request for sub-agent '{full_id}'."
343 ));
344 };
345 let key = req.secret_key.clone();
346 let ttl = std::time::Duration::from_mins(5);
347 let Some(secret) = self.resolve_subagent_secret(&key) else {
348 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
349 let _ = mgr.deny_secret(&full_id);
350 return Some(format!(
351 "Secret '{key}' could not be resolved from the vault; request denied."
352 ));
353 };
354 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
355 if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
356 return Some(format!("Approve failed: {e}"));
357 }
358 if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
359 let _ = mgr.deny_secret(&full_id);
360 return Some(format!("Secret delivery failed: {e}"));
361 }
362 Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
363 }
364 fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
365 let full_id = match self.resolve_agent_id_prefix(id)? {
366 Ok(fid) => fid,
367 Err(msg) => return Some(msg),
368 };
369 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
370 match mgr.deny_secret(&full_id) {
371 Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
372 Err(e) => Some(format!("Deny failed: {e}")),
373 }
374 }
375 pub(super) async fn handle_agent_command(
376 &mut self,
377 cmd: zeph_subagent::AgentCommand,
378 ) -> Option<String> {
379 use zeph_subagent::AgentCommand;
380
381 match cmd {
382 AgentCommand::List => self.handle_agent_list(),
383 AgentCommand::Background { name, prompt } => {
384 self.handle_agent_background(&name, &prompt).await
385 }
386 AgentCommand::Spawn { name, prompt }
387 | AgentCommand::Mention {
388 agent: name,
389 prompt,
390 } => self.handle_agent_spawn_foreground(&name, &prompt).await,
391 AgentCommand::Status => self.handle_agent_status(),
392 AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
393 AgentCommand::Approve { id } => self.handle_agent_approve(&id),
394 AgentCommand::Deny { id } => self.handle_agent_deny(&id),
395 AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
396 _ => None,
397 }
398 }
399 pub(crate) fn handle_agents_definitions_list(&self) -> String {
404 use std::fmt::Write as _;
405
406 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
407 return String::new();
408 };
409 let defs = mgr.definitions();
410 if defs.is_empty() {
411 return String::new();
412 }
413 let mut out = String::from("Sub-agents:\n");
414 for d in defs {
415 let memory_label = match d.memory {
416 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
417 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
418 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
419 Some(_) => " [memory:unknown]",
420 None => "",
421 };
422 if let Some(ref src) = d.source {
423 let _ = writeln!(
424 out,
425 " {}{} — {} ({})",
426 d.name, memory_label, d.description, src
427 );
428 } else {
429 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
430 }
431 }
432 out
433 }
434 pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
439 use zeph_subagent::AgentsCommand;
440
441 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
442 return "Sub-agent manager is not available.".to_owned();
443 };
444
445 match cmd {
446 AgentsCommand::List => self.handle_agents_definitions_list(),
447 AgentsCommand::Show { name } => {
448 match mgr.definitions().iter().find(|d| d.name == name) {
449 Some(d) => format!(
450 "Agent: {}\nDescription: {}\nSource: {}\n",
451 d.name,
452 d.description,
453 d.source.as_deref().unwrap_or("unknown"),
454 ),
455 None => format!("No sub-agent definition named '{name}'."),
456 }
457 }
458 AgentsCommand::Create { name } => {
459 format!(
460 "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
461 See the sub-agent documentation for the required frontmatter."
462 )
463 }
464 AgentsCommand::Edit { name } => {
465 format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
466 }
467 AgentsCommand::Delete { name } => {
468 format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
469 }
470 _ => "Unknown agents command.".to_owned(),
471 }
472 }
473 async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
474 let provider = self.provider.clone();
475 let tool_executor = Arc::clone(&self.tool_executor);
476 let skills = self.filtered_skills_for(name).await;
477 let cfg = self.services.orchestration.subagent_config.clone();
478 let mut spawn_ctx = self.build_spawn_context(&cfg);
479 self.ensure_session_durable_ctx().await;
483 match resolve_durable_spawn_gate(
484 self.services.session.durable_subagent,
485 self.services.session.durable_ctx.as_deref(),
486 )
487 .await
488 {
489 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
490 DurableSpawnGate::Replayed { result, .. } => {
491 let short = &result.task_id[..8.min(result.task_id.len())];
492 return Some(if result.output.is_empty() {
493 format!(
494 "[sub-agent {short}] completed (no output, replayed from durable journal)"
495 )
496 } else {
497 format!(
498 "[sub-agent {short}] completed (replayed from durable journal):\n{}",
499 result.output
500 )
501 });
502 }
503 DurableSpawnGate::None => {}
504 }
505 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
506 match mgr
507 .spawn(
508 name,
509 prompt,
510 provider,
511 tool_executor,
512 skills,
513 &cfg,
514 spawn_ctx,
515 )
516 .await
517 {
518 Ok(id) => Some(format!(
519 "Sub-agent '{name}' started in background (id: {short})",
520 short = &id[..8.min(id.len())]
521 )),
522 Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
523 }
524 }
525 async fn notify_replayed_foreground_subagent(
534 &mut self,
535 name: &str,
536 result: zeph_subagent::SubagentResult,
537 promise_id: zeph_durable::PromiseId,
538 ) -> String {
539 let success = result.state == zeph_subagent::SubAgentState::Completed;
540 let task_id = result.task_id.clone();
541
542 let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
547 match ctx.claim_promise_notification(promise_id).await {
548 Ok(claimed) => claimed,
549 Err(e) => {
550 tracing::warn!(
551 error = %e,
552 "durable: promise-notification claim failed; \
553 firing the replayed sub-agent notice directly"
554 );
555 true
556 }
557 }
558 } else {
559 true
560 };
561
562 let text = if success {
563 result.output
564 } else {
565 result.error.unwrap_or_else(|| "unknown error".to_owned())
566 };
567
568 if should_notify {
569 let _ = self
570 .channel
571 .send(&format!(
572 "Sub-agent '{name}' replayed from durable journal (already finished \
573 before the parent restarted)."
574 ))
575 .await;
576 let _ = self
577 .channel
578 .notify_foreground_subagent_completed(&task_id, name, success)
579 .await;
580 }
581 text
582 }
583
584 async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
585 let provider = self.provider.clone();
586 let tool_executor = Arc::clone(&self.tool_executor);
587 let skills = self.filtered_skills_for(name).await;
588 let cfg = self.services.orchestration.subagent_config.clone();
589 let mut spawn_ctx = self.build_spawn_context(&cfg);
590 self.ensure_session_durable_ctx().await;
595 match resolve_durable_spawn_gate(
596 self.services.session.durable_subagent,
597 self.services.session.durable_ctx.as_deref(),
598 )
599 .await
600 {
601 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
602 DurableSpawnGate::Replayed { result, promise_id } => {
603 return Some(
604 self.notify_replayed_foreground_subagent(name, result, promise_id)
605 .await,
606 );
607 }
608 DurableSpawnGate::None => {}
609 }
610 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
611 let task_id = match mgr
612 .spawn(
613 name,
614 prompt,
615 provider,
616 tool_executor,
617 skills,
618 &cfg,
619 spawn_ctx,
620 )
621 .await
622 {
623 Ok(id) => id,
624 Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
625 };
626 let short = task_id[..8.min(task_id.len())].to_owned();
627 let _ = self
628 .channel
629 .send(&format!("Sub-agent '{name}' running... (id: {short})"))
630 .await;
631 let _ = self
632 .channel
633 .notify_foreground_subagent_started(&task_id, name)
634 .await;
635 let label = format!("Sub-agent '{name}'");
636 let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
637 let _ = self
639 .channel
640 .notify_foreground_subagent_completed(&task_id, name, false)
641 .await;
642 return None;
643 };
644 let _ = self
645 .channel
646 .notify_foreground_subagent_completed(&task_id, name, success)
647 .await;
648 Some(result)
649 }
650 fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
651 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
652 let ids: Vec<String> = mgr
654 .statuses()
655 .into_iter()
656 .map(|(task_id, _)| task_id)
657 .filter(|task_id| task_id.starts_with(id))
658 .collect();
659 match ids.as_slice() {
660 [] => Some(format!("No sub-agent with id prefix '{id}'")),
661 [full_id] => {
662 let full_id = full_id.clone();
663 match mgr.cancel(&full_id) {
664 Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
665 Err(e) => Some(format!("Cancel failed: {e}")),
666 }
667 }
668 _ => Some(format!(
669 "Ambiguous id prefix '{id}': matches {} agents",
670 ids.len()
671 )),
672 }
673 }
674 async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
675 let cfg = self.services.orchestration.subagent_config.clone();
676 let def_name = {
679 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
680 match mgr.def_name_for_resume(id, &cfg).await {
681 Ok(name) => name,
682 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
683 }
684 };
685 let skills = self.filtered_skills_for(&def_name).await;
686 let provider = self.provider.clone();
687 let tool_executor = Arc::clone(&self.tool_executor);
688 let spawn_ctx = self.build_spawn_context(&cfg);
697 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
698 let (task_id, _) = match mgr
699 .resume(
700 id,
701 prompt,
702 provider,
703 tool_executor,
704 skills,
705 &cfg,
706 Some(&spawn_ctx),
707 )
708 .await
709 {
710 Ok(pair) => pair,
711 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
712 };
713 let short = task_id[..8.min(task_id.len())].to_owned();
714 let _ = self
715 .channel
716 .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
717 .await;
718 let _ = self
719 .channel
720 .notify_foreground_subagent_started(&task_id, &def_name)
721 .await;
722 let Some((result, success)) = self
723 .poll_subagent_until_done(&task_id, "Resumed sub-agent")
724 .await
725 else {
726 let _ = self
728 .channel
729 .notify_foreground_subagent_completed(&task_id, &def_name, false)
730 .await;
731 return None;
732 };
733 let _ = self
734 .channel
735 .notify_foreground_subagent_completed(&task_id, &def_name, success)
736 .await;
737 Some(result)
738 }
739 pub(super) async fn filtered_skills_for(&mut self, agent_name: &str) -> Option<Vec<String>> {
765 let def_skills = {
766 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
767 mgr.definitions()
768 .iter()
769 .find(|d| d.name == agent_name)?
770 .skills
771 .clone()
772 };
773
774 let trust_map = match self.build_skill_trust_map().await {
781 crate::agent::trust_commands::SkillTrustMapLoad::Fresh(map) => {
782 self.services.skill.trust_snapshot.write().clone_from(&map);
783 map
784 }
785 crate::agent::trust_commands::SkillTrustMapLoad::LoadFailed => {
786 tracing::warn!(
787 "filtered_skills_for: trust snapshot load failed, reusing previous \
788 snapshot for sub-agent skill filtering"
789 );
790 self.services.skill.trust_snapshot.read().clone()
791 }
792 };
793 let trust_levels = crate::skill_invoker::snapshot_map_to_trust_levels(&trust_map);
794
795 let reg = self.services.skill.registry.read();
796 let skills = match zeph_subagent::filter_skills(®, &def_skills, &trust_levels) {
797 Ok(skills) => skills,
798 Err(e) => {
799 tracing::warn!(error = %e, "skill filtering failed for sub-agent");
800 return None;
801 }
802 };
803 if skills.is_empty() {
804 return None;
805 }
806
807 if !def_skills.include.is_empty() {
810 return Some(skills.into_iter().map(|s| s.body).collect());
811 }
812
813 let total = skills.len();
814 let budget = self.services.skill.subagent_skill_token_budget;
815 let counter = &self.runtime.metrics.token_counter;
816
817 let mut bodies: Vec<String> = Vec::with_capacity(total);
818 let mut running_tokens = 0usize;
819 let mut omitted_names: Vec<&str> = Vec::new();
820
821 for skill in &skills {
822 let skill_tokens = counter.count_tokens(&skill.body);
823 if !bodies.is_empty() && running_tokens + skill_tokens > budget {
824 omitted_names.push(skill.meta.name.as_str());
825 continue;
826 }
827 running_tokens += skill_tokens;
828 bodies.push(skill.body.clone());
829 }
830
831 if !omitted_names.is_empty() {
832 let included = bodies.len();
833 tracing::warn!(
834 agent_name,
835 included,
836 total,
837 budget_tokens = budget,
838 "sub-agent skill body budget exceeded; truncated skill set"
839 );
840 bodies.push(format!(
841 "[skill budget: {included}/{total} skills included, budget={budget} tokens — omitted: {}]",
842 omitted_names.join(", ")
843 ));
844 }
845
846 Some(bodies)
847 }
848 pub(super) fn effective_delegation_mode(&self) -> zeph_config::DelegationMode {
857 self.services
858 .orchestration
859 .subagent_config
860 .effective_delegation_mode()
861 }
862
863 pub(super) fn session_budget(&self) -> &zeph_subagent::SessionSpawnBudget {
876 self.services
877 .orchestration
878 .subagent_manager
879 .as_ref()
880 .map_or(
881 &self.services.orchestration.session_spawn_budget,
882 zeph_subagent::SubAgentManager::session_budget,
883 )
884 }
885
886 fn format_session_spawns_line(&self) -> String {
898 let max = self
899 .services
900 .orchestration
901 .subagent_config
902 .max_spawns_per_session;
903 let spawned = self.session_budget().spawned();
904 if max == 0 {
905 format!("Session spawns: {spawned}/unlimited")
906 } else {
907 format!("Session spawns: {spawned}/{max}")
908 }
909 }
910
911 pub(super) fn build_spawn_context(
913 &self,
914 cfg: &zeph_config::SubAgentConfig,
915 ) -> zeph_subagent::SpawnContext {
916 zeph_subagent::SpawnContext {
917 parent_messages: self.extract_parent_messages(cfg),
918 parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
919 parent_provider_name: {
920 let name = &self.runtime.config.active_provider_name;
921 if name.is_empty() {
922 None
923 } else {
924 Some(name.clone())
925 }
926 },
927 spawn_depth: self.runtime.config.spawn_depth,
928 mcp_tool_names: self.extract_mcp_tool_names(),
929 seed_trajectory_score: {
931 let child = self.services.security.trajectory.spawn_child();
932 let score = child.score_now();
933 if score > 0.0 { Some(score) } else { None }
934 },
935 content_isolation: self.runtime.config.security.content_isolation.clone(),
936 orchestrator_name: Some("zeph".to_owned()),
937 orchestrator_role: Some("orchestrator".to_owned()),
938 session_mcp_servers: Vec::new(),
939 debug_dump_sink: self.runtime.debug.debug_dumper.clone().map(|d| {
947 Arc::new(crate::debug_dump::PiiScrubbingDumpSink::new(
948 d,
949 self.services.security.pii_filter.clone(),
950 )) as Arc<dyn zeph_llm::debug_dump::DebugDumpSink>
951 }),
952 max_trust_level: Some(self.parent_effective_trust_level()),
959 turn_trust_floor: self.services.skill.turn_trust_floor.clone(),
964 origin: zeph_subagent::SpawnOrigin::Explicit,
972 inherited_tool_allowlist: self
993 .runtime
994 .config
995 .permission_policy
996 .effective_tool_allowlist(
997 self.tool_executor
998 .tool_definitions_erased()
999 .into_iter()
1000 .map(|d| zeph_subagent::normalize_tool_id(d.id.as_ref())),
1001 ),
1002 ..Default::default()
1003 }
1004 }
1005 fn parent_effective_trust_level(&self) -> zeph_common::SkillTrustLevel {
1015 if let Some(floor) = &self.services.skill.turn_trust_floor {
1016 return floor.get();
1017 }
1018 let snapshot = self.services.skill.trust_snapshot.read();
1019 crate::agent::context::compute_effective_trust(
1020 self.services.skill.skill_fallback_mode,
1021 &self.services.skill.active_skill_names,
1022 &snapshot,
1023 )
1024 }
1025 fn extract_parent_messages(
1032 &self,
1033 config: &zeph_config::SubAgentConfig,
1034 ) -> Vec<zeph_llm::provider::Message> {
1035 use zeph_config::ParentContextPolicy;
1036 use zeph_llm::provider::Role;
1037
1038 if config.parent_context_policy == ParentContextPolicy::None
1039 || config.context_window_turns == 0
1040 {
1041 return Vec::new();
1042 }
1043
1044 let non_system: Vec<_> = self
1045 .msg
1046 .messages
1047 .iter()
1048 .filter(|m| m.role != Role::System)
1049 .cloned()
1050 .collect();
1051
1052 let take_count = config
1053 .context_window_turns
1054 .saturating_mul(2)
1055 .min(config.max_parent_messages);
1056 let start = non_system.len().saturating_sub(take_count);
1057 let mut msgs = non_system[start..].to_vec();
1058
1059 let max_chars = 128_000usize / 4;
1061 let requested = msgs.len();
1062 trim_parent_messages(&mut msgs, max_chars);
1063 if msgs.len() < requested {
1064 tracing::info!(
1065 kept = msgs.len(),
1066 requested,
1067 "[subagent] truncated parent history due to token budget or orphan pruning"
1068 );
1069 }
1070
1071 if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
1072 use zeph_sanitizer::{ContentSource, ContentSourceKind};
1073 let source =
1074 ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
1075 msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
1076 }
1077
1078 msgs
1079 }
1080 fn extract_mcp_tool_names(&self) -> Vec<String> {
1082 self.tool_executor
1083 .tool_definitions_erased()
1084 .into_iter()
1085 .filter(ToolDef::is_mcp_tool)
1086 .map(|t| t.id.to_string())
1087 .collect()
1088 }
1089 pub(super) fn classify_source_kind(
1093 skill_dir: &std::path::Path,
1094 managed_dir: Option<&std::path::PathBuf>,
1095 bundled_names: &std::collections::HashSet<String>,
1096 ) -> zeph_memory::store::SourceKind {
1097 if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
1098 let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1099 let has_marker = skill_dir.join(".bundled").exists();
1100 if has_marker && bundled_names.contains(skill_name) {
1101 zeph_memory::store::SourceKind::Bundled
1102 } else {
1103 if has_marker {
1104 tracing::warn!(
1105 skill = %skill_name,
1106 "skill has .bundled marker but is not in the bundled skill \
1107 allowlist — classifying as Hub"
1108 );
1109 }
1110 zeph_memory::store::SourceKind::Hub
1111 }
1112 } else {
1113 zeph_memory::store::SourceKind::Local
1114 }
1115 }
1116}
1117
1118enum DurableSpawnGate {
1120 Fresh(zeph_subagent::DurableResolverSeat),
1123 Replayed {
1129 result: zeph_subagent::SubagentResult,
1130 promise_id: zeph_durable::PromiseId,
1131 },
1132 None,
1142}
1143
1144async fn resolve_durable_spawn_gate(
1148 enabled: bool,
1149 ctx: Option<&zeph_durable::DurableContext>,
1150) -> DurableSpawnGate {
1151 let Some(ctx) = ctx.filter(|_| enabled) else {
1152 return DurableSpawnGate::None;
1153 };
1154 let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
1155 Ok(pair) => pair,
1156 Err(e) => {
1157 tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
1158 return DurableSpawnGate::None;
1159 }
1160 };
1161 if let Some(seat) = seat {
1162 return DurableSpawnGate::Fresh(seat);
1163 }
1164 match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
1167 Ok(Some(result)) => DurableSpawnGate::Replayed {
1168 result,
1169 promise_id: promise.id(),
1170 },
1171 Ok(None) => {
1172 tracing::warn!(
1181 "durable: resumed sub-agent promise still pending after restart — original \
1182 child did not resolve before the crash; re-spawning may duplicate side effects \
1183 (#5944 residual v1 gap)"
1184 );
1185 DurableSpawnGate::None
1186 }
1187 Err(e) => {
1188 tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
1189 DurableSpawnGate::None
1190 }
1191 }
1192}
1193
1194pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
1202 use zeph_llm::provider::MessagePart;
1203 if m.parts.is_empty() {
1204 return m.content.len();
1205 }
1206 m.parts
1207 .iter()
1208 .map(|p| match p {
1209 MessagePart::Text { text }
1210 | MessagePart::Recall { text }
1211 | MessagePart::CodeContext { text }
1212 | MessagePart::Summary { text }
1213 | MessagePart::CrossSession { text } => text.len(),
1214 MessagePart::ToolOutput { body, .. } => body.len(),
1215 MessagePart::ToolUse { id, name, input } => {
1216 50 + id.len() + name.len() + input.to_string().len()
1217 }
1218 MessagePart::ToolResult {
1219 tool_use_id,
1220 content,
1221 ..
1222 } => 50 + tool_use_id.len() + content.len(),
1223 MessagePart::Image(img) => img.data.len() * 4 / 3,
1224 MessagePart::ThinkingBlock {
1225 thinking,
1226 signature,
1227 } => 50 + thinking.len() + signature.len(),
1228 MessagePart::RedactedThinkingBlock { data } => data.len(),
1229 MessagePart::Compaction { summary } => summary.len(),
1230 _ => 0,
1231 })
1232 .sum()
1233}
1234
1235pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
1254 use zeph_llm::provider::{MessagePart, Role};
1255
1256 let mut total_chars = 0usize;
1260 let mut drop_before = 0usize; for (i, m) in msgs.iter().enumerate().rev() {
1262 total_chars += estimate_parts_size(m);
1263 if total_chars > max_chars {
1264 drop_before = i + 1;
1265 break;
1266 }
1267 }
1268 if drop_before > 0 {
1269 msgs.drain(..drop_before);
1270 }
1271
1272 let emitted_tool_ids: std::collections::HashSet<String> = msgs
1276 .iter()
1277 .filter(|m| m.role == Role::Assistant)
1278 .flat_map(|m| m.parts.iter())
1279 .filter_map(|p| {
1280 if let MessagePart::ToolUse { id, .. } = p {
1281 Some(id.clone())
1282 } else {
1283 None
1284 }
1285 })
1286 .collect();
1287
1288 let mut orphans_removed = 0usize;
1289 for m in msgs.iter_mut() {
1290 if m.role != Role::User || m.parts.is_empty() {
1291 continue;
1292 }
1293 let before = m.parts.len();
1294 m.parts.retain(|p| match p {
1295 MessagePart::ToolResult { tool_use_id, .. } => {
1296 emitted_tool_ids.contains(tool_use_id.as_str())
1297 }
1298 _ => true,
1299 });
1300 let dropped = before - m.parts.len();
1301 if dropped > 0 {
1302 orphans_removed += dropped;
1303 if m.parts.is_empty() {
1304 m.content.clear();
1305 } else {
1306 m.rebuild_content();
1307 }
1308 }
1309 }
1310
1311 let consumed_tool_ids: std::collections::HashSet<String> = msgs
1319 .iter()
1320 .filter(|m| m.role == Role::User)
1321 .flat_map(|m| m.parts.iter())
1322 .filter_map(|p| {
1323 if let MessagePart::ToolResult { tool_use_id, .. } = p {
1324 Some(tool_use_id.clone())
1325 } else {
1326 None
1327 }
1328 })
1329 .collect();
1330
1331 let last_assistant_idx = msgs
1333 .iter()
1334 .enumerate()
1335 .rev()
1336 .find(|(_, m)| m.role == Role::Assistant)
1337 .map(|(i, _)| i);
1338
1339 for (idx, m) in msgs.iter_mut().enumerate() {
1340 if m.role != Role::Assistant || m.parts.is_empty() {
1341 continue;
1342 }
1343 if Some(idx) == last_assistant_idx {
1345 continue;
1346 }
1347 let before = m.parts.len();
1348 m.parts.retain(|p| match p {
1349 MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1350 _ => true,
1351 });
1352 let dropped = before - m.parts.len();
1353 if dropped > 0 {
1354 orphans_removed += dropped;
1355 if m.parts.is_empty() {
1356 m.content.clear();
1357 } else {
1358 m.rebuild_content();
1359 }
1360 }
1361 }
1362
1363 msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1365
1366 if orphans_removed > 0 {
1367 tracing::debug!(
1368 orphans = orphans_removed,
1369 "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1370 );
1371 }
1372}
1373
1374fn sanitize_parent_messages(
1380 mut msgs: Vec<zeph_llm::provider::Message>,
1381 sanitizer: &zeph_sanitizer::ContentSanitizer,
1382 source: &zeph_sanitizer::ContentSource,
1383) -> Vec<zeph_llm::provider::Message> {
1384 use zeph_llm::provider::MessagePart;
1385 for msg in &mut msgs {
1386 let mut changed = false;
1387 for part in &mut msg.parts {
1388 if let MessagePart::Text { text } = part {
1389 let clean = sanitizer.sanitize(text, source.clone());
1390 if clean.body != *text {
1391 *text = clean.body;
1392 changed = true;
1393 }
1394 }
1395 }
1396 if changed {
1397 msg.rebuild_content();
1398 }
1399 }
1400 msgs
1401}
1402
1403impl<C: Channel + Send + 'static> zeph_commands::SubagentAccess for Agent<C> {
1404 fn handle_agent_dispatch<'a>(
1407 &'a mut self,
1408 input: &'a str,
1409 ) -> std::pin::Pin<
1410 Box<
1411 dyn std::future::Future<Output = Result<Option<String>, zeph_commands::CommandError>>
1412 + Send
1413 + 'a,
1414 >,
1415 > {
1416 Box::pin(async move {
1417 match self.dispatch_agent_command(input).await {
1418 Some(Err(e)) => Err(zeph_commands::CommandError::new(e.to_string())),
1419 Some(Ok(())) | None => Ok(None),
1420 }
1421 })
1422 }
1423
1424 fn handle_agents<'a>(
1427 &'a mut self,
1428 args: &'a str,
1429 ) -> std::pin::Pin<
1430 Box<
1431 dyn std::future::Future<Output = Result<String, zeph_commands::CommandError>>
1432 + Send
1433 + 'a,
1434 >,
1435 > {
1436 use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1437 use zeph_subagent::AgentsCommand;
1438
1439 let args_owned = args.trim().to_owned();
1440 Box::pin(async move {
1441 let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1443
1444 let fleet_section = if show_fleet {
1445 let snapshots = self.services.autonomous_registry.list();
1446 let entries: Vec<FleetEntry> = snapshots
1447 .into_iter()
1448 .map(|s| FleetEntry {
1449 goal_id: s.goal_id,
1450 goal_text_short: s.goal_text_short,
1451 state: s.state,
1452 turns_executed: s.turns_executed,
1453 max_turns: s.max_turns,
1454 elapsed: s.elapsed,
1455 })
1456 .collect();
1457 format_fleet_section(&entries)
1458 } else {
1459 String::new()
1460 };
1461
1462 let definitions_section = if show_fleet || args_owned == "list" {
1464 self.handle_agents_definitions_list()
1465 } else {
1466 match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1468 Ok(cmd) => self.handle_agents_crud(cmd),
1469 Err(e) => e.to_string(),
1470 }
1471 };
1472
1473 let mut out = fleet_section;
1474 if !definitions_section.is_empty() {
1475 if !out.is_empty() {
1476 out.push('\n');
1477 }
1478 out.push_str(&definitions_section);
1479 }
1480
1481 if out.is_empty() {
1482 "No active autonomous sessions or sub-agent definitions found."
1483 .clone_into(&mut out);
1484 }
1485
1486 Ok(out)
1487 })
1488 }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493 use zeph_tools::{ErasedToolExecutor, ToolCall};
1494
1495 use super::*;
1496 use crate::agent::agent_tests::*;
1497
1498 fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1501 let provider = mock_provider(vec![]);
1502 let channel = MockChannel::new(vec![]);
1503 let registry = create_test_registry();
1504 let executor = MockToolExecutor::no_tools();
1505 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1506 agent.services.skill.available_custom_secrets.insert(
1507 stored_key.to_owned(),
1508 crate::vault::Secret::new(value.to_owned()),
1509 );
1510 agent
1511 }
1512
1513 #[test]
1514 fn resolve_subagent_secret_exact_match() {
1515 let agent = agent_with_custom_secret("my_key", "the-value");
1516 let resolved = agent.resolve_subagent_secret("my_key");
1517 assert_eq!(
1518 resolved.map(|s| s.expose().to_owned()),
1519 Some("the-value".to_owned())
1520 );
1521 }
1522
1523 #[test]
1524 fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1525 let agent = agent_with_custom_secret("my_api_key", "dash-value");
1528 let resolved = agent.resolve_subagent_secret("my-api-key");
1529 assert_eq!(
1530 resolved.map(|s| s.expose().to_owned()),
1531 Some("dash-value".to_owned())
1532 );
1533 }
1534
1535 #[test]
1536 fn resolve_subagent_secret_normalizes_case() {
1537 let agent = agent_with_custom_secret("upper_key", "case-value");
1538 let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1539 assert_eq!(
1540 resolved.map(|s| s.expose().to_owned()),
1541 Some("case-value".to_owned())
1542 );
1543 }
1544
1545 #[test]
1546 fn resolve_subagent_secret_missing_key_returns_none() {
1547 let agent = agent_with_custom_secret("known_key", "value");
1548 assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1549 }
1550
1551 #[test]
1552 fn resolve_subagent_secret_empty_map_returns_none() {
1553 let provider = mock_provider(vec![]);
1554 let channel = MockChannel::new(vec![]);
1555 let registry = create_test_registry();
1556 let executor = MockToolExecutor::no_tools();
1557 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1558 assert!(agent.resolve_subagent_secret("anything").is_none());
1559 }
1560
1561 #[tokio::test]
1564 async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1565 use zeph_tools::registry::InvocationHint;
1566
1567 let provider = mock_provider(vec![]);
1568 let channel = MockChannel::new(vec![]);
1569 let registry = create_test_registry();
1570 let executor = MockToolExecutor::no_tools().with_definitions(vec![
1571 ToolDef {
1572 id: "read".into(),
1573 description: "built-in tool".into(),
1574 schema: schemars::Schema::default(),
1575 invocation: InvocationHint::ToolCall,
1576 output_schema: None,
1577 server_id: None,
1578 },
1579 ToolDef {
1580 id: "github_create_issue".into(),
1581 description: "MCP tool".into(),
1582 schema: schemars::Schema::default(),
1583 invocation: InvocationHint::ToolCall,
1584 output_schema: None,
1585 server_id: Some("github".into()),
1586 },
1587 ]);
1588 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1589
1590 assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1591 }
1592
1593 async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1598 let provider = mock_provider(vec!["ok".into()]);
1599 let channel = MockChannel::new(vec![]);
1600 let registry = create_test_registry();
1601 let executor = MockToolExecutor::no_tools();
1602 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1603 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1604 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1605 enabled: true,
1606 agent_turns: true,
1607 ..zeph_config::DurableConfig::default()
1608 });
1609 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1610 agent.services.session.durable_subagent = subagent_enabled;
1611
1612 agent.ensure_session_durable_ctx().await;
1613 assert!(
1614 agent.services.session.durable_ctx.is_some(),
1615 "test setup: durable_ctx must be populated before exercising the seat gate"
1616 );
1617 agent
1618 }
1619
1620 #[tokio::test]
1621 async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1622 let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1623
1624 let gate = resolve_durable_spawn_gate(
1625 agent.services.session.durable_subagent,
1626 agent.services.session.durable_ctx.as_deref(),
1627 )
1628 .await;
1629
1630 assert!(
1631 matches!(gate, DurableSpawnGate::Fresh(_)),
1632 "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1633 not just wire the config-to-builder plumbing"
1634 );
1635 }
1636
1637 #[tokio::test]
1638 async fn seat_absent_when_subagent_disabled() {
1639 let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1640
1641 let gate = resolve_durable_spawn_gate(
1642 agent.services.session.durable_subagent,
1643 agent.services.session.durable_ctx.as_deref(),
1644 )
1645 .await;
1646
1647 assert!(
1648 matches!(gate, DurableSpawnGate::None),
1649 "FR-008: durable_subagent=false must keep the seat gate closed even when \
1650 durable_ctx is populated"
1651 );
1652 }
1653
1654 fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1664 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1665 use zeph_subagent::hooks::SubagentHooks;
1666
1667 zeph_subagent::SubAgentDef {
1668 name: name.to_owned(),
1669 description: "A helper bot".into(),
1670 model: None,
1671 tools: ToolPolicy::InheritAll,
1672 disallowed_tools: vec![],
1673 permissions: SubAgentPermissions::default(),
1674 skills: SkillFilter::default(),
1675 system_prompt: "You are helpful.".into(),
1676 hooks: SubagentHooks::default(),
1677 memory: None,
1678 source: None,
1679 file_path: None,
1680 }
1681 }
1682
1683 async fn agent_with_durable_and_manager(
1687 db_url: &str,
1688 conversation_id: i64,
1689 ) -> Agent<MockChannel> {
1690 let provider = mock_provider(vec!["ok".into()]);
1691 let channel = MockChannel::new(vec![]);
1692 let registry = create_test_registry();
1693 let executor = MockToolExecutor::no_tools();
1694 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1695 agent.services.memory.persistence.conversation_id =
1696 Some(zeph_memory::ConversationId(conversation_id));
1697 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1698 enabled: true,
1699 agent_turns: true,
1700 ..zeph_config::DurableConfig::default()
1701 });
1702 agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1703 agent.services.session.durable_subagent = true;
1704
1705 let mut mgr = zeph_subagent::SubAgentManager::new(4);
1706 mgr.definitions_mut().push(subagent_def("helper"));
1707 agent.services.orchestration.subagent_manager = Some(mgr);
1708
1709 agent.ensure_session_durable_ctx().await;
1710 assert!(
1711 agent.services.session.durable_ctx.is_some(),
1712 "test setup: durable_ctx must be populated before exercising the handler"
1713 );
1714 agent
1715 }
1716
1717 #[tokio::test]
1718 async fn handle_agent_background_replays_finished_child_without_respawning() {
1719 let dir = tempfile::tempdir().unwrap();
1720 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1721
1722 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1724 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1725 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1726 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1727 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1728 Ok("child finished before crash".to_owned());
1729 zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1730 agent1
1731 .services
1732 .session
1733 .durable_writer
1734 .as_ref()
1735 .unwrap()
1736 .flush()
1737 .await
1738 .unwrap();
1739 drop(agent1);
1744
1745 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1748
1749 let resp = agent2
1750 .handle_agent_background("helper", "do work")
1751 .await
1752 .unwrap();
1753 assert!(
1754 resp.contains("replayed from durable journal"),
1755 "expected a replay notice, got: {resp}"
1756 );
1757 assert!(
1758 resp.contains("child finished before crash"),
1759 "expected the journaled output to be surfaced, got: {resp}"
1760 );
1761 assert!(
1762 agent2
1763 .services
1764 .orchestration
1765 .subagent_manager
1766 .as_ref()
1767 .unwrap()
1768 .statuses()
1769 .is_empty(),
1770 "mgr.spawn must not be called when the child result is replayed"
1771 );
1772 }
1773
1774 #[tokio::test]
1775 async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1776 let dir = tempfile::tempdir().unwrap();
1777 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1778
1779 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1781 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1782 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1783 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1784 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1785 Ok("foreground child output".to_owned());
1786 zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1787 ctx1.step(
1795 zeph_durable::StepDescriptor::idempotent(
1796 "post_spawn_marker",
1797 b"post_spawn_marker".to_vec(),
1798 ),
1799 |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1800 )
1801 .await
1802 .unwrap();
1803 agent1
1804 .services
1805 .session
1806 .durable_writer
1807 .as_ref()
1808 .unwrap()
1809 .flush()
1810 .await
1811 .unwrap();
1812 drop(agent1);
1815
1816 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1819
1820 let resp = agent2
1821 .handle_agent_spawn_foreground("helper", "do work")
1822 .await
1823 .unwrap();
1824 assert_eq!(resp, "foreground child output");
1825 assert!(
1826 agent2
1827 .channel
1828 .sent_messages()
1829 .iter()
1830 .any(|m| m.contains("replayed from durable journal")),
1831 "expected the replay notice to be sent to the channel"
1832 );
1833 assert_eq!(
1834 agent2.channel.notify_completed_calls().len(),
1835 1,
1836 "expected exactly one TUI completion notification on the first replay"
1837 );
1838 assert!(
1839 agent2
1840 .services
1841 .orchestration
1842 .subagent_manager
1843 .as_ref()
1844 .unwrap()
1845 .statuses()
1846 .is_empty(),
1847 "mgr.spawn must not be called when the child result is replayed"
1848 );
1849 drop(agent2);
1850
1851 let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1856
1857 let resp = agent3
1858 .handle_agent_spawn_foreground("helper", "do work")
1859 .await
1860 .unwrap();
1861 assert_eq!(resp, "foreground child output");
1862 assert!(
1863 !agent3
1864 .channel
1865 .sent_messages()
1866 .iter()
1867 .any(|m| m.contains("replayed from durable journal")),
1868 "replay notice must not re-fire on a second replay after a parent restart"
1869 );
1870 assert!(
1871 agent3.channel.notify_completed_calls().is_empty(),
1872 "TUI completion event must not re-fire on a second replay after a parent restart"
1873 );
1874 }
1875
1876 #[tokio::test]
1877 async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1878 let dir = tempfile::tempdir().unwrap();
1879 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1880
1881 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1884 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1885 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1886 assert!(
1887 seat.is_some(),
1888 "test setup: run 1 must be fresh and yield a resolver seat"
1889 );
1890 agent1
1891 .services
1892 .session
1893 .durable_writer
1894 .as_ref()
1895 .unwrap()
1896 .flush()
1897 .await
1898 .unwrap();
1899 drop(agent1);
1902
1903 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1907
1908 let resp = agent2
1909 .handle_agent_background("helper", "do work")
1910 .await
1911 .unwrap();
1912 assert!(
1913 resp.contains("started in background"),
1914 "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1915 );
1916 assert_eq!(
1917 agent2
1918 .services
1919 .orchestration
1920 .subagent_manager
1921 .as_ref()
1922 .unwrap()
1923 .statuses()
1924 .len(),
1925 1,
1926 "exactly one real spawn must occur on the still-pending fallback path"
1927 );
1928 }
1929
1930 #[test]
1933 fn build_spawn_context_leaves_debug_dump_sink_none_without_dumper() {
1934 let provider = mock_provider(vec![]);
1935 let channel = MockChannel::new(vec![]);
1936 let registry = create_test_registry();
1937 let agent = Agent::new(
1938 provider,
1939 channel,
1940 registry,
1941 None,
1942 5,
1943 MockToolExecutor::no_tools(),
1944 );
1945
1946 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1947 assert!(
1948 ctx.debug_dump_sink.is_none(),
1949 "no DebugDumper configured, so SpawnContext must carry no sink"
1950 );
1951 }
1952
1953 #[tokio::test]
1954 async fn build_spawn_context_wires_debug_dump_sink_when_dumper_present() {
1955 let dir = tempfile::tempdir().unwrap();
1956 let dumper =
1957 crate::debug_dump::DebugDumper::new(dir.path(), crate::debug_dump::DumpFormat::Raw)
1958 .unwrap();
1959
1960 let provider = mock_provider(vec![]);
1961 let channel = MockChannel::new(vec![]);
1962 let registry = create_test_registry();
1963 let mut agent = Agent::new(
1964 provider,
1965 channel,
1966 registry,
1967 None,
1968 5,
1969 MockToolExecutor::no_tools(),
1970 );
1971 agent.runtime.debug.debug_dumper = Some(dumper);
1972
1973 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1974 let sink = ctx
1975 .debug_dump_sink
1976 .expect("a configured DebugDumper must be threaded into SpawnContext");
1977
1978 let id = sink.dump_request("mock", &[], &[], serde_json::Value::Null);
1981 sink.dump_response(id, &zeph_llm::provider::ChatResponse::Text("ok".into()));
1982 }
1983
1984 #[test]
1987 fn build_spawn_context_leaves_inherited_tool_allowlist_none_by_default() {
1988 let provider = mock_provider(vec![]);
1991 let channel = MockChannel::new(vec![]);
1992 let registry = create_test_registry();
1993 let executor = MockToolExecutor::no_tools().with_definitions(vec![ToolDef {
1994 id: "bash".into(),
1995 description: "shell".into(),
1996 schema: schemars::Schema::default(),
1997 invocation: zeph_tools::registry::InvocationHint::ToolCall,
1998 output_schema: None,
1999 server_id: None,
2000 }]);
2001 let agent = Agent::new(provider, channel, registry, None, 5, executor);
2002
2003 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2004 assert!(ctx.inherited_tool_allowlist.is_none());
2005 }
2006
2007 #[test]
2008 fn build_spawn_context_populates_inherited_tool_allowlist_from_parent_policy() {
2009 let provider = mock_provider(vec![]);
2013 let channel = MockChannel::new(vec![]);
2014 let registry = create_test_registry();
2015 let executor = MockToolExecutor::no_tools().with_definitions(vec![
2016 ToolDef {
2017 id: "bash".into(),
2018 description: "shell".into(),
2019 schema: schemars::Schema::default(),
2020 invocation: zeph_tools::registry::InvocationHint::ToolCall,
2021 output_schema: None,
2022 server_id: None,
2023 },
2024 ToolDef {
2025 id: "read".into(),
2026 description: "read a file".into(),
2027 schema: schemars::Schema::default(),
2028 invocation: zeph_tools::registry::InvocationHint::ToolCall,
2029 output_schema: None,
2030 server_id: None,
2031 },
2032 ]);
2033 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2034
2035 let mut rules = std::collections::HashMap::new();
2036 rules.insert(
2037 "bash".to_owned(),
2038 vec![zeph_config::tools::PermissionRule {
2039 pattern: "*".to_owned(),
2040 action: zeph_config::tools::PermissionAction::Deny,
2041 }],
2042 );
2043 agent.runtime.config.permission_policy = zeph_tools::PermissionPolicy::new(rules)
2044 .with_autonomy(zeph_config::tools::AutonomyLevel::Supervised);
2045
2046 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2047 let allowlist = ctx
2048 .inherited_tool_allowlist
2049 .expect("a wholesale-denied bash tool must produce a narrowed Some(set)");
2050 assert!(!allowlist.contains("bash"));
2051 assert!(allowlist.contains("read"));
2052 }
2053
2054 #[test]
2057 fn build_spawn_context_leaves_max_trust_level_trusted_when_no_active_skills() {
2058 let provider = mock_provider(vec![]);
2059 let channel = MockChannel::new(vec![]);
2060 let registry = create_test_registry();
2061 let agent = Agent::new(
2062 provider,
2063 channel,
2064 registry,
2065 None,
2066 5,
2067 MockToolExecutor::no_tools(),
2068 );
2069
2070 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2071 assert_eq!(
2072 ctx.max_trust_level,
2073 Some(zeph_common::SkillTrustLevel::Trusted),
2074 "with no active skills this turn, the parent's own effective trust is Trusted, \
2075 so the cap must impose no additional restriction"
2076 );
2077 }
2078
2079 #[test]
2080 fn build_spawn_context_caps_trust_to_least_trusted_active_skill() {
2081 let provider = mock_provider(vec![]);
2082 let channel = MockChannel::new(vec![]);
2083 let registry = create_test_registry();
2084 let mut agent = Agent::new(
2085 provider,
2086 channel,
2087 registry,
2088 None,
2089 5,
2090 MockToolExecutor::no_tools(),
2091 );
2092 agent.services.skill.active_skill_names = vec!["trusted-skill".into(), "evil-skill".into()];
2093 agent.services.skill.trust_snapshot.write().insert(
2094 "trusted-skill".into(),
2095 crate::skill_invoker::SkillTrustSnapshot {
2096 trust_level: zeph_common::SkillTrustLevel::Trusted,
2097 requires_trust_check: false,
2098 blake3_hash: String::new(),
2099 },
2100 );
2101 agent.services.skill.trust_snapshot.write().insert(
2102 "evil-skill".into(),
2103 crate::skill_invoker::SkillTrustSnapshot {
2104 trust_level: zeph_common::SkillTrustLevel::Quarantined,
2105 requires_trust_check: false,
2106 blake3_hash: String::new(),
2107 },
2108 );
2109
2110 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2111 assert_eq!(
2112 ctx.max_trust_level,
2113 Some(zeph_common::SkillTrustLevel::Quarantined),
2114 "the cap must be the LEAST-trusted of all active skills this turn (weakest-link), \
2115 matching the fold `apply_skill_trust_and_gating` applies to the parent's own gate"
2116 );
2117 }
2118
2119 #[test]
2125 fn build_spawn_context_ignores_fallback_mode_registry_trust_for_cap() {
2126 let provider = mock_provider(vec![]);
2127 let channel = MockChannel::new(vec![]);
2128 let registry = create_test_registry();
2129 let mut agent = Agent::new(
2130 provider,
2131 channel,
2132 registry,
2133 None,
2134 5,
2135 MockToolExecutor::no_tools(),
2136 );
2137 agent.services.skill.skill_fallback_mode = true;
2140 agent.services.skill.active_skill_names =
2141 vec!["trusted-skill".into(), "blocked-skill".into()];
2142 agent.services.skill.trust_snapshot.write().insert(
2143 "blocked-skill".into(),
2144 crate::skill_invoker::SkillTrustSnapshot {
2145 trust_level: zeph_common::SkillTrustLevel::Blocked,
2146 requires_trust_check: false,
2147 blake3_hash: String::new(),
2148 },
2149 );
2150
2151 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2152 assert_eq!(
2153 ctx.max_trust_level,
2154 Some(zeph_common::SkillTrustLevel::Trusted),
2155 "skill_fallback_mode must force the subagent cap to Trusted regardless of registry \
2156 contents, matching the D4 guard applied to the parent's own gate"
2157 );
2158 }
2159
2160 #[test]
2166 fn build_spawn_context_reads_wired_turn_trust_floor_directly() {
2167 let provider = mock_provider(vec![]);
2168 let channel = MockChannel::new(vec![]);
2169 let registry = create_test_registry();
2170 let mut agent = Agent::new(
2171 provider,
2172 channel,
2173 registry,
2174 None,
2175 5,
2176 MockToolExecutor::no_tools(),
2177 );
2178 let floor = zeph_common::TurnTrustFloor::new(zeph_common::SkillTrustLevel::Trusted);
2182 floor.fold(zeph_common::SkillTrustLevel::Quarantined);
2183 agent.services.skill.turn_trust_floor = Some(floor);
2184
2185 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2186 assert_eq!(
2187 ctx.max_trust_level,
2188 Some(zeph_common::SkillTrustLevel::Quarantined),
2189 "a wired turn_trust_floor must be read directly, reflecting mid-turn folds that \
2190 active_skill_names alone cannot see"
2191 );
2192 }
2193
2194 #[derive(Default)]
2202 struct TrustRecordingExecutor {
2203 recorded: Arc<Mutex<Option<zeph_tools::SkillTrustLevel>>>,
2204 }
2205
2206 impl ToolExecutor for TrustRecordingExecutor {
2207 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2208 Ok(None)
2209 }
2210
2211 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2212
2213 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
2214 *self.recorded.lock().unwrap() = Some(level);
2215 }
2216
2217 zeph_tools::tool_executor_no_inner_defaults!();
2218 }
2219
2220 #[tokio::test]
2221 async fn spawning_a_subagent_caps_trust_to_parent_effective_level() {
2222 let provider = mock_provider(vec![]);
2223 let channel = MockChannel::new(vec![]);
2224 let registry = create_test_registry();
2225 let executor = TrustRecordingExecutor::default();
2226 let recorded = Arc::clone(&executor.recorded);
2227 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2228
2229 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2230 mgr.definitions_mut().push(subagent_def("helper"));
2231 agent.services.orchestration.subagent_manager = Some(mgr);
2232
2233 let memory = test_memory_for_trust().await;
2240 memory
2241 .sqlite()
2242 .upsert_skill_trust(
2243 "evil-skill",
2244 zeph_common::SkillTrustLevel::Quarantined,
2245 zeph_memory::store::SourceKind::Local,
2246 None,
2247 None,
2248 "hash-evil",
2249 )
2250 .await
2251 .unwrap();
2252 agent = agent.with_memory(memory, zeph_memory::ConversationId(1), 50, 5, 50);
2253 agent.services.skill.active_skill_names = vec!["evil-skill".into()];
2254
2255 let resp = agent.handle_agent_background("helper", "do work").await;
2256 assert!(
2257 resp.is_some_and(|r| r.contains("started in background")),
2258 "test setup: the real production spawn path must succeed"
2259 );
2260
2261 assert_eq!(
2262 *recorded.lock().unwrap(),
2263 Some(zeph_tools::SkillTrustLevel::Quarantined),
2264 "a sub-agent spawned while the parent's own effective trust is Quarantined must \
2265 never receive a higher (Trusted) effective trust on its own tool executor — \
2266 #6493's escalation gap"
2267 );
2268 }
2269
2270 #[derive(Default)]
2278 struct RecordingExecutor {
2279 calls: Mutex<u32>,
2280 }
2281
2282 impl ToolExecutor for RecordingExecutor {
2283 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2284 Ok(None)
2285 }
2286
2287 async fn execute_tool_call(
2288 &self,
2289 call: &ToolCall,
2290 ) -> Result<Option<ToolOutput>, ToolError> {
2291 *self.calls.lock().unwrap() += 1;
2292 Ok(Some(ToolOutput {
2293 tool_name: call.tool_id.clone(),
2294 summary: "ran".into(),
2295 blocks_executed: 1,
2296 ..Default::default()
2297 }))
2298 }
2299
2300 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2301
2302 zeph_tools::tool_executor_no_inner_defaults!();
2303 }
2304
2305 #[tokio::test]
2306 async fn spawning_a_subagent_tool_call_reaches_parents_own_executor() {
2307 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
2316
2317 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
2318 ChatResponse::ToolUse {
2319 text: None,
2320 tool_calls: vec![ToolUseRequest {
2321 id: "call-1".into(),
2322 name: "bash".into(),
2323 input: serde_json::json!({"command": "echo hi"}),
2324 }],
2325 thinking_blocks: vec![],
2326 },
2327 ChatResponse::Text("final answer".into()),
2328 ]);
2329
2330 let channel = MockChannel::new(vec![]);
2331 let registry = create_test_registry();
2332 let recorder = Arc::new(RecordingExecutor::default());
2333 let mut agent = Agent::new(
2334 AnyProvider::Mock(mock),
2335 channel,
2336 registry,
2337 None,
2338 5,
2339 RecordingExecutor::default(),
2340 );
2341 agent.tool_executor = Arc::clone(&recorder) as Arc<dyn ErasedToolExecutor>;
2344
2345 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2346 mgr.definitions_mut().push(subagent_def("helper"));
2347 agent.services.orchestration.subagent_manager = Some(mgr);
2348
2349 let resp = agent.handle_agent_background("helper", "do work").await;
2350 assert!(
2351 resp.is_some_and(|r| r.contains("started in background")),
2352 "test setup: the real production spawn path must succeed"
2353 );
2354
2355 for _ in 0..50 {
2356 if !agent.poll_subagents().await.is_empty() {
2357 break;
2358 }
2359 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2360 }
2361
2362 assert!(
2363 *recorder.calls.lock().unwrap() >= 1,
2364 "the sub-agent's tool call must reach the parent's own tool executor instance, \
2365 proving no fresh/ungated executor is substituted for the child"
2366 );
2367 }
2368
2369 #[tokio::test]
2379 async fn notify_completed_subagents_notifies_channel_of_background_completion() {
2380 let provider = mock_provider(vec!["done".into()]);
2381 let channel = MockChannel::new(vec![]);
2382 let registry = create_test_registry();
2383 let executor = MockToolExecutor::no_tools();
2384 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2385
2386 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2387 mgr.definitions_mut().push(subagent_def("helper"));
2388 agent.services.orchestration.subagent_manager = Some(mgr);
2389
2390 let resp = agent.handle_agent_background("helper", "do work").await;
2391 assert!(
2392 resp.is_some_and(|r| r.contains("started in background")),
2393 "test setup: the background spawn must succeed"
2394 );
2395
2396 let mut notified = Vec::new();
2397 for _ in 0..50 {
2398 agent.notify_completed_subagents().await.unwrap();
2399 notified = agent.channel.notify_background_completed_calls();
2400 if !notified.is_empty() {
2401 break;
2402 }
2403 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2404 }
2405
2406 assert_eq!(
2407 notified.len(),
2408 1,
2409 "exactly one background-completion notification must be recorded"
2410 );
2411 let (_task_id, name, success) = ¬ified[0];
2412 assert_eq!(name, "helper");
2413 assert!(
2414 *success,
2415 "MockProvider's clean text response must be treated as a success"
2416 );
2417 }
2418
2419 #[tokio::test]
2426 async fn notify_completed_subagents_masks_generic_secret_shape_in_notice() {
2427 let provider = mock_provider(vec![
2431 "thinking about it".into(),
2432 "here is a key: sk-test-abc123def456, use it wisely".into(),
2433 ]);
2434 let channel = MockChannel::new(vec![]);
2435 let registry = create_test_registry();
2436 let executor = MockToolExecutor::no_tools();
2437 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2438
2439 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2440 mgr.definitions_mut().push(subagent_def("helper"));
2441 agent.services.orchestration.subagent_manager = Some(mgr);
2442
2443 let resp = agent.handle_agent_background("helper", "do work").await;
2444 assert!(
2445 resp.is_some_and(|r| r.contains("started in background")),
2446 "test setup: the background spawn must succeed"
2447 );
2448
2449 let mut sent = Vec::new();
2450 for _ in 0..50 {
2451 agent.notify_completed_subagents().await.unwrap();
2452 sent = agent.channel.sent_messages();
2453 if !sent.is_empty() {
2454 break;
2455 }
2456 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2457 }
2458
2459 let notice = sent
2460 .iter()
2461 .find(|m| m.contains("completed"))
2462 .expect("a completion notice must have been sent");
2463 assert!(
2464 !notice.contains("sk-test-abc123def456"),
2465 "generic secret-shaped string must not appear verbatim in the completion notice: {notice}"
2466 );
2467 assert!(
2468 notice.contains("[REDACTED]"),
2469 "masked placeholder must be present in the completion notice: {notice}"
2470 );
2471 }
2472
2473 fn registry_with_skills(
2483 count: usize,
2484 words_per_skill: usize,
2485 ) -> (SkillRegistry, tempfile::TempDir) {
2486 let temp_dir = tempfile::tempdir().unwrap();
2487 for i in 0..count {
2488 let skill_dir = temp_dir.path().join(format!("skill-{i}"));
2489 std::fs::create_dir(&skill_dir).unwrap();
2490 let body = "lorem ".repeat(words_per_skill);
2491 std::fs::write(
2492 skill_dir.join("SKILL.md"),
2493 format!("---\nname: skill-{i}\ndescription: Test skill {i}\n---\n{body}"),
2494 )
2495 .unwrap();
2496 }
2497 let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
2498 (registry, temp_dir)
2499 }
2500
2501 fn agent_with_skill_registry_and_def(
2502 registry: SkillRegistry,
2503 def: zeph_subagent::SubAgentDef,
2504 ) -> Agent<MockChannel> {
2505 let provider = mock_provider(vec![]);
2506 let channel = MockChannel::new(vec![]);
2507 let executor = MockToolExecutor::no_tools();
2508 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2509 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2510 mgr.definitions_mut().push(def);
2511 agent.services.orchestration.subagent_manager = Some(mgr);
2512 agent
2513 }
2514
2515 fn agent_with_skill_registry_and_helper_def(registry: SkillRegistry) -> Agent<MockChannel> {
2516 agent_with_skill_registry_and_def(registry, subagent_def("helper"))
2517 }
2518
2519 #[tokio::test]
2520 async fn filtered_skills_for_under_budget_returns_all_bodies_no_marker() {
2521 let (registry, _temp_dir) = registry_with_skills(3, 20);
2524 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2525 agent.services.skill.subagent_skill_token_budget = 1_000_000;
2526
2527 let bodies = agent
2528 .filtered_skills_for("helper")
2529 .await
2530 .expect("3 skills with a huge budget must return Some");
2531
2532 assert_eq!(
2533 bodies.len(),
2534 3,
2535 "no truncation marker expected when everything fits under budget"
2536 );
2537 for body in &bodies {
2538 assert!(
2539 body.contains("lorem"),
2540 "every returned entry must be a real skill body, not a marker: {body}"
2541 );
2542 }
2543 }
2544
2545 #[tokio::test]
2546 async fn filtered_skills_for_over_budget_truncates_with_marker() {
2547 let (registry, _temp_dir) = registry_with_skills(5, 500);
2550 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2551 agent.services.skill.subagent_skill_token_budget = 10;
2552
2553 let bodies = agent
2554 .filtered_skills_for("helper")
2555 .await
2556 .expect("at least the first skill must always be included");
2557
2558 let marker_count = bodies
2559 .iter()
2560 .filter(|b| b.starts_with("[skill budget:"))
2561 .count();
2562 assert_eq!(
2563 marker_count, 1,
2564 "exactly one truncation marker entry must be appended, got bodies: {bodies:?}"
2565 );
2566 let marker = bodies
2567 .iter()
2568 .find(|b| b.starts_with("[skill budget:"))
2569 .unwrap();
2570 let included = bodies.len() - 1;
2571 assert!(
2572 included < 5,
2573 "budget=10 tokens must not fit all 5 large skills, included={included}"
2574 );
2575 assert!(
2576 included >= 1,
2577 "the first skill must always be included even when it alone exceeds the budget, \
2578 got included={included}"
2579 );
2580 assert!(
2581 bodies[0].contains("lorem"),
2582 "the always-included first entry must be a real skill body, not the marker: {}",
2583 bodies[0]
2584 );
2585 assert!(
2586 marker.contains(&format!("{included}/5 skills included")),
2587 "marker must report the correct included/total count: {marker}"
2588 );
2589 assert!(
2590 marker.contains("budget=10 tokens"),
2591 "marker must report the configured budget: {marker}"
2592 );
2593 }
2594
2595 #[tokio::test]
2596 async fn filtered_skills_for_mid_budget_greedily_fills_multiple_fitting_skills() {
2597 let (registry, _temp_dir) = registry_with_skills(5, 500);
2604 let single_body = "lorem ".repeat(500);
2605 let per_skill_tokens = zeph_memory::TokenCounter::new().count_tokens(&single_body);
2606 assert!(
2607 per_skill_tokens > 1,
2608 "test setup: per-skill token count must be large enough for 2*T+1 to exclude a 3rd \
2609 skill, got {per_skill_tokens}"
2610 );
2611
2612 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2613 agent.services.skill.subagent_skill_token_budget = 2 * per_skill_tokens + 1;
2614
2615 let bodies = agent
2616 .filtered_skills_for("helper")
2617 .await
2618 .expect("at least the first skill must always be included");
2619
2620 let marker = bodies
2621 .iter()
2622 .find(|b| b.starts_with("[skill budget:"))
2623 .unwrap_or_else(|| panic!("expected a truncation marker, got bodies: {bodies:?}"));
2624 let included = bodies.len() - 1;
2625 assert_eq!(
2626 included, 2,
2627 "budget=2*T+1 must fit exactly 2 of the 5 identical-cost skills, got {included}"
2628 );
2629 assert!(
2630 marker.contains("2/5 skills included"),
2631 "marker must report the correct included/total count: {marker}"
2632 );
2633 let omitted_segment = marker
2637 .split("omitted: ")
2638 .nth(1)
2639 .and_then(|s| s.strip_suffix(']'))
2640 .unwrap_or_else(|| panic!("marker missing 'omitted: ...]' segment: {marker}"));
2641 let mut omitted_names: Vec<&str> = omitted_segment.split(", ").collect();
2642 omitted_names.sort_unstable();
2643 assert_eq!(
2644 omitted_names,
2645 vec!["skill-2", "skill-3", "skill-4"],
2646 "marker must name exactly the 3 truncated skills, got marker: {marker}"
2647 );
2648 }
2649
2650 #[tokio::test]
2651 async fn filtered_skills_for_explicit_include_is_never_capped() {
2652 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
2657 use zeph_subagent::hooks::SubagentHooks;
2658
2659 let (registry, _temp_dir) = registry_with_skills(5, 500);
2660 let def = zeph_subagent::SubAgentDef {
2661 name: "curated".to_owned(),
2662 description: "A curated helper".into(),
2663 model: None,
2664 tools: ToolPolicy::InheritAll,
2665 disallowed_tools: vec![],
2666 permissions: SubAgentPermissions::default(),
2667 skills: SkillFilter {
2668 include: vec!["skill-*".to_owned()],
2669 exclude: vec![],
2670 },
2671 system_prompt: "You are helpful.".into(),
2672 hooks: SubagentHooks::default(),
2673 memory: None,
2674 source: None,
2675 file_path: None,
2676 };
2677 let mut agent = agent_with_skill_registry_and_def(registry, def);
2678 agent.services.skill.subagent_skill_token_budget = 10;
2681
2682 let bodies = agent
2683 .filtered_skills_for("curated")
2684 .await
2685 .expect("explicit include must still match all 5 skill-* skills");
2686
2687 assert_eq!(
2688 bodies.len(),
2689 5,
2690 "explicit include list must never be truncated by the budget, got: {bodies:?}"
2691 );
2692 assert!(
2693 bodies.iter().all(|b| b.contains("lorem")),
2694 "every entry must be a real skill body, not a truncation marker: {bodies:?}"
2695 );
2696 }
2697
2698 async fn test_memory_for_trust() -> Arc<zeph_memory::semantic::SemanticMemory> {
2701 let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
2702 Arc::new(
2703 zeph_memory::semantic::SemanticMemory::new(
2704 ":memory:",
2705 "http://127.0.0.1:1",
2706 None,
2707 provider,
2708 "test-model",
2709 )
2710 .await
2711 .unwrap(),
2712 )
2713 }
2714
2715 #[tokio::test]
2724 async fn filtered_skills_for_excludes_quarantined_and_blocked_skill_bodies() {
2725 let temp_dir = tempfile::tempdir().unwrap();
2726 for (name, body) in [
2727 ("trusted-skill", "TRUSTED_BODY_MARKER"),
2728 ("quarantined-skill", "QUARANTINED_BODY_MARKER"),
2729 ("blocked-skill", "BLOCKED_BODY_MARKER"),
2730 ] {
2731 let skill_dir = temp_dir.path().join(name);
2732 std::fs::create_dir(&skill_dir).unwrap();
2733 std::fs::write(
2734 skill_dir.join("SKILL.md"),
2735 format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
2736 )
2737 .unwrap();
2738 }
2739 let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
2740 let memory = test_memory_for_trust().await;
2741 memory
2742 .sqlite()
2743 .upsert_skill_trust(
2744 "quarantined-skill",
2745 zeph_common::SkillTrustLevel::Quarantined,
2746 zeph_memory::store::SourceKind::Local,
2747 None,
2748 None,
2749 "hash-quarantined",
2750 )
2751 .await
2752 .unwrap();
2753 memory
2754 .sqlite()
2755 .upsert_skill_trust(
2756 "blocked-skill",
2757 zeph_common::SkillTrustLevel::Blocked,
2758 zeph_memory::store::SourceKind::Local,
2759 None,
2760 None,
2761 "hash-blocked",
2762 )
2763 .await
2764 .unwrap();
2765 let mut agent = agent_with_skill_registry_and_helper_def(registry).with_memory(
2766 memory,
2767 zeph_memory::ConversationId(1),
2768 50,
2769 5,
2770 50,
2771 );
2772 agent.services.skill.subagent_skill_token_budget = 1_000_000;
2773
2774 let bodies = agent
2775 .filtered_skills_for("helper")
2776 .await
2777 .expect("the Trusted skill alone must still be returned");
2778
2779 assert_eq!(
2780 bodies.len(),
2781 1,
2782 "only the Trusted skill's body may be injected, got: {bodies:?}"
2783 );
2784 assert!(bodies[0].contains("TRUSTED_BODY_MARKER"));
2785 assert!(
2786 !bodies.iter().any(|b| b.contains("QUARANTINED_BODY_MARKER")
2787 || b.contains("BLOCKED_BODY_MARKER")),
2788 "Quarantined and Blocked skill bodies must never be injected, got: {bodies:?}"
2789 );
2790 }
2791}