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 #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
24 pub async fn poll_subagents(&mut self) -> Vec<(String, String)> {
25 let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
26 return vec![];
27 };
28
29 let finished: Vec<String> = mgr
30 .statuses()
31 .into_iter()
32 .filter_map(|(id, status)| {
33 if matches!(
34 status.state,
35 zeph_subagent::SubAgentState::Completed
36 | zeph_subagent::SubAgentState::Failed
37 | zeph_subagent::SubAgentState::Canceled
38 ) {
39 Some(id)
40 } else {
41 None
42 }
43 })
44 .collect();
45
46 let mut results = vec![];
47 for task_id in finished {
48 match mgr.collect(&task_id).await {
49 Ok(result) => results.push((task_id, result)),
50 Err(e) => {
51 tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
52 }
53 }
54 }
55 results
56 }
57 pub(super) fn refresh_subagent_metrics(&mut self) {
64 let Some(ref mgr) = self.services.orchestration.subagent_manager else {
65 return;
66 };
67 let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
68 .statuses()
69 .into_iter()
70 .map(|(id, s)| {
71 let def = mgr.agents_def(&id);
72 crate::metrics::SubAgentMetrics {
73 name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
74 id: id.clone(),
75 state: format!("{:?}", s.state).to_lowercase(),
76 turns_used: s.turns_used,
77 max_turns: def.map_or(20, |d| d.permissions.max_turns),
78 background: def.is_some_and(|d| d.permissions.background),
79 elapsed_secs: s.started_at.elapsed().as_secs(),
80 permission_mode: def.map_or_else(String::new, |d| {
81 use zeph_subagent::def::PermissionMode;
82 match d.permissions.permission_mode {
83 PermissionMode::AcceptEdits => "accept_edits".into(),
84 PermissionMode::DontAsk => "dont_ask".into(),
85 PermissionMode::BypassPermissions => "bypass_permissions".into(),
86 PermissionMode::Plan => "plan".into(),
87 _ => String::new(),
88 }
89 }),
90 transcript_dir: mgr
91 .agent_transcript_dir(&id)
92 .map(|p| p.to_string_lossy().into_owned()),
93 }
94 })
95 .collect();
96 self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
97 }
98 pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
100 let completed = self.poll_subagents().await;
101 for (task_id, result) in completed {
102 let notice = if result.is_empty() {
103 format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
104 } else {
105 format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
106 };
107 if let Err(e) = self.channel.send(¬ice).await {
108 tracing::warn!(error = %e, "failed to send sub-agent completion notice");
109 }
110 }
111 Ok(())
112 }
113 async fn poll_subagent_until_done(
117 &mut self,
118 task_id: &str,
119 label: &str,
120 ) -> Option<(String, bool)> {
121 use zeph_subagent::SubAgentState;
122 let result = loop {
123 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
124
125 #[allow(clippy::redundant_closure_for_method_calls)]
129 let pending = self
130 .services
131 .orchestration
132 .subagent_manager
133 .as_mut()
134 .and_then(|m| m.try_recv_secret_request());
135 if let Some((req_task_id, req)) = pending {
136 let confirm_prompt = format!(
139 "Sub-agent requests secret '{}'. Allow?",
140 crate::text::truncate_to_chars(&req.secret_key, 100)
141 );
142 let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
143 if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
144 if approved {
145 let ttl = std::time::Duration::from_mins(5);
146 let key = req.secret_key.clone();
147 if mgr.approve_secret(&req_task_id, &key, ttl).is_ok() {
148 let _ = mgr.deliver_secret(&req_task_id, key);
149 }
150 } else {
151 let _ = mgr.deny_secret(&req_task_id);
152 }
153 }
154 }
155
156 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
157 let statuses = mgr.statuses();
158 let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
159 break (format!("{label} completed (no status available)."), true);
160 };
161 match status.state {
162 SubAgentState::Completed => {
163 let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
164 break (format!("{label} completed: {msg}"), true);
165 }
166 SubAgentState::Failed => {
167 let msg = status
168 .last_message
169 .clone()
170 .unwrap_or_else(|| "unknown error".into());
171 break (format!("{label} failed: {msg}"), false);
172 }
173 SubAgentState::Canceled => {
174 break (format!("{label} was cancelled."), false);
175 }
176 _ => {
177 let _ = self
178 .channel
179 .send_status(&format!(
180 "{label}: turn {}/{}",
181 status.turns_used,
182 self.services
183 .orchestration
184 .subagent_manager
185 .as_ref()
186 .and_then(|m| m.agents_def(task_id))
187 .map_or(20, |d| d.permissions.max_turns)
188 ))
189 .await;
190 }
191 }
192 };
193 Some(result)
194 }
195 fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
198 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
199 let full_ids: Vec<String> = mgr
200 .statuses()
201 .into_iter()
202 .map(|(tid, _)| tid)
203 .filter(|tid| tid.starts_with(prefix))
204 .collect();
205 Some(match full_ids.as_slice() {
206 [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
207 [fid] => Ok(fid.clone()),
208 _ => Err(format!(
209 "Ambiguous id prefix '{prefix}': matches {} agents",
210 full_ids.len()
211 )),
212 })
213 }
214 fn handle_agent_list(&self) -> Option<String> {
215 use std::fmt::Write as _;
216 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
217 let defs = mgr.definitions();
218 if defs.is_empty() {
219 return Some("No sub-agent definitions found.".into());
220 }
221 let mut out = String::from("Available sub-agents:\n");
222 for d in defs {
223 let memory_label = match d.memory {
224 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
225 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
226 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
227 Some(_) => " [memory:unknown]",
228 None => "",
229 };
230 if let Some(ref src) = d.source {
231 let _ = writeln!(
232 out,
233 " {}{} — {} ({})",
234 d.name, memory_label, d.description, src
235 );
236 } else {
237 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
238 }
239 }
240 Some(out)
241 }
242 fn handle_agent_status(&self) -> Option<String> {
243 use std::fmt::Write as _;
244 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
245 let statuses = mgr.statuses();
246 if statuses.is_empty() {
247 return Some("No active sub-agents.".into());
248 }
249 let mut out = String::from("Active sub-agents:\n");
250 for (id, s) in &statuses {
251 let state = format!("{:?}", s.state).to_lowercase();
252 let elapsed = s.started_at.elapsed().as_secs();
253 let _ = writeln!(
254 out,
255 " [{short}] {state} turns={t} elapsed={elapsed}s {msg}",
256 short = &id[..8.min(id.len())],
257 t = s.turns_used,
258 msg = s.last_message.as_deref().unwrap_or(""),
259 );
260 if let Some(def) = mgr.agents_def(id)
262 && let Some(scope) = def.memory
263 && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
264 {
265 let _ = writeln!(out, " memory: {}", dir.display());
266 }
267 }
268 Some(out)
269 }
270 fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
271 let full_id = match self.resolve_agent_id_prefix(id)? {
272 Ok(fid) => fid,
273 Err(msg) => return Some(msg),
274 };
275 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
276 if let Some((tid, req)) = mgr.try_recv_secret_request()
277 && tid == full_id
278 {
279 let key = req.secret_key.clone();
280 let ttl = std::time::Duration::from_mins(5);
281 if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
282 return Some(format!("Approve failed: {e}"));
283 }
284 if let Err(e) = mgr.deliver_secret(&full_id, key.clone()) {
285 return Some(format!("Secret delivery failed: {e}"));
286 }
287 return Some(format!("Secret '{key}' approved for sub-agent {full_id}."));
288 }
289 Some(format!(
290 "No pending secret request for sub-agent '{full_id}'."
291 ))
292 }
293 fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
294 let full_id = match self.resolve_agent_id_prefix(id)? {
295 Ok(fid) => fid,
296 Err(msg) => return Some(msg),
297 };
298 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
299 match mgr.deny_secret(&full_id) {
300 Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
301 Err(e) => Some(format!("Deny failed: {e}")),
302 }
303 }
304 pub(super) async fn handle_agent_command(
305 &mut self,
306 cmd: zeph_subagent::AgentCommand,
307 ) -> Option<String> {
308 use zeph_subagent::AgentCommand;
309
310 match cmd {
311 AgentCommand::List => self.handle_agent_list(),
312 AgentCommand::Background { name, prompt } => {
313 self.handle_agent_background(&name, &prompt).await
314 }
315 AgentCommand::Spawn { name, prompt }
316 | AgentCommand::Mention {
317 agent: name,
318 prompt,
319 } => self.handle_agent_spawn_foreground(&name, &prompt).await,
320 AgentCommand::Status => self.handle_agent_status(),
321 AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
322 AgentCommand::Approve { id } => self.handle_agent_approve(&id),
323 AgentCommand::Deny { id } => self.handle_agent_deny(&id),
324 AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
325 _ => None,
326 }
327 }
328 pub(crate) fn handle_agents_definitions_list(&self) -> String {
333 use std::fmt::Write as _;
334
335 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
336 return String::new();
337 };
338 let defs = mgr.definitions();
339 if defs.is_empty() {
340 return String::new();
341 }
342 let mut out = String::from("Sub-agents:\n");
343 for d in defs {
344 let memory_label = match d.memory {
345 Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
346 Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
347 Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
348 Some(_) => " [memory:unknown]",
349 None => "",
350 };
351 if let Some(ref src) = d.source {
352 let _ = writeln!(
353 out,
354 " {}{} — {} ({})",
355 d.name, memory_label, d.description, src
356 );
357 } else {
358 let _ = writeln!(out, " {}{} — {}", d.name, memory_label, d.description);
359 }
360 }
361 out
362 }
363 pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
368 use zeph_subagent::AgentsCommand;
369
370 let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
371 return "Sub-agent manager is not available.".to_owned();
372 };
373
374 match cmd {
375 AgentsCommand::List => self.handle_agents_definitions_list(),
376 AgentsCommand::Show { name } => {
377 match mgr.definitions().iter().find(|d| d.name == name) {
378 Some(d) => format!(
379 "Agent: {}\nDescription: {}\nSource: {}\n",
380 d.name,
381 d.description,
382 d.source.as_deref().unwrap_or("unknown"),
383 ),
384 None => format!("No sub-agent definition named '{name}'."),
385 }
386 }
387 AgentsCommand::Create { name } => {
388 format!(
389 "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
390 See the sub-agent documentation for the required frontmatter."
391 )
392 }
393 AgentsCommand::Edit { name } => {
394 format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
395 }
396 AgentsCommand::Delete { name } => {
397 format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
398 }
399 _ => "Unknown agents command.".to_owned(),
400 }
401 }
402 async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
403 let provider = self.provider.clone();
404 let tool_executor = Arc::clone(&self.tool_executor);
405 let skills = self.filtered_skills_for(name);
406 let cfg = self.services.orchestration.subagent_config.clone();
407 let mut spawn_ctx = self.build_spawn_context(&cfg);
408 self.ensure_session_durable_ctx().await;
411 if let Some(seat) = maybe_make_durable_seat(
412 self.services.session.durable_subagent,
413 self.services.session.durable_ctx.as_deref(),
414 )
415 .await
416 {
417 spawn_ctx.durable_resolver = Some(seat);
418 }
419 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
420 match mgr
421 .spawn(
422 name,
423 prompt,
424 provider,
425 tool_executor,
426 skills,
427 &cfg,
428 spawn_ctx,
429 )
430 .await
431 {
432 Ok(id) => Some(format!(
433 "Sub-agent '{name}' started in background (id: {short})",
434 short = &id[..8.min(id.len())]
435 )),
436 Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
437 }
438 }
439 async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
440 let provider = self.provider.clone();
441 let tool_executor = Arc::clone(&self.tool_executor);
442 let skills = self.filtered_skills_for(name);
443 let cfg = self.services.orchestration.subagent_config.clone();
444 let mut spawn_ctx = self.build_spawn_context(&cfg);
445 self.ensure_session_durable_ctx().await;
449 if let Some(seat) = maybe_make_durable_seat(
450 self.services.session.durable_subagent,
451 self.services.session.durable_ctx.as_deref(),
452 )
453 .await
454 {
455 spawn_ctx.durable_resolver = Some(seat);
456 }
457 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
458 let task_id = match mgr
459 .spawn(
460 name,
461 prompt,
462 provider,
463 tool_executor,
464 skills,
465 &cfg,
466 spawn_ctx,
467 )
468 .await
469 {
470 Ok(id) => id,
471 Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
472 };
473 let short = task_id[..8.min(task_id.len())].to_owned();
474 let _ = self
475 .channel
476 .send(&format!("Sub-agent '{name}' running... (id: {short})"))
477 .await;
478 let _ = self
479 .channel
480 .notify_foreground_subagent_started(&task_id, name)
481 .await;
482 let label = format!("Sub-agent '{name}'");
483 let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
484 let _ = self
486 .channel
487 .notify_foreground_subagent_completed(&task_id, name, false)
488 .await;
489 return None;
490 };
491 let _ = self
492 .channel
493 .notify_foreground_subagent_completed(&task_id, name, success)
494 .await;
495 Some(result)
496 }
497 fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
498 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
499 let ids: Vec<String> = mgr
501 .statuses()
502 .into_iter()
503 .map(|(task_id, _)| task_id)
504 .filter(|task_id| task_id.starts_with(id))
505 .collect();
506 match ids.as_slice() {
507 [] => Some(format!("No sub-agent with id prefix '{id}'")),
508 [full_id] => {
509 let full_id = full_id.clone();
510 match mgr.cancel(&full_id) {
511 Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
512 Err(e) => Some(format!("Cancel failed: {e}")),
513 }
514 }
515 _ => Some(format!(
516 "Ambiguous id prefix '{id}': matches {} agents",
517 ids.len()
518 )),
519 }
520 }
521 async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
522 let cfg = self.services.orchestration.subagent_config.clone();
523 let def_name = {
526 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
527 match mgr.def_name_for_resume(id, &cfg).await {
528 Ok(name) => name,
529 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
530 }
531 };
532 let skills = self.filtered_skills_for(&def_name);
533 let provider = self.provider.clone();
534 let tool_executor = Arc::clone(&self.tool_executor);
535 let mgr = self.services.orchestration.subagent_manager.as_mut()?;
536 let (task_id, _) = match mgr
537 .resume(id, prompt, provider, tool_executor, skills, &cfg, None)
538 .await
539 {
540 Ok(pair) => pair,
541 Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
542 };
543 let short = task_id[..8.min(task_id.len())].to_owned();
544 let _ = self
545 .channel
546 .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
547 .await;
548 let _ = self
549 .channel
550 .notify_foreground_subagent_started(&task_id, &def_name)
551 .await;
552 let Some((result, success)) = self
553 .poll_subagent_until_done(&task_id, "Resumed sub-agent")
554 .await
555 else {
556 let _ = self
558 .channel
559 .notify_foreground_subagent_completed(&task_id, &def_name, false)
560 .await;
561 return None;
562 };
563 let _ = self
564 .channel
565 .notify_foreground_subagent_completed(&task_id, &def_name, success)
566 .await;
567 Some(result)
568 }
569 pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
570 let mgr = self.services.orchestration.subagent_manager.as_ref()?;
571 let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
572 let reg = self.services.skill.registry.read();
573 match zeph_subagent::filter_skills(®, &def.skills) {
574 Ok(skills) => {
575 let bodies: Vec<String> = skills.into_iter().map(|s| s.body.clone()).collect();
576 if bodies.is_empty() {
577 None
578 } else {
579 Some(bodies)
580 }
581 }
582 Err(e) => {
583 tracing::warn!(error = %e, "skill filtering failed for sub-agent");
584 None
585 }
586 }
587 }
588 pub(super) fn build_spawn_context(
590 &self,
591 cfg: &zeph_config::SubAgentConfig,
592 ) -> zeph_subagent::SpawnContext {
593 zeph_subagent::SpawnContext {
594 parent_messages: self.extract_parent_messages(cfg),
595 parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
596 parent_provider_name: {
597 let name = &self.runtime.config.active_provider_name;
598 if name.is_empty() {
599 None
600 } else {
601 Some(name.clone())
602 }
603 },
604 spawn_depth: self.runtime.config.spawn_depth,
605 mcp_tool_names: self.extract_mcp_tool_names(),
606 seed_trajectory_score: {
608 let child = self.services.security.trajectory.spawn_child();
609 let score = child.score_now();
610 if score > 0.0 { Some(score) } else { None }
611 },
612 content_isolation: self.runtime.config.security.content_isolation.clone(),
613 orchestrator_name: Some("zeph".to_owned()),
614 orchestrator_role: Some("orchestrator".to_owned()),
615 session_mcp_servers: Vec::new(),
616 ..Default::default()
619 }
620 }
621 fn extract_parent_messages(
628 &self,
629 config: &zeph_config::SubAgentConfig,
630 ) -> Vec<zeph_llm::provider::Message> {
631 use zeph_config::ParentContextPolicy;
632 use zeph_llm::provider::Role;
633
634 if config.parent_context_policy == ParentContextPolicy::None
635 || config.context_window_turns == 0
636 {
637 return Vec::new();
638 }
639
640 let non_system: Vec<_> = self
641 .msg
642 .messages
643 .iter()
644 .filter(|m| m.role != Role::System)
645 .cloned()
646 .collect();
647
648 let take_count = config
649 .context_window_turns
650 .saturating_mul(2)
651 .min(config.max_parent_messages);
652 let start = non_system.len().saturating_sub(take_count);
653 let mut msgs = non_system[start..].to_vec();
654
655 let max_chars = 128_000usize / 4;
657 let requested = msgs.len();
658 trim_parent_messages(&mut msgs, max_chars);
659 if msgs.len() < requested {
660 tracing::info!(
661 kept = msgs.len(),
662 requested,
663 "[subagent] truncated parent history due to token budget or orphan pruning"
664 );
665 }
666
667 if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
668 use zeph_sanitizer::{ContentSource, ContentSourceKind};
669 let source =
670 ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
671 msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
672 }
673
674 msgs
675 }
676 fn extract_mcp_tool_names(&self) -> Vec<String> {
678 self.tool_executor
679 .tool_definitions_erased()
680 .into_iter()
681 .filter(ToolDef::is_mcp_tool)
682 .map(|t| t.id.to_string())
683 .collect()
684 }
685 pub(super) fn classify_source_kind(
689 skill_dir: &std::path::Path,
690 managed_dir: Option<&std::path::PathBuf>,
691 bundled_names: &std::collections::HashSet<String>,
692 ) -> zeph_memory::store::SourceKind {
693 if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
694 let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
695 let has_marker = skill_dir.join(".bundled").exists();
696 if has_marker && bundled_names.contains(skill_name) {
697 zeph_memory::store::SourceKind::Bundled
698 } else {
699 if has_marker {
700 tracing::warn!(
701 skill = %skill_name,
702 "skill has .bundled marker but is not in the bundled skill \
703 allowlist — classifying as Hub"
704 );
705 }
706 zeph_memory::store::SourceKind::Hub
707 }
708 } else {
709 zeph_memory::store::SourceKind::Local
710 }
711 }
712}
713
714async fn maybe_make_durable_seat(
723 enabled: bool,
724 ctx: Option<&zeph_durable::DurableContext>,
725) -> Option<zeph_subagent::DurableResolverSeat> {
726 let ctx = ctx.filter(|_| enabled)?;
727 match zeph_subagent::make_durable_promise(ctx).await {
728 Ok((_promise, Some(seat))) => Some(seat),
729 Ok((_promise, None)) => None, Err(e) => {
731 tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
732 None
733 }
734 }
735}
736
737pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
745 use zeph_llm::provider::MessagePart;
746 if m.parts.is_empty() {
747 return m.content.len();
748 }
749 m.parts
750 .iter()
751 .map(|p| match p {
752 MessagePart::Text { text }
753 | MessagePart::Recall { text }
754 | MessagePart::CodeContext { text }
755 | MessagePart::Summary { text }
756 | MessagePart::CrossSession { text } => text.len(),
757 MessagePart::ToolOutput { body, .. } => body.len(),
758 MessagePart::ToolUse { id, name, input } => {
759 50 + id.len() + name.len() + input.to_string().len()
760 }
761 MessagePart::ToolResult {
762 tool_use_id,
763 content,
764 ..
765 } => 50 + tool_use_id.len() + content.len(),
766 MessagePart::Image(img) => img.data.len() * 4 / 3,
767 MessagePart::ThinkingBlock {
768 thinking,
769 signature,
770 } => 50 + thinking.len() + signature.len(),
771 MessagePart::RedactedThinkingBlock { data } => data.len(),
772 MessagePart::Compaction { summary } => summary.len(),
773 _ => 0,
774 })
775 .sum()
776}
777
778pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
797 use zeph_llm::provider::{MessagePart, Role};
798
799 let mut total_chars = 0usize;
803 let mut drop_before = 0usize; for (i, m) in msgs.iter().enumerate().rev() {
805 total_chars += estimate_parts_size(m);
806 if total_chars > max_chars {
807 drop_before = i + 1;
808 break;
809 }
810 }
811 if drop_before > 0 {
812 msgs.drain(..drop_before);
813 }
814
815 let emitted_tool_ids: std::collections::HashSet<String> = msgs
819 .iter()
820 .filter(|m| m.role == Role::Assistant)
821 .flat_map(|m| m.parts.iter())
822 .filter_map(|p| {
823 if let MessagePart::ToolUse { id, .. } = p {
824 Some(id.clone())
825 } else {
826 None
827 }
828 })
829 .collect();
830
831 let mut orphans_removed = 0usize;
832 for m in msgs.iter_mut() {
833 if m.role != Role::User || m.parts.is_empty() {
834 continue;
835 }
836 let before = m.parts.len();
837 m.parts.retain(|p| match p {
838 MessagePart::ToolResult { tool_use_id, .. } => {
839 emitted_tool_ids.contains(tool_use_id.as_str())
840 }
841 _ => true,
842 });
843 let dropped = before - m.parts.len();
844 if dropped > 0 {
845 orphans_removed += dropped;
846 if m.parts.is_empty() {
847 m.content.clear();
848 } else {
849 m.rebuild_content();
850 }
851 }
852 }
853
854 let consumed_tool_ids: std::collections::HashSet<String> = msgs
862 .iter()
863 .filter(|m| m.role == Role::User)
864 .flat_map(|m| m.parts.iter())
865 .filter_map(|p| {
866 if let MessagePart::ToolResult { tool_use_id, .. } = p {
867 Some(tool_use_id.clone())
868 } else {
869 None
870 }
871 })
872 .collect();
873
874 let last_assistant_idx = msgs
876 .iter()
877 .enumerate()
878 .rev()
879 .find(|(_, m)| m.role == Role::Assistant)
880 .map(|(i, _)| i);
881
882 for (idx, m) in msgs.iter_mut().enumerate() {
883 if m.role != Role::Assistant || m.parts.is_empty() {
884 continue;
885 }
886 if Some(idx) == last_assistant_idx {
888 continue;
889 }
890 let before = m.parts.len();
891 m.parts.retain(|p| match p {
892 MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
893 _ => true,
894 });
895 let dropped = before - m.parts.len();
896 if dropped > 0 {
897 orphans_removed += dropped;
898 if m.parts.is_empty() {
899 m.content.clear();
900 } else {
901 m.rebuild_content();
902 }
903 }
904 }
905
906 msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
908
909 if orphans_removed > 0 {
910 tracing::debug!(
911 orphans = orphans_removed,
912 "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
913 );
914 }
915}
916
917fn sanitize_parent_messages(
923 mut msgs: Vec<zeph_llm::provider::Message>,
924 sanitizer: &zeph_sanitizer::ContentSanitizer,
925 source: &zeph_sanitizer::ContentSource,
926) -> Vec<zeph_llm::provider::Message> {
927 use zeph_llm::provider::MessagePart;
928 for msg in &mut msgs {
929 let mut changed = false;
930 for part in &mut msg.parts {
931 if let MessagePart::Text { text } = part {
932 let clean = sanitizer.sanitize(text, source.clone());
933 if clean.body != *text {
934 *text = clean.body;
935 changed = true;
936 }
937 }
938 }
939 if changed {
940 msg.rebuild_content();
941 }
942 }
943 msgs
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use crate::agent::agent_tests::*;
950
951 #[tokio::test]
954 async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
955 use zeph_tools::registry::InvocationHint;
956
957 let provider = mock_provider(vec![]);
958 let channel = MockChannel::new(vec![]);
959 let registry = create_test_registry();
960 let executor = MockToolExecutor::no_tools().with_definitions(vec![
961 ToolDef {
962 id: "read".into(),
963 description: "built-in tool".into(),
964 schema: schemars::Schema::default(),
965 invocation: InvocationHint::ToolCall,
966 output_schema: None,
967 server_id: None,
968 },
969 ToolDef {
970 id: "github_create_issue".into(),
971 description: "MCP tool".into(),
972 schema: schemars::Schema::default(),
973 invocation: InvocationHint::ToolCall,
974 output_schema: None,
975 server_id: Some("github".into()),
976 },
977 ]);
978 let agent = Agent::new(provider, channel, registry, None, 5, executor);
979
980 assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
981 }
982
983 async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
988 let provider = mock_provider(vec!["ok".into()]);
989 let channel = MockChannel::new(vec![]);
990 let registry = create_test_registry();
991 let executor = MockToolExecutor::no_tools();
992 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
993 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
994 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
995 enabled: true,
996 agent_turns: true,
997 ..zeph_config::DurableConfig::default()
998 });
999 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1000 agent.services.session.durable_subagent = subagent_enabled;
1001
1002 agent.ensure_session_durable_ctx().await;
1003 assert!(
1004 agent.services.session.durable_ctx.is_some(),
1005 "test setup: durable_ctx must be populated before exercising the seat gate"
1006 );
1007 agent
1008 }
1009
1010 #[tokio::test]
1011 async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1012 let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1013
1014 let seat = maybe_make_durable_seat(
1015 agent.services.session.durable_subagent,
1016 agent.services.session.durable_ctx.as_deref(),
1017 )
1018 .await;
1019
1020 assert!(
1021 seat.is_some(),
1022 "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1023 not just wire the config-to-builder plumbing"
1024 );
1025 }
1026
1027 #[tokio::test]
1028 async fn seat_absent_when_subagent_disabled() {
1029 let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1030
1031 let seat = maybe_make_durable_seat(
1032 agent.services.session.durable_subagent,
1033 agent.services.session.durable_ctx.as_deref(),
1034 )
1035 .await;
1036
1037 assert!(
1038 seat.is_none(),
1039 "FR-008: durable_subagent=false must keep the seat gate closed even when \
1040 durable_ctx is populated"
1041 );
1042 }
1043}