1use std::sync::Arc;
12
13use zeph_tools::registry::ToolDef;
14
15use super::{Agent, error};
16use crate::channel::Channel;
17
18impl<C: Channel> Agent<C> {
19 pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
27 let normalized = key.to_lowercase().replace('-', "_");
28 self.services
29 .skill
30 .available_custom_secrets
31 .get(&normalized)
32 .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
33 }
34
35 #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
40 pub async fn poll_subagents(&mut self) -> Vec<(String, String)> {
41 let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
42 return vec![];
43 };
44
45 let finished: Vec<String> = mgr
46 .statuses()
47 .into_iter()
48 .filter_map(|(id, status)| {
49 if matches!(
50 status.state,
51 zeph_subagent::SubAgentState::Completed
52 | zeph_subagent::SubAgentState::Failed
53 | zeph_subagent::SubAgentState::Canceled
54 ) {
55 Some(id)
56 } else {
57 None
58 }
59 })
60 .collect();
61
62 let mut results = vec![];
63 for task_id in finished {
64 match mgr.collect(&task_id).await {
65 Ok(result) => results.push((task_id, result)),
66 Err(e) => {
67 tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
68 }
69 }
70 }
71 results
72 }
73 pub(super) fn refresh_subagent_metrics(&mut self) {
80 let Some(ref mgr) = self.services.orchestration.subagent_manager else {
81 return;
82 };
83 let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
84 .statuses()
85 .into_iter()
86 .map(|(id, s)| {
87 let def = mgr.agents_def(&id);
88 crate::metrics::SubAgentMetrics {
89 name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
90 id: id.clone(),
91 state: format!("{:?}", s.state).to_lowercase(),
92 turns_used: s.turns_used,
93 max_turns: def.map_or(20, |d| d.permissions.max_turns),
94 background: def.is_some_and(|d| d.permissions.background),
95 elapsed_secs: s.started_at.elapsed().as_secs(),
96 permission_mode: def.map_or_else(String::new, |d| {
97 use zeph_subagent::def::PermissionMode;
98 match d.permissions.permission_mode {
99 PermissionMode::AcceptEdits => "accept_edits".into(),
100 PermissionMode::DontAsk => "dont_ask".into(),
101 PermissionMode::BypassPermissions => "bypass_permissions".into(),
102 PermissionMode::Plan => "plan".into(),
103 _ => String::new(),
104 }
105 }),
106 transcript_dir: mgr
107 .agent_transcript_dir(&id)
108 .map(|p| p.to_string_lossy().into_owned()),
109 }
110 })
111 .collect();
112 self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
113 }
114 pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
116 let completed = self.poll_subagents().await;
117 for (task_id, result) in completed {
118 let notice = if result.is_empty() {
119 format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
120 } else {
121 format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
122 };
123 if let Err(e) = self.channel.send(¬ice).await {
124 tracing::warn!(error = %e, "failed to send sub-agent completion notice");
125 }
126 }
127 Ok(())
128 }
129 async fn poll_subagent_until_done(
133 &mut self,
134 task_id: &str,
135 label: &str,
136 ) -> Option<(String, bool)> {
137 use zeph_subagent::SubAgentState;
138 let result = loop {
139 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
140
141 #[allow(clippy::redundant_closure_for_method_calls)]
145 let pending = self
146 .services
147 .orchestration
148 .subagent_manager
149 .as_mut()
150 .and_then(|m| m.try_recv_secret_request());
151 if let Some((req_task_id, req)) = pending {
152 let confirm_prompt = format!(
155 "Sub-agent requests secret '{}'. Allow?",
156 crate::text::truncate_to_chars(&req.secret_key, 100)
157 );
158 let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
159 if approved {
160 let ttl = std::time::Duration::from_mins(5);
161 let key = req.secret_key.clone();
162 let resolved = self.resolve_subagent_secret(&key);
163 if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
164 if let Some(secret) = resolved {
165 if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
166 && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
167 {
168 tracing::warn!(error = %e, "sub-agent secret delivery failed");
169 let _ = mgr.deny_secret(&req_task_id);
170 }
171 } else {
172 tracing::warn!(
173 "sub-agent requested secret not resolvable from vault; denying"
174 );
175 let _ = mgr.deny_secret(&req_task_id);
176 }
177 }
178 } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
179 let _ = mgr.deny_secret(&req_task_id);
180 }
181 }
182
183 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
184 let statuses = mgr.statuses();
185 let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
186 break (format!("{label} completed (no status available)."), true);
187 };
188 match status.state {
189 SubAgentState::Completed => {
190 let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
191 break (format!("{label} completed: {msg}"), true);
192 }
193 SubAgentState::Failed => {
194 let msg = status
195 .last_message
196 .clone()
197 .unwrap_or_else(|| "unknown error".into());
198 break (format!("{label} failed: {msg}"), false);
199 }
200 SubAgentState::Canceled => {
201 break (format!("{label} was cancelled."), false);
202 }
203 _ => {
204 self.channel
205 .send_status_best_effort(&format!(
206 "{label}: turn {}/{}",
207 status.turns_used,
208 self.services
209 .orchestration
210 .subagent_manager
211 .as_ref()
212 .and_then(|m| m.agents_def(task_id))
213 .map_or(20, |d| d.permissions.max_turns)
214 ))
215 .await;
216 }
217 }
218 };
219 Some(result)
220 }
221 fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
224 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
225 let full_ids: Vec<String> = mgr
226 .statuses()
227 .into_iter()
228 .map(|(tid, _)| tid)
229 .filter(|tid| tid.starts_with(prefix))
230 .collect();
231 Some(match full_ids.as_slice() {
232 [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
233 [fid] => Ok(fid.clone()),
234 _ => Err(format!(
235 "Ambiguous id prefix '{prefix}': matches {} agents",
236 full_ids.len()
237 )),
238 })
239 }
240 fn handle_agent_list(&self) -> Option<String> {
241 use std::fmt::Write as _;
242 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
243 let defs = mgr.definitions();
244 if defs.is_empty() {
245 return Some("No sub-agent definitions found.".into());
246 }
247 let mut out = String::from("Available sub-agents:\n");
248 for d in defs {
249 let memory_label = match d.memory {
250 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
251 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
252 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
253 Some(_) => " [memory:unknown]",
254 None => "",
255 };
256 if let Some(ref src) = d.source {
257 let _ = writeln!(
258 out,
259 " {}{} — {} ({})",
260 d.name, memory_label, d.description, src
261 );
262 } else {
263 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
264 }
265 }
266 Some(out)
267 }
268 fn handle_agent_status(&self) -> Option<String> {
269 use std::fmt::Write as _;
270 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
271 let statuses = mgr.statuses();
272 if statuses.is_empty() {
273 return Some("No active sub-agents.".into());
274 }
275 let mut out = String::from("Active sub-agents:\n");
276 for (id, s) in &statuses {
277 let state = format!("{:?}", s.state).to_lowercase();
278 let elapsed = s.started_at.elapsed().as_secs();
279 let _ = writeln!(
280 out,
281 " [{short}] {state} turns={t} elapsed={elapsed}s {msg}",
282 short = &id[..8.min(id.len())],
283 t = s.turns_used,
284 msg = s.last_message.as_deref().unwrap_or(""),
285 );
286 if let Some(def) = mgr.agents_def(id)
288 && let Some(scope) = def.memory
289 && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
290 {
291 let _ = writeln!(out, " memory: {}", dir.display());
292 }
293 }
294 Some(out)
295 }
296 fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
297 let full_id = match self.resolve_agent_id_prefix(id)? {
298 Ok(fid) => fid,
299 Err(msg) => return Some(msg),
300 };
301 let req = {
302 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
303 mgr.try_recv_secret_request_for(&full_id)
304 };
305 let Some(req) = req else {
306 return Some(format!(
307 "No pending secret request for sub-agent '{full_id}'."
308 ));
309 };
310 let key = req.secret_key.clone();
311 let ttl = std::time::Duration::from_mins(5);
312 let Some(secret) = self.resolve_subagent_secret(&key) else {
313 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
314 let _ = mgr.deny_secret(&full_id);
315 return Some(format!(
316 "Secret '{key}' could not be resolved from the vault; request denied."
317 ));
318 };
319 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
320 if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
321 return Some(format!("Approve failed: {e}"));
322 }
323 if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
324 let _ = mgr.deny_secret(&full_id);
325 return Some(format!("Secret delivery failed: {e}"));
326 }
327 Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
328 }
329 fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
330 let full_id = match self.resolve_agent_id_prefix(id)? {
331 Ok(fid) => fid,
332 Err(msg) => return Some(msg),
333 };
334 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
335 match mgr.deny_secret(&full_id) {
336 Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
337 Err(e) => Some(format!("Deny failed: {e}")),
338 }
339 }
340 pub(super) async fn handle_agent_command(
341 &mut self,
342 cmd: zeph_subagent::AgentCommand,
343 ) -> Option<String> {
344 use zeph_subagent::AgentCommand;
345
346 match cmd {
347 AgentCommand::List => self.handle_agent_list(),
348 AgentCommand::Background { name, prompt } => {
349 self.handle_agent_background(&name, &prompt).await
350 }
351 AgentCommand::Spawn { name, prompt }
352 | AgentCommand::Mention {
353 agent: name,
354 prompt,
355 } => self.handle_agent_spawn_foreground(&name, &prompt).await,
356 AgentCommand::Status => self.handle_agent_status(),
357 AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
358 AgentCommand::Approve { id } => self.handle_agent_approve(&id),
359 AgentCommand::Deny { id } => self.handle_agent_deny(&id),
360 AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
361 _ => None,
362 }
363 }
364 pub(crate) fn handle_agents_definitions_list(&self) -> String {
369 use std::fmt::Write as _;
370
371 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
372 return String::new();
373 };
374 let defs = mgr.definitions();
375 if defs.is_empty() {
376 return String::new();
377 }
378 let mut out = String::from("Sub-agents:\n");
379 for d in defs {
380 let memory_label = match d.memory {
381 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
382 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
383 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
384 Some(_) => " [memory:unknown]",
385 None => "",
386 };
387 if let Some(ref src) = d.source {
388 let _ = writeln!(
389 out,
390 " {}{} — {} ({})",
391 d.name, memory_label, d.description, src
392 );
393 } else {
394 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
395 }
396 }
397 out
398 }
399 pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
404 use zeph_subagent::AgentsCommand;
405
406 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
407 return "Sub-agent manager is not available.".to_owned();
408 };
409
410 match cmd {
411 AgentsCommand::List => self.handle_agents_definitions_list(),
412 AgentsCommand::Show { name } => {
413 match mgr.definitions().iter().find(|d| d.name == name) {
414 Some(d) => format!(
415 "Agent: {}\nDescription: {}\nSource: {}\n",
416 d.name,
417 d.description,
418 d.source.as_deref().unwrap_or("unknown"),
419 ),
420 None => format!("No sub-agent definition named '{name}'."),
421 }
422 }
423 AgentsCommand::Create { name } => {
424 format!(
425 "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
426 See the sub-agent documentation for the required frontmatter."
427 )
428 }
429 AgentsCommand::Edit { name } => {
430 format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
431 }
432 AgentsCommand::Delete { name } => {
433 format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
434 }
435 _ => "Unknown agents command.".to_owned(),
436 }
437 }
438 async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
439 let provider = self.provider.clone();
440 let tool_executor = Arc::clone(&self.tool_executor);
441 let skills = self.filtered_skills_for(name);
442 let cfg = self.services.orchestration.subagent_config.clone();
443 let mut spawn_ctx = self.build_spawn_context(&cfg);
444 self.ensure_session_durable_ctx().await;
448 match resolve_durable_spawn_gate(
449 self.services.session.durable_subagent,
450 self.services.session.durable_ctx.as_deref(),
451 )
452 .await
453 {
454 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
455 DurableSpawnGate::Replayed { result, .. } => {
456 let short = &result.task_id[..8.min(result.task_id.len())];
457 return Some(if result.output.is_empty() {
458 format!(
459 "[sub-agent {short}] completed (no output, replayed from durable journal)"
460 )
461 } else {
462 format!(
463 "[sub-agent {short}] completed (replayed from durable journal):\n{}",
464 result.output
465 )
466 });
467 }
468 DurableSpawnGate::None => {}
469 }
470 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
471 match mgr
472 .spawn(
473 name,
474 prompt,
475 provider,
476 tool_executor,
477 skills,
478 &cfg,
479 spawn_ctx,
480 )
481 .await
482 {
483 Ok(id) => Some(format!(
484 "Sub-agent '{name}' started in background (id: {short})",
485 short = &id[..8.min(id.len())]
486 )),
487 Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
488 }
489 }
490 async fn notify_replayed_foreground_subagent(
499 &mut self,
500 name: &str,
501 result: zeph_subagent::SubagentResult,
502 promise_id: zeph_durable::PromiseId,
503 ) -> String {
504 let success = result.state == zeph_subagent::SubAgentState::Completed;
505 let task_id = result.task_id.clone();
506
507 let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
512 match ctx.claim_promise_notification(promise_id).await {
513 Ok(claimed) => claimed,
514 Err(e) => {
515 tracing::warn!(
516 error = %e,
517 "durable: promise-notification claim failed; \
518 firing the replayed sub-agent notice directly"
519 );
520 true
521 }
522 }
523 } else {
524 true
525 };
526
527 let text = if success {
528 result.output
529 } else {
530 result.error.unwrap_or_else(|| "unknown error".to_owned())
531 };
532
533 if should_notify {
534 let _ = self
535 .channel
536 .send(&format!(
537 "Sub-agent '{name}' replayed from durable journal (already finished \
538 before the parent restarted)."
539 ))
540 .await;
541 let _ = self
542 .channel
543 .notify_foreground_subagent_completed(&task_id, name, success)
544 .await;
545 }
546 text
547 }
548
549 async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
550 let provider = self.provider.clone();
551 let tool_executor = Arc::clone(&self.tool_executor);
552 let skills = self.filtered_skills_for(name);
553 let cfg = self.services.orchestration.subagent_config.clone();
554 let mut spawn_ctx = self.build_spawn_context(&cfg);
555 self.ensure_session_durable_ctx().await;
560 match resolve_durable_spawn_gate(
561 self.services.session.durable_subagent,
562 self.services.session.durable_ctx.as_deref(),
563 )
564 .await
565 {
566 DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
567 DurableSpawnGate::Replayed { result, promise_id } => {
568 return Some(
569 self.notify_replayed_foreground_subagent(name, result, promise_id)
570 .await,
571 );
572 }
573 DurableSpawnGate::None => {}
574 }
575 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
576 let task_id = match mgr
577 .spawn(
578 name,
579 prompt,
580 provider,
581 tool_executor,
582 skills,
583 &cfg,
584 spawn_ctx,
585 )
586 .await
587 {
588 Ok(id) => id,
589 Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
590 };
591 let short = task_id[..8.min(task_id.len())].to_owned();
592 let _ = self
593 .channel
594 .send(&format!("Sub-agent '{name}' running... (id: {short})"))
595 .await;
596 let _ = self
597 .channel
598 .notify_foreground_subagent_started(&task_id, name)
599 .await;
600 let label = format!("Sub-agent '{name}'");
601 let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
602 let _ = self
604 .channel
605 .notify_foreground_subagent_completed(&task_id, name, false)
606 .await;
607 return None;
608 };
609 let _ = self
610 .channel
611 .notify_foreground_subagent_completed(&task_id, name, success)
612 .await;
613 Some(result)
614 }
615 fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
616 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
617 let ids: Vec<String> = mgr
619 .statuses()
620 .into_iter()
621 .map(|(task_id, _)| task_id)
622 .filter(|task_id| task_id.starts_with(id))
623 .collect();
624 match ids.as_slice() {
625 [] => Some(format!("No sub-agent with id prefix '{id}'")),
626 [full_id] => {
627 let full_id = full_id.clone();
628 match mgr.cancel(&full_id) {
629 Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
630 Err(e) => Some(format!("Cancel failed: {e}")),
631 }
632 }
633 _ => Some(format!(
634 "Ambiguous id prefix '{id}': matches {} agents",
635 ids.len()
636 )),
637 }
638 }
639 async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
640 let cfg = self.services.orchestration.subagent_config.clone();
641 let def_name = {
644 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
645 match mgr.def_name_for_resume(id, &cfg).await {
646 Ok(name) => name,
647 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
648 }
649 };
650 let skills = self.filtered_skills_for(&def_name);
651 let provider = self.provider.clone();
652 let tool_executor = Arc::clone(&self.tool_executor);
653 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
654 let (task_id, _) = match mgr
655 .resume(id, prompt, provider, tool_executor, skills, &cfg, None)
656 .await
657 {
658 Ok(pair) => pair,
659 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
660 };
661 let short = task_id[..8.min(task_id.len())].to_owned();
662 let _ = self
663 .channel
664 .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
665 .await;
666 let _ = self
667 .channel
668 .notify_foreground_subagent_started(&task_id, &def_name)
669 .await;
670 let Some((result, success)) = self
671 .poll_subagent_until_done(&task_id, "Resumed sub-agent")
672 .await
673 else {
674 let _ = self
676 .channel
677 .notify_foreground_subagent_completed(&task_id, &def_name, false)
678 .await;
679 return None;
680 };
681 let _ = self
682 .channel
683 .notify_foreground_subagent_completed(&task_id, &def_name, success)
684 .await;
685 Some(result)
686 }
687 pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
688 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
689 let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
690 let reg = self.services.skill.registry.read();
691 match zeph_subagent::filter_skills(®, &def.skills) {
692 Ok(skills) => {
693 let bodies: Vec<String> = skills.into_iter().map(|s| s.body.clone()).collect();
694 if bodies.is_empty() {
695 None
696 } else {
697 Some(bodies)
698 }
699 }
700 Err(e) => {
701 tracing::warn!(error = %e, "skill filtering failed for sub-agent");
702 None
703 }
704 }
705 }
706 pub(super) fn build_spawn_context(
708 &self,
709 cfg: &zeph_config::SubAgentConfig,
710 ) -> zeph_subagent::SpawnContext {
711 zeph_subagent::SpawnContext {
712 parent_messages: self.extract_parent_messages(cfg),
713 parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
714 parent_provider_name: {
715 let name = &self.runtime.config.active_provider_name;
716 if name.is_empty() {
717 None
718 } else {
719 Some(name.clone())
720 }
721 },
722 spawn_depth: self.runtime.config.spawn_depth,
723 mcp_tool_names: self.extract_mcp_tool_names(),
724 seed_trajectory_score: {
726 let child = self.services.security.trajectory.spawn_child();
727 let score = child.score_now();
728 if score > 0.0 { Some(score) } else { None }
729 },
730 content_isolation: self.runtime.config.security.content_isolation.clone(),
731 orchestrator_name: Some("zeph".to_owned()),
732 orchestrator_role: Some("orchestrator".to_owned()),
733 session_mcp_servers: Vec::new(),
734 ..Default::default()
737 }
738 }
739 fn extract_parent_messages(
746 &self,
747 config: &zeph_config::SubAgentConfig,
748 ) -> Vec<zeph_llm::provider::Message> {
749 use zeph_config::ParentContextPolicy;
750 use zeph_llm::provider::Role;
751
752 if config.parent_context_policy == ParentContextPolicy::None
753 || config.context_window_turns == 0
754 {
755 return Vec::new();
756 }
757
758 let non_system: Vec<_> = self
759 .msg
760 .messages
761 .iter()
762 .filter(|m| m.role != Role::System)
763 .cloned()
764 .collect();
765
766 let take_count = config
767 .context_window_turns
768 .saturating_mul(2)
769 .min(config.max_parent_messages);
770 let start = non_system.len().saturating_sub(take_count);
771 let mut msgs = non_system[start..].to_vec();
772
773 let max_chars = 128_000usize / 4;
775 let requested = msgs.len();
776 trim_parent_messages(&mut msgs, max_chars);
777 if msgs.len() < requested {
778 tracing::info!(
779 kept = msgs.len(),
780 requested,
781 "[subagent] truncated parent history due to token budget or orphan pruning"
782 );
783 }
784
785 if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
786 use zeph_sanitizer::{ContentSource, ContentSourceKind};
787 let source =
788 ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
789 msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
790 }
791
792 msgs
793 }
794 fn extract_mcp_tool_names(&self) -> Vec<String> {
796 self.tool_executor
797 .tool_definitions_erased()
798 .into_iter()
799 .filter(ToolDef::is_mcp_tool)
800 .map(|t| t.id.to_string())
801 .collect()
802 }
803 pub(super) fn classify_source_kind(
807 skill_dir: &std::path::Path,
808 managed_dir: Option<&std::path::PathBuf>,
809 bundled_names: &std::collections::HashSet<String>,
810 ) -> zeph_memory::store::SourceKind {
811 if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
812 let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
813 let has_marker = skill_dir.join(".bundled").exists();
814 if has_marker && bundled_names.contains(skill_name) {
815 zeph_memory::store::SourceKind::Bundled
816 } else {
817 if has_marker {
818 tracing::warn!(
819 skill = %skill_name,
820 "skill has .bundled marker but is not in the bundled skill \
821 allowlist — classifying as Hub"
822 );
823 }
824 zeph_memory::store::SourceKind::Hub
825 }
826 } else {
827 zeph_memory::store::SourceKind::Local
828 }
829 }
830}
831
832enum DurableSpawnGate {
834 Fresh(zeph_subagent::DurableResolverSeat),
837 Replayed {
843 result: zeph_subagent::SubagentResult,
844 promise_id: zeph_durable::PromiseId,
845 },
846 None,
856}
857
858async fn resolve_durable_spawn_gate(
862 enabled: bool,
863 ctx: Option<&zeph_durable::DurableContext>,
864) -> DurableSpawnGate {
865 let Some(ctx) = ctx.filter(|_| enabled) else {
866 return DurableSpawnGate::None;
867 };
868 let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
869 Ok(pair) => pair,
870 Err(e) => {
871 tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
872 return DurableSpawnGate::None;
873 }
874 };
875 if let Some(seat) = seat {
876 return DurableSpawnGate::Fresh(seat);
877 }
878 match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
881 Ok(Some(result)) => DurableSpawnGate::Replayed {
882 result,
883 promise_id: promise.id(),
884 },
885 Ok(None) => {
886 tracing::warn!(
895 "durable: resumed sub-agent promise still pending after restart — original \
896 child did not resolve before the crash; re-spawning may duplicate side effects \
897 (#5944 residual v1 gap)"
898 );
899 DurableSpawnGate::None
900 }
901 Err(e) => {
902 tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
903 DurableSpawnGate::None
904 }
905 }
906}
907
908pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
916 use zeph_llm::provider::MessagePart;
917 if m.parts.is_empty() {
918 return m.content.len();
919 }
920 m.parts
921 .iter()
922 .map(|p| match p {
923 MessagePart::Text { text }
924 | MessagePart::Recall { text }
925 | MessagePart::CodeContext { text }
926 | MessagePart::Summary { text }
927 | MessagePart::CrossSession { text } => text.len(),
928 MessagePart::ToolOutput { body, .. } => body.len(),
929 MessagePart::ToolUse { id, name, input } => {
930 50 + id.len() + name.len() + input.to_string().len()
931 }
932 MessagePart::ToolResult {
933 tool_use_id,
934 content,
935 ..
936 } => 50 + tool_use_id.len() + content.len(),
937 MessagePart::Image(img) => img.data.len() * 4 / 3,
938 MessagePart::ThinkingBlock {
939 thinking,
940 signature,
941 } => 50 + thinking.len() + signature.len(),
942 MessagePart::RedactedThinkingBlock { data } => data.len(),
943 MessagePart::Compaction { summary } => summary.len(),
944 _ => 0,
945 })
946 .sum()
947}
948
949pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
968 use zeph_llm::provider::{MessagePart, Role};
969
970 let mut total_chars = 0usize;
974 let mut drop_before = 0usize; for (i, m) in msgs.iter().enumerate().rev() {
976 total_chars += estimate_parts_size(m);
977 if total_chars > max_chars {
978 drop_before = i + 1;
979 break;
980 }
981 }
982 if drop_before > 0 {
983 msgs.drain(..drop_before);
984 }
985
986 let emitted_tool_ids: std::collections::HashSet<String> = msgs
990 .iter()
991 .filter(|m| m.role == Role::Assistant)
992 .flat_map(|m| m.parts.iter())
993 .filter_map(|p| {
994 if let MessagePart::ToolUse { id, .. } = p {
995 Some(id.clone())
996 } else {
997 None
998 }
999 })
1000 .collect();
1001
1002 let mut orphans_removed = 0usize;
1003 for m in msgs.iter_mut() {
1004 if m.role != Role::User || m.parts.is_empty() {
1005 continue;
1006 }
1007 let before = m.parts.len();
1008 m.parts.retain(|p| match p {
1009 MessagePart::ToolResult { tool_use_id, .. } => {
1010 emitted_tool_ids.contains(tool_use_id.as_str())
1011 }
1012 _ => true,
1013 });
1014 let dropped = before - m.parts.len();
1015 if dropped > 0 {
1016 orphans_removed += dropped;
1017 if m.parts.is_empty() {
1018 m.content.clear();
1019 } else {
1020 m.rebuild_content();
1021 }
1022 }
1023 }
1024
1025 let consumed_tool_ids: std::collections::HashSet<String> = msgs
1033 .iter()
1034 .filter(|m| m.role == Role::User)
1035 .flat_map(|m| m.parts.iter())
1036 .filter_map(|p| {
1037 if let MessagePart::ToolResult { tool_use_id, .. } = p {
1038 Some(tool_use_id.clone())
1039 } else {
1040 None
1041 }
1042 })
1043 .collect();
1044
1045 let last_assistant_idx = msgs
1047 .iter()
1048 .enumerate()
1049 .rev()
1050 .find(|(_, m)| m.role == Role::Assistant)
1051 .map(|(i, _)| i);
1052
1053 for (idx, m) in msgs.iter_mut().enumerate() {
1054 if m.role != Role::Assistant || m.parts.is_empty() {
1055 continue;
1056 }
1057 if Some(idx) == last_assistant_idx {
1059 continue;
1060 }
1061 let before = m.parts.len();
1062 m.parts.retain(|p| match p {
1063 MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1064 _ => true,
1065 });
1066 let dropped = before - m.parts.len();
1067 if dropped > 0 {
1068 orphans_removed += dropped;
1069 if m.parts.is_empty() {
1070 m.content.clear();
1071 } else {
1072 m.rebuild_content();
1073 }
1074 }
1075 }
1076
1077 msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1079
1080 if orphans_removed > 0 {
1081 tracing::debug!(
1082 orphans = orphans_removed,
1083 "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1084 );
1085 }
1086}
1087
1088fn sanitize_parent_messages(
1094 mut msgs: Vec<zeph_llm::provider::Message>,
1095 sanitizer: &zeph_sanitizer::ContentSanitizer,
1096 source: &zeph_sanitizer::ContentSource,
1097) -> Vec<zeph_llm::provider::Message> {
1098 use zeph_llm::provider::MessagePart;
1099 for msg in &mut msgs {
1100 let mut changed = false;
1101 for part in &mut msg.parts {
1102 if let MessagePart::Text { text } = part {
1103 let clean = sanitizer.sanitize(text, source.clone());
1104 if clean.body != *text {
1105 *text = clean.body;
1106 changed = true;
1107 }
1108 }
1109 }
1110 if changed {
1111 msg.rebuild_content();
1112 }
1113 }
1114 msgs
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120 use crate::agent::agent_tests::*;
1121
1122 fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1125 let provider = mock_provider(vec![]);
1126 let channel = MockChannel::new(vec![]);
1127 let registry = create_test_registry();
1128 let executor = MockToolExecutor::no_tools();
1129 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1130 agent.services.skill.available_custom_secrets.insert(
1131 stored_key.to_owned(),
1132 crate::vault::Secret::new(value.to_owned()),
1133 );
1134 agent
1135 }
1136
1137 #[test]
1138 fn resolve_subagent_secret_exact_match() {
1139 let agent = agent_with_custom_secret("my_key", "the-value");
1140 let resolved = agent.resolve_subagent_secret("my_key");
1141 assert_eq!(
1142 resolved.map(|s| s.expose().to_owned()),
1143 Some("the-value".to_owned())
1144 );
1145 }
1146
1147 #[test]
1148 fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1149 let agent = agent_with_custom_secret("my_api_key", "dash-value");
1152 let resolved = agent.resolve_subagent_secret("my-api-key");
1153 assert_eq!(
1154 resolved.map(|s| s.expose().to_owned()),
1155 Some("dash-value".to_owned())
1156 );
1157 }
1158
1159 #[test]
1160 fn resolve_subagent_secret_normalizes_case() {
1161 let agent = agent_with_custom_secret("upper_key", "case-value");
1162 let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1163 assert_eq!(
1164 resolved.map(|s| s.expose().to_owned()),
1165 Some("case-value".to_owned())
1166 );
1167 }
1168
1169 #[test]
1170 fn resolve_subagent_secret_missing_key_returns_none() {
1171 let agent = agent_with_custom_secret("known_key", "value");
1172 assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1173 }
1174
1175 #[test]
1176 fn resolve_subagent_secret_empty_map_returns_none() {
1177 let provider = mock_provider(vec![]);
1178 let channel = MockChannel::new(vec![]);
1179 let registry = create_test_registry();
1180 let executor = MockToolExecutor::no_tools();
1181 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1182 assert!(agent.resolve_subagent_secret("anything").is_none());
1183 }
1184
1185 #[tokio::test]
1188 async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1189 use zeph_tools::registry::InvocationHint;
1190
1191 let provider = mock_provider(vec![]);
1192 let channel = MockChannel::new(vec![]);
1193 let registry = create_test_registry();
1194 let executor = MockToolExecutor::no_tools().with_definitions(vec![
1195 ToolDef {
1196 id: "read".into(),
1197 description: "built-in tool".into(),
1198 schema: schemars::Schema::default(),
1199 invocation: InvocationHint::ToolCall,
1200 output_schema: None,
1201 server_id: None,
1202 },
1203 ToolDef {
1204 id: "github_create_issue".into(),
1205 description: "MCP tool".into(),
1206 schema: schemars::Schema::default(),
1207 invocation: InvocationHint::ToolCall,
1208 output_schema: None,
1209 server_id: Some("github".into()),
1210 },
1211 ]);
1212 let agent = Agent::new(provider, channel, registry, None, 5, executor);
1213
1214 assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1215 }
1216
1217 async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1222 let provider = mock_provider(vec!["ok".into()]);
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.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1228 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1229 enabled: true,
1230 agent_turns: true,
1231 ..zeph_config::DurableConfig::default()
1232 });
1233 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1234 agent.services.session.durable_subagent = subagent_enabled;
1235
1236 agent.ensure_session_durable_ctx().await;
1237 assert!(
1238 agent.services.session.durable_ctx.is_some(),
1239 "test setup: durable_ctx must be populated before exercising the seat gate"
1240 );
1241 agent
1242 }
1243
1244 #[tokio::test]
1245 async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1246 let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1247
1248 let gate = resolve_durable_spawn_gate(
1249 agent.services.session.durable_subagent,
1250 agent.services.session.durable_ctx.as_deref(),
1251 )
1252 .await;
1253
1254 assert!(
1255 matches!(gate, DurableSpawnGate::Fresh(_)),
1256 "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1257 not just wire the config-to-builder plumbing"
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn seat_absent_when_subagent_disabled() {
1263 let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1264
1265 let gate = resolve_durable_spawn_gate(
1266 agent.services.session.durable_subagent,
1267 agent.services.session.durable_ctx.as_deref(),
1268 )
1269 .await;
1270
1271 assert!(
1272 matches!(gate, DurableSpawnGate::None),
1273 "FR-008: durable_subagent=false must keep the seat gate closed even when \
1274 durable_ctx is populated"
1275 );
1276 }
1277
1278 fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1288 use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1289 use zeph_subagent::hooks::SubagentHooks;
1290
1291 zeph_subagent::SubAgentDef {
1292 name: name.to_owned(),
1293 description: "A helper bot".into(),
1294 model: None,
1295 tools: ToolPolicy::InheritAll,
1296 disallowed_tools: vec![],
1297 permissions: SubAgentPermissions::default(),
1298 skills: SkillFilter::default(),
1299 system_prompt: "You are helpful.".into(),
1300 hooks: SubagentHooks::default(),
1301 memory: None,
1302 source: None,
1303 file_path: None,
1304 }
1305 }
1306
1307 async fn agent_with_durable_and_manager(
1311 db_url: &str,
1312 conversation_id: i64,
1313 ) -> Agent<MockChannel> {
1314 let provider = mock_provider(vec!["ok".into()]);
1315 let channel = MockChannel::new(vec![]);
1316 let registry = create_test_registry();
1317 let executor = MockToolExecutor::no_tools();
1318 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1319 agent.services.memory.persistence.conversation_id =
1320 Some(zeph_memory::ConversationId(conversation_id));
1321 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1322 enabled: true,
1323 agent_turns: true,
1324 ..zeph_config::DurableConfig::default()
1325 });
1326 agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1327 agent.services.session.durable_subagent = true;
1328
1329 let mut mgr = zeph_subagent::SubAgentManager::new(4);
1330 mgr.definitions_mut().push(subagent_def("helper"));
1331 agent.services.orchestration.subagent_manager = Some(mgr);
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 handler"
1337 );
1338 agent
1339 }
1340
1341 #[tokio::test]
1342 async fn handle_agent_background_replays_finished_child_without_respawning() {
1343 let dir = tempfile::tempdir().unwrap();
1344 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1345
1346 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1348 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1349 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1350 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1351 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1352 Ok("child finished before crash".to_owned());
1353 zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1354 agent1
1355 .services
1356 .session
1357 .durable_writer
1358 .as_ref()
1359 .unwrap()
1360 .flush()
1361 .await
1362 .unwrap();
1363 drop(agent1);
1368
1369 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1372
1373 let resp = agent2
1374 .handle_agent_background("helper", "do work")
1375 .await
1376 .unwrap();
1377 assert!(
1378 resp.contains("replayed from durable journal"),
1379 "expected a replay notice, got: {resp}"
1380 );
1381 assert!(
1382 resp.contains("child finished before crash"),
1383 "expected the journaled output to be surfaced, got: {resp}"
1384 );
1385 assert!(
1386 agent2
1387 .services
1388 .orchestration
1389 .subagent_manager
1390 .as_ref()
1391 .unwrap()
1392 .statuses()
1393 .is_empty(),
1394 "mgr.spawn must not be called when the child result is replayed"
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1402
1403 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1405 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1406 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1407 let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1408 let loop_result: Result<String, zeph_subagent::SubAgentError> =
1409 Ok("foreground child output".to_owned());
1410 zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1411 ctx1.step(
1419 zeph_durable::StepDescriptor::idempotent(
1420 "post_spawn_marker",
1421 b"post_spawn_marker".to_vec(),
1422 ),
1423 |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1424 )
1425 .await
1426 .unwrap();
1427 agent1
1428 .services
1429 .session
1430 .durable_writer
1431 .as_ref()
1432 .unwrap()
1433 .flush()
1434 .await
1435 .unwrap();
1436 drop(agent1);
1439
1440 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1443
1444 let resp = agent2
1445 .handle_agent_spawn_foreground("helper", "do work")
1446 .await
1447 .unwrap();
1448 assert_eq!(resp, "foreground child output");
1449 assert!(
1450 agent2
1451 .channel
1452 .sent_messages()
1453 .iter()
1454 .any(|m| m.contains("replayed from durable journal")),
1455 "expected the replay notice to be sent to the channel"
1456 );
1457 assert_eq!(
1458 agent2.channel.notify_completed_calls().len(),
1459 1,
1460 "expected exactly one TUI completion notification on the first replay"
1461 );
1462 assert!(
1463 agent2
1464 .services
1465 .orchestration
1466 .subagent_manager
1467 .as_ref()
1468 .unwrap()
1469 .statuses()
1470 .is_empty(),
1471 "mgr.spawn must not be called when the child result is replayed"
1472 );
1473 drop(agent2);
1474
1475 let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1480
1481 let resp = agent3
1482 .handle_agent_spawn_foreground("helper", "do work")
1483 .await
1484 .unwrap();
1485 assert_eq!(resp, "foreground child output");
1486 assert!(
1487 !agent3
1488 .channel
1489 .sent_messages()
1490 .iter()
1491 .any(|m| m.contains("replayed from durable journal")),
1492 "replay notice must not re-fire on a second replay after a parent restart"
1493 );
1494 assert!(
1495 agent3.channel.notify_completed_calls().is_empty(),
1496 "TUI completion event must not re-fire on a second replay after a parent restart"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1504
1505 let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1508 let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1509 let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1510 assert!(
1511 seat.is_some(),
1512 "test setup: run 1 must be fresh and yield a resolver seat"
1513 );
1514 agent1
1515 .services
1516 .session
1517 .durable_writer
1518 .as_ref()
1519 .unwrap()
1520 .flush()
1521 .await
1522 .unwrap();
1523 drop(agent1);
1526
1527 let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1531
1532 let resp = agent2
1533 .handle_agent_background("helper", "do work")
1534 .await
1535 .unwrap();
1536 assert!(
1537 resp.contains("started in background"),
1538 "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1539 );
1540 assert_eq!(
1541 agent2
1542 .services
1543 .orchestration
1544 .subagent_manager
1545 .as_ref()
1546 .unwrap()
1547 .statuses()
1548 .len(),
1549 1,
1550 "exactly one real spawn must occur on the still-pending fallback path"
1551 );
1552 }
1553}