1use std::sync::Arc;
12
13use zeph_tools::registry::ToolDef;
14
15use super::{Agent, error};
16use crate::channel::Channel;
17
18const LIVE_TRANSCRIPT_TAIL_LINES: usize = 20;
21
22impl<C: Channel> Agent<C> {
23 pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
31 let normalized = key.to_lowercase().replace('-', "_");
32 self.services
33 .skill
34 .available_custom_secrets
35 .get(&normalized)
36 .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
37 }
38
39 #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
44 pub async fn poll_subagents(&mut self) -> Vec<(String, String)> {
45 let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
46 return vec![];
47 };
48
49 let finished: Vec<String> = mgr
50 .statuses()
51 .into_iter()
52 .filter_map(|(id, status)| {
53 if matches!(
54 status.state,
55 zeph_subagent::SubAgentState::Completed
56 | zeph_subagent::SubAgentState::Failed
57 | zeph_subagent::SubAgentState::Canceled
58 ) {
59 Some(id)
60 } else {
61 None
62 }
63 })
64 .collect();
65
66 let mut results = vec![];
67 for task_id in finished {
68 match mgr.collect(&task_id).await {
69 Ok(result) => results.push((task_id, result)),
70 Err(e) => {
71 tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
72 }
73 }
74 }
75 results
76 }
77 pub(super) fn refresh_subagent_metrics(&mut self) {
84 let Some(ref mgr) = self.services.orchestration.subagent_manager else {
85 return;
86 };
87 let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
88 .statuses()
89 .into_iter()
90 .map(|(id, s)| {
91 let def = mgr.agents_def(&id);
92 crate::metrics::SubAgentMetrics {
93 name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
94 id: id.clone(),
95 state: format!("{:?}", s.state).to_lowercase(),
96 turns_used: s.turns_used,
97 max_turns: def.map_or(20, |d| d.permissions.max_turns),
98 background: def.is_some_and(|d| d.permissions.background),
99 elapsed_secs: s.started_at.elapsed().as_secs(),
100 permission_mode: def.map_or_else(String::new, |d| {
101 use zeph_subagent::def::PermissionMode;
102 match d.permissions.permission_mode {
103 PermissionMode::AcceptEdits => "accept_edits".into(),
104 PermissionMode::DontAsk => "dont_ask".into(),
105 PermissionMode::BypassPermissions => "bypass_permissions".into(),
106 PermissionMode::Plan => "plan".into(),
107 _ => String::new(),
108 }
109 }),
110 transcript_dir: mgr
111 .agent_transcript_dir(&id)
112 .map(|p| p.to_string_lossy().into_owned()),
113 live_transcript: mgr.forwarded_tail(&id, LIVE_TRANSCRIPT_TAIL_LINES),
114 }
115 })
116 .collect();
117 self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
118 }
119 pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
121 let completed = self.poll_subagents().await;
122 for (task_id, result) in completed {
123 let notice = if result.is_empty() {
124 format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
125 } else {
126 format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
127 };
128 if let Err(e) = self.channel.send(¬ice).await {
129 tracing::warn!(error = %e, "failed to send sub-agent completion notice");
130 }
131 }
132 Ok(())
133 }
134 async fn poll_subagent_until_done(
138 &mut self,
139 task_id: &str,
140 label: &str,
141 ) -> Option<(String, bool)> {
142 use zeph_subagent::SubAgentState;
143 let result = loop {
144 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
145
146 #[allow(clippy::redundant_closure_for_method_calls)]
150 let pending = self
151 .services
152 .orchestration
153 .subagent_manager
154 .as_mut()
155 .and_then(|m| m.try_recv_secret_request());
156 if let Some((req_task_id, req)) = pending {
157 let confirm_prompt = format!(
160 "Sub-agent requests secret '{}'. Allow?",
161 crate::text::truncate_to_chars(&req.secret_key, 100)
162 );
163 let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
164 if approved {
165 let ttl = std::time::Duration::from_mins(5);
166 let key = req.secret_key.clone();
167 let resolved = self.resolve_subagent_secret(&key);
168 if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
169 if let Some(secret) = resolved {
170 if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
171 && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
172 {
173 tracing::warn!(error = %e, "sub-agent secret delivery failed");
174 let _ = mgr.deny_secret(&req_task_id);
175 }
176 } else {
177 tracing::warn!(
178 "sub-agent requested secret not resolvable from vault; denying"
179 );
180 let _ = mgr.deny_secret(&req_task_id);
181 }
182 }
183 } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
184 let _ = mgr.deny_secret(&req_task_id);
185 }
186 }
187
188 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
189 let statuses = mgr.statuses();
190 let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
191 break (format!("{label} completed (no status available)."), true);
192 };
193 match status.state {
194 SubAgentState::Completed => {
195 let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
196 break (format!("{label} completed: {msg}"), true);
197 }
198 SubAgentState::Failed => {
199 let msg = status
200 .last_message
201 .clone()
202 .unwrap_or_else(|| "unknown error".into());
203 break (format!("{label} failed: {msg}"), false);
204 }
205 SubAgentState::Canceled => {
206 break (format!("{label} was cancelled."), false);
207 }
208 _ => {
209 self.channel
210 .send_status_best_effort(&format!(
211 "{label}: turn {}/{}",
212 status.turns_used,
213 self.services
214 .orchestration
215 .subagent_manager
216 .as_ref()
217 .and_then(|m| m.agents_def(task_id))
218 .map_or(20, |d| d.permissions.max_turns)
219 ))
220 .await;
221 }
222 }
223 };
224 Some(result)
225 }
226 fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
229 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
230 let full_ids: Vec<String> = mgr
231 .statuses()
232 .into_iter()
233 .map(|(tid, _)| tid)
234 .filter(|tid| tid.starts_with(prefix))
235 .collect();
236 Some(match full_ids.as_slice() {
237 [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
238 [fid] => Ok(fid.clone()),
239 _ => Err(format!(
240 "Ambiguous id prefix '{prefix}': matches {} agents",
241 full_ids.len()
242 )),
243 })
244 }
245 fn handle_agent_list(&self) -> Option<String> {
246 use std::fmt::Write as _;
247 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
248 let defs = mgr.definitions();
249 if defs.is_empty() {
250 return Some("No sub-agent definitions found.".into());
251 }
252 let mut out = String::from("Available sub-agents:\n");
253 for d in defs {
254 let memory_label = match d.memory {
255 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
256 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
257 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
258 Some(_) => " [memory:unknown]",
259 None => "",
260 };
261 if let Some(ref src) = d.source {
262 let _ = writeln!(
263 out,
264 " {}{} — {} ({})",
265 d.name, memory_label, d.description, src
266 );
267 } else {
268 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
269 }
270 }
271 Some(out)
272 }
273 fn handle_agent_status(&self) -> Option<String> {
274 use std::fmt::Write as _;
275 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
276 let statuses = mgr.statuses();
277 if statuses.is_empty() {
278 return Some("No active sub-agents.".into());
279 }
280 let mut out = String::from("Active sub-agents:\n");
281 for (id, s) in &statuses {
282 let state = format!("{:?}", s.state).to_lowercase();
283 let elapsed = s.started_at.elapsed().as_secs();
284 let _ = writeln!(
285 out,
286 " [{short}] {state} turns={t} elapsed={elapsed}s {msg}",
287 short = &id[..8.min(id.len())],
288 t = s.turns_used,
289 msg = s.last_message.as_deref().unwrap_or(""),
290 );
291 if let Some(def) = mgr.agents_def(id)
293 && let Some(scope) = def.memory
294 && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
295 {
296 let _ = writeln!(out, " memory: {}", dir.display());
297 }
298 }
299 Some(out)
300 }
301 fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
302 let full_id = match self.resolve_agent_id_prefix(id)? {
303 Ok(fid) => fid,
304 Err(msg) => return Some(msg),
305 };
306 let req = {
307 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
308 mgr.try_recv_secret_request_for(&full_id)
309 };
310 let Some(req) = req else {
311 return Some(format!(
312 "No pending secret request for sub-agent '{full_id}'."
313 ));
314 };
315 let key = req.secret_key.clone();
316 let ttl = std::time::Duration::from_mins(5);
317 let Some(secret) = self.resolve_subagent_secret(&key) else {
318 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
319 let _ = mgr.deny_secret(&full_id);
320 return Some(format!(
321 "Secret '{key}' could not be resolved from the vault; request denied."
322 ));
323 };
324 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
325 if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
326 return Some(format!("Approve failed: {e}"));
327 }
328 if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
329 let _ = mgr.deny_secret(&full_id);
330 return Some(format!("Secret delivery failed: {e}"));
331 }
332 Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
333 }
334 fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
335 let full_id = match self.resolve_agent_id_prefix(id)? {
336 Ok(fid) => fid,
337 Err(msg) => return Some(msg),
338 };
339 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
340 match mgr.deny_secret(&full_id) {
341 Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
342 Err(e) => Some(format!("Deny failed: {e}")),
343 }
344 }
345 pub(super) async fn handle_agent_command(
346 &mut self,
347 cmd: zeph_subagent::AgentCommand,
348 ) -> Option<String> {
349 use zeph_subagent::AgentCommand;
350
351 match cmd {
352 AgentCommand::List => self.handle_agent_list(),
353 AgentCommand::Background { name, prompt } => {
354 self.handle_agent_background(&name, &prompt).await
355 }
356 AgentCommand::Spawn { name, prompt }
357 | AgentCommand::Mention {
358 agent: name,
359 prompt,
360 } => self.handle_agent_spawn_foreground(&name, &prompt).await,
361 AgentCommand::Status => self.handle_agent_status(),
362 AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
363 AgentCommand::Approve { id } => self.handle_agent_approve(&id),
364 AgentCommand::Deny { id } => self.handle_agent_deny(&id),
365 AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
366 _ => None,
367 }
368 }
369 pub(crate) fn handle_agents_definitions_list(&self) -> String {
374 use std::fmt::Write as _;
375
376 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
377 return String::new();
378 };
379 let defs = mgr.definitions();
380 if defs.is_empty() {
381 return String::new();
382 }
383 let mut out = String::from("Sub-agents:\n");
384 for d in defs {
385 let memory_label = match d.memory {
386 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
387 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
388 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
389 Some(_) => " [memory:unknown]",
390 None => "",
391 };
392 if let Some(ref src) = d.source {
393 let _ = writeln!(
394 out,
395 " {}{} — {} ({})",
396 d.name, memory_label, d.description, src
397 );
398 } else {
399 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
400 }
401 }
402 out
403 }
404 pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
409 use zeph_subagent::AgentsCommand;
410
411 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
412 return "Sub-agent manager is not available.".to_owned();
413 };
414
415 match cmd {
416 AgentsCommand::List => self.handle_agents_definitions_list(),
417 AgentsCommand::Show { name } => {
418 match mgr.definitions().iter().find(|d| d.name == name) {
419 Some(d) => format!(
420 "Agent: {}\nDescription: {}\nSource: {}\n",
421 d.name,
422 d.description,
423 d.source.as_deref().unwrap_or("unknown"),
424 ),
425 None => format!("No sub-agent definition named '{name}'."),
426 }
427 }
428 AgentsCommand::Create { name } => {
429 format!(
430 "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
431 See the sub-agent documentation for the required frontmatter."
432 )
433 }
434 AgentsCommand::Edit { name } => {
435 format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
436 }
437 AgentsCommand::Delete { name } => {
438 format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
439 }
440 _ => "Unknown agents command.".to_owned(),
441 }
442 }
443 async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
444 let provider = self.provider.clone();
445 let tool_executor = Arc::clone(&self.tool_executor);
446 let skills = self.filtered_skills_for(name);
447 let cfg = self.services.orchestration.subagent_config.clone();
448 let mut spawn_ctx = self.build_spawn_context(&cfg);
449 self.ensure_session_durable_ctx().await;
453 match resolve_durable_spawn_gate(
454 self.services.session.durable_subagent,
455 self.services.session.durable_ctx.as_deref(),
456 )
457 .await
458 {
459 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
460 DurableSpawnGate::Replayed { result, .. } => {
461 let short = &result.task_id[..8.min(result.task_id.len())];
462 return Some(if result.output.is_empty() {
463 format!(
464 "[sub-agent {short}] completed (no output, replayed from durable journal)"
465 )
466 } else {
467 format!(
468 "[sub-agent {short}] completed (replayed from durable journal):\n{}",
469 result.output
470 )
471 });
472 }
473 DurableSpawnGate::None => {}
474 }
475 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
476 match mgr
477 .spawn(
478 name,
479 prompt,
480 provider,
481 tool_executor,
482 skills,
483 &cfg,
484 spawn_ctx,
485 )
486 .await
487 {
488 Ok(id) => Some(format!(
489 "Sub-agent '{name}' started in background (id: {short})",
490 short = &id[..8.min(id.len())]
491 )),
492 Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
493 }
494 }
495 async fn notify_replayed_foreground_subagent(
504 &mut self,
505 name: &str,
506 result: zeph_subagent::SubagentResult,
507 promise_id: zeph_durable::PromiseId,
508 ) -> String {
509 let success = result.state == zeph_subagent::SubAgentState::Completed;
510 let task_id = result.task_id.clone();
511
512 let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
517 match ctx.claim_promise_notification(promise_id).await {
518 Ok(claimed) => claimed,
519 Err(e) => {
520 tracing::warn!(
521 error = %e,
522 "durable: promise-notification claim failed; \
523 firing the replayed sub-agent notice directly"
524 );
525 true
526 }
527 }
528 } else {
529 true
530 };
531
532 let text = if success {
533 result.output
534 } else {
535 result.error.unwrap_or_else(|| "unknown error".to_owned())
536 };
537
538 if should_notify {
539 let _ = self
540 .channel
541 .send(&format!(
542 "Sub-agent '{name}' replayed from durable journal (already finished \
543 before the parent restarted)."
544 ))
545 .await;
546 let _ = self
547 .channel
548 .notify_foreground_subagent_completed(&task_id, name, success)
549 .await;
550 }
551 text
552 }
553
554 async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
555 let provider = self.provider.clone();
556 let tool_executor = Arc::clone(&self.tool_executor);
557 let skills = self.filtered_skills_for(name);
558 let cfg = self.services.orchestration.subagent_config.clone();
559 let mut spawn_ctx = self.build_spawn_context(&cfg);
560 self.ensure_session_durable_ctx().await;
565 match resolve_durable_spawn_gate(
566 self.services.session.durable_subagent,
567 self.services.session.durable_ctx.as_deref(),
568 )
569 .await
570 {
571 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
572 DurableSpawnGate::Replayed { result, promise_id } => {
573 return Some(
574 self.notify_replayed_foreground_subagent(name, result, promise_id)
575 .await,
576 );
577 }
578 DurableSpawnGate::None => {}
579 }
580 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
581 let task_id = match mgr
582 .spawn(
583 name,
584 prompt,
585 provider,
586 tool_executor,
587 skills,
588 &cfg,
589 spawn_ctx,
590 )
591 .await
592 {
593 Ok(id) => id,
594 Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
595 };
596 let short = task_id[..8.min(task_id.len())].to_owned();
597 let _ = self
598 .channel
599 .send(&format!("Sub-agent '{name}' running... (id: {short})"))
600 .await;
601 let _ = self
602 .channel
603 .notify_foreground_subagent_started(&task_id, name)
604 .await;
605 let label = format!("Sub-agent '{name}'");
606 let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
607 let _ = self
609 .channel
610 .notify_foreground_subagent_completed(&task_id, name, false)
611 .await;
612 return None;
613 };
614 let _ = self
615 .channel
616 .notify_foreground_subagent_completed(&task_id, name, success)
617 .await;
618 Some(result)
619 }
620 fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
621 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
622 let ids: Vec<String> = mgr
624 .statuses()
625 .into_iter()
626 .map(|(task_id, _)| task_id)
627 .filter(|task_id| task_id.starts_with(id))
628 .collect();
629 match ids.as_slice() {
630 [] => Some(format!("No sub-agent with id prefix '{id}'")),
631 [full_id] => {
632 let full_id = full_id.clone();
633 match mgr.cancel(&full_id) {
634 Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
635 Err(e) => Some(format!("Cancel failed: {e}")),
636 }
637 }
638 _ => Some(format!(
639 "Ambiguous id prefix '{id}': matches {} agents",
640 ids.len()
641 )),
642 }
643 }
644 async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
645 let cfg = self.services.orchestration.subagent_config.clone();
646 let def_name = {
649 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
650 match mgr.def_name_for_resume(id, &cfg).await {
651 Ok(name) => name,
652 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
653 }
654 };
655 let skills = self.filtered_skills_for(&def_name);
656 let provider = self.provider.clone();
657 let tool_executor = Arc::clone(&self.tool_executor);
658 let spawn_ctx = self.build_spawn_context(&cfg);
667 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
668 let (task_id, _) = match mgr
669 .resume(
670 id,
671 prompt,
672 provider,
673 tool_executor,
674 skills,
675 &cfg,
676 Some(&spawn_ctx),
677 )
678 .await
679 {
680 Ok(pair) => pair,
681 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
682 };
683 let short = task_id[..8.min(task_id.len())].to_owned();
684 let _ = self
685 .channel
686 .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
687 .await;
688 let _ = self
689 .channel
690 .notify_foreground_subagent_started(&task_id, &def_name)
691 .await;
692 let Some((result, success)) = self
693 .poll_subagent_until_done(&task_id, "Resumed sub-agent")
694 .await
695 else {
696 let _ = self
698 .channel
699 .notify_foreground_subagent_completed(&task_id, &def_name, false)
700 .await;
701 return None;
702 };
703 let _ = self
704 .channel
705 .notify_foreground_subagent_completed(&task_id, &def_name, success)
706 .await;
707 Some(result)
708 }
709 pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
735 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
736 let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
737 let reg = self.services.skill.registry.read();
738 let skills = match zeph_subagent::filter_skills(®, &def.skills) {
739 Ok(skills) => skills,
740 Err(e) => {
741 tracing::warn!(error = %e, "skill filtering failed for sub-agent");
742 return None;
743 }
744 };
745 if skills.is_empty() {
746 return None;
747 }
748
749 if !def.skills.include.is_empty() {
752 return Some(skills.into_iter().map(|s| s.body).collect());
753 }
754
755 let total = skills.len();
756 let budget = self.services.skill.subagent_skill_token_budget;
757 let counter = &self.runtime.metrics.token_counter;
758
759 let mut bodies: Vec<String> = Vec::with_capacity(total);
760 let mut running_tokens = 0usize;
761 let mut omitted_names: Vec<&str> = Vec::new();
762
763 for skill in &skills {
764 let skill_tokens = counter.count_tokens(&skill.body);
765 if !bodies.is_empty() && running_tokens + skill_tokens > budget {
766 omitted_names.push(skill.meta.name.as_str());
767 continue;
768 }
769 running_tokens += skill_tokens;
770 bodies.push(skill.body.clone());
771 }
772
773 if !omitted_names.is_empty() {
774 let included = bodies.len();
775 tracing::warn!(
776 agent_name,
777 included,
778 total,
779 budget_tokens = budget,
780 "sub-agent skill body budget exceeded; truncated skill set"
781 );
782 bodies.push(format!(
783 "[skill budget: {included}/{total} skills included, budget={budget} tokens — omitted: {}]",
784 omitted_names.join(", ")
785 ));
786 }
787
788 Some(bodies)
789 }
790 pub(super) fn build_spawn_context(
792 &self,
793 cfg: &zeph_config::SubAgentConfig,
794 ) -> zeph_subagent::SpawnContext {
795 zeph_subagent::SpawnContext {
796 parent_messages: self.extract_parent_messages(cfg),
797 parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
798 parent_provider_name: {
799 let name = &self.runtime.config.active_provider_name;
800 if name.is_empty() {
801 None
802 } else {
803 Some(name.clone())
804 }
805 },
806 spawn_depth: self.runtime.config.spawn_depth,
807 mcp_tool_names: self.extract_mcp_tool_names(),
808 seed_trajectory_score: {
810 let child = self.services.security.trajectory.spawn_child();
811 let score = child.score_now();
812 if score > 0.0 { Some(score) } else { None }
813 },
814 content_isolation: self.runtime.config.security.content_isolation.clone(),
815 orchestrator_name: Some("zeph".to_owned()),
816 orchestrator_role: Some("orchestrator".to_owned()),
817 session_mcp_servers: Vec::new(),
818 debug_dump_sink: self.runtime.debug.debug_dumper.clone().map(|d| {
826 Arc::new(crate::debug_dump::PiiScrubbingDumpSink::new(
827 d,
828 self.services.security.pii_filter.clone(),
829 )) as Arc<dyn zeph_llm::debug_dump::DebugDumpSink>
830 }),
831 ..Default::default()
834 }
835 }
836 fn extract_parent_messages(
843 &self,
844 config: &zeph_config::SubAgentConfig,
845 ) -> Vec<zeph_llm::provider::Message> {
846 use zeph_config::ParentContextPolicy;
847 use zeph_llm::provider::Role;
848
849 if config.parent_context_policy == ParentContextPolicy::None
850 || config.context_window_turns == 0
851 {
852 return Vec::new();
853 }
854
855 let non_system: Vec<_> = self
856 .msg
857 .messages
858 .iter()
859 .filter(|m| m.role != Role::System)
860 .cloned()
861 .collect();
862
863 let take_count = config
864 .context_window_turns
865 .saturating_mul(2)
866 .min(config.max_parent_messages);
867 let start = non_system.len().saturating_sub(take_count);
868 let mut msgs = non_system[start..].to_vec();
869
870 let max_chars = 128_000usize / 4;
872 let requested = msgs.len();
873 trim_parent_messages(&mut msgs, max_chars);
874 if msgs.len() < requested {
875 tracing::info!(
876 kept = msgs.len(),
877 requested,
878 "[subagent] truncated parent history due to token budget or orphan pruning"
879 );
880 }
881
882 if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
883 use zeph_sanitizer::{ContentSource, ContentSourceKind};
884 let source =
885 ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
886 msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
887 }
888
889 msgs
890 }
891 fn extract_mcp_tool_names(&self) -> Vec<String> {
893 self.tool_executor
894 .tool_definitions_erased()
895 .into_iter()
896 .filter(ToolDef::is_mcp_tool)
897 .map(|t| t.id.to_string())
898 .collect()
899 }
900 pub(super) fn classify_source_kind(
904 skill_dir: &std::path::Path,
905 managed_dir: Option<&std::path::PathBuf>,
906 bundled_names: &std::collections::HashSet<String>,
907 ) -> zeph_memory::store::SourceKind {
908 if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
909 let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
910 let has_marker = skill_dir.join(".bundled").exists();
911 if has_marker && bundled_names.contains(skill_name) {
912 zeph_memory::store::SourceKind::Bundled
913 } else {
914 if has_marker {
915 tracing::warn!(
916 skill = %skill_name,
917 "skill has .bundled marker but is not in the bundled skill \
918 allowlist — classifying as Hub"
919 );
920 }
921 zeph_memory::store::SourceKind::Hub
922 }
923 } else {
924 zeph_memory::store::SourceKind::Local
925 }
926 }
927}
928
929enum DurableSpawnGate {
931 Fresh(zeph_subagent::DurableResolverSeat),
934 Replayed {
940 result: zeph_subagent::SubagentResult,
941 promise_id: zeph_durable::PromiseId,
942 },
943 None,
953}
954
955async fn resolve_durable_spawn_gate(
959 enabled: bool,
960 ctx: Option<&zeph_durable::DurableContext>,
961) -> DurableSpawnGate {
962 let Some(ctx) = ctx.filter(|_| enabled) else {
963 return DurableSpawnGate::None;
964 };
965 let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
966 Ok(pair) => pair,
967 Err(e) => {
968 tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
969 return DurableSpawnGate::None;
970 }
971 };
972 if let Some(seat) = seat {
973 return DurableSpawnGate::Fresh(seat);
974 }
975 match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
978 Ok(Some(result)) => DurableSpawnGate::Replayed {
979 result,
980 promise_id: promise.id(),
981 },
982 Ok(None) => {
983 tracing::warn!(
992 "durable: resumed sub-agent promise still pending after restart — original \
993 child did not resolve before the crash; re-spawning may duplicate side effects \
994 (#5944 residual v1 gap)"
995 );
996 DurableSpawnGate::None
997 }
998 Err(e) => {
999 tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
1000 DurableSpawnGate::None
1001 }
1002 }
1003}
1004
1005pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
1013 use zeph_llm::provider::MessagePart;
1014 if m.parts.is_empty() {
1015 return m.content.len();
1016 }
1017 m.parts
1018 .iter()
1019 .map(|p| match p {
1020 MessagePart::Text { text }
1021 | MessagePart::Recall { text }
1022 | MessagePart::CodeContext { text }
1023 | MessagePart::Summary { text }
1024 | MessagePart::CrossSession { text } => text.len(),
1025 MessagePart::ToolOutput { body, .. } => body.len(),
1026 MessagePart::ToolUse { id, name, input } => {
1027 50 + id.len() + name.len() + input.to_string().len()
1028 }
1029 MessagePart::ToolResult {
1030 tool_use_id,
1031 content,
1032 ..
1033 } => 50 + tool_use_id.len() + content.len(),
1034 MessagePart::Image(img) => img.data.len() * 4 / 3,
1035 MessagePart::ThinkingBlock {
1036 thinking,
1037 signature,
1038 } => 50 + thinking.len() + signature.len(),
1039 MessagePart::RedactedThinkingBlock { data } => data.len(),
1040 MessagePart::Compaction { summary } => summary.len(),
1041 _ => 0,
1042 })
1043 .sum()
1044}
1045
1046pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
1065 use zeph_llm::provider::{MessagePart, Role};
1066
1067 let mut total_chars = 0usize;
1071 let mut drop_before = 0usize; for (i, m) in msgs.iter().enumerate().rev() {
1073 total_chars += estimate_parts_size(m);
1074 if total_chars > max_chars {
1075 drop_before = i + 1;
1076 break;
1077 }
1078 }
1079 if drop_before > 0 {
1080 msgs.drain(..drop_before);
1081 }
1082
1083 let emitted_tool_ids: std::collections::HashSet<String> = msgs
1087 .iter()
1088 .filter(|m| m.role == Role::Assistant)
1089 .flat_map(|m| m.parts.iter())
1090 .filter_map(|p| {
1091 if let MessagePart::ToolUse { id, .. } = p {
1092 Some(id.clone())
1093 } else {
1094 None
1095 }
1096 })
1097 .collect();
1098
1099 let mut orphans_removed = 0usize;
1100 for m in msgs.iter_mut() {
1101 if m.role != Role::User || m.parts.is_empty() {
1102 continue;
1103 }
1104 let before = m.parts.len();
1105 m.parts.retain(|p| match p {
1106 MessagePart::ToolResult { tool_use_id, .. } => {
1107 emitted_tool_ids.contains(tool_use_id.as_str())
1108 }
1109 _ => true,
1110 });
1111 let dropped = before - m.parts.len();
1112 if dropped > 0 {
1113 orphans_removed += dropped;
1114 if m.parts.is_empty() {
1115 m.content.clear();
1116 } else {
1117 m.rebuild_content();
1118 }
1119 }
1120 }
1121
1122 let consumed_tool_ids: std::collections::HashSet<String> = msgs
1130 .iter()
1131 .filter(|m| m.role == Role::User)
1132 .flat_map(|m| m.parts.iter())
1133 .filter_map(|p| {
1134 if let MessagePart::ToolResult { tool_use_id, .. } = p {
1135 Some(tool_use_id.clone())
1136 } else {
1137 None
1138 }
1139 })
1140 .collect();
1141
1142 let last_assistant_idx = msgs
1144 .iter()
1145 .enumerate()
1146 .rev()
1147 .find(|(_, m)| m.role == Role::Assistant)
1148 .map(|(i, _)| i);
1149
1150 for (idx, m) in msgs.iter_mut().enumerate() {
1151 if m.role != Role::Assistant || m.parts.is_empty() {
1152 continue;
1153 }
1154 if Some(idx) == last_assistant_idx {
1156 continue;
1157 }
1158 let before = m.parts.len();
1159 m.parts.retain(|p| match p {
1160 MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1161 _ => true,
1162 });
1163 let dropped = before - m.parts.len();
1164 if dropped > 0 {
1165 orphans_removed += dropped;
1166 if m.parts.is_empty() {
1167 m.content.clear();
1168 } else {
1169 m.rebuild_content();
1170 }
1171 }
1172 }
1173
1174 msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1176
1177 if orphans_removed > 0 {
1178 tracing::debug!(
1179 orphans = orphans_removed,
1180 "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1181 );
1182 }
1183}
1184
1185fn sanitize_parent_messages(
1191 mut msgs: Vec<zeph_llm::provider::Message>,
1192 sanitizer: &zeph_sanitizer::ContentSanitizer,
1193 source: &zeph_sanitizer::ContentSource,
1194) -> Vec<zeph_llm::provider::Message> {
1195 use zeph_llm::provider::MessagePart;
1196 for msg in &mut msgs {
1197 let mut changed = false;
1198 for part in &mut msg.parts {
1199 if let MessagePart::Text { text } = part {
1200 let clean = sanitizer.sanitize(text, source.clone());
1201 if clean.body != *text {
1202 *text = clean.body;
1203 changed = true;
1204 }
1205 }
1206 }
1207 if changed {
1208 msg.rebuild_content();
1209 }
1210 }
1211 msgs
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217 use crate::agent::agent_tests::*;
1218
1219 fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1222 let provider = mock_provider(vec![]);
1223 let channel = MockChannel::new(vec![]);
1224 let registry = create_test_registry();
1225 let executor = MockToolExecutor::no_tools();
1226 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1227 agent.services.skill.available_custom_secrets.insert(
1228 stored_key.to_owned(),
1229 crate::vault::Secret::new(value.to_owned()),
1230 );
1231 agent
1232 }
1233
1234 #[test]
1235 fn resolve_subagent_secret_exact_match() {
1236 let agent = agent_with_custom_secret("my_key", "the-value");
1237 let resolved = agent.resolve_subagent_secret("my_key");
1238 assert_eq!(
1239 resolved.map(|s| s.expose().to_owned()),
1240 Some("the-value".to_owned())
1241 );
1242 }
1243
1244 #[test]
1245 fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1246 let agent = agent_with_custom_secret("my_api_key", "dash-value");
1249 let resolved = agent.resolve_subagent_secret("my-api-key");
1250 assert_eq!(
1251 resolved.map(|s| s.expose().to_owned()),
1252 Some("dash-value".to_owned())
1253 );
1254 }
1255
1256 #[test]
1257 fn resolve_subagent_secret_normalizes_case() {
1258 let agent = agent_with_custom_secret("upper_key", "case-value");
1259 let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1260 assert_eq!(
1261 resolved.map(|s| s.expose().to_owned()),
1262 Some("case-value".to_owned())
1263 );
1264 }
1265
1266 #[test]
1267 fn resolve_subagent_secret_missing_key_returns_none() {
1268 let agent = agent_with_custom_secret("known_key", "value");
1269 assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1270 }
1271
1272 #[test]
1273 fn resolve_subagent_secret_empty_map_returns_none() {
1274 let provider = mock_provider(vec![]);
1275 let channel = MockChannel::new(vec![]);
1276 let registry = create_test_registry();
1277 let executor = MockToolExecutor::no_tools();
1278 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1279 assert!(agent.resolve_subagent_secret("anything").is_none());
1280 }
1281
1282 #[tokio::test]
1285 async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1286 use zeph_tools::registry::InvocationHint;
1287
1288 let provider = mock_provider(vec![]);
1289 let channel = MockChannel::new(vec![]);
1290 let registry = create_test_registry();
1291 let executor = MockToolExecutor::no_tools().with_definitions(vec![
1292 ToolDef {
1293 id: "read".into(),
1294 description: "built-in tool".into(),
1295 schema: schemars::Schema::default(),
1296 invocation: InvocationHint::ToolCall,
1297 output_schema: None,
1298 server_id: None,
1299 },
1300 ToolDef {
1301 id: "github_create_issue".into(),
1302 description: "MCP tool".into(),
1303 schema: schemars::Schema::default(),
1304 invocation: InvocationHint::ToolCall,
1305 output_schema: None,
1306 server_id: Some("github".into()),
1307 },
1308 ]);
1309 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1310
1311 assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1312 }
1313
1314 async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1319 let provider = mock_provider(vec!["ok".into()]);
1320 let channel = MockChannel::new(vec![]);
1321 let registry = create_test_registry();
1322 let executor = MockToolExecutor::no_tools();
1323 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1324 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1325 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1326 enabled: true,
1327 agent_turns: true,
1328 ..zeph_config::DurableConfig::default()
1329 });
1330 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1331 agent.services.session.durable_subagent = subagent_enabled;
1332
1333 agent.ensure_session_durable_ctx().await;
1334 assert!(
1335 agent.services.session.durable_ctx.is_some(),
1336 "test setup: durable_ctx must be populated before exercising the seat gate"
1337 );
1338 agent
1339 }
1340
1341 #[tokio::test]
1342 async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1343 let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1344
1345 let gate = resolve_durable_spawn_gate(
1346 agent.services.session.durable_subagent,
1347 agent.services.session.durable_ctx.as_deref(),
1348 )
1349 .await;
1350
1351 assert!(
1352 matches!(gate, DurableSpawnGate::Fresh(_)),
1353 "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1354 not just wire the config-to-builder plumbing"
1355 );
1356 }
1357
1358 #[tokio::test]
1359 async fn seat_absent_when_subagent_disabled() {
1360 let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1361
1362 let gate = resolve_durable_spawn_gate(
1363 agent.services.session.durable_subagent,
1364 agent.services.session.durable_ctx.as_deref(),
1365 )
1366 .await;
1367
1368 assert!(
1369 matches!(gate, DurableSpawnGate::None),
1370 "FR-008: durable_subagent=false must keep the seat gate closed even when \
1371 durable_ctx is populated"
1372 );
1373 }
1374
1375 fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1385 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1386 use zeph_subagent::hooks::SubagentHooks;
1387
1388 zeph_subagent::SubAgentDef {
1389 name: name.to_owned(),
1390 description: "A helper bot".into(),
1391 model: None,
1392 tools: ToolPolicy::InheritAll,
1393 disallowed_tools: vec![],
1394 permissions: SubAgentPermissions::default(),
1395 skills: SkillFilter::default(),
1396 system_prompt: "You are helpful.".into(),
1397 hooks: SubagentHooks::default(),
1398 memory: None,
1399 source: None,
1400 file_path: None,
1401 }
1402 }
1403
1404 async fn agent_with_durable_and_manager(
1408 db_url: &str,
1409 conversation_id: i64,
1410 ) -> Agent<MockChannel> {
1411 let provider = mock_provider(vec!["ok".into()]);
1412 let channel = MockChannel::new(vec![]);
1413 let registry = create_test_registry();
1414 let executor = MockToolExecutor::no_tools();
1415 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1416 agent.services.memory.persistence.conversation_id =
1417 Some(zeph_memory::ConversationId(conversation_id));
1418 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1419 enabled: true,
1420 agent_turns: true,
1421 ..zeph_config::DurableConfig::default()
1422 });
1423 agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1424 agent.services.session.durable_subagent = true;
1425
1426 let mut mgr = zeph_subagent::SubAgentManager::new(4);
1427 mgr.definitions_mut().push(subagent_def("helper"));
1428 agent.services.orchestration.subagent_manager = Some(mgr);
1429
1430 agent.ensure_session_durable_ctx().await;
1431 assert!(
1432 agent.services.session.durable_ctx.is_some(),
1433 "test setup: durable_ctx must be populated before exercising the handler"
1434 );
1435 agent
1436 }
1437
1438 #[tokio::test]
1439 async fn handle_agent_background_replays_finished_child_without_respawning() {
1440 let dir = tempfile::tempdir().unwrap();
1441 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1442
1443 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1445 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1446 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1447 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1448 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1449 Ok("child finished before crash".to_owned());
1450 zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1451 agent1
1452 .services
1453 .session
1454 .durable_writer
1455 .as_ref()
1456 .unwrap()
1457 .flush()
1458 .await
1459 .unwrap();
1460 drop(agent1);
1465
1466 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1469
1470 let resp = agent2
1471 .handle_agent_background("helper", "do work")
1472 .await
1473 .unwrap();
1474 assert!(
1475 resp.contains("replayed from durable journal"),
1476 "expected a replay notice, got: {resp}"
1477 );
1478 assert!(
1479 resp.contains("child finished before crash"),
1480 "expected the journaled output to be surfaced, got: {resp}"
1481 );
1482 assert!(
1483 agent2
1484 .services
1485 .orchestration
1486 .subagent_manager
1487 .as_ref()
1488 .unwrap()
1489 .statuses()
1490 .is_empty(),
1491 "mgr.spawn must not be called when the child result is replayed"
1492 );
1493 }
1494
1495 #[tokio::test]
1496 async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1497 let dir = tempfile::tempdir().unwrap();
1498 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1499
1500 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1502 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1503 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1504 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1505 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1506 Ok("foreground child output".to_owned());
1507 zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1508 ctx1.step(
1516 zeph_durable::StepDescriptor::idempotent(
1517 "post_spawn_marker",
1518 b"post_spawn_marker".to_vec(),
1519 ),
1520 |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1521 )
1522 .await
1523 .unwrap();
1524 agent1
1525 .services
1526 .session
1527 .durable_writer
1528 .as_ref()
1529 .unwrap()
1530 .flush()
1531 .await
1532 .unwrap();
1533 drop(agent1);
1536
1537 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1540
1541 let resp = agent2
1542 .handle_agent_spawn_foreground("helper", "do work")
1543 .await
1544 .unwrap();
1545 assert_eq!(resp, "foreground child output");
1546 assert!(
1547 agent2
1548 .channel
1549 .sent_messages()
1550 .iter()
1551 .any(|m| m.contains("replayed from durable journal")),
1552 "expected the replay notice to be sent to the channel"
1553 );
1554 assert_eq!(
1555 agent2.channel.notify_completed_calls().len(),
1556 1,
1557 "expected exactly one TUI completion notification on the first replay"
1558 );
1559 assert!(
1560 agent2
1561 .services
1562 .orchestration
1563 .subagent_manager
1564 .as_ref()
1565 .unwrap()
1566 .statuses()
1567 .is_empty(),
1568 "mgr.spawn must not be called when the child result is replayed"
1569 );
1570 drop(agent2);
1571
1572 let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1577
1578 let resp = agent3
1579 .handle_agent_spawn_foreground("helper", "do work")
1580 .await
1581 .unwrap();
1582 assert_eq!(resp, "foreground child output");
1583 assert!(
1584 !agent3
1585 .channel
1586 .sent_messages()
1587 .iter()
1588 .any(|m| m.contains("replayed from durable journal")),
1589 "replay notice must not re-fire on a second replay after a parent restart"
1590 );
1591 assert!(
1592 agent3.channel.notify_completed_calls().is_empty(),
1593 "TUI completion event must not re-fire on a second replay after a parent restart"
1594 );
1595 }
1596
1597 #[tokio::test]
1598 async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1599 let dir = tempfile::tempdir().unwrap();
1600 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1601
1602 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1605 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1606 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1607 assert!(
1608 seat.is_some(),
1609 "test setup: run 1 must be fresh and yield a resolver seat"
1610 );
1611 agent1
1612 .services
1613 .session
1614 .durable_writer
1615 .as_ref()
1616 .unwrap()
1617 .flush()
1618 .await
1619 .unwrap();
1620 drop(agent1);
1623
1624 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1628
1629 let resp = agent2
1630 .handle_agent_background("helper", "do work")
1631 .await
1632 .unwrap();
1633 assert!(
1634 resp.contains("started in background"),
1635 "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1636 );
1637 assert_eq!(
1638 agent2
1639 .services
1640 .orchestration
1641 .subagent_manager
1642 .as_ref()
1643 .unwrap()
1644 .statuses()
1645 .len(),
1646 1,
1647 "exactly one real spawn must occur on the still-pending fallback path"
1648 );
1649 }
1650
1651 #[test]
1654 fn build_spawn_context_leaves_debug_dump_sink_none_without_dumper() {
1655 let provider = mock_provider(vec![]);
1656 let channel = MockChannel::new(vec![]);
1657 let registry = create_test_registry();
1658 let agent = Agent::new(
1659 provider,
1660 channel,
1661 registry,
1662 None,
1663 5,
1664 MockToolExecutor::no_tools(),
1665 );
1666
1667 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1668 assert!(
1669 ctx.debug_dump_sink.is_none(),
1670 "no DebugDumper configured, so SpawnContext must carry no sink"
1671 );
1672 }
1673
1674 #[tokio::test]
1675 async fn build_spawn_context_wires_debug_dump_sink_when_dumper_present() {
1676 let dir = tempfile::tempdir().unwrap();
1677 let dumper =
1678 crate::debug_dump::DebugDumper::new(dir.path(), crate::debug_dump::DumpFormat::Raw)
1679 .unwrap();
1680
1681 let provider = mock_provider(vec![]);
1682 let channel = MockChannel::new(vec![]);
1683 let registry = create_test_registry();
1684 let mut agent = Agent::new(
1685 provider,
1686 channel,
1687 registry,
1688 None,
1689 5,
1690 MockToolExecutor::no_tools(),
1691 );
1692 agent.runtime.debug.debug_dumper = Some(dumper);
1693
1694 let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1695 let sink = ctx
1696 .debug_dump_sink
1697 .expect("a configured DebugDumper must be threaded into SpawnContext");
1698
1699 let id = sink.dump_request("mock", &[], &[], serde_json::Value::Null);
1702 sink.dump_response(id, &zeph_llm::provider::ChatResponse::Text("ok".into()));
1703 }
1704
1705 fn registry_with_skills(
1715 count: usize,
1716 words_per_skill: usize,
1717 ) -> (SkillRegistry, tempfile::TempDir) {
1718 let temp_dir = tempfile::tempdir().unwrap();
1719 for i in 0..count {
1720 let skill_dir = temp_dir.path().join(format!("skill-{i}"));
1721 std::fs::create_dir(&skill_dir).unwrap();
1722 let body = "lorem ".repeat(words_per_skill);
1723 std::fs::write(
1724 skill_dir.join("SKILL.md"),
1725 format!("---\nname: skill-{i}\ndescription: Test skill {i}\n---\n{body}"),
1726 )
1727 .unwrap();
1728 }
1729 let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
1730 (registry, temp_dir)
1731 }
1732
1733 fn agent_with_skill_registry_and_def(
1734 registry: SkillRegistry,
1735 def: zeph_subagent::SubAgentDef,
1736 ) -> Agent<MockChannel> {
1737 let provider = mock_provider(vec![]);
1738 let channel = MockChannel::new(vec![]);
1739 let executor = MockToolExecutor::no_tools();
1740 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1741 let mut mgr = zeph_subagent::SubAgentManager::new(4);
1742 mgr.definitions_mut().push(def);
1743 agent.services.orchestration.subagent_manager = Some(mgr);
1744 agent
1745 }
1746
1747 fn agent_with_skill_registry_and_helper_def(registry: SkillRegistry) -> Agent<MockChannel> {
1748 agent_with_skill_registry_and_def(registry, subagent_def("helper"))
1749 }
1750
1751 #[test]
1752 fn filtered_skills_for_under_budget_returns_all_bodies_no_marker() {
1753 let (registry, _temp_dir) = registry_with_skills(3, 20);
1756 let mut agent = agent_with_skill_registry_and_helper_def(registry);
1757 agent.services.skill.subagent_skill_token_budget = 1_000_000;
1758
1759 let bodies = agent
1760 .filtered_skills_for("helper")
1761 .expect("3 skills with a huge budget must return Some");
1762
1763 assert_eq!(
1764 bodies.len(),
1765 3,
1766 "no truncation marker expected when everything fits under budget"
1767 );
1768 for body in &bodies {
1769 assert!(
1770 body.contains("lorem"),
1771 "every returned entry must be a real skill body, not a marker: {body}"
1772 );
1773 }
1774 }
1775
1776 #[test]
1777 fn filtered_skills_for_over_budget_truncates_with_marker() {
1778 let (registry, _temp_dir) = registry_with_skills(5, 500);
1781 let mut agent = agent_with_skill_registry_and_helper_def(registry);
1782 agent.services.skill.subagent_skill_token_budget = 10;
1783
1784 let bodies = agent
1785 .filtered_skills_for("helper")
1786 .expect("at least the first skill must always be included");
1787
1788 let marker_count = bodies
1789 .iter()
1790 .filter(|b| b.starts_with("[skill budget:"))
1791 .count();
1792 assert_eq!(
1793 marker_count, 1,
1794 "exactly one truncation marker entry must be appended, got bodies: {bodies:?}"
1795 );
1796 let marker = bodies
1797 .iter()
1798 .find(|b| b.starts_with("[skill budget:"))
1799 .unwrap();
1800 let included = bodies.len() - 1;
1801 assert!(
1802 included < 5,
1803 "budget=10 tokens must not fit all 5 large skills, included={included}"
1804 );
1805 assert!(
1806 included >= 1,
1807 "the first skill must always be included even when it alone exceeds the budget, \
1808 got included={included}"
1809 );
1810 assert!(
1811 bodies[0].contains("lorem"),
1812 "the always-included first entry must be a real skill body, not the marker: {}",
1813 bodies[0]
1814 );
1815 assert!(
1816 marker.contains(&format!("{included}/5 skills included")),
1817 "marker must report the correct included/total count: {marker}"
1818 );
1819 assert!(
1820 marker.contains("budget=10 tokens"),
1821 "marker must report the configured budget: {marker}"
1822 );
1823 }
1824
1825 #[test]
1826 fn filtered_skills_for_mid_budget_greedily_fills_multiple_fitting_skills() {
1827 let (registry, _temp_dir) = registry_with_skills(5, 500);
1834 let single_body = "lorem ".repeat(500);
1835 let per_skill_tokens = zeph_memory::TokenCounter::new().count_tokens(&single_body);
1836 assert!(
1837 per_skill_tokens > 1,
1838 "test setup: per-skill token count must be large enough for 2*T+1 to exclude a 3rd \
1839 skill, got {per_skill_tokens}"
1840 );
1841
1842 let mut agent = agent_with_skill_registry_and_helper_def(registry);
1843 agent.services.skill.subagent_skill_token_budget = 2 * per_skill_tokens + 1;
1844
1845 let bodies = agent
1846 .filtered_skills_for("helper")
1847 .expect("at least the first skill must always be included");
1848
1849 let marker = bodies
1850 .iter()
1851 .find(|b| b.starts_with("[skill budget:"))
1852 .unwrap_or_else(|| panic!("expected a truncation marker, got bodies: {bodies:?}"));
1853 let included = bodies.len() - 1;
1854 assert_eq!(
1855 included, 2,
1856 "budget=2*T+1 must fit exactly 2 of the 5 identical-cost skills, got {included}"
1857 );
1858 assert!(
1859 marker.contains("2/5 skills included"),
1860 "marker must report the correct included/total count: {marker}"
1861 );
1862 let omitted_segment = marker
1866 .split("omitted: ")
1867 .nth(1)
1868 .and_then(|s| s.strip_suffix(']'))
1869 .unwrap_or_else(|| panic!("marker missing 'omitted: ...]' segment: {marker}"));
1870 let mut omitted_names: Vec<&str> = omitted_segment.split(", ").collect();
1871 omitted_names.sort_unstable();
1872 assert_eq!(
1873 omitted_names,
1874 vec!["skill-2", "skill-3", "skill-4"],
1875 "marker must name exactly the 3 truncated skills, got marker: {marker}"
1876 );
1877 }
1878
1879 #[test]
1880 fn filtered_skills_for_explicit_include_is_never_capped() {
1881 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1886 use zeph_subagent::hooks::SubagentHooks;
1887
1888 let (registry, _temp_dir) = registry_with_skills(5, 500);
1889 let def = zeph_subagent::SubAgentDef {
1890 name: "curated".to_owned(),
1891 description: "A curated helper".into(),
1892 model: None,
1893 tools: ToolPolicy::InheritAll,
1894 disallowed_tools: vec![],
1895 permissions: SubAgentPermissions::default(),
1896 skills: SkillFilter {
1897 include: vec!["skill-*".to_owned()],
1898 exclude: vec![],
1899 },
1900 system_prompt: "You are helpful.".into(),
1901 hooks: SubagentHooks::default(),
1902 memory: None,
1903 source: None,
1904 file_path: None,
1905 };
1906 let mut agent = agent_with_skill_registry_and_def(registry, def);
1907 agent.services.skill.subagent_skill_token_budget = 10;
1910
1911 let bodies = agent
1912 .filtered_skills_for("curated")
1913 .expect("explicit include must still match all 5 skill-* skills");
1914
1915 assert_eq!(
1916 bodies.len(),
1917 5,
1918 "explicit include list must never be truncated by the budget, got: {bodies:?}"
1919 );
1920 assert!(
1921 bodies.iter().all(|b| b.contains("lorem")),
1922 "every entry must be a real skill body, not a truncation marker: {bodies:?}"
1923 );
1924 }
1925}