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 mode_label = match mgr.delegation_mode() {
268 zeph_config::DelegationMode::Disabled => "disabled",
269 zeph_config::DelegationMode::ExplicitRequestOnly => "explicit_request_only",
270 zeph_config::DelegationMode::Proactive => "proactive",
271 _ => "unknown",
272 };
273 let defs = mgr.definitions();
274 if defs.is_empty() {
275 return Some(format!(
276 "Delegation mode: {mode_label}\nNo sub-agent definitions found."
277 ));
278 }
279 let mut out = format!("Delegation mode: {mode_label}\nAvailable sub-agents:\n");
280 for d in defs {
281 let memory_label = match d.memory {
282 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
283 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
284 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
285 Some(_) => " [memory:unknown]",
286 None => "",
287 };
288 if let Some(ref src) = d.source {
289 let _ = writeln!(
290 out,
291 " {}{} — {} ({})",
292 d.name, memory_label, d.description, src
293 );
294 } else {
295 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
296 }
297 }
298 Some(out)
299 }
300 fn handle_agent_status(&self) -> Option<String> {
301 use std::fmt::Write as _;
302 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
303 let statuses = mgr.statuses();
304 if statuses.is_empty() {
305 return Some("No active sub-agents.".into());
306 }
307 let mut out = String::from("Active sub-agents:\n");
308 for (id, s) in &statuses {
309 let state = format!("{:?}", s.state).to_lowercase();
310 let elapsed = s.started_at.elapsed().as_secs();
311 let _ = writeln!(
312 out,
313 " [{short}] {state} turns={t} elapsed={elapsed}s {msg}",
314 short = &id[..8.min(id.len())],
315 t = s.turns_used,
316 msg = s.last_message.as_deref().unwrap_or(""),
317 );
318 if let Some(def) = mgr.agents_def(id)
320 && let Some(scope) = def.memory
321 && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
322 {
323 let _ = writeln!(out, " memory: {}", dir.display());
324 }
325 }
326 Some(out)
327 }
328 fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
329 let full_id = match self.resolve_agent_id_prefix(id)? {
330 Ok(fid) => fid,
331 Err(msg) => return Some(msg),
332 };
333 let req = {
334 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
335 mgr.try_recv_secret_request_for(&full_id)
336 };
337 let Some(req) = req else {
338 return Some(format!(
339 "No pending secret request for sub-agent '{full_id}'."
340 ));
341 };
342 let key = req.secret_key.clone();
343 let ttl = std::time::Duration::from_mins(5);
344 let Some(secret) = self.resolve_subagent_secret(&key) else {
345 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
346 let _ = mgr.deny_secret(&full_id);
347 return Some(format!(
348 "Secret '{key}' could not be resolved from the vault; request denied."
349 ));
350 };
351 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
352 if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
353 return Some(format!("Approve failed: {e}"));
354 }
355 if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
356 let _ = mgr.deny_secret(&full_id);
357 return Some(format!("Secret delivery failed: {e}"));
358 }
359 Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
360 }
361 fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
362 let full_id = match self.resolve_agent_id_prefix(id)? {
363 Ok(fid) => fid,
364 Err(msg) => return Some(msg),
365 };
366 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
367 match mgr.deny_secret(&full_id) {
368 Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
369 Err(e) => Some(format!("Deny failed: {e}")),
370 }
371 }
372 pub(super) async fn handle_agent_command(
373 &mut self,
374 cmd: zeph_subagent::AgentCommand,
375 ) -> Option<String> {
376 use zeph_subagent::AgentCommand;
377
378 match cmd {
379 AgentCommand::List => self.handle_agent_list(),
380 AgentCommand::Background { name, prompt } => {
381 self.handle_agent_background(&name, &prompt).await
382 }
383 AgentCommand::Spawn { name, prompt }
384 | AgentCommand::Mention {
385 agent: name,
386 prompt,
387 } => self.handle_agent_spawn_foreground(&name, &prompt).await,
388 AgentCommand::Status => self.handle_agent_status(),
389 AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
390 AgentCommand::Approve { id } => self.handle_agent_approve(&id),
391 AgentCommand::Deny { id } => self.handle_agent_deny(&id),
392 AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
393 _ => None,
394 }
395 }
396 pub(crate) fn handle_agents_definitions_list(&self) -> String {
401 use std::fmt::Write as _;
402
403 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
404 return String::new();
405 };
406 let defs = mgr.definitions();
407 if defs.is_empty() {
408 return String::new();
409 }
410 let mut out = String::from("Sub-agents:\n");
411 for d in defs {
412 let memory_label = match d.memory {
413 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
414 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
415 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
416 Some(_) => " [memory:unknown]",
417 None => "",
418 };
419 if let Some(ref src) = d.source {
420 let _ = writeln!(
421 out,
422 " {}{} — {} ({})",
423 d.name, memory_label, d.description, src
424 );
425 } else {
426 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
427 }
428 }
429 out
430 }
431 pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
436 use zeph_subagent::AgentsCommand;
437
438 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
439 return "Sub-agent manager is not available.".to_owned();
440 };
441
442 match cmd {
443 AgentsCommand::List => self.handle_agents_definitions_list(),
444 AgentsCommand::Show { name } => {
445 match mgr.definitions().iter().find(|d| d.name == name) {
446 Some(d) => format!(
447 "Agent: {}\nDescription: {}\nSource: {}\n",
448 d.name,
449 d.description,
450 d.source.as_deref().unwrap_or("unknown"),
451 ),
452 None => format!("No sub-agent definition named '{name}'."),
453 }
454 }
455 AgentsCommand::Create { name } => {
456 format!(
457 "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
458 See the sub-agent documentation for the required frontmatter."
459 )
460 }
461 AgentsCommand::Edit { name } => {
462 format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
463 }
464 AgentsCommand::Delete { name } => {
465 format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
466 }
467 _ => "Unknown agents command.".to_owned(),
468 }
469 }
470 async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
471 let provider = self.provider.clone();
472 let tool_executor = Arc::clone(&self.tool_executor);
473 let skills = self.filtered_skills_for(name);
474 let cfg = self.services.orchestration.subagent_config.clone();
475 let mut spawn_ctx = self.build_spawn_context(&cfg);
476 self.ensure_session_durable_ctx().await;
480 match resolve_durable_spawn_gate(
481 self.services.session.durable_subagent,
482 self.services.session.durable_ctx.as_deref(),
483 )
484 .await
485 {
486 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
487 DurableSpawnGate::Replayed { result, .. } => {
488 let short = &result.task_id[..8.min(result.task_id.len())];
489 return Some(if result.output.is_empty() {
490 format!(
491 "[sub-agent {short}] completed (no output, replayed from durable journal)"
492 )
493 } else {
494 format!(
495 "[sub-agent {short}] completed (replayed from durable journal):\n{}",
496 result.output
497 )
498 });
499 }
500 DurableSpawnGate::None => {}
501 }
502 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
503 match mgr
504 .spawn(
505 name,
506 prompt,
507 provider,
508 tool_executor,
509 skills,
510 &cfg,
511 spawn_ctx,
512 )
513 .await
514 {
515 Ok(id) => Some(format!(
516 "Sub-agent '{name}' started in background (id: {short})",
517 short = &id[..8.min(id.len())]
518 )),
519 Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
520 }
521 }
522 async fn notify_replayed_foreground_subagent(
531 &mut self,
532 name: &str,
533 result: zeph_subagent::SubagentResult,
534 promise_id: zeph_durable::PromiseId,
535 ) -> String {
536 let success = result.state == zeph_subagent::SubAgentState::Completed;
537 let task_id = result.task_id.clone();
538
539 let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
544 match ctx.claim_promise_notification(promise_id).await {
545 Ok(claimed) => claimed,
546 Err(e) => {
547 tracing::warn!(
548 error = %e,
549 "durable: promise-notification claim failed; \
550 firing the replayed sub-agent notice directly"
551 );
552 true
553 }
554 }
555 } else {
556 true
557 };
558
559 let text = if success {
560 result.output
561 } else {
562 result.error.unwrap_or_else(|| "unknown error".to_owned())
563 };
564
565 if should_notify {
566 let _ = self
567 .channel
568 .send(&format!(
569 "Sub-agent '{name}' replayed from durable journal (already finished \
570 before the parent restarted)."
571 ))
572 .await;
573 let _ = self
574 .channel
575 .notify_foreground_subagent_completed(&task_id, name, success)
576 .await;
577 }
578 text
579 }
580
581 async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
582 let provider = self.provider.clone();
583 let tool_executor = Arc::clone(&self.tool_executor);
584 let skills = self.filtered_skills_for(name);
585 let cfg = self.services.orchestration.subagent_config.clone();
586 let mut spawn_ctx = self.build_spawn_context(&cfg);
587 self.ensure_session_durable_ctx().await;
592 match resolve_durable_spawn_gate(
593 self.services.session.durable_subagent,
594 self.services.session.durable_ctx.as_deref(),
595 )
596 .await
597 {
598 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
599 DurableSpawnGate::Replayed { result, promise_id } => {
600 return Some(
601 self.notify_replayed_foreground_subagent(name, result, promise_id)
602 .await,
603 );
604 }
605 DurableSpawnGate::None => {}
606 }
607 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
608 let task_id = match mgr
609 .spawn(
610 name,
611 prompt,
612 provider,
613 tool_executor,
614 skills,
615 &cfg,
616 spawn_ctx,
617 )
618 .await
619 {
620 Ok(id) => id,
621 Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
622 };
623 let short = task_id[..8.min(task_id.len())].to_owned();
624 let _ = self
625 .channel
626 .send(&format!("Sub-agent '{name}' running... (id: {short})"))
627 .await;
628 let _ = self
629 .channel
630 .notify_foreground_subagent_started(&task_id, name)
631 .await;
632 let label = format!("Sub-agent '{name}'");
633 let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
634 let _ = self
636 .channel
637 .notify_foreground_subagent_completed(&task_id, name, false)
638 .await;
639 return None;
640 };
641 let _ = self
642 .channel
643 .notify_foreground_subagent_completed(&task_id, name, success)
644 .await;
645 Some(result)
646 }
647 fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
648 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
649 let ids: Vec<String> = mgr
651 .statuses()
652 .into_iter()
653 .map(|(task_id, _)| task_id)
654 .filter(|task_id| task_id.starts_with(id))
655 .collect();
656 match ids.as_slice() {
657 [] => Some(format!("No sub-agent with id prefix '{id}'")),
658 [full_id] => {
659 let full_id = full_id.clone();
660 match mgr.cancel(&full_id) {
661 Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
662 Err(e) => Some(format!("Cancel failed: {e}")),
663 }
664 }
665 _ => Some(format!(
666 "Ambiguous id prefix '{id}': matches {} agents",
667 ids.len()
668 )),
669 }
670 }
671 async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
672 let cfg = self.services.orchestration.subagent_config.clone();
673 let def_name = {
676 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
677 match mgr.def_name_for_resume(id, &cfg).await {
678 Ok(name) => name,
679 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
680 }
681 };
682 let skills = self.filtered_skills_for(&def_name);
683 let provider = self.provider.clone();
684 let tool_executor = Arc::clone(&self.tool_executor);
685 let spawn_ctx = self.build_spawn_context(&cfg);
694 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
695 let (task_id, _) = match mgr
696 .resume(
697 id,
698 prompt,
699 provider,
700 tool_executor,
701 skills,
702 &cfg,
703 Some(&spawn_ctx),
704 )
705 .await
706 {
707 Ok(pair) => pair,
708 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
709 };
710 let short = task_id[..8.min(task_id.len())].to_owned();
711 let _ = self
712 .channel
713 .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
714 .await;
715 let _ = self
716 .channel
717 .notify_foreground_subagent_started(&task_id, &def_name)
718 .await;
719 let Some((result, success)) = self
720 .poll_subagent_until_done(&task_id, "Resumed sub-agent")
721 .await
722 else {
723 let _ = self
725 .channel
726 .notify_foreground_subagent_completed(&task_id, &def_name, false)
727 .await;
728 return None;
729 };
730 let _ = self
731 .channel
732 .notify_foreground_subagent_completed(&task_id, &def_name, success)
733 .await;
734 Some(result)
735 }
736 pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
762 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
763 let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
764 let reg = self.services.skill.registry.read();
765 let skills = match zeph_subagent::filter_skills(®, &def.skills) {
766 Ok(skills) => skills,
767 Err(e) => {
768 tracing::warn!(error = %e, "skill filtering failed for sub-agent");
769 return None;
770 }
771 };
772 if skills.is_empty() {
773 return None;
774 }
775
776 if !def.skills.include.is_empty() {
779 return Some(skills.into_iter().map(|s| s.body).collect());
780 }
781
782 let total = skills.len();
783 let budget = self.services.skill.subagent_skill_token_budget;
784 let counter = &self.runtime.metrics.token_counter;
785
786 let mut bodies: Vec<String> = Vec::with_capacity(total);
787 let mut running_tokens = 0usize;
788 let mut omitted_names: Vec<&str> = Vec::new();
789
790 for skill in &skills {
791 let skill_tokens = counter.count_tokens(&skill.body);
792 if !bodies.is_empty() && running_tokens + skill_tokens > budget {
793 omitted_names.push(skill.meta.name.as_str());
794 continue;
795 }
796 running_tokens += skill_tokens;
797 bodies.push(skill.body.clone());
798 }
799
800 if !omitted_names.is_empty() {
801 let included = bodies.len();
802 tracing::warn!(
803 agent_name,
804 included,
805 total,
806 budget_tokens = budget,
807 "sub-agent skill body budget exceeded; truncated skill set"
808 );
809 bodies.push(format!(
810 "[skill budget: {included}/{total} skills included, budget={budget} tokens — omitted: {}]",
811 omitted_names.join(", ")
812 ));
813 }
814
815 Some(bodies)
816 }
817 pub(super) fn effective_delegation_mode(&self) -> zeph_config::DelegationMode {
826 self.services
827 .orchestration
828 .subagent_config
829 .effective_delegation_mode()
830 }
831 pub(super) fn build_spawn_context(
833 &self,
834 cfg: &zeph_config::SubAgentConfig,
835 ) -> zeph_subagent::SpawnContext {
836 zeph_subagent::SpawnContext {
837 parent_messages: self.extract_parent_messages(cfg),
838 parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
839 parent_provider_name: {
840 let name = &self.runtime.config.active_provider_name;
841 if name.is_empty() {
842 None
843 } else {
844 Some(name.clone())
845 }
846 },
847 spawn_depth: self.runtime.config.spawn_depth,
848 mcp_tool_names: self.extract_mcp_tool_names(),
849 seed_trajectory_score: {
851 let child = self.services.security.trajectory.spawn_child();
852 let score = child.score_now();
853 if score > 0.0 { Some(score) } else { None }
854 },
855 content_isolation: self.runtime.config.security.content_isolation.clone(),
856 orchestrator_name: Some("zeph".to_owned()),
857 orchestrator_role: Some("orchestrator".to_owned()),
858 session_mcp_servers: Vec::new(),
859 debug_dump_sink: self.runtime.debug.debug_dumper.clone().map(|d| {
867 Arc::new(crate::debug_dump::PiiScrubbingDumpSink::new(
868 d,
869 self.services.security.pii_filter.clone(),
870 )) as Arc<dyn zeph_llm::debug_dump::DebugDumpSink>
871 }),
872 max_trust_level: Some(self.parent_effective_trust_level()),
879 origin: zeph_subagent::SpawnOrigin::Explicit,
887 inherited_tool_allowlist: self
908 .runtime
909 .config
910 .permission_policy
911 .effective_tool_allowlist(
912 self.tool_executor
913 .tool_definitions_erased()
914 .into_iter()
915 .map(|d| zeph_subagent::normalize_tool_id(d.id.as_ref())),
916 ),
917 ..Default::default()
918 }
919 }
920 fn parent_effective_trust_level(&self) -> zeph_common::SkillTrustLevel {
928 if self.services.skill.active_skill_names.is_empty() {
929 return zeph_common::SkillTrustLevel::Trusted;
930 }
931 let snapshot = self.services.skill.trust_snapshot.read();
932 self.services
933 .skill
934 .active_skill_names
935 .iter()
936 .filter_map(|name| snapshot.get(name).map(|s| s.trust_level))
937 .fold(zeph_common::SkillTrustLevel::Trusted, |acc, lvl| {
938 acc.min_trust(lvl)
939 })
940 }
941 fn extract_parent_messages(
948 &self,
949 config: &zeph_config::SubAgentConfig,
950 ) -> Vec<zeph_llm::provider::Message> {
951 use zeph_config::ParentContextPolicy;
952 use zeph_llm::provider::Role;
953
954 if config.parent_context_policy == ParentContextPolicy::None
955 || config.context_window_turns == 0
956 {
957 return Vec::new();
958 }
959
960 let non_system: Vec<_> = self
961 .msg
962 .messages
963 .iter()
964 .filter(|m| m.role != Role::System)
965 .cloned()
966 .collect();
967
968 let take_count = config
969 .context_window_turns
970 .saturating_mul(2)
971 .min(config.max_parent_messages);
972 let start = non_system.len().saturating_sub(take_count);
973 let mut msgs = non_system[start..].to_vec();
974
975 let max_chars = 128_000usize / 4;
977 let requested = msgs.len();
978 trim_parent_messages(&mut msgs, max_chars);
979 if msgs.len() < requested {
980 tracing::info!(
981 kept = msgs.len(),
982 requested,
983 "[subagent] truncated parent history due to token budget or orphan pruning"
984 );
985 }
986
987 if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
988 use zeph_sanitizer::{ContentSource, ContentSourceKind};
989 let source =
990 ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
991 msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
992 }
993
994 msgs
995 }
996 fn extract_mcp_tool_names(&self) -> Vec<String> {
998 self.tool_executor
999 .tool_definitions_erased()
1000 .into_iter()
1001 .filter(ToolDef::is_mcp_tool)
1002 .map(|t| t.id.to_string())
1003 .collect()
1004 }
1005 pub(super) fn classify_source_kind(
1009 skill_dir: &std::path::Path,
1010 managed_dir: Option<&std::path::PathBuf>,
1011 bundled_names: &std::collections::HashSet<String>,
1012 ) -> zeph_memory::store::SourceKind {
1013 if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
1014 let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1015 let has_marker = skill_dir.join(".bundled").exists();
1016 if has_marker && bundled_names.contains(skill_name) {
1017 zeph_memory::store::SourceKind::Bundled
1018 } else {
1019 if has_marker {
1020 tracing::warn!(
1021 skill = %skill_name,
1022 "skill has .bundled marker but is not in the bundled skill \
1023 allowlist — classifying as Hub"
1024 );
1025 }
1026 zeph_memory::store::SourceKind::Hub
1027 }
1028 } else {
1029 zeph_memory::store::SourceKind::Local
1030 }
1031 }
1032}
1033
1034enum DurableSpawnGate {
1036 Fresh(zeph_subagent::DurableResolverSeat),
1039 Replayed {
1045 result: zeph_subagent::SubagentResult,
1046 promise_id: zeph_durable::PromiseId,
1047 },
1048 None,
1058}
1059
1060async fn resolve_durable_spawn_gate(
1064 enabled: bool,
1065 ctx: Option<&zeph_durable::DurableContext>,
1066) -> DurableSpawnGate {
1067 let Some(ctx) = ctx.filter(|_| enabled) else {
1068 return DurableSpawnGate::None;
1069 };
1070 let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
1071 Ok(pair) => pair,
1072 Err(e) => {
1073 tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
1074 return DurableSpawnGate::None;
1075 }
1076 };
1077 if let Some(seat) = seat {
1078 return DurableSpawnGate::Fresh(seat);
1079 }
1080 match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
1083 Ok(Some(result)) => DurableSpawnGate::Replayed {
1084 result,
1085 promise_id: promise.id(),
1086 },
1087 Ok(None) => {
1088 tracing::warn!(
1097 "durable: resumed sub-agent promise still pending after restart — original \
1098 child did not resolve before the crash; re-spawning may duplicate side effects \
1099 (#5944 residual v1 gap)"
1100 );
1101 DurableSpawnGate::None
1102 }
1103 Err(e) => {
1104 tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
1105 DurableSpawnGate::None
1106 }
1107 }
1108}
1109
1110pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
1118 use zeph_llm::provider::MessagePart;
1119 if m.parts.is_empty() {
1120 return m.content.len();
1121 }
1122 m.parts
1123 .iter()
1124 .map(|p| match p {
1125 MessagePart::Text { text }
1126 | MessagePart::Recall { text }
1127 | MessagePart::CodeContext { text }
1128 | MessagePart::Summary { text }
1129 | MessagePart::CrossSession { text } => text.len(),
1130 MessagePart::ToolOutput { body, .. } => body.len(),
1131 MessagePart::ToolUse { id, name, input } => {
1132 50 + id.len() + name.len() + input.to_string().len()
1133 }
1134 MessagePart::ToolResult {
1135 tool_use_id,
1136 content,
1137 ..
1138 } => 50 + tool_use_id.len() + content.len(),
1139 MessagePart::Image(img) => img.data.len() * 4 / 3,
1140 MessagePart::ThinkingBlock {
1141 thinking,
1142 signature,
1143 } => 50 + thinking.len() + signature.len(),
1144 MessagePart::RedactedThinkingBlock { data } => data.len(),
1145 MessagePart::Compaction { summary } => summary.len(),
1146 _ => 0,
1147 })
1148 .sum()
1149}
1150
1151pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
1170 use zeph_llm::provider::{MessagePart, Role};
1171
1172 let mut total_chars = 0usize;
1176 let mut drop_before = 0usize; for (i, m) in msgs.iter().enumerate().rev() {
1178 total_chars += estimate_parts_size(m);
1179 if total_chars > max_chars {
1180 drop_before = i + 1;
1181 break;
1182 }
1183 }
1184 if drop_before > 0 {
1185 msgs.drain(..drop_before);
1186 }
1187
1188 let emitted_tool_ids: std::collections::HashSet<String> = msgs
1192 .iter()
1193 .filter(|m| m.role == Role::Assistant)
1194 .flat_map(|m| m.parts.iter())
1195 .filter_map(|p| {
1196 if let MessagePart::ToolUse { id, .. } = p {
1197 Some(id.clone())
1198 } else {
1199 None
1200 }
1201 })
1202 .collect();
1203
1204 let mut orphans_removed = 0usize;
1205 for m in msgs.iter_mut() {
1206 if m.role != Role::User || m.parts.is_empty() {
1207 continue;
1208 }
1209 let before = m.parts.len();
1210 m.parts.retain(|p| match p {
1211 MessagePart::ToolResult { tool_use_id, .. } => {
1212 emitted_tool_ids.contains(tool_use_id.as_str())
1213 }
1214 _ => true,
1215 });
1216 let dropped = before - m.parts.len();
1217 if dropped > 0 {
1218 orphans_removed += dropped;
1219 if m.parts.is_empty() {
1220 m.content.clear();
1221 } else {
1222 m.rebuild_content();
1223 }
1224 }
1225 }
1226
1227 let consumed_tool_ids: std::collections::HashSet<String> = msgs
1235 .iter()
1236 .filter(|m| m.role == Role::User)
1237 .flat_map(|m| m.parts.iter())
1238 .filter_map(|p| {
1239 if let MessagePart::ToolResult { tool_use_id, .. } = p {
1240 Some(tool_use_id.clone())
1241 } else {
1242 None
1243 }
1244 })
1245 .collect();
1246
1247 let last_assistant_idx = msgs
1249 .iter()
1250 .enumerate()
1251 .rev()
1252 .find(|(_, m)| m.role == Role::Assistant)
1253 .map(|(i, _)| i);
1254
1255 for (idx, m) in msgs.iter_mut().enumerate() {
1256 if m.role != Role::Assistant || m.parts.is_empty() {
1257 continue;
1258 }
1259 if Some(idx) == last_assistant_idx {
1261 continue;
1262 }
1263 let before = m.parts.len();
1264 m.parts.retain(|p| match p {
1265 MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1266 _ => true,
1267 });
1268 let dropped = before - m.parts.len();
1269 if dropped > 0 {
1270 orphans_removed += dropped;
1271 if m.parts.is_empty() {
1272 m.content.clear();
1273 } else {
1274 m.rebuild_content();
1275 }
1276 }
1277 }
1278
1279 msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1281
1282 if orphans_removed > 0 {
1283 tracing::debug!(
1284 orphans = orphans_removed,
1285 "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1286 );
1287 }
1288}
1289
1290fn sanitize_parent_messages(
1296 mut msgs: Vec<zeph_llm::provider::Message>,
1297 sanitizer: &zeph_sanitizer::ContentSanitizer,
1298 source: &zeph_sanitizer::ContentSource,
1299) -> Vec<zeph_llm::provider::Message> {
1300 use zeph_llm::provider::MessagePart;
1301 for msg in &mut msgs {
1302 let mut changed = false;
1303 for part in &mut msg.parts {
1304 if let MessagePart::Text { text } = part {
1305 let clean = sanitizer.sanitize(text, source.clone());
1306 if clean.body != *text {
1307 *text = clean.body;
1308 changed = true;
1309 }
1310 }
1311 }
1312 if changed {
1313 msg.rebuild_content();
1314 }
1315 }
1316 msgs
1317}
1318
1319impl<C: Channel + Send + 'static> zeph_commands::SubagentAccess for Agent<C> {
1320 fn handle_agent_dispatch<'a>(
1323 &'a mut self,
1324 input: &'a str,
1325 ) -> std::pin::Pin<
1326 Box<
1327 dyn std::future::Future<Output = Result<Option<String>, zeph_commands::CommandError>>
1328 + Send
1329 + 'a,
1330 >,
1331 > {
1332 Box::pin(async move {
1333 match self.dispatch_agent_command(input).await {
1334 Some(Err(e)) => Err(zeph_commands::CommandError::new(e.to_string())),
1335 Some(Ok(())) | None => Ok(None),
1336 }
1337 })
1338 }
1339
1340 fn handle_agents<'a>(
1343 &'a mut self,
1344 args: &'a str,
1345 ) -> std::pin::Pin<
1346 Box<
1347 dyn std::future::Future<Output = Result<String, zeph_commands::CommandError>>
1348 + Send
1349 + 'a,
1350 >,
1351 > {
1352 use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1353 use zeph_subagent::AgentsCommand;
1354
1355 let args_owned = args.trim().to_owned();
1356 Box::pin(async move {
1357 let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1359
1360 let fleet_section = if show_fleet {
1361 let snapshots = self.services.autonomous_registry.list();
1362 let entries: Vec<FleetEntry> = snapshots
1363 .into_iter()
1364 .map(|s| FleetEntry {
1365 goal_id: s.goal_id,
1366 goal_text_short: s.goal_text_short,
1367 state: s.state,
1368 turns_executed: s.turns_executed,
1369 max_turns: s.max_turns,
1370 elapsed: s.elapsed,
1371 })
1372 .collect();
1373 format_fleet_section(&entries)
1374 } else {
1375 String::new()
1376 };
1377
1378 let definitions_section = if show_fleet || args_owned == "list" {
1380 self.handle_agents_definitions_list()
1381 } else {
1382 match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1384 Ok(cmd) => self.handle_agents_crud(cmd),
1385 Err(e) => e.to_string(),
1386 }
1387 };
1388
1389 let mut out = fleet_section;
1390 if !definitions_section.is_empty() {
1391 if !out.is_empty() {
1392 out.push('\n');
1393 }
1394 out.push_str(&definitions_section);
1395 }
1396
1397 if out.is_empty() {
1398 "No active autonomous sessions or sub-agent definitions found."
1399 .clone_into(&mut out);
1400 }
1401
1402 Ok(out)
1403 })
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use zeph_tools::{ErasedToolExecutor, ToolCall};
1410
1411 use super::*;
1412 use crate::agent::agent_tests::*;
1413
1414 fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1417 let provider = mock_provider(vec![]);
1418 let channel = MockChannel::new(vec![]);
1419 let registry = create_test_registry();
1420 let executor = MockToolExecutor::no_tools();
1421 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1422 agent.services.skill.available_custom_secrets.insert(
1423 stored_key.to_owned(),
1424 crate::vault::Secret::new(value.to_owned()),
1425 );
1426 agent
1427 }
1428
1429 #[test]
1430 fn resolve_subagent_secret_exact_match() {
1431 let agent = agent_with_custom_secret("my_key", "the-value");
1432 let resolved = agent.resolve_subagent_secret("my_key");
1433 assert_eq!(
1434 resolved.map(|s| s.expose().to_owned()),
1435 Some("the-value".to_owned())
1436 );
1437 }
1438
1439 #[test]
1440 fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1441 let agent = agent_with_custom_secret("my_api_key", "dash-value");
1444 let resolved = agent.resolve_subagent_secret("my-api-key");
1445 assert_eq!(
1446 resolved.map(|s| s.expose().to_owned()),
1447 Some("dash-value".to_owned())
1448 );
1449 }
1450
1451 #[test]
1452 fn resolve_subagent_secret_normalizes_case() {
1453 let agent = agent_with_custom_secret("upper_key", "case-value");
1454 let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1455 assert_eq!(
1456 resolved.map(|s| s.expose().to_owned()),
1457 Some("case-value".to_owned())
1458 );
1459 }
1460
1461 #[test]
1462 fn resolve_subagent_secret_missing_key_returns_none() {
1463 let agent = agent_with_custom_secret("known_key", "value");
1464 assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1465 }
1466
1467 #[test]
1468 fn resolve_subagent_secret_empty_map_returns_none() {
1469 let provider = mock_provider(vec![]);
1470 let channel = MockChannel::new(vec![]);
1471 let registry = create_test_registry();
1472 let executor = MockToolExecutor::no_tools();
1473 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1474 assert!(agent.resolve_subagent_secret("anything").is_none());
1475 }
1476
1477 #[tokio::test]
1480 async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1481 use zeph_tools::registry::InvocationHint;
1482
1483 let provider = mock_provider(vec![]);
1484 let channel = MockChannel::new(vec![]);
1485 let registry = create_test_registry();
1486 let executor = MockToolExecutor::no_tools().with_definitions(vec![
1487 ToolDef {
1488 id: "read".into(),
1489 description: "built-in tool".into(),
1490 schema: schemars::Schema::default(),
1491 invocation: InvocationHint::ToolCall,
1492 output_schema: None,
1493 server_id: None,
1494 },
1495 ToolDef {
1496 id: "github_create_issue".into(),
1497 description: "MCP tool".into(),
1498 schema: schemars::Schema::default(),
1499 invocation: InvocationHint::ToolCall,
1500 output_schema: None,
1501 server_id: Some("github".into()),
1502 },
1503 ]);
1504 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1505
1506 assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1507 }
1508
1509 async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1514 let provider = mock_provider(vec!["ok".into()]);
1515 let channel = MockChannel::new(vec![]);
1516 let registry = create_test_registry();
1517 let executor = MockToolExecutor::no_tools();
1518 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1519 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1520 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1521 enabled: true,
1522 agent_turns: true,
1523 ..zeph_config::DurableConfig::default()
1524 });
1525 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1526 agent.services.session.durable_subagent = subagent_enabled;
1527
1528 agent.ensure_session_durable_ctx().await;
1529 assert!(
1530 agent.services.session.durable_ctx.is_some(),
1531 "test setup: durable_ctx must be populated before exercising the seat gate"
1532 );
1533 agent
1534 }
1535
1536 #[tokio::test]
1537 async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1538 let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1539
1540 let gate = resolve_durable_spawn_gate(
1541 agent.services.session.durable_subagent,
1542 agent.services.session.durable_ctx.as_deref(),
1543 )
1544 .await;
1545
1546 assert!(
1547 matches!(gate, DurableSpawnGate::Fresh(_)),
1548 "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1549 not just wire the config-to-builder plumbing"
1550 );
1551 }
1552
1553 #[tokio::test]
1554 async fn seat_absent_when_subagent_disabled() {
1555 let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1556
1557 let gate = resolve_durable_spawn_gate(
1558 agent.services.session.durable_subagent,
1559 agent.services.session.durable_ctx.as_deref(),
1560 )
1561 .await;
1562
1563 assert!(
1564 matches!(gate, DurableSpawnGate::None),
1565 "FR-008: durable_subagent=false must keep the seat gate closed even when \
1566 durable_ctx is populated"
1567 );
1568 }
1569
1570 fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1580 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1581 use zeph_subagent::hooks::SubagentHooks;
1582
1583 zeph_subagent::SubAgentDef {
1584 name: name.to_owned(),
1585 description: "A helper bot".into(),
1586 model: None,
1587 tools: ToolPolicy::InheritAll,
1588 disallowed_tools: vec![],
1589 permissions: SubAgentPermissions::default(),
1590 skills: SkillFilter::default(),
1591 system_prompt: "You are helpful.".into(),
1592 hooks: SubagentHooks::default(),
1593 memory: None,
1594 source: None,
1595 file_path: None,
1596 }
1597 }
1598
1599 async fn agent_with_durable_and_manager(
1603 db_url: &str,
1604 conversation_id: i64,
1605 ) -> Agent<MockChannel> {
1606 let provider = mock_provider(vec!["ok".into()]);
1607 let channel = MockChannel::new(vec![]);
1608 let registry = create_test_registry();
1609 let executor = MockToolExecutor::no_tools();
1610 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1611 agent.services.memory.persistence.conversation_id =
1612 Some(zeph_memory::ConversationId(conversation_id));
1613 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1614 enabled: true,
1615 agent_turns: true,
1616 ..zeph_config::DurableConfig::default()
1617 });
1618 agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1619 agent.services.session.durable_subagent = true;
1620
1621 let mut mgr = zeph_subagent::SubAgentManager::new(4);
1622 mgr.definitions_mut().push(subagent_def("helper"));
1623 agent.services.orchestration.subagent_manager = Some(mgr);
1624
1625 agent.ensure_session_durable_ctx().await;
1626 assert!(
1627 agent.services.session.durable_ctx.is_some(),
1628 "test setup: durable_ctx must be populated before exercising the handler"
1629 );
1630 agent
1631 }
1632
1633 #[tokio::test]
1634 async fn handle_agent_background_replays_finished_child_without_respawning() {
1635 let dir = tempfile::tempdir().unwrap();
1636 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1637
1638 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1640 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1641 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1642 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1643 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1644 Ok("child finished before crash".to_owned());
1645 zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1646 agent1
1647 .services
1648 .session
1649 .durable_writer
1650 .as_ref()
1651 .unwrap()
1652 .flush()
1653 .await
1654 .unwrap();
1655 drop(agent1);
1660
1661 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1664
1665 let resp = agent2
1666 .handle_agent_background("helper", "do work")
1667 .await
1668 .unwrap();
1669 assert!(
1670 resp.contains("replayed from durable journal"),
1671 "expected a replay notice, got: {resp}"
1672 );
1673 assert!(
1674 resp.contains("child finished before crash"),
1675 "expected the journaled output to be surfaced, got: {resp}"
1676 );
1677 assert!(
1678 agent2
1679 .services
1680 .orchestration
1681 .subagent_manager
1682 .as_ref()
1683 .unwrap()
1684 .statuses()
1685 .is_empty(),
1686 "mgr.spawn must not be called when the child result is replayed"
1687 );
1688 }
1689
1690 #[tokio::test]
1691 async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1692 let dir = tempfile::tempdir().unwrap();
1693 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1694
1695 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1697 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1698 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1699 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1700 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1701 Ok("foreground child output".to_owned());
1702 zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1703 ctx1.step(
1711 zeph_durable::StepDescriptor::idempotent(
1712 "post_spawn_marker",
1713 b"post_spawn_marker".to_vec(),
1714 ),
1715 |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1716 )
1717 .await
1718 .unwrap();
1719 agent1
1720 .services
1721 .session
1722 .durable_writer
1723 .as_ref()
1724 .unwrap()
1725 .flush()
1726 .await
1727 .unwrap();
1728 drop(agent1);
1731
1732 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1735
1736 let resp = agent2
1737 .handle_agent_spawn_foreground("helper", "do work")
1738 .await
1739 .unwrap();
1740 assert_eq!(resp, "foreground child output");
1741 assert!(
1742 agent2
1743 .channel
1744 .sent_messages()
1745 .iter()
1746 .any(|m| m.contains("replayed from durable journal")),
1747 "expected the replay notice to be sent to the channel"
1748 );
1749 assert_eq!(
1750 agent2.channel.notify_completed_calls().len(),
1751 1,
1752 "expected exactly one TUI completion notification on the first replay"
1753 );
1754 assert!(
1755 agent2
1756 .services
1757 .orchestration
1758 .subagent_manager
1759 .as_ref()
1760 .unwrap()
1761 .statuses()
1762 .is_empty(),
1763 "mgr.spawn must not be called when the child result is replayed"
1764 );
1765 drop(agent2);
1766
1767 let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1772
1773 let resp = agent3
1774 .handle_agent_spawn_foreground("helper", "do work")
1775 .await
1776 .unwrap();
1777 assert_eq!(resp, "foreground child output");
1778 assert!(
1779 !agent3
1780 .channel
1781 .sent_messages()
1782 .iter()
1783 .any(|m| m.contains("replayed from durable journal")),
1784 "replay notice must not re-fire on a second replay after a parent restart"
1785 );
1786 assert!(
1787 agent3.channel.notify_completed_calls().is_empty(),
1788 "TUI completion event must not re-fire on a second replay after a parent restart"
1789 );
1790 }
1791
1792 #[tokio::test]
1793 async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1796
1797 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1800 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1801 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1802 assert!(
1803 seat.is_some(),
1804 "test setup: run 1 must be fresh and yield a resolver seat"
1805 );
1806 agent1
1807 .services
1808 .session
1809 .durable_writer
1810 .as_ref()
1811 .unwrap()
1812 .flush()
1813 .await
1814 .unwrap();
1815 drop(agent1);
1818
1819 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1823
1824 let resp = agent2
1825 .handle_agent_background("helper", "do work")
1826 .await
1827 .unwrap();
1828 assert!(
1829 resp.contains("started in background"),
1830 "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1831 );
1832 assert_eq!(
1833 agent2
1834 .services
1835 .orchestration
1836 .subagent_manager
1837 .as_ref()
1838 .unwrap()
1839 .statuses()
1840 .len(),
1841 1,
1842 "exactly one real spawn must occur on the still-pending fallback path"
1843 );
1844 }
1845
1846 #[test]
1849 fn build_spawn_context_leaves_debug_dump_sink_none_without_dumper() {
1850 let provider = mock_provider(vec![]);
1851 let channel = MockChannel::new(vec![]);
1852 let registry = create_test_registry();
1853 let agent = Agent::new(
1854 provider,
1855 channel,
1856 registry,
1857 None,
1858 5,
1859 MockToolExecutor::no_tools(),
1860 );
1861
1862 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1863 assert!(
1864 ctx.debug_dump_sink.is_none(),
1865 "no DebugDumper configured, so SpawnContext must carry no sink"
1866 );
1867 }
1868
1869 #[tokio::test]
1870 async fn build_spawn_context_wires_debug_dump_sink_when_dumper_present() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let dumper =
1873 crate::debug_dump::DebugDumper::new(dir.path(), crate::debug_dump::DumpFormat::Raw)
1874 .unwrap();
1875
1876 let provider = mock_provider(vec![]);
1877 let channel = MockChannel::new(vec![]);
1878 let registry = create_test_registry();
1879 let mut agent = Agent::new(
1880 provider,
1881 channel,
1882 registry,
1883 None,
1884 5,
1885 MockToolExecutor::no_tools(),
1886 );
1887 agent.runtime.debug.debug_dumper = Some(dumper);
1888
1889 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1890 let sink = ctx
1891 .debug_dump_sink
1892 .expect("a configured DebugDumper must be threaded into SpawnContext");
1893
1894 let id = sink.dump_request("mock", &[], &[], serde_json::Value::Null);
1897 sink.dump_response(id, &zeph_llm::provider::ChatResponse::Text("ok".into()));
1898 }
1899
1900 #[test]
1903 fn build_spawn_context_leaves_inherited_tool_allowlist_none_by_default() {
1904 let provider = mock_provider(vec![]);
1907 let channel = MockChannel::new(vec![]);
1908 let registry = create_test_registry();
1909 let executor = MockToolExecutor::no_tools().with_definitions(vec![ToolDef {
1910 id: "bash".into(),
1911 description: "shell".into(),
1912 schema: schemars::Schema::default(),
1913 invocation: zeph_tools::registry::InvocationHint::ToolCall,
1914 output_schema: None,
1915 server_id: None,
1916 }]);
1917 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1918
1919 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1920 assert!(ctx.inherited_tool_allowlist.is_none());
1921 }
1922
1923 #[test]
1924 fn build_spawn_context_populates_inherited_tool_allowlist_from_parent_policy() {
1925 let provider = mock_provider(vec![]);
1929 let channel = MockChannel::new(vec![]);
1930 let registry = create_test_registry();
1931 let executor = MockToolExecutor::no_tools().with_definitions(vec![
1932 ToolDef {
1933 id: "bash".into(),
1934 description: "shell".into(),
1935 schema: schemars::Schema::default(),
1936 invocation: zeph_tools::registry::InvocationHint::ToolCall,
1937 output_schema: None,
1938 server_id: None,
1939 },
1940 ToolDef {
1941 id: "read".into(),
1942 description: "read a file".into(),
1943 schema: schemars::Schema::default(),
1944 invocation: zeph_tools::registry::InvocationHint::ToolCall,
1945 output_schema: None,
1946 server_id: None,
1947 },
1948 ]);
1949 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1950
1951 let mut rules = std::collections::HashMap::new();
1952 rules.insert(
1953 "bash".to_owned(),
1954 vec![zeph_config::tools::PermissionRule {
1955 pattern: "*".to_owned(),
1956 action: zeph_config::tools::PermissionAction::Deny,
1957 }],
1958 );
1959 agent.runtime.config.permission_policy = zeph_tools::PermissionPolicy::new(rules)
1960 .with_autonomy(zeph_config::tools::AutonomyLevel::Supervised);
1961
1962 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1963 let allowlist = ctx
1964 .inherited_tool_allowlist
1965 .expect("a wholesale-denied bash tool must produce a narrowed Some(set)");
1966 assert!(!allowlist.contains("bash"));
1967 assert!(allowlist.contains("read"));
1968 }
1969
1970 #[test]
1973 fn build_spawn_context_leaves_max_trust_level_trusted_when_no_active_skills() {
1974 let provider = mock_provider(vec![]);
1975 let channel = MockChannel::new(vec![]);
1976 let registry = create_test_registry();
1977 let agent = Agent::new(
1978 provider,
1979 channel,
1980 registry,
1981 None,
1982 5,
1983 MockToolExecutor::no_tools(),
1984 );
1985
1986 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1987 assert_eq!(
1988 ctx.max_trust_level,
1989 Some(zeph_common::SkillTrustLevel::Trusted),
1990 "with no active skills this turn, the parent's own effective trust is Trusted, \
1991 so the cap must impose no additional restriction"
1992 );
1993 }
1994
1995 #[test]
1996 fn build_spawn_context_caps_trust_to_least_trusted_active_skill() {
1997 let provider = mock_provider(vec![]);
1998 let channel = MockChannel::new(vec![]);
1999 let registry = create_test_registry();
2000 let mut agent = Agent::new(
2001 provider,
2002 channel,
2003 registry,
2004 None,
2005 5,
2006 MockToolExecutor::no_tools(),
2007 );
2008 agent.services.skill.active_skill_names = vec!["trusted-skill".into(), "evil-skill".into()];
2009 agent.services.skill.trust_snapshot.write().insert(
2010 "trusted-skill".into(),
2011 crate::skill_invoker::SkillTrustSnapshot {
2012 trust_level: zeph_common::SkillTrustLevel::Trusted,
2013 requires_trust_check: false,
2014 blake3_hash: String::new(),
2015 },
2016 );
2017 agent.services.skill.trust_snapshot.write().insert(
2018 "evil-skill".into(),
2019 crate::skill_invoker::SkillTrustSnapshot {
2020 trust_level: zeph_common::SkillTrustLevel::Quarantined,
2021 requires_trust_check: false,
2022 blake3_hash: String::new(),
2023 },
2024 );
2025
2026 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2027 assert_eq!(
2028 ctx.max_trust_level,
2029 Some(zeph_common::SkillTrustLevel::Quarantined),
2030 "the cap must be the LEAST-trusted of all active skills this turn (weakest-link), \
2031 matching the fold `apply_skill_trust_and_gating` applies to the parent's own gate"
2032 );
2033 }
2034
2035 #[derive(Default)]
2043 struct TrustRecordingExecutor {
2044 recorded: Arc<Mutex<Option<zeph_tools::SkillTrustLevel>>>,
2045 }
2046
2047 impl ToolExecutor for TrustRecordingExecutor {
2048 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2049 Ok(None)
2050 }
2051
2052 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2053
2054 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
2055 *self.recorded.lock().unwrap() = Some(level);
2056 }
2057
2058 zeph_tools::tool_executor_no_inner_defaults!();
2059 }
2060
2061 #[tokio::test]
2062 async fn spawning_a_subagent_caps_trust_to_parent_effective_level() {
2063 let provider = mock_provider(vec![]);
2064 let channel = MockChannel::new(vec![]);
2065 let registry = create_test_registry();
2066 let executor = TrustRecordingExecutor::default();
2067 let recorded = Arc::clone(&executor.recorded);
2068 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2069
2070 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2071 mgr.definitions_mut().push(subagent_def("helper"));
2072 agent.services.orchestration.subagent_manager = Some(mgr);
2073
2074 agent.services.skill.active_skill_names = vec!["evil-skill".into()];
2076 agent.services.skill.trust_snapshot.write().insert(
2077 "evil-skill".into(),
2078 crate::skill_invoker::SkillTrustSnapshot {
2079 trust_level: zeph_common::SkillTrustLevel::Quarantined,
2080 requires_trust_check: false,
2081 blake3_hash: String::new(),
2082 },
2083 );
2084
2085 let resp = agent.handle_agent_background("helper", "do work").await;
2086 assert!(
2087 resp.is_some_and(|r| r.contains("started in background")),
2088 "test setup: the real production spawn path must succeed"
2089 );
2090
2091 assert_eq!(
2092 *recorded.lock().unwrap(),
2093 Some(zeph_tools::SkillTrustLevel::Quarantined),
2094 "a sub-agent spawned while the parent's own effective trust is Quarantined must \
2095 never receive a higher (Trusted) effective trust on its own tool executor — \
2096 #6493's escalation gap"
2097 );
2098 }
2099
2100 #[derive(Default)]
2108 struct RecordingExecutor {
2109 calls: Mutex<u32>,
2110 }
2111
2112 impl ToolExecutor for RecordingExecutor {
2113 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2114 Ok(None)
2115 }
2116
2117 async fn execute_tool_call(
2118 &self,
2119 call: &ToolCall,
2120 ) -> Result<Option<ToolOutput>, ToolError> {
2121 *self.calls.lock().unwrap() += 1;
2122 Ok(Some(ToolOutput {
2123 tool_name: call.tool_id.clone(),
2124 summary: "ran".into(),
2125 blocks_executed: 1,
2126 ..Default::default()
2127 }))
2128 }
2129
2130 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2131
2132 zeph_tools::tool_executor_no_inner_defaults!();
2133 }
2134
2135 #[tokio::test]
2136 async fn spawning_a_subagent_tool_call_reaches_parents_own_executor() {
2137 use zeph_llm::provider::{ChatResponse, ToolUseRequest};
2146
2147 let (mock, _counter) = MockProvider::default().with_tool_use(vec![
2148 ChatResponse::ToolUse {
2149 text: None,
2150 tool_calls: vec![ToolUseRequest {
2151 id: "call-1".into(),
2152 name: "bash".into(),
2153 input: serde_json::json!({"command": "echo hi"}),
2154 }],
2155 thinking_blocks: vec![],
2156 },
2157 ChatResponse::Text("final answer".into()),
2158 ]);
2159
2160 let channel = MockChannel::new(vec![]);
2161 let registry = create_test_registry();
2162 let recorder = Arc::new(RecordingExecutor::default());
2163 let mut agent = Agent::new(
2164 AnyProvider::Mock(mock),
2165 channel,
2166 registry,
2167 None,
2168 5,
2169 RecordingExecutor::default(),
2170 );
2171 agent.tool_executor = Arc::clone(&recorder) as Arc<dyn ErasedToolExecutor>;
2174
2175 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2176 mgr.definitions_mut().push(subagent_def("helper"));
2177 agent.services.orchestration.subagent_manager = Some(mgr);
2178
2179 let resp = agent.handle_agent_background("helper", "do work").await;
2180 assert!(
2181 resp.is_some_and(|r| r.contains("started in background")),
2182 "test setup: the real production spawn path must succeed"
2183 );
2184
2185 for _ in 0..50 {
2186 if !agent.poll_subagents().await.is_empty() {
2187 break;
2188 }
2189 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2190 }
2191
2192 assert!(
2193 *recorder.calls.lock().unwrap() >= 1,
2194 "the sub-agent's tool call must reach the parent's own tool executor instance, \
2195 proving no fresh/ungated executor is substituted for the child"
2196 );
2197 }
2198
2199 #[tokio::test]
2209 async fn notify_completed_subagents_notifies_channel_of_background_completion() {
2210 let provider = mock_provider(vec!["done".into()]);
2211 let channel = MockChannel::new(vec![]);
2212 let registry = create_test_registry();
2213 let executor = MockToolExecutor::no_tools();
2214 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2215
2216 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2217 mgr.definitions_mut().push(subagent_def("helper"));
2218 agent.services.orchestration.subagent_manager = Some(mgr);
2219
2220 let resp = agent.handle_agent_background("helper", "do work").await;
2221 assert!(
2222 resp.is_some_and(|r| r.contains("started in background")),
2223 "test setup: the background spawn must succeed"
2224 );
2225
2226 let mut notified = Vec::new();
2227 for _ in 0..50 {
2228 agent.notify_completed_subagents().await.unwrap();
2229 notified = agent.channel.notify_background_completed_calls();
2230 if !notified.is_empty() {
2231 break;
2232 }
2233 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2234 }
2235
2236 assert_eq!(
2237 notified.len(),
2238 1,
2239 "exactly one background-completion notification must be recorded"
2240 );
2241 let (_task_id, name, success) = ¬ified[0];
2242 assert_eq!(name, "helper");
2243 assert!(
2244 *success,
2245 "MockProvider's clean text response must be treated as a success"
2246 );
2247 }
2248
2249 #[tokio::test]
2256 async fn notify_completed_subagents_masks_generic_secret_shape_in_notice() {
2257 let provider = mock_provider(vec![
2261 "thinking about it".into(),
2262 "here is a key: sk-test-abc123def456, use it wisely".into(),
2263 ]);
2264 let channel = MockChannel::new(vec![]);
2265 let registry = create_test_registry();
2266 let executor = MockToolExecutor::no_tools();
2267 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2268
2269 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2270 mgr.definitions_mut().push(subagent_def("helper"));
2271 agent.services.orchestration.subagent_manager = Some(mgr);
2272
2273 let resp = agent.handle_agent_background("helper", "do work").await;
2274 assert!(
2275 resp.is_some_and(|r| r.contains("started in background")),
2276 "test setup: the background spawn must succeed"
2277 );
2278
2279 let mut sent = Vec::new();
2280 for _ in 0..50 {
2281 agent.notify_completed_subagents().await.unwrap();
2282 sent = agent.channel.sent_messages();
2283 if !sent.is_empty() {
2284 break;
2285 }
2286 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2287 }
2288
2289 let notice = sent
2290 .iter()
2291 .find(|m| m.contains("completed"))
2292 .expect("a completion notice must have been sent");
2293 assert!(
2294 !notice.contains("sk-test-abc123def456"),
2295 "generic secret-shaped string must not appear verbatim in the completion notice: {notice}"
2296 );
2297 assert!(
2298 notice.contains("[REDACTED]"),
2299 "masked placeholder must be present in the completion notice: {notice}"
2300 );
2301 }
2302
2303 fn registry_with_skills(
2313 count: usize,
2314 words_per_skill: usize,
2315 ) -> (SkillRegistry, tempfile::TempDir) {
2316 let temp_dir = tempfile::tempdir().unwrap();
2317 for i in 0..count {
2318 let skill_dir = temp_dir.path().join(format!("skill-{i}"));
2319 std::fs::create_dir(&skill_dir).unwrap();
2320 let body = "lorem ".repeat(words_per_skill);
2321 std::fs::write(
2322 skill_dir.join("SKILL.md"),
2323 format!("---\nname: skill-{i}\ndescription: Test skill {i}\n---\n{body}"),
2324 )
2325 .unwrap();
2326 }
2327 let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
2328 (registry, temp_dir)
2329 }
2330
2331 fn agent_with_skill_registry_and_def(
2332 registry: SkillRegistry,
2333 def: zeph_subagent::SubAgentDef,
2334 ) -> Agent<MockChannel> {
2335 let provider = mock_provider(vec![]);
2336 let channel = MockChannel::new(vec![]);
2337 let executor = MockToolExecutor::no_tools();
2338 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2339 let mut mgr = zeph_subagent::SubAgentManager::new(4);
2340 mgr.definitions_mut().push(def);
2341 agent.services.orchestration.subagent_manager = Some(mgr);
2342 agent
2343 }
2344
2345 fn agent_with_skill_registry_and_helper_def(registry: SkillRegistry) -> Agent<MockChannel> {
2346 agent_with_skill_registry_and_def(registry, subagent_def("helper"))
2347 }
2348
2349 #[test]
2350 fn filtered_skills_for_under_budget_returns_all_bodies_no_marker() {
2351 let (registry, _temp_dir) = registry_with_skills(3, 20);
2354 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2355 agent.services.skill.subagent_skill_token_budget = 1_000_000;
2356
2357 let bodies = agent
2358 .filtered_skills_for("helper")
2359 .expect("3 skills with a huge budget must return Some");
2360
2361 assert_eq!(
2362 bodies.len(),
2363 3,
2364 "no truncation marker expected when everything fits under budget"
2365 );
2366 for body in &bodies {
2367 assert!(
2368 body.contains("lorem"),
2369 "every returned entry must be a real skill body, not a marker: {body}"
2370 );
2371 }
2372 }
2373
2374 #[test]
2375 fn filtered_skills_for_over_budget_truncates_with_marker() {
2376 let (registry, _temp_dir) = registry_with_skills(5, 500);
2379 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2380 agent.services.skill.subagent_skill_token_budget = 10;
2381
2382 let bodies = agent
2383 .filtered_skills_for("helper")
2384 .expect("at least the first skill must always be included");
2385
2386 let marker_count = bodies
2387 .iter()
2388 .filter(|b| b.starts_with("[skill budget:"))
2389 .count();
2390 assert_eq!(
2391 marker_count, 1,
2392 "exactly one truncation marker entry must be appended, got bodies: {bodies:?}"
2393 );
2394 let marker = bodies
2395 .iter()
2396 .find(|b| b.starts_with("[skill budget:"))
2397 .unwrap();
2398 let included = bodies.len() - 1;
2399 assert!(
2400 included < 5,
2401 "budget=10 tokens must not fit all 5 large skills, included={included}"
2402 );
2403 assert!(
2404 included >= 1,
2405 "the first skill must always be included even when it alone exceeds the budget, \
2406 got included={included}"
2407 );
2408 assert!(
2409 bodies[0].contains("lorem"),
2410 "the always-included first entry must be a real skill body, not the marker: {}",
2411 bodies[0]
2412 );
2413 assert!(
2414 marker.contains(&format!("{included}/5 skills included")),
2415 "marker must report the correct included/total count: {marker}"
2416 );
2417 assert!(
2418 marker.contains("budget=10 tokens"),
2419 "marker must report the configured budget: {marker}"
2420 );
2421 }
2422
2423 #[test]
2424 fn filtered_skills_for_mid_budget_greedily_fills_multiple_fitting_skills() {
2425 let (registry, _temp_dir) = registry_with_skills(5, 500);
2432 let single_body = "lorem ".repeat(500);
2433 let per_skill_tokens = zeph_memory::TokenCounter::new().count_tokens(&single_body);
2434 assert!(
2435 per_skill_tokens > 1,
2436 "test setup: per-skill token count must be large enough for 2*T+1 to exclude a 3rd \
2437 skill, got {per_skill_tokens}"
2438 );
2439
2440 let mut agent = agent_with_skill_registry_and_helper_def(registry);
2441 agent.services.skill.subagent_skill_token_budget = 2 * per_skill_tokens + 1;
2442
2443 let bodies = agent
2444 .filtered_skills_for("helper")
2445 .expect("at least the first skill must always be included");
2446
2447 let marker = bodies
2448 .iter()
2449 .find(|b| b.starts_with("[skill budget:"))
2450 .unwrap_or_else(|| panic!("expected a truncation marker, got bodies: {bodies:?}"));
2451 let included = bodies.len() - 1;
2452 assert_eq!(
2453 included, 2,
2454 "budget=2*T+1 must fit exactly 2 of the 5 identical-cost skills, got {included}"
2455 );
2456 assert!(
2457 marker.contains("2/5 skills included"),
2458 "marker must report the correct included/total count: {marker}"
2459 );
2460 let omitted_segment = marker
2464 .split("omitted: ")
2465 .nth(1)
2466 .and_then(|s| s.strip_suffix(']'))
2467 .unwrap_or_else(|| panic!("marker missing 'omitted: ...]' segment: {marker}"));
2468 let mut omitted_names: Vec<&str> = omitted_segment.split(", ").collect();
2469 omitted_names.sort_unstable();
2470 assert_eq!(
2471 omitted_names,
2472 vec!["skill-2", "skill-3", "skill-4"],
2473 "marker must name exactly the 3 truncated skills, got marker: {marker}"
2474 );
2475 }
2476
2477 #[test]
2478 fn filtered_skills_for_explicit_include_is_never_capped() {
2479 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
2484 use zeph_subagent::hooks::SubagentHooks;
2485
2486 let (registry, _temp_dir) = registry_with_skills(5, 500);
2487 let def = zeph_subagent::SubAgentDef {
2488 name: "curated".to_owned(),
2489 description: "A curated helper".into(),
2490 model: None,
2491 tools: ToolPolicy::InheritAll,
2492 disallowed_tools: vec![],
2493 permissions: SubAgentPermissions::default(),
2494 skills: SkillFilter {
2495 include: vec!["skill-*".to_owned()],
2496 exclude: vec![],
2497 },
2498 system_prompt: "You are helpful.".into(),
2499 hooks: SubagentHooks::default(),
2500 memory: None,
2501 source: None,
2502 file_path: None,
2503 };
2504 let mut agent = agent_with_skill_registry_and_def(registry, def);
2505 agent.services.skill.subagent_skill_token_budget = 10;
2508
2509 let bodies = agent
2510 .filtered_skills_for("curated")
2511 .expect("explicit include must still match all 5 skill-* skills");
2512
2513 assert_eq!(
2514 bodies.len(),
2515 5,
2516 "explicit include list must never be truncated by the budget, got: {bodies:?}"
2517 );
2518 assert!(
2519 bodies.iter().all(|b| b.contains("lorem")),
2520 "every entry must be a real skill body, not a truncation marker: {bodies:?}"
2521 );
2522 }
2523}