Skip to main content

zeph_core/agent/
subagent_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent command handlers and spawn-context assembly.
5//!
6//! Extracted from `agent/mod.rs` (#4923). Handles `/agent` command dispatch (list,
7//! status, approve/deny, spawn, cancel, resume), background polling of running
8//! sub-agents, and construction of the bounded parent-message context handed to a
9//! freshly spawned sub-agent.
10
11use 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    /// Poll all active sub-agents for completed/failed/canceled results.
20    ///
21    /// Non-blocking: returns immediately with a list of `(task_id, result)` pairs
22    /// for agents that have finished. Each completed agent is removed from the manager.
23    #[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    /// Run the chat loop, receiving messages via the channel until EOF or shutdown.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if channel I/O or LLM communication fails.
62    /// Refresh sub-agent metrics snapshot for the TUI metrics panel.
63    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    /// Non-blocking poll: notify the user when background sub-agents complete.
99    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(&notice).await {
108                tracing::warn!(error = %e, "failed to send sub-agent completion notice");
109            }
110        }
111        Ok(())
112    }
113    /// Poll a sub-agent until it reaches a terminal state, bridging secret requests to the
114    /// channel. Returns a human-readable status string and success flag suitable for
115    /// sending to the user and emitting lifecycle events.
116    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            // Bridge secret requests from sub-agent to channel.confirm().
126            // Fetch the pending request first, then release the borrow before
127            // calling channel.confirm() (which requires &mut self).
128            #[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                // req.secret_key is pre-validated to [a-zA-Z0-9_-] in manager.rs
137                // (SEC-P1-02), so it is safe to embed in the prompt string.
138                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    /// Resolve a unique full `task_id` from a prefix. Returns `None` if the manager is absent,
196    /// `Some(Err(msg))` on ambiguity/not-found, `Some(Ok(full_id))` on success.
197    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            // Show memory directory path for agents with memory enabled.
261            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    /// Return the sub-agent definitions section formatted for the `/agents` fleet view.
329    ///
330    /// Produces a "Sub-agents:" header followed by one line per definition.
331    /// Returns an empty string when no sub-agent manager is configured.
332    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    /// Execute an `/agents` CRUD subcommand and return a formatted string.
364    ///
365    /// Handles `show`, `create`, `edit`, `delete` (the `list` case is handled by
366    /// [`handle_agents_definitions_list`] and never reaches this method).
367    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        // Background durable: seat wired so child can resolve; promise dropped (background
409        // results are collected via poll_subagents, not await_durable_subagent).
410        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        // Wire the durable resolver seat so the child can resolve its promise on exit.
446        // The promise (await side) is dropped here; foreground result is collected via
447        // poll_subagent_until_done which reads the join-handle output directly.
448        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            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
485            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        // Accept prefix match on task_id.
500        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        // Resolve definition name from transcript meta before spawning so we can
524        // look up skills by definition name rather than the UUID prefix (S1 fix).
525        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            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
557            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(&reg, &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    /// Build a `SpawnContext` from current agent state for sub-agent spawning.
589    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            // F3 spec 050 §4: propagate seeded score when parent is >= Elevated.
607            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            // Constraint propagation (#3993): populated by orchestration layer when spawning
617            // with explicit trust/tool restrictions. Top-level agent sessions leave these None.
618            ..Default::default()
619        }
620    }
621    /// Extract recent parent messages for history propagation (Section 5.7 in spec).
622    ///
623    /// Filters system messages, applies `context_window_turns` and `max_parent_messages` caps,
624    /// applies a 25% context window cap using a 4-chars-per-token heuristic, prunes orphaned
625    /// `ToolUse`/`ToolResult` pairs at the slice boundary, and optionally sanitizes text parts
626    /// through the IPI pipeline according to `parent_context_policy`.
627    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        // Cap at 25% of model context window and prune orphaned tool pairs.
656        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    /// Extract MCP tool names from the tool executor for diagnostic annotation.
677    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    /// Classify a skill directory's source kind using on-disk markers and the bundled allowlist.
686    ///
687    /// Must be called from a blocking context (uses synchronous FS I/O).
688    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
714/// Create a durable resolver seat for the next sub-agent spawn when the gate is open.
715///
716/// Returns `Some(seat)` when `enabled` is `true` and `ctx` is `Some` and the promise row
717/// was freshly created (first run). The seat goes into `SpawnContext::durable_resolver` for
718/// the child's background task (INV-9 channel rule).
719///
720/// Returns `None` when the gate is closed, on a resumed parent (token unrecoverable, INV-9),
721/// or on error (logged at `warn`) — the caller degrades to the plain spawn path.
722async 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, // Resumed: token unrecoverable (INV-9).
730        Err(e) => {
731            tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
732            None
733        }
734    }
735}
736
737/// Estimates the JSON payload size of a single [`zeph_llm::provider::Message`] for token-budget
738/// accounting.
739///
740/// When `parts` is empty the message is a legacy text-only message and `content.len()` is used
741/// directly. Otherwise each part is measured individually so that structured variants (images,
742/// tool invocations, thinking blocks) are accounted for rather than relying on the already-flat
743/// `content` string, which may not reflect the actual API payload size.
744pub(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
778/// Applies token-budget truncation and orphaned-tool-pair pruning to a parent message slice.
779///
780/// Budget truncation keeps the **most recent** messages that fit within `max_chars`
781/// (a suffix), so the subagent always receives the freshest context.
782///
783/// Two passes are performed after budget truncation:
784///
785/// 1. Remove `ToolResult` parts from user messages whose matching `ToolUse` is no longer in the
786///    slice (truncated away).
787/// 2. Remove `ToolUse` parts from **interior** assistant messages whose matching `ToolResult`
788///    was removed in pass 1 or was already absent. The trailing assistant message is exempt —
789///    its unanswered `ToolUse` calls are not orphaned; the slice just ends before the result.
790///
791/// Messages that become fully empty after pruning are removed from `msgs`.
792///
793/// `rebuild_content` is called **only** when `retain` actually removed parts — preserving the
794/// existing `content` field (and any `ThinkingBlock` text embedded there) for unmodified
795/// messages.
796pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
797    use zeph_llm::provider::{MessagePart, Role};
798
799    // Token-budget cap: keep the most recent messages that fit within max_chars.
800    // We iterate from the end (newest) and drain from the front once the budget is exceeded,
801    // so the subagent always receives the most recent context rather than stale early messages.
802    let mut total_chars = 0usize;
803    let mut drop_before = 0usize; // index of the first message to keep
804    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    // Pass 1: collect ToolUse IDs emitted by assistant messages; prune orphaned ToolResult
816    // parts from user messages that reference a ToolUse no longer present in the slice.
817    // Use owned Strings to avoid holding immutable borrows across the subsequent mutable loop.
818    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    // Pass 2: collect ToolResult IDs present in user messages after pass 1; prune ToolUse
855    // parts from assistant messages whose result is confirmed absent.
856    //
857    // The trailing assistant message is exempt: it may legitimately contain unanswered
858    // ToolUse calls (the slice ends before the result arrives). Only interior assistant
859    // messages — those followed by at least one user message — can have provably orphaned
860    // ToolUse parts (the conversation moved on without answering them).
861    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    // Index of the last assistant message — exempt from pass 2.
875    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        // Skip the trailing assistant message — its unanswered ToolUse calls are not orphaned.
887        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    // Remove messages that were emptied by orphan pruning.
907    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
917/// Sanitize text parts of `msgs` through the IPI pipeline.
918///
919/// Only [`MessagePart::Text`] parts are passed through the sanitizer; structured parts
920/// (`ToolUse`, `ToolResult`, `Recall`, `CodeContext`) are left untouched.  After sanitization
921/// the message `content` field is rebuilt to stay consistent with the updated parts.
922fn 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    /// #5712 regression: MCP tool identification must key off `ToolDef::server_id`, not a
952    /// `"mcp_"` name prefix that real `McpTool::sanitized_id()` output never produces.
953    #[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    /// Agent with `durable_ctx` populated via the real `ensure_session_durable_ctx` bootstrap
984    /// path (mirrors `durable_bootstrap::tests::agent_with_conversation`), with
985    /// `durable_subagent` set per `subagent_enabled` — used to test the FR-003/US-002 seat
986    /// wiring gate at `maybe_make_durable_seat`, not just the config-to-builder plumbing.
987    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}