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
18/// Number of trailing forwarded-transcript lines surfaced per subagent in
19/// [`crate::metrics::SubAgentMetrics::live_transcript`] (issue #6359, FR-005).
20const LIVE_TRANSCRIPT_TAIL_LINES: usize = 20;
21
22impl<C: Channel> Agent<C> {
23    /// Resolve a sub-agent's requested vault-secret key against the custom secrets already
24    /// resolved from the vault at startup (`ZEPH_SECRET_<NAME>` keys — the same pre-resolved
25    /// map used for skill `requires_secrets` injection, see `tool_execution::inject_active_skill_env`).
26    ///
27    /// Matching is case-insensitive with `-` normalized to `_`, mirroring the vault-key
28    /// naming convention (`ZEPH_SECRET_MY-KEY` and `ZEPH_SECRET_MY_KEY` both resolve to
29    /// `my_key`). Returns `None` when `key` was never resolved from the vault at startup.
30    pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
31        let normalized = key.to_lowercase().replace('-', "_");
32        self.services
33            .skill
34            .available_custom_secrets
35            .get(&normalized)
36            .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
37    }
38
39    /// Poll all active sub-agents for completed/failed/canceled results.
40    ///
41    /// Non-blocking: returns immediately with a list of `(task_id, result)` pairs
42    /// for agents that have finished. Each completed agent is removed from the manager.
43    #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
44    pub async fn poll_subagents(&mut self) -> Vec<(String, String)> {
45        let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
46            return vec![];
47        };
48
49        let finished: Vec<String> = mgr
50            .statuses()
51            .into_iter()
52            .filter_map(|(id, status)| {
53                if matches!(
54                    status.state,
55                    zeph_subagent::SubAgentState::Completed
56                        | zeph_subagent::SubAgentState::Failed
57                        | zeph_subagent::SubAgentState::Canceled
58                ) {
59                    Some(id)
60                } else {
61                    None
62                }
63            })
64            .collect();
65
66        let mut results = vec![];
67        for task_id in finished {
68            match mgr.collect(&task_id).await {
69                Ok(result) => results.push((task_id, result)),
70                Err(e) => {
71                    tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
72                }
73            }
74        }
75        results
76    }
77    /// Run the chat loop, receiving messages via the channel until EOF or shutdown.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if channel I/O or LLM communication fails.
82    /// Refresh sub-agent metrics snapshot for the TUI metrics panel.
83    pub(super) fn refresh_subagent_metrics(&mut self) {
84        let Some(ref mgr) = self.services.orchestration.subagent_manager else {
85            return;
86        };
87        let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
88            .statuses()
89            .into_iter()
90            .map(|(id, s)| {
91                let def = mgr.agents_def(&id);
92                crate::metrics::SubAgentMetrics {
93                    name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
94                    id: id.clone(),
95                    state: format!("{:?}", s.state).to_lowercase(),
96                    turns_used: s.turns_used,
97                    max_turns: def.map_or(20, |d| d.permissions.max_turns),
98                    background: def.is_some_and(|d| d.permissions.background),
99                    elapsed_secs: s.started_at.elapsed().as_secs(),
100                    permission_mode: def.map_or_else(String::new, |d| {
101                        use zeph_subagent::def::PermissionMode;
102                        match d.permissions.permission_mode {
103                            PermissionMode::AcceptEdits => "accept_edits".into(),
104                            PermissionMode::DontAsk => "dont_ask".into(),
105                            PermissionMode::BypassPermissions => "bypass_permissions".into(),
106                            PermissionMode::Plan => "plan".into(),
107                            _ => String::new(),
108                        }
109                    }),
110                    transcript_dir: mgr
111                        .agent_transcript_dir(&id)
112                        .map(|p| p.to_string_lossy().into_owned()),
113                    live_transcript: mgr.forwarded_tail(&id, LIVE_TRANSCRIPT_TAIL_LINES),
114                }
115            })
116            .collect();
117        self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
118    }
119    /// Non-blocking poll: notify the user when background sub-agents complete.
120    pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
121        let completed = self.poll_subagents().await;
122        for (task_id, result) in completed {
123            let notice = if result.is_empty() {
124                format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
125            } else {
126                format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
127            };
128            if let Err(e) = self.channel.send(&notice).await {
129                tracing::warn!(error = %e, "failed to send sub-agent completion notice");
130            }
131        }
132        Ok(())
133    }
134    /// Poll a sub-agent until it reaches a terminal state, bridging secret requests to the
135    /// channel. Returns a human-readable status string and success flag suitable for
136    /// sending to the user and emitting lifecycle events.
137    async fn poll_subagent_until_done(
138        &mut self,
139        task_id: &str,
140        label: &str,
141    ) -> Option<(String, bool)> {
142        use zeph_subagent::SubAgentState;
143        let result = loop {
144            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
145
146            // Bridge secret requests from sub-agent to channel.confirm().
147            // Fetch the pending request first, then release the borrow before
148            // calling channel.confirm() (which requires &mut self).
149            #[allow(clippy::redundant_closure_for_method_calls)]
150            let pending = self
151                .services
152                .orchestration
153                .subagent_manager
154                .as_mut()
155                .and_then(|m| m.try_recv_secret_request());
156            if let Some((req_task_id, req)) = pending {
157                // req.secret_key is pre-validated to [a-zA-Z0-9_-] in manager.rs
158                // (SEC-P1-02), so it is safe to embed in the prompt string.
159                let confirm_prompt = format!(
160                    "Sub-agent requests secret '{}'. Allow?",
161                    crate::text::truncate_to_chars(&req.secret_key, 100)
162                );
163                let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
164                if approved {
165                    let ttl = std::time::Duration::from_mins(5);
166                    let key = req.secret_key.clone();
167                    let resolved = self.resolve_subagent_secret(&key);
168                    if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
169                        if let Some(secret) = resolved {
170                            if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
171                                && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
172                            {
173                                tracing::warn!(error = %e, "sub-agent secret delivery failed");
174                                let _ = mgr.deny_secret(&req_task_id);
175                            }
176                        } else {
177                            tracing::warn!(
178                                "sub-agent requested secret not resolvable from vault; denying"
179                            );
180                            let _ = mgr.deny_secret(&req_task_id);
181                        }
182                    }
183                } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
184                    let _ = mgr.deny_secret(&req_task_id);
185                }
186            }
187
188            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
189            let statuses = mgr.statuses();
190            let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
191                break (format!("{label} completed (no status available)."), true);
192            };
193            match status.state {
194                SubAgentState::Completed => {
195                    let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
196                    break (format!("{label} completed: {msg}"), true);
197                }
198                SubAgentState::Failed => {
199                    let msg = status
200                        .last_message
201                        .clone()
202                        .unwrap_or_else(|| "unknown error".into());
203                    break (format!("{label} failed: {msg}"), false);
204                }
205                SubAgentState::Canceled => {
206                    break (format!("{label} was cancelled."), false);
207                }
208                _ => {
209                    self.channel
210                        .send_status_best_effort(&format!(
211                            "{label}: turn {}/{}",
212                            status.turns_used,
213                            self.services
214                                .orchestration
215                                .subagent_manager
216                                .as_ref()
217                                .and_then(|m| m.agents_def(task_id))
218                                .map_or(20, |d| d.permissions.max_turns)
219                        ))
220                        .await;
221                }
222            }
223        };
224        Some(result)
225    }
226    /// Resolve a unique full `task_id` from a prefix. Returns `None` if the manager is absent,
227    /// `Some(Err(msg))` on ambiguity/not-found, `Some(Ok(full_id))` on success.
228    fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
229        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
230        let full_ids: Vec<String> = mgr
231            .statuses()
232            .into_iter()
233            .map(|(tid, _)| tid)
234            .filter(|tid| tid.starts_with(prefix))
235            .collect();
236        Some(match full_ids.as_slice() {
237            [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
238            [fid] => Ok(fid.clone()),
239            _ => Err(format!(
240                "Ambiguous id prefix '{prefix}': matches {} agents",
241                full_ids.len()
242            )),
243        })
244    }
245    fn handle_agent_list(&self) -> Option<String> {
246        use std::fmt::Write as _;
247        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
248        let defs = mgr.definitions();
249        if defs.is_empty() {
250            return Some("No sub-agent definitions found.".into());
251        }
252        let mut out = String::from("Available sub-agents:\n");
253        for d in defs {
254            let memory_label = match d.memory {
255                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
256                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
257                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
258                Some(_) => " [memory:unknown]",
259                None => "",
260            };
261            if let Some(ref src) = d.source {
262                let _ = writeln!(
263                    out,
264                    "  {}{} — {} ({})",
265                    d.name, memory_label, d.description, src
266                );
267            } else {
268                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
269            }
270        }
271        Some(out)
272    }
273    fn handle_agent_status(&self) -> Option<String> {
274        use std::fmt::Write as _;
275        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
276        let statuses = mgr.statuses();
277        if statuses.is_empty() {
278            return Some("No active sub-agents.".into());
279        }
280        let mut out = String::from("Active sub-agents:\n");
281        for (id, s) in &statuses {
282            let state = format!("{:?}", s.state).to_lowercase();
283            let elapsed = s.started_at.elapsed().as_secs();
284            let _ = writeln!(
285                out,
286                "  [{short}] {state}  turns={t}  elapsed={elapsed}s  {msg}",
287                short = &id[..8.min(id.len())],
288                t = s.turns_used,
289                msg = s.last_message.as_deref().unwrap_or(""),
290            );
291            // Show memory directory path for agents with memory enabled.
292            if let Some(def) = mgr.agents_def(id)
293                && let Some(scope) = def.memory
294                && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
295            {
296                let _ = writeln!(out, "       memory: {}", dir.display());
297            }
298        }
299        Some(out)
300    }
301    fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
302        let full_id = match self.resolve_agent_id_prefix(id)? {
303            Ok(fid) => fid,
304            Err(msg) => return Some(msg),
305        };
306        let req = {
307            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
308            mgr.try_recv_secret_request_for(&full_id)
309        };
310        let Some(req) = req else {
311            return Some(format!(
312                "No pending secret request for sub-agent '{full_id}'."
313            ));
314        };
315        let key = req.secret_key.clone();
316        let ttl = std::time::Duration::from_mins(5);
317        let Some(secret) = self.resolve_subagent_secret(&key) else {
318            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
319            let _ = mgr.deny_secret(&full_id);
320            return Some(format!(
321                "Secret '{key}' could not be resolved from the vault; request denied."
322            ));
323        };
324        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
325        if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
326            return Some(format!("Approve failed: {e}"));
327        }
328        if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
329            let _ = mgr.deny_secret(&full_id);
330            return Some(format!("Secret delivery failed: {e}"));
331        }
332        Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
333    }
334    fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
335        let full_id = match self.resolve_agent_id_prefix(id)? {
336            Ok(fid) => fid,
337            Err(msg) => return Some(msg),
338        };
339        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
340        match mgr.deny_secret(&full_id) {
341            Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
342            Err(e) => Some(format!("Deny failed: {e}")),
343        }
344    }
345    pub(super) async fn handle_agent_command(
346        &mut self,
347        cmd: zeph_subagent::AgentCommand,
348    ) -> Option<String> {
349        use zeph_subagent::AgentCommand;
350
351        match cmd {
352            AgentCommand::List => self.handle_agent_list(),
353            AgentCommand::Background { name, prompt } => {
354                self.handle_agent_background(&name, &prompt).await
355            }
356            AgentCommand::Spawn { name, prompt }
357            | AgentCommand::Mention {
358                agent: name,
359                prompt,
360            } => self.handle_agent_spawn_foreground(&name, &prompt).await,
361            AgentCommand::Status => self.handle_agent_status(),
362            AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
363            AgentCommand::Approve { id } => self.handle_agent_approve(&id),
364            AgentCommand::Deny { id } => self.handle_agent_deny(&id),
365            AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
366            _ => None,
367        }
368    }
369    /// Return the sub-agent definitions section formatted for the `/agents` fleet view.
370    ///
371    /// Produces a "Sub-agents:" header followed by one line per definition.
372    /// Returns an empty string when no sub-agent manager is configured.
373    pub(crate) fn handle_agents_definitions_list(&self) -> String {
374        use std::fmt::Write as _;
375
376        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
377            return String::new();
378        };
379        let defs = mgr.definitions();
380        if defs.is_empty() {
381            return String::new();
382        }
383        let mut out = String::from("Sub-agents:\n");
384        for d in defs {
385            let memory_label = match d.memory {
386                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
387                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
388                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
389                Some(_) => " [memory:unknown]",
390                None => "",
391            };
392            if let Some(ref src) = d.source {
393                let _ = writeln!(
394                    out,
395                    "  {}{} — {} ({})",
396                    d.name, memory_label, d.description, src
397                );
398            } else {
399                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
400            }
401        }
402        out
403    }
404    /// Execute an `/agents` CRUD subcommand and return a formatted string.
405    ///
406    /// Handles `show`, `create`, `edit`, `delete` (the `list` case is handled by
407    /// [`handle_agents_definitions_list`] and never reaches this method).
408    pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
409        use zeph_subagent::AgentsCommand;
410
411        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
412            return "Sub-agent manager is not available.".to_owned();
413        };
414
415        match cmd {
416            AgentsCommand::List => self.handle_agents_definitions_list(),
417            AgentsCommand::Show { name } => {
418                match mgr.definitions().iter().find(|d| d.name == name) {
419                    Some(d) => format!(
420                        "Agent: {}\nDescription: {}\nSource: {}\n",
421                        d.name,
422                        d.description,
423                        d.source.as_deref().unwrap_or("unknown"),
424                    ),
425                    None => format!("No sub-agent definition named '{name}'."),
426                }
427            }
428            AgentsCommand::Create { name } => {
429                format!(
430                    "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
431                     See the sub-agent documentation for the required frontmatter."
432                )
433            }
434            AgentsCommand::Edit { name } => {
435                format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
436            }
437            AgentsCommand::Delete { name } => {
438                format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
439            }
440            _ => "Unknown agents command.".to_owned(),
441        }
442    }
443    async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
444        let provider = self.provider.clone();
445        let tool_executor = Arc::clone(&self.tool_executor);
446        let skills = self.filtered_skills_for(name);
447        let cfg = self.services.orchestration.subagent_config.clone();
448        let mut spawn_ctx = self.build_spawn_context(&cfg);
449        // Background durable: seat wired so child can resolve; on a fresh run the promise
450        // (await side) is dropped — background results are collected via poll_subagents. On a
451        // resumed run whose child already finished, replay short-circuits below instead.
452        self.ensure_session_durable_ctx().await;
453        match resolve_durable_spawn_gate(
454            self.services.session.durable_subagent,
455            self.services.session.durable_ctx.as_deref(),
456        )
457        .await
458        {
459            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
460            DurableSpawnGate::Replayed { result, .. } => {
461                let short = &result.task_id[..8.min(result.task_id.len())];
462                return Some(if result.output.is_empty() {
463                    format!(
464                        "[sub-agent {short}] completed (no output, replayed from durable journal)"
465                    )
466                } else {
467                    format!(
468                        "[sub-agent {short}] completed (replayed from durable journal):\n{}",
469                        result.output
470                    )
471                });
472            }
473            DurableSpawnGate::None => {}
474        }
475        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
476        match mgr
477            .spawn(
478                name,
479                prompt,
480                provider,
481                tool_executor,
482                skills,
483                &cfg,
484                spawn_ctx,
485            )
486            .await
487        {
488            Ok(id) => Some(format!(
489                "Sub-agent '{name}' started in background (id: {short})",
490                short = &id[..8.min(id.len())]
491            )),
492            Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
493        }
494    }
495    /// Handle a [`DurableSpawnGate::Replayed`] result for a foreground spawn.
496    ///
497    /// Gates the channel side effects (user notice + TUI completion event) behind an
498    /// out-of-band `notified_at` claim on the sub-agent's durable promise, so a parent that
499    /// restarts *again* after already taking the replay branch once does not re-fire them
500    /// (#6027). The claim consumes no durable step id, so unlike a `ctx.step()`-based guard it
501    /// cannot perturb INV-2 step-id determinism or cause `ReplayDivergence`. Returns the
502    /// journaled output/error text either way.
503    async fn notify_replayed_foreground_subagent(
504        &mut self,
505        name: &str,
506        result: zeph_subagent::SubagentResult,
507        promise_id: zeph_durable::PromiseId,
508    ) -> String {
509        let success = result.state == zeph_subagent::SubAgentState::Completed;
510        let task_id = result.task_id.clone();
511
512        // Out-of-band, step-counter-independent claim: the FIRST caller to set `notified_at` fires
513        // the channel side effects; every later replay is suppressed. Unlike a ctx.step this consumes
514        // no StepId, so it cannot cause ReplayDivergence under any restart count (#6027). Degrade to
515        // firing directly when durable is off (no replay can happen) or the claim errors.
516        let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
517            match ctx.claim_promise_notification(promise_id).await {
518                Ok(claimed) => claimed,
519                Err(e) => {
520                    tracing::warn!(
521                        error = %e,
522                        "durable: promise-notification claim failed; \
523                         firing the replayed sub-agent notice directly"
524                    );
525                    true
526                }
527            }
528        } else {
529            true
530        };
531
532        let text = if success {
533            result.output
534        } else {
535            result.error.unwrap_or_else(|| "unknown error".to_owned())
536        };
537
538        if should_notify {
539            let _ = self
540                .channel
541                .send(&format!(
542                    "Sub-agent '{name}' replayed from durable journal (already finished \
543                     before the parent restarted)."
544                ))
545                .await;
546            let _ = self
547                .channel
548                .notify_foreground_subagent_completed(&task_id, name, success)
549                .await;
550        }
551        text
552    }
553
554    async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
555        let provider = self.provider.clone();
556        let tool_executor = Arc::clone(&self.tool_executor);
557        let skills = self.filtered_skills_for(name);
558        let cfg = self.services.orchestration.subagent_config.clone();
559        let mut spawn_ctx = self.build_spawn_context(&cfg);
560        // Wire the durable resolver seat so the child can resolve its promise on exit. On a
561        // fresh run the promise (await side) is dropped here; foreground result is collected
562        // via poll_subagent_until_done which reads the join-handle output directly. On a
563        // resumed run whose child already finished, replay short-circuits below instead.
564        self.ensure_session_durable_ctx().await;
565        match resolve_durable_spawn_gate(
566            self.services.session.durable_subagent,
567            self.services.session.durable_ctx.as_deref(),
568        )
569        .await
570        {
571            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
572            DurableSpawnGate::Replayed { result, promise_id } => {
573                return Some(
574                    self.notify_replayed_foreground_subagent(name, result, promise_id)
575                        .await,
576                );
577            }
578            DurableSpawnGate::None => {}
579        }
580        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
581        let task_id = match mgr
582            .spawn(
583                name,
584                prompt,
585                provider,
586                tool_executor,
587                skills,
588                &cfg,
589                spawn_ctx,
590            )
591            .await
592        {
593            Ok(id) => id,
594            Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
595        };
596        let short = task_id[..8.min(task_id.len())].to_owned();
597        let _ = self
598            .channel
599            .send(&format!("Sub-agent '{name}' running... (id: {short})"))
600            .await;
601        let _ = self
602            .channel
603            .notify_foreground_subagent_started(&task_id, name)
604            .await;
605        let label = format!("Sub-agent '{name}'");
606        let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
607            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
608            let _ = self
609                .channel
610                .notify_foreground_subagent_completed(&task_id, name, false)
611                .await;
612            return None;
613        };
614        let _ = self
615            .channel
616            .notify_foreground_subagent_completed(&task_id, name, success)
617            .await;
618        Some(result)
619    }
620    fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
621        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
622        // Accept prefix match on task_id.
623        let ids: Vec<String> = mgr
624            .statuses()
625            .into_iter()
626            .map(|(task_id, _)| task_id)
627            .filter(|task_id| task_id.starts_with(id))
628            .collect();
629        match ids.as_slice() {
630            [] => Some(format!("No sub-agent with id prefix '{id}'")),
631            [full_id] => {
632                let full_id = full_id.clone();
633                match mgr.cancel(&full_id) {
634                    Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
635                    Err(e) => Some(format!("Cancel failed: {e}")),
636                }
637            }
638            _ => Some(format!(
639                "Ambiguous id prefix '{id}': matches {} agents",
640                ids.len()
641            )),
642        }
643    }
644    async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
645        let cfg = self.services.orchestration.subagent_config.clone();
646        // Resolve definition name from transcript meta before spawning so we can
647        // look up skills by definition name rather than the UUID prefix (S1 fix).
648        let def_name = {
649            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
650            match mgr.def_name_for_resume(id, &cfg).await {
651                Ok(name) => name,
652                Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
653            }
654        };
655        let skills = self.filtered_skills_for(&def_name);
656        let provider = self.provider.clone();
657        let tool_executor = Arc::clone(&self.tool_executor);
658        // Built before borrowing `subagent_manager` mutably below (build_spawn_context takes
659        // `&self`). Previously this call site passed `None`, which meant resumed sub-agents
660        // never got a `debug_dump_sink` — their LLM calls went uncaptured by `--debug-dump`
661        // even though fresh spawns correctly wired it (#6391). `resume()` only reads
662        // `max_trust_level`/`inherited_tool_allowlist`/`network_denied`/`debug_dump_sink` off
663        // `spawn_context` (see `manager/spawn.rs::resume`) — the first three are already at
664        // `build_spawn_context`'s top-level defaults (`None`/`None`/`false`, identical to what
665        // `None` produced here), so this only changes `debug_dump_sink` for this call site.
666        let spawn_ctx = self.build_spawn_context(&cfg);
667        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
668        let (task_id, _) = match mgr
669            .resume(
670                id,
671                prompt,
672                provider,
673                tool_executor,
674                skills,
675                &cfg,
676                Some(&spawn_ctx),
677            )
678            .await
679        {
680            Ok(pair) => pair,
681            Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
682        };
683        let short = task_id[..8.min(task_id.len())].to_owned();
684        let _ = self
685            .channel
686            .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
687            .await;
688        let _ = self
689            .channel
690            .notify_foreground_subagent_started(&task_id, &def_name)
691            .await;
692        let Some((result, success)) = self
693            .poll_subagent_until_done(&task_id, "Resumed sub-agent")
694            .await
695        else {
696            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
697            let _ = self
698                .channel
699                .notify_foreground_subagent_completed(&task_id, &def_name, false)
700                .await;
701            return None;
702        };
703        let _ = self
704            .channel
705            .notify_foreground_subagent_completed(&task_id, &def_name, success)
706            .await;
707        Some(result)
708    }
709    /// Resolve the skill bodies to inject into a freshly spawned (or resumed) sub-agent's
710    /// one-shot system prompt.
711    ///
712    /// A sub-agent definition with an empty `skills.include` filter inherits every skill in
713    /// the registry (documented, intentional — see [`zeph_config::SkillFilter`]). Unlike the
714    /// main agent's per-turn skill matcher, these bodies are injected once, at spawn time, with
715    /// no relevance ranking and no later opportunity to trim — an unbounded include set can
716    /// silently blow the turn-1 context budget (#6421).
717    ///
718    /// The `subagent_skill_token_budget` cap applies **only** to that empty-include case. A
719    /// definition with an explicit, hand-curated `skills.include` list is never capped here —
720    /// the operator opted into that specific set on purpose, and applying the same budget would
721    /// silently regress configs that were never broken; #6421 is about the *default* (empty)
722    /// case only.
723    ///
724    /// When capped, bodies are accumulated in the order [`zeph_subagent::filter_skills`] returns
725    /// them — the registry's directory-walk order, i.e. alphabetical by skill directory name,
726    /// **not** relevance-ranked. A task-critical skill whose directory happens to sort late is
727    /// systematically the first cut on every default-include spawn; operators who hit this can
728    /// curate `include` explicitly or raise the budget. Accumulation is a greedy best-fit, not a
729    /// hard prefix cut: an over-budget skill is skipped (not a stopping point), so a smaller
730    /// skill later in the order can still be packed in afterward. The first skill is always
731    /// included even if it alone exceeds the budget, so a single oversized skill never starves
732    /// the whole set. Any skills left out are surfaced via a synthetic marker entry rather than
733    /// silently dropped.
734    pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
735        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
736        let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
737        let reg = self.services.skill.registry.read();
738        let skills = match zeph_subagent::filter_skills(&reg, &def.skills) {
739            Ok(skills) => skills,
740            Err(e) => {
741                tracing::warn!(error = %e, "skill filtering failed for sub-agent");
742                return None;
743            }
744        };
745        if skills.is_empty() {
746            return None;
747        }
748
749        // #6421 scope: only the empty-include "inherit everything" case is capped. An explicit,
750        // hand-curated include list is trusted as-is (see doc comment above).
751        if !def.skills.include.is_empty() {
752            return Some(skills.into_iter().map(|s| s.body).collect());
753        }
754
755        let total = skills.len();
756        let budget = self.services.skill.subagent_skill_token_budget;
757        let counter = &self.runtime.metrics.token_counter;
758
759        let mut bodies: Vec<String> = Vec::with_capacity(total);
760        let mut running_tokens = 0usize;
761        let mut omitted_names: Vec<&str> = Vec::new();
762
763        for skill in &skills {
764            let skill_tokens = counter.count_tokens(&skill.body);
765            if !bodies.is_empty() && running_tokens + skill_tokens > budget {
766                omitted_names.push(skill.meta.name.as_str());
767                continue;
768            }
769            running_tokens += skill_tokens;
770            bodies.push(skill.body.clone());
771        }
772
773        if !omitted_names.is_empty() {
774            let included = bodies.len();
775            tracing::warn!(
776                agent_name,
777                included,
778                total,
779                budget_tokens = budget,
780                "sub-agent skill body budget exceeded; truncated skill set"
781            );
782            bodies.push(format!(
783                "[skill budget: {included}/{total} skills included, budget={budget} tokens — omitted: {}]",
784                omitted_names.join(", ")
785            ));
786        }
787
788        Some(bodies)
789    }
790    /// Build a `SpawnContext` from current agent state for sub-agent spawning.
791    pub(super) fn build_spawn_context(
792        &self,
793        cfg: &zeph_config::SubAgentConfig,
794    ) -> zeph_subagent::SpawnContext {
795        zeph_subagent::SpawnContext {
796            parent_messages: self.extract_parent_messages(cfg),
797            parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
798            parent_provider_name: {
799                let name = &self.runtime.config.active_provider_name;
800                if name.is_empty() {
801                    None
802                } else {
803                    Some(name.clone())
804                }
805            },
806            spawn_depth: self.runtime.config.spawn_depth,
807            mcp_tool_names: self.extract_mcp_tool_names(),
808            // F3 spec 050 §4: propagate seeded score when parent is >= Elevated.
809            seed_trajectory_score: {
810                let child = self.services.security.trajectory.spawn_child();
811                let score = child.score_now();
812                if score > 0.0 { Some(score) } else { None }
813            },
814            content_isolation: self.runtime.config.security.content_isolation.clone(),
815            orchestrator_name: Some("zeph".to_owned()),
816            orchestrator_role: Some("orchestrator".to_owned()),
817            session_mcp_servers: Vec::new(),
818            // Threaded down so sub-agent LLM calls are captured through the same
819            // `--debug-dump` pipeline as the top-level agent loop (#6391). `None` when
820            // debug dumps are disabled, mirroring the top-level `debug_dumper: None` case.
821            // Wrapped in `PiiScrubbingDumpSink` so sub-agent dumps get the same optional
822            // `PiiFilter` layer top-level dumps get via `write_chat_debug_dump` — the plain
823            // `DebugDumpSink` impl on `DebugDumper` only applies the baseline
824            // `scrub_content`/`redact_binary_blobs` pass (#6407).
825            debug_dump_sink: self.runtime.debug.debug_dumper.clone().map(|d| {
826                Arc::new(crate::debug_dump::PiiScrubbingDumpSink::new(
827                    d,
828                    self.services.security.pii_filter.clone(),
829                )) as Arc<dyn zeph_llm::debug_dump::DebugDumpSink>
830            }),
831            // Constraint propagation (#3993): populated by orchestration layer when spawning
832            // with explicit trust/tool restrictions. Top-level agent sessions leave these None.
833            ..Default::default()
834        }
835    }
836    /// Extract recent parent messages for history propagation (Section 5.7 in spec).
837    ///
838    /// Filters system messages, applies `context_window_turns` and `max_parent_messages` caps,
839    /// applies a 25% context window cap using a 4-chars-per-token heuristic, prunes orphaned
840    /// `ToolUse`/`ToolResult` pairs at the slice boundary, and optionally sanitizes text parts
841    /// through the IPI pipeline according to `parent_context_policy`.
842    fn extract_parent_messages(
843        &self,
844        config: &zeph_config::SubAgentConfig,
845    ) -> Vec<zeph_llm::provider::Message> {
846        use zeph_config::ParentContextPolicy;
847        use zeph_llm::provider::Role;
848
849        if config.parent_context_policy == ParentContextPolicy::None
850            || config.context_window_turns == 0
851        {
852            return Vec::new();
853        }
854
855        let non_system: Vec<_> = self
856            .msg
857            .messages
858            .iter()
859            .filter(|m| m.role != Role::System)
860            .cloned()
861            .collect();
862
863        let take_count = config
864            .context_window_turns
865            .saturating_mul(2)
866            .min(config.max_parent_messages);
867        let start = non_system.len().saturating_sub(take_count);
868        let mut msgs = non_system[start..].to_vec();
869
870        // Cap at 25% of model context window and prune orphaned tool pairs.
871        let max_chars = 128_000usize / 4;
872        let requested = msgs.len();
873        trim_parent_messages(&mut msgs, max_chars);
874        if msgs.len() < requested {
875            tracing::info!(
876                kept = msgs.len(),
877                requested,
878                "[subagent] truncated parent history due to token budget or orphan pruning"
879            );
880        }
881
882        if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
883            use zeph_sanitizer::{ContentSource, ContentSourceKind};
884            let source =
885                ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
886            msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
887        }
888
889        msgs
890    }
891    /// Extract MCP tool names from the tool executor for diagnostic annotation.
892    fn extract_mcp_tool_names(&self) -> Vec<String> {
893        self.tool_executor
894            .tool_definitions_erased()
895            .into_iter()
896            .filter(ToolDef::is_mcp_tool)
897            .map(|t| t.id.to_string())
898            .collect()
899    }
900    /// Classify a skill directory's source kind using on-disk markers and the bundled allowlist.
901    ///
902    /// Must be called from a blocking context (uses synchronous FS I/O).
903    pub(super) fn classify_source_kind(
904        skill_dir: &std::path::Path,
905        managed_dir: Option<&std::path::PathBuf>,
906        bundled_names: &std::collections::HashSet<String>,
907    ) -> zeph_memory::store::SourceKind {
908        if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
909            let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
910            let has_marker = skill_dir.join(".bundled").exists();
911            if has_marker && bundled_names.contains(skill_name) {
912                zeph_memory::store::SourceKind::Bundled
913            } else {
914                if has_marker {
915                    tracing::warn!(
916                        skill = %skill_name,
917                        "skill has .bundled marker but is not in the bundled skill \
918                         allowlist — classifying as Hub"
919                    );
920                }
921                zeph_memory::store::SourceKind::Hub
922            }
923        } else {
924            zeph_memory::store::SourceKind::Local
925        }
926    }
927}
928
929/// Outcome of checking the durable-execution gate before a sub-agent spawn (spec-064 §P4).
930enum DurableSpawnGate {
931    /// Fresh run: wire this seat into `SpawnContext::durable_resolver` so the child resolves
932    /// the promise on exit (INV-9 channel rule).
933    Fresh(zeph_subagent::DurableResolverSeat),
934    /// Resumed run whose child already resolved its promise before the parent crashed. The
935    /// caller must skip `spawn` entirely and replay this result instead — spawning here would
936    /// duplicate the LLM calls and any side-effecting tool calls the finished child already
937    /// performed (#5944). `promise_id` lets the foreground caller claim a one-time replay
938    /// notification (#6027) via [`zeph_durable::DurableContext::claim_promise_notification`].
939    Replayed {
940        result: zeph_subagent::SubagentResult,
941        promise_id: zeph_durable::PromiseId,
942    },
943    /// Gate closed: durable subagent support disabled, a resumed run whose child promise is
944    /// still pending (out of v1 scope — see `durable.rs` module docs "Scope boundary"), or an
945    /// error (logged at `warn`). The caller degrades to a plain spawn with no durable wiring.
946    ///
947    /// The still-pending case is safe only because the current architecture is
948    /// LocalBackend-only, in-process tokio tasks (spec-064 INV-9): a parent-process crash
949    /// necessarily kills its in-process children too, so a still-pending promise on resume
950    /// means the original child is genuinely gone, and re-spawning cannot duplicate a live
951    /// child. See `durable.rs` "Scope boundary".
952    None,
953}
954
955/// Check the durable-execution gate for the next sub-agent spawn.
956///
957/// See [`DurableSpawnGate`] for the three possible outcomes.
958async fn resolve_durable_spawn_gate(
959    enabled: bool,
960    ctx: Option<&zeph_durable::DurableContext>,
961) -> DurableSpawnGate {
962    let Some(ctx) = ctx.filter(|_| enabled) else {
963        return DurableSpawnGate::None;
964    };
965    let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
966        Ok(pair) => pair,
967        Err(e) => {
968            tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
969            return DurableSpawnGate::None;
970        }
971    };
972    if let Some(seat) = seat {
973        return DurableSpawnGate::Fresh(seat);
974    }
975    // Resumed: token unrecoverable (INV-9). Check without blocking whether the child already
976    // resolved the promise before the crash — replay it instead of re-spawning a duplicate.
977    match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
978        Ok(Some(result)) => DurableSpawnGate::Replayed {
979            result,
980            promise_id: promise.id(),
981        },
982        Ok(None) => {
983            // Safe to fall back to a plain spawn here only because the current architecture
984            // is LocalBackend-only, in-process tokio tasks: the parent process crashing kills
985            // its in-process children too, so a still-pending promise on resume means the
986            // original child is genuinely gone, not merely unreachable. Re-attaching to a
987            // live child would require cross-process liveness detection, which is out of v1
988            // scope — see `durable.rs` module docs "Scope boundary" and spec-064 INV-9 (the
989            // resolver token is unrecoverable by design, so it cannot be re-minted to attempt
990            // reattachment).
991            tracing::warn!(
992                "durable: resumed sub-agent promise still pending after restart — original \
993                 child did not resolve before the crash; re-spawning may duplicate side effects \
994                 (#5944 residual v1 gap)"
995            );
996            DurableSpawnGate::None
997        }
998        Err(e) => {
999            tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
1000            DurableSpawnGate::None
1001        }
1002    }
1003}
1004
1005/// Estimates the JSON payload size of a single [`zeph_llm::provider::Message`] for token-budget
1006/// accounting.
1007///
1008/// When `parts` is empty the message is a legacy text-only message and `content.len()` is used
1009/// directly. Otherwise each part is measured individually so that structured variants (images,
1010/// tool invocations, thinking blocks) are accounted for rather than relying on the already-flat
1011/// `content` string, which may not reflect the actual API payload size.
1012pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
1013    use zeph_llm::provider::MessagePart;
1014    if m.parts.is_empty() {
1015        return m.content.len();
1016    }
1017    m.parts
1018        .iter()
1019        .map(|p| match p {
1020            MessagePart::Text { text }
1021            | MessagePart::Recall { text }
1022            | MessagePart::CodeContext { text }
1023            | MessagePart::Summary { text }
1024            | MessagePart::CrossSession { text } => text.len(),
1025            MessagePart::ToolOutput { body, .. } => body.len(),
1026            MessagePart::ToolUse { id, name, input } => {
1027                50 + id.len() + name.len() + input.to_string().len()
1028            }
1029            MessagePart::ToolResult {
1030                tool_use_id,
1031                content,
1032                ..
1033            } => 50 + tool_use_id.len() + content.len(),
1034            MessagePart::Image(img) => img.data.len() * 4 / 3,
1035            MessagePart::ThinkingBlock {
1036                thinking,
1037                signature,
1038            } => 50 + thinking.len() + signature.len(),
1039            MessagePart::RedactedThinkingBlock { data } => data.len(),
1040            MessagePart::Compaction { summary } => summary.len(),
1041            _ => 0,
1042        })
1043        .sum()
1044}
1045
1046/// Applies token-budget truncation and orphaned-tool-pair pruning to a parent message slice.
1047///
1048/// Budget truncation keeps the **most recent** messages that fit within `max_chars`
1049/// (a suffix), so the subagent always receives the freshest context.
1050///
1051/// Two passes are performed after budget truncation:
1052///
1053/// 1. Remove `ToolResult` parts from user messages whose matching `ToolUse` is no longer in the
1054///    slice (truncated away).
1055/// 2. Remove `ToolUse` parts from **interior** assistant messages whose matching `ToolResult`
1056///    was removed in pass 1 or was already absent. The trailing assistant message is exempt —
1057///    its unanswered `ToolUse` calls are not orphaned; the slice just ends before the result.
1058///
1059/// Messages that become fully empty after pruning are removed from `msgs`.
1060///
1061/// `rebuild_content` is called **only** when `retain` actually removed parts — preserving the
1062/// existing `content` field (and any `ThinkingBlock` text embedded there) for unmodified
1063/// messages.
1064pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
1065    use zeph_llm::provider::{MessagePart, Role};
1066
1067    // Token-budget cap: keep the most recent messages that fit within max_chars.
1068    // We iterate from the end (newest) and drain from the front once the budget is exceeded,
1069    // so the subagent always receives the most recent context rather than stale early messages.
1070    let mut total_chars = 0usize;
1071    let mut drop_before = 0usize; // index of the first message to keep
1072    for (i, m) in msgs.iter().enumerate().rev() {
1073        total_chars += estimate_parts_size(m);
1074        if total_chars > max_chars {
1075            drop_before = i + 1;
1076            break;
1077        }
1078    }
1079    if drop_before > 0 {
1080        msgs.drain(..drop_before);
1081    }
1082
1083    // Pass 1: collect ToolUse IDs emitted by assistant messages; prune orphaned ToolResult
1084    // parts from user messages that reference a ToolUse no longer present in the slice.
1085    // Use owned Strings to avoid holding immutable borrows across the subsequent mutable loop.
1086    let emitted_tool_ids: std::collections::HashSet<String> = msgs
1087        .iter()
1088        .filter(|m| m.role == Role::Assistant)
1089        .flat_map(|m| m.parts.iter())
1090        .filter_map(|p| {
1091            if let MessagePart::ToolUse { id, .. } = p {
1092                Some(id.clone())
1093            } else {
1094                None
1095            }
1096        })
1097        .collect();
1098
1099    let mut orphans_removed = 0usize;
1100    for m in msgs.iter_mut() {
1101        if m.role != Role::User || m.parts.is_empty() {
1102            continue;
1103        }
1104        let before = m.parts.len();
1105        m.parts.retain(|p| match p {
1106            MessagePart::ToolResult { tool_use_id, .. } => {
1107                emitted_tool_ids.contains(tool_use_id.as_str())
1108            }
1109            _ => true,
1110        });
1111        let dropped = before - m.parts.len();
1112        if dropped > 0 {
1113            orphans_removed += dropped;
1114            if m.parts.is_empty() {
1115                m.content.clear();
1116            } else {
1117                m.rebuild_content();
1118            }
1119        }
1120    }
1121
1122    // Pass 2: collect ToolResult IDs present in user messages after pass 1; prune ToolUse
1123    // parts from assistant messages whose result is confirmed absent.
1124    //
1125    // The trailing assistant message is exempt: it may legitimately contain unanswered
1126    // ToolUse calls (the slice ends before the result arrives). Only interior assistant
1127    // messages — those followed by at least one user message — can have provably orphaned
1128    // ToolUse parts (the conversation moved on without answering them).
1129    let consumed_tool_ids: std::collections::HashSet<String> = msgs
1130        .iter()
1131        .filter(|m| m.role == Role::User)
1132        .flat_map(|m| m.parts.iter())
1133        .filter_map(|p| {
1134            if let MessagePart::ToolResult { tool_use_id, .. } = p {
1135                Some(tool_use_id.clone())
1136            } else {
1137                None
1138            }
1139        })
1140        .collect();
1141
1142    // Index of the last assistant message — exempt from pass 2.
1143    let last_assistant_idx = msgs
1144        .iter()
1145        .enumerate()
1146        .rev()
1147        .find(|(_, m)| m.role == Role::Assistant)
1148        .map(|(i, _)| i);
1149
1150    for (idx, m) in msgs.iter_mut().enumerate() {
1151        if m.role != Role::Assistant || m.parts.is_empty() {
1152            continue;
1153        }
1154        // Skip the trailing assistant message — its unanswered ToolUse calls are not orphaned.
1155        if Some(idx) == last_assistant_idx {
1156            continue;
1157        }
1158        let before = m.parts.len();
1159        m.parts.retain(|p| match p {
1160            MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1161            _ => true,
1162        });
1163        let dropped = before - m.parts.len();
1164        if dropped > 0 {
1165            orphans_removed += dropped;
1166            if m.parts.is_empty() {
1167                m.content.clear();
1168            } else {
1169                m.rebuild_content();
1170            }
1171        }
1172    }
1173
1174    // Remove messages that were emptied by orphan pruning.
1175    msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1176
1177    if orphans_removed > 0 {
1178        tracing::debug!(
1179            orphans = orphans_removed,
1180            "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1181        );
1182    }
1183}
1184
1185/// Sanitize text parts of `msgs` through the IPI pipeline.
1186///
1187/// Only [`MessagePart::Text`] parts are passed through the sanitizer; structured parts
1188/// (`ToolUse`, `ToolResult`, `Recall`, `CodeContext`) are left untouched.  After sanitization
1189/// the message `content` field is rebuilt to stay consistent with the updated parts.
1190fn sanitize_parent_messages(
1191    mut msgs: Vec<zeph_llm::provider::Message>,
1192    sanitizer: &zeph_sanitizer::ContentSanitizer,
1193    source: &zeph_sanitizer::ContentSource,
1194) -> Vec<zeph_llm::provider::Message> {
1195    use zeph_llm::provider::MessagePart;
1196    for msg in &mut msgs {
1197        let mut changed = false;
1198        for part in &mut msg.parts {
1199            if let MessagePart::Text { text } = part {
1200                let clean = sanitizer.sanitize(text, source.clone());
1201                if clean.body != *text {
1202                    *text = clean.body;
1203                    changed = true;
1204                }
1205            }
1206        }
1207        if changed {
1208            msg.rebuild_content();
1209        }
1210    }
1211    msgs
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use crate::agent::agent_tests::*;
1218
1219    // ── resolve_subagent_secret tests (#5941/#5942) ─────────────────────────
1220
1221    fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1222        let provider = mock_provider(vec![]);
1223        let channel = MockChannel::new(vec![]);
1224        let registry = create_test_registry();
1225        let executor = MockToolExecutor::no_tools();
1226        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1227        agent.services.skill.available_custom_secrets.insert(
1228            stored_key.to_owned(),
1229            crate::vault::Secret::new(value.to_owned()),
1230        );
1231        agent
1232    }
1233
1234    #[test]
1235    fn resolve_subagent_secret_exact_match() {
1236        let agent = agent_with_custom_secret("my_key", "the-value");
1237        let resolved = agent.resolve_subagent_secret("my_key");
1238        assert_eq!(
1239            resolved.map(|s| s.expose().to_owned()),
1240            Some("the-value".to_owned())
1241        );
1242    }
1243
1244    #[test]
1245    fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1246        // Stored key is underscored (as produced by ZEPH_SECRET_<NAME> normalization);
1247        // the sub-agent may request it with dashes instead.
1248        let agent = agent_with_custom_secret("my_api_key", "dash-value");
1249        let resolved = agent.resolve_subagent_secret("my-api-key");
1250        assert_eq!(
1251            resolved.map(|s| s.expose().to_owned()),
1252            Some("dash-value".to_owned())
1253        );
1254    }
1255
1256    #[test]
1257    fn resolve_subagent_secret_normalizes_case() {
1258        let agent = agent_with_custom_secret("upper_key", "case-value");
1259        let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1260        assert_eq!(
1261            resolved.map(|s| s.expose().to_owned()),
1262            Some("case-value".to_owned())
1263        );
1264    }
1265
1266    #[test]
1267    fn resolve_subagent_secret_missing_key_returns_none() {
1268        let agent = agent_with_custom_secret("known_key", "value");
1269        assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1270    }
1271
1272    #[test]
1273    fn resolve_subagent_secret_empty_map_returns_none() {
1274        let provider = mock_provider(vec![]);
1275        let channel = MockChannel::new(vec![]);
1276        let registry = create_test_registry();
1277        let executor = MockToolExecutor::no_tools();
1278        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1279        assert!(agent.resolve_subagent_secret("anything").is_none());
1280    }
1281
1282    /// #5712 regression: MCP tool identification must key off `ToolDef::server_id`, not a
1283    /// `"mcp_"` name prefix that real `McpTool::sanitized_id()` output never produces.
1284    #[tokio::test]
1285    async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1286        use zeph_tools::registry::InvocationHint;
1287
1288        let provider = mock_provider(vec![]);
1289        let channel = MockChannel::new(vec![]);
1290        let registry = create_test_registry();
1291        let executor = MockToolExecutor::no_tools().with_definitions(vec![
1292            ToolDef {
1293                id: "read".into(),
1294                description: "built-in tool".into(),
1295                schema: schemars::Schema::default(),
1296                invocation: InvocationHint::ToolCall,
1297                output_schema: None,
1298                server_id: None,
1299            },
1300            ToolDef {
1301                id: "github_create_issue".into(),
1302                description: "MCP tool".into(),
1303                schema: schemars::Schema::default(),
1304                invocation: InvocationHint::ToolCall,
1305                output_schema: None,
1306                server_id: Some("github".into()),
1307            },
1308        ]);
1309        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1310
1311        assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1312    }
1313
1314    /// Agent with `durable_ctx` populated via the real `ensure_session_durable_ctx` bootstrap
1315    /// path (mirrors `durable_bootstrap::tests::agent_with_conversation`), with
1316    /// `durable_subagent` set per `subagent_enabled` — used to test the FR-003/US-002 seat
1317    /// wiring gate at `resolve_durable_spawn_gate`, not just the config-to-builder plumbing.
1318    async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1319        let provider = mock_provider(vec!["ok".into()]);
1320        let channel = MockChannel::new(vec![]);
1321        let registry = create_test_registry();
1322        let executor = MockToolExecutor::no_tools();
1323        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1324        agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1325        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1326            enabled: true,
1327            agent_turns: true,
1328            ..zeph_config::DurableConfig::default()
1329        });
1330        agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1331        agent.services.session.durable_subagent = subagent_enabled;
1332
1333        agent.ensure_session_durable_ctx().await;
1334        assert!(
1335            agent.services.session.durable_ctx.is_some(),
1336            "test setup: durable_ctx must be populated before exercising the seat gate"
1337        );
1338        agent
1339    }
1340
1341    #[tokio::test]
1342    async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1343        let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1344
1345        let gate = resolve_durable_spawn_gate(
1346            agent.services.session.durable_subagent,
1347            agent.services.session.durable_ctx.as_deref(),
1348        )
1349        .await;
1350
1351        assert!(
1352            matches!(gate, DurableSpawnGate::Fresh(_)),
1353            "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1354             not just wire the config-to-builder plumbing"
1355        );
1356    }
1357
1358    #[tokio::test]
1359    async fn seat_absent_when_subagent_disabled() {
1360        let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1361
1362        let gate = resolve_durable_spawn_gate(
1363            agent.services.session.durable_subagent,
1364            agent.services.session.durable_ctx.as_deref(),
1365        )
1366        .await;
1367
1368        assert!(
1369            matches!(gate, DurableSpawnGate::None),
1370            "FR-008: durable_subagent=false must keep the seat gate closed even when \
1371             durable_ctx is populated"
1372        );
1373    }
1374
1375    // ── #5944 end-to-end replay regression tests ────────────────────────────
1376    //
1377    // These simulate a real parent-process restart: two *separate* `Agent` instances
1378    // pointed at the same on-disk sqlite durable journal and the same `conversation_id`,
1379    // so the second instance's `DurableContext` genuinely re-derives the first's
1380    // `ExecutionId`/`PromiseId` (mirrors `try_replay_durable_subagent_sees_already_resolved_promise_on_resume`
1381    // in `zeph-subagent/src/durable.rs`, but at the `handle_agent_background`/
1382    // `handle_agent_spawn_foreground` call-site level rather than the adapter level).
1383
1384    fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1385        use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1386        use zeph_subagent::hooks::SubagentHooks;
1387
1388        zeph_subagent::SubAgentDef {
1389            name: name.to_owned(),
1390            description: "A helper bot".into(),
1391            model: None,
1392            tools: ToolPolicy::InheritAll,
1393            disallowed_tools: vec![],
1394            permissions: SubAgentPermissions::default(),
1395            skills: SkillFilter::default(),
1396            system_prompt: "You are helpful.".into(),
1397            hooks: SubagentHooks::default(),
1398            memory: None,
1399            source: None,
1400            file_path: None,
1401        }
1402    }
1403
1404    /// Builds an `Agent` wired for durable sub-agent spawns against a real sqlite file at
1405    /// `db_url`, with a `SubAgentManager` carrying a single "helper" definition so
1406    /// `handle_agent_background`/`handle_agent_spawn_foreground` can run past the gate check.
1407    async fn agent_with_durable_and_manager(
1408        db_url: &str,
1409        conversation_id: i64,
1410    ) -> Agent<MockChannel> {
1411        let provider = mock_provider(vec!["ok".into()]);
1412        let channel = MockChannel::new(vec![]);
1413        let registry = create_test_registry();
1414        let executor = MockToolExecutor::no_tools();
1415        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1416        agent.services.memory.persistence.conversation_id =
1417            Some(zeph_memory::ConversationId(conversation_id));
1418        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1419            enabled: true,
1420            agent_turns: true,
1421            ..zeph_config::DurableConfig::default()
1422        });
1423        agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1424        agent.services.session.durable_subagent = true;
1425
1426        let mut mgr = zeph_subagent::SubAgentManager::new(4);
1427        mgr.definitions_mut().push(subagent_def("helper"));
1428        agent.services.orchestration.subagent_manager = Some(mgr);
1429
1430        agent.ensure_session_durable_ctx().await;
1431        assert!(
1432            agent.services.session.durable_ctx.is_some(),
1433            "test setup: durable_ctx must be populated before exercising the handler"
1434        );
1435        agent
1436    }
1437
1438    #[tokio::test]
1439    async fn handle_agent_background_replays_finished_child_without_respawning() {
1440        let dir = tempfile::tempdir().unwrap();
1441        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1442
1443        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1444        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1445        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1446        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1447        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1448        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1449            Ok("child finished before crash".to_owned());
1450        zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1451        agent1
1452            .services
1453            .session
1454            .durable_writer
1455            .as_ref()
1456            .unwrap()
1457            .flush()
1458            .await
1459            .unwrap();
1460        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1461        // a real crash closes the process's file descriptors (and thus the flock) before the
1462        // restarted parent below re-opens the same execution; without this, run 2's
1463        // `open_execution_exclusive` would see run 1 as still live and correctly refuse to open.
1464        drop(agent1);
1465
1466        // "Run 2": a brand-new `Agent` (simulating the restarted parent) with the same
1467        // conversation_id and db file re-derives the same promise and must see it resolved.
1468        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1469
1470        let resp = agent2
1471            .handle_agent_background("helper", "do work")
1472            .await
1473            .unwrap();
1474        assert!(
1475            resp.contains("replayed from durable journal"),
1476            "expected a replay notice, got: {resp}"
1477        );
1478        assert!(
1479            resp.contains("child finished before crash"),
1480            "expected the journaled output to be surfaced, got: {resp}"
1481        );
1482        assert!(
1483            agent2
1484                .services
1485                .orchestration
1486                .subagent_manager
1487                .as_ref()
1488                .unwrap()
1489                .statuses()
1490                .is_empty(),
1491            "mgr.spawn must not be called when the child result is replayed"
1492        );
1493    }
1494
1495    #[tokio::test]
1496    async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1497        let dir = tempfile::tempdir().unwrap();
1498        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1499
1500        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1501        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1502        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1503        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1504        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1505        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1506            Ok("foreground child output".to_owned());
1507        zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1508        // C1 regression guard (#6027): journal a durable step AFTER the promise, exactly the
1509        // foreground-spawn-followed-by-another-turn topology that triggered the original
1510        // ReplayDivergence bug (a replay-only `ctx.step()` used to land at this same ordinal
1511        // position and collide with whatever the fresh run had already recorded there). The
1512        // `notified_at` claim consumes no step id, so it can never collide with this marker —
1513        // if it regressed to a step-based mechanism, the assertions below would fail with a
1514        // `ReplayDivergence` error instead of the expected replayed output.
1515        ctx1.step(
1516            zeph_durable::StepDescriptor::idempotent(
1517                "post_spawn_marker",
1518                b"post_spawn_marker".to_vec(),
1519            ),
1520            |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1521        )
1522        .await
1523        .unwrap();
1524        agent1
1525            .services
1526            .session
1527            .durable_writer
1528            .as_ref()
1529            .unwrap()
1530            .flush()
1531            .await
1532            .unwrap();
1533        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1534        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1535        drop(agent1);
1536
1537        // "Run 2": a brand-new `Agent` re-derives the same promise and must see it resolved,
1538        // returning the journaled output directly instead of spawning and polling a new child.
1539        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1540
1541        let resp = agent2
1542            .handle_agent_spawn_foreground("helper", "do work")
1543            .await
1544            .unwrap();
1545        assert_eq!(resp, "foreground child output");
1546        assert!(
1547            agent2
1548                .channel
1549                .sent_messages()
1550                .iter()
1551                .any(|m| m.contains("replayed from durable journal")),
1552            "expected the replay notice to be sent to the channel"
1553        );
1554        assert_eq!(
1555            agent2.channel.notify_completed_calls().len(),
1556            1,
1557            "expected exactly one TUI completion notification on the first replay"
1558        );
1559        assert!(
1560            agent2
1561                .services
1562                .orchestration
1563                .subagent_manager
1564                .as_ref()
1565                .unwrap()
1566                .statuses()
1567                .is_empty(),
1568            "mgr.spawn must not be called when the child result is replayed"
1569        );
1570        drop(agent2);
1571
1572        // "Run 3": the parent restarts *again* after already taking the replay branch once.
1573        // Per #6027, the channel side effects (notice + completion event) must not re-fire on
1574        // this second replay — only the first winner of the out-of-band `notified_at` claim
1575        // fires them; the journaled output is still returned.
1576        let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1577
1578        let resp = agent3
1579            .handle_agent_spawn_foreground("helper", "do work")
1580            .await
1581            .unwrap();
1582        assert_eq!(resp, "foreground child output");
1583        assert!(
1584            !agent3
1585                .channel
1586                .sent_messages()
1587                .iter()
1588                .any(|m| m.contains("replayed from durable journal")),
1589            "replay notice must not re-fire on a second replay after a parent restart"
1590        );
1591        assert!(
1592            agent3.channel.notify_completed_calls().is_empty(),
1593            "TUI completion event must not re-fire on a second replay after a parent restart"
1594        );
1595    }
1596
1597    #[tokio::test]
1598    async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1599        let dir = tempfile::tempdir().unwrap();
1600        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1601
1602        // "Run 1": the promise is created (child spawned) but never resolved — simulates a
1603        // child that was still genuinely running (or lost) when the parent crashed.
1604        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1605        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1606        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1607        assert!(
1608            seat.is_some(),
1609            "test setup: run 1 must be fresh and yield a resolver seat"
1610        );
1611        agent1
1612            .services
1613            .session
1614            .durable_writer
1615            .as_ref()
1616            .unwrap()
1617            .flush()
1618            .await
1619            .unwrap();
1620        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1621        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1622        drop(agent1);
1623
1624        // "Run 2": resumed execution observes the same promise still pending — per the
1625        // documented v1 scope boundary (INV-9: no way to recover an orphaned resolver token)
1626        // the gate must degrade to a plain spawn rather than replay or block indefinitely.
1627        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1628
1629        let resp = agent2
1630            .handle_agent_background("helper", "do work")
1631            .await
1632            .unwrap();
1633        assert!(
1634            resp.contains("started in background"),
1635            "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1636        );
1637        assert_eq!(
1638            agent2
1639                .services
1640                .orchestration
1641                .subagent_manager
1642                .as_ref()
1643                .unwrap()
1644                .statuses()
1645                .len(),
1646            1,
1647            "exactly one real spawn must occur on the still-pending fallback path"
1648        );
1649    }
1650
1651    // ── build_spawn_context: debug_dump_sink wiring (#6391) ─────────────────
1652
1653    #[test]
1654    fn build_spawn_context_leaves_debug_dump_sink_none_without_dumper() {
1655        let provider = mock_provider(vec![]);
1656        let channel = MockChannel::new(vec![]);
1657        let registry = create_test_registry();
1658        let agent = Agent::new(
1659            provider,
1660            channel,
1661            registry,
1662            None,
1663            5,
1664            MockToolExecutor::no_tools(),
1665        );
1666
1667        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1668        assert!(
1669            ctx.debug_dump_sink.is_none(),
1670            "no DebugDumper configured, so SpawnContext must carry no sink"
1671        );
1672    }
1673
1674    #[tokio::test]
1675    async fn build_spawn_context_wires_debug_dump_sink_when_dumper_present() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let dumper =
1678            crate::debug_dump::DebugDumper::new(dir.path(), crate::debug_dump::DumpFormat::Raw)
1679                .unwrap();
1680
1681        let provider = mock_provider(vec![]);
1682        let channel = MockChannel::new(vec![]);
1683        let registry = create_test_registry();
1684        let mut agent = Agent::new(
1685            provider,
1686            channel,
1687            registry,
1688            None,
1689            5,
1690            MockToolExecutor::no_tools(),
1691        );
1692        agent.runtime.debug.debug_dumper = Some(dumper);
1693
1694        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1695        let sink = ctx
1696            .debug_dump_sink
1697            .expect("a configured DebugDumper must be threaded into SpawnContext");
1698
1699        // Exercise the sink through the trait, same as `zeph-subagent`'s agent loop would —
1700        // proves the wiring produces a working `Arc<dyn DebugDumpSink>`, not just `Some(_)`.
1701        let id = sink.dump_request("mock", &[], &[], serde_json::Value::Null);
1702        sink.dump_response(id, &zeph_llm::provider::ChatResponse::Text("ok".into()));
1703    }
1704
1705    // ── filtered_skills_for token-budget cap (#6421) ─────────────────────────
1706
1707    /// Builds a `SkillRegistry` with `count` skills on disk, each named `skill-N` with a body
1708    /// made of `words_per_skill` repeated words — enough real text that `TokenCounter` charges
1709    /// a nontrivial, predictable-in-sign (if not exact) token count per skill.
1710    ///
1711    /// Returns the backing `TempDir` alongside the registry: skill bodies are loaded lazily
1712    /// from disk on first access (`SkillRegistry::skill`/`body`), so the caller must keep the
1713    /// directory alive for as long as the registry is used, not just during `load`.
1714    fn registry_with_skills(
1715        count: usize,
1716        words_per_skill: usize,
1717    ) -> (SkillRegistry, tempfile::TempDir) {
1718        let temp_dir = tempfile::tempdir().unwrap();
1719        for i in 0..count {
1720            let skill_dir = temp_dir.path().join(format!("skill-{i}"));
1721            std::fs::create_dir(&skill_dir).unwrap();
1722            let body = "lorem ".repeat(words_per_skill);
1723            std::fs::write(
1724                skill_dir.join("SKILL.md"),
1725                format!("---\nname: skill-{i}\ndescription: Test skill {i}\n---\n{body}"),
1726            )
1727            .unwrap();
1728        }
1729        let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
1730        (registry, temp_dir)
1731    }
1732
1733    fn agent_with_skill_registry_and_def(
1734        registry: SkillRegistry,
1735        def: zeph_subagent::SubAgentDef,
1736    ) -> Agent<MockChannel> {
1737        let provider = mock_provider(vec![]);
1738        let channel = MockChannel::new(vec![]);
1739        let executor = MockToolExecutor::no_tools();
1740        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1741        let mut mgr = zeph_subagent::SubAgentManager::new(4);
1742        mgr.definitions_mut().push(def);
1743        agent.services.orchestration.subagent_manager = Some(mgr);
1744        agent
1745    }
1746
1747    fn agent_with_skill_registry_and_helper_def(registry: SkillRegistry) -> Agent<MockChannel> {
1748        agent_with_skill_registry_and_def(registry, subagent_def("helper"))
1749    }
1750
1751    #[test]
1752    fn filtered_skills_for_under_budget_returns_all_bodies_no_marker() {
1753        // Default `SkillFilter` (empty include/exclude) inherits every registry skill —
1754        // the #6421 scenario — but the budget here is generous enough that nothing is cut.
1755        let (registry, _temp_dir) = registry_with_skills(3, 20);
1756        let mut agent = agent_with_skill_registry_and_helper_def(registry);
1757        agent.services.skill.subagent_skill_token_budget = 1_000_000;
1758
1759        let bodies = agent
1760            .filtered_skills_for("helper")
1761            .expect("3 skills with a huge budget must return Some");
1762
1763        assert_eq!(
1764            bodies.len(),
1765            3,
1766            "no truncation marker expected when everything fits under budget"
1767        );
1768        for body in &bodies {
1769            assert!(
1770                body.contains("lorem"),
1771                "every returned entry must be a real skill body, not a marker: {body}"
1772            );
1773        }
1774    }
1775
1776    #[test]
1777    fn filtered_skills_for_over_budget_truncates_with_marker() {
1778        // 5 skills, each with a large body; a tiny budget forces truncation well before the
1779        // full set is accumulated.
1780        let (registry, _temp_dir) = registry_with_skills(5, 500);
1781        let mut agent = agent_with_skill_registry_and_helper_def(registry);
1782        agent.services.skill.subagent_skill_token_budget = 10;
1783
1784        let bodies = agent
1785            .filtered_skills_for("helper")
1786            .expect("at least the first skill must always be included");
1787
1788        let marker_count = bodies
1789            .iter()
1790            .filter(|b| b.starts_with("[skill budget:"))
1791            .count();
1792        assert_eq!(
1793            marker_count, 1,
1794            "exactly one truncation marker entry must be appended, got bodies: {bodies:?}"
1795        );
1796        let marker = bodies
1797            .iter()
1798            .find(|b| b.starts_with("[skill budget:"))
1799            .unwrap();
1800        let included = bodies.len() - 1;
1801        assert!(
1802            included < 5,
1803            "budget=10 tokens must not fit all 5 large skills, included={included}"
1804        );
1805        assert!(
1806            included >= 1,
1807            "the first skill must always be included even when it alone exceeds the budget, \
1808             got included={included}"
1809        );
1810        assert!(
1811            bodies[0].contains("lorem"),
1812            "the always-included first entry must be a real skill body, not the marker: {}",
1813            bodies[0]
1814        );
1815        assert!(
1816            marker.contains(&format!("{included}/5 skills included")),
1817            "marker must report the correct included/total count: {marker}"
1818        );
1819        assert!(
1820            marker.contains("budget=10 tokens"),
1821            "marker must report the configured budget: {marker}"
1822        );
1823    }
1824
1825    #[test]
1826    fn filtered_skills_for_mid_budget_greedily_fills_multiple_fitting_skills() {
1827        // 5 identical-body skills so each costs exactly the same token count T (computed via the
1828        // same TokenCounter the fix uses). A budget of `2*T + 1` fits skill-0 and skill-1 exactly
1829        // (running total 2T <= budget) but not a 3rd (3T > budget) — this exercises the greedy
1830        // `running_tokens + skill_tokens > budget` accumulation for a *fitting* 2nd skill, not
1831        // just the always-included first one (S2: the over-budget test alone never reaches this
1832        // arithmetic since its budget is too small to fit even a 2nd skill).
1833        let (registry, _temp_dir) = registry_with_skills(5, 500);
1834        let single_body = "lorem ".repeat(500);
1835        let per_skill_tokens = zeph_memory::TokenCounter::new().count_tokens(&single_body);
1836        assert!(
1837            per_skill_tokens > 1,
1838            "test setup: per-skill token count must be large enough for 2*T+1 to exclude a 3rd \
1839             skill, got {per_skill_tokens}"
1840        );
1841
1842        let mut agent = agent_with_skill_registry_and_helper_def(registry);
1843        agent.services.skill.subagent_skill_token_budget = 2 * per_skill_tokens + 1;
1844
1845        let bodies = agent
1846            .filtered_skills_for("helper")
1847            .expect("at least the first skill must always be included");
1848
1849        let marker = bodies
1850            .iter()
1851            .find(|b| b.starts_with("[skill budget:"))
1852            .unwrap_or_else(|| panic!("expected a truncation marker, got bodies: {bodies:?}"));
1853        let included = bodies.len() - 1;
1854        assert_eq!(
1855            included, 2,
1856            "budget=2*T+1 must fit exactly 2 of the 5 identical-cost skills, got {included}"
1857        );
1858        assert!(
1859            marker.contains("2/5 skills included"),
1860            "marker must report the correct included/total count: {marker}"
1861        );
1862        // Registry order is alphabetical by skill directory name (skill-0..skill-4), so the
1863        // 2 included skills are skill-0/skill-1 and the 3 omitted are skill-2/3/4 — assert the
1864        // marker's omitted-name list matches exactly, not just the count (closes Gap 3).
1865        let omitted_segment = marker
1866            .split("omitted: ")
1867            .nth(1)
1868            .and_then(|s| s.strip_suffix(']'))
1869            .unwrap_or_else(|| panic!("marker missing 'omitted: ...]' segment: {marker}"));
1870        let mut omitted_names: Vec<&str> = omitted_segment.split(", ").collect();
1871        omitted_names.sort_unstable();
1872        assert_eq!(
1873            omitted_names,
1874            vec!["skill-2", "skill-3", "skill-4"],
1875            "marker must name exactly the 3 truncated skills, got marker: {marker}"
1876        );
1877    }
1878
1879    #[test]
1880    fn filtered_skills_for_explicit_include_is_never_capped() {
1881        // S1 (scope decision): the budget cap applies only to the empty-include "inherit
1882        // everything" case #6421 is about. A definition with an explicit, hand-curated
1883        // `skills.include` list must be returned uncapped even when its total size would
1884        // otherwise exceed the configured budget — the operator opted into that set on purpose.
1885        use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1886        use zeph_subagent::hooks::SubagentHooks;
1887
1888        let (registry, _temp_dir) = registry_with_skills(5, 500);
1889        let def = zeph_subagent::SubAgentDef {
1890            name: "curated".to_owned(),
1891            description: "A curated helper".into(),
1892            model: None,
1893            tools: ToolPolicy::InheritAll,
1894            disallowed_tools: vec![],
1895            permissions: SubAgentPermissions::default(),
1896            skills: SkillFilter {
1897                include: vec!["skill-*".to_owned()],
1898                exclude: vec![],
1899            },
1900            system_prompt: "You are helpful.".into(),
1901            hooks: SubagentHooks::default(),
1902            memory: None,
1903            source: None,
1904            file_path: None,
1905        };
1906        let mut agent = agent_with_skill_registry_and_def(registry, def);
1907        // Budget far too small to fit all 5 skills — would definitely truncate the empty-include
1908        // path, but must have zero effect here.
1909        agent.services.skill.subagent_skill_token_budget = 10;
1910
1911        let bodies = agent
1912            .filtered_skills_for("curated")
1913            .expect("explicit include must still match all 5 skill-* skills");
1914
1915        assert_eq!(
1916            bodies.len(),
1917            5,
1918            "explicit include list must never be truncated by the budget, got: {bodies:?}"
1919        );
1920        assert!(
1921            bodies.iter().all(|b| b.contains("lorem")),
1922            "every entry must be a real skill body, not a truncation marker: {bodies:?}"
1923        );
1924    }
1925}