Skip to main content

polyc_agent/
step.rs

1//! The `TurnStep` seam: a small, ordered set of composable steps the turn loop
2//! drives, each owning one cohesive slice of turn behavior.
3//!
4//! Slice 2 of #649 introduces the seam and proves it by migrating exactly one
5//! behavior out of the turn-loop monolith — the forced closing completion. The
6//! turn function's post-loop tail is now a driver over a list of
7//! [`TurnStep`]s; later slices migrate the remaining stanzas behind the same
8//! interface.
9//!
10//! A step reads and mutates the turn's working state through a [`TurnCtx`] and
11//! reports back with a [`StepOutcome`] (keep going, pause for a human, or end
12//! the turn early).
13
14use std::collections::{HashMap, HashSet};
15
16use async_trait::async_trait;
17use futures::SinkExt as _;
18use polyc_crypto::canon::canon_args;
19use polyc_llm::{
20    CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role, StopReason,
21    ToolSpec, Usage,
22    request::ToolCall,
23    turn::{collect_turn, collect_turn_observed},
24};
25use polyc_proto::proto::polychrome::agent::v1::Message;
26
27use crate::{
28    CallContext, CallDisposition, HandoffRequest, PendingApproval, ResolvedCall, RunTurnOptions,
29    ToolExecutor, append_injected_notes, cap_tool_result, consume_decisions, forced_result,
30    gate_decision, gate_missing, matching_decision_indexes, push_internal_note, push_reasoning,
31    resolve_approved_call, run_and_redact, session_approves, splice_results_after, text_message,
32    tool_result_message, untrusted_content_in_context,
33};
34
35/// The runtime-injected ground-truth note (`#743` change 1b) pushed after a
36/// resume executes at least one previously-approved call: model-visible
37/// (a System message in [`TurnCtx::messages`]) but never user-visible
38/// (`internal_only` in [`TurnCtx::outputs`], via [`push_internal_note`]).
39///
40/// The resume's continuation text MUST still post — the codeless invite ack,
41/// the demote confirmation, etc. are genuine narration, not a status guess —
42/// so this does not suppress anything. It structurally corrects what the
43/// model would otherwise have to infer from a bare tool result: that a person
44/// already approved the call and it already ran, so the model's job now is to
45/// report the outcome, not to describe (or re-describe) approval status. Same
46/// mechanism as the `#67` approver-injected context: runtime-supplied ground
47/// truth, not a prompt-level instruction the model could ignore as mere text.
48///
49/// The "who reports approval status" sentence is not restated here — the
50/// note embeds [`polyc_llm::APPROVAL_STATUS_GROUND_RULE`] verbatim, the
51/// same single source [`polyc_llm::GATED_TOOL_APPROVAL_NOTE`] embeds, so
52/// the two moments the model hears the rule can never drift apart (#1141).
53pub(crate) static RESUME_EXECUTED_GROUND_TRUTH_NOTE: std::sync::LazyLock<String> =
54    std::sync::LazyLock::new(|| {
55        format!(
56            "A person approved this request and the tool has already run — the results above \
57             are final. Tell the user what happened. {}",
58            polyc_llm::APPROVAL_STATUS_GROUND_RULE
59        )
60    });
61
62/// The turn's working state, threaded through each [`TurnStep`].
63///
64/// Owns what were locals in the turn function — the working transcript, the
65/// accumulated wire outputs, the folded usage, the last stop reason, and the
66/// loop-control flags — and borrows the turn's immutable inputs (the provider,
67/// tool executor, model, and options) for the lifetime `'a` so a step can dial
68/// the provider without re-plumbing them.
69// Independent working flags a step reads/sets separately; folding them into an
70// enum would force artificial combinations (a turn that executed tools also
71// produced text, and either can coexist with a fired escape hatch).
72#[allow(clippy::struct_excessive_bools)]
73pub struct TurnCtx<'a, P, T>
74where
75    P: LlmProvider + ?Sized,
76    T: ToolExecutor + ?Sized,
77{
78    /// The LLM provider the turn dials.
79    pub provider: &'a P,
80    /// The executor that advertises and runs this turn's tools.
81    pub tools: &'a T,
82    /// The model identifier for provider requests.
83    pub model: &'a str,
84    /// The options the turn was invoked with (streaming channel, decisions).
85    pub options: &'a RunTurnOptions,
86    /// The working transcript driven through the loop and any post-steps.
87    pub messages: Vec<LlmMessage>,
88    /// The wire messages produced so far — assistant text and tool results.
89    pub outputs: Vec<Message>,
90    /// Usage folded across every provider call this turn has made.
91    pub total_usage: Usage,
92    /// Stop reason of the most recent provider step.
93    pub last_stop: Option<StopReason>,
94    /// Whether any tool ran this turn (resume pre-pass or the loop).
95    pub executed_tools: bool,
96    /// Whether the model ever emitted user-visible text this turn.
97    pub produced_text: bool,
98    /// Whether native search grounding was allowed for any step this turn
99    /// made — see [`crate::TurnResult::grounded`] for why this is
100    /// conservative (allowed, not necessarily used) and monotonic once set.
101    pub grounded: bool,
102    /// The pending handoff request, if the model asked to hand off.
103    pub pending_handoff: Option<HandoffRequest>,
104    /// STICKY/TERMINAL denials keyed to the tool *signature* (name + canonical
105    /// args) rather than the provider call-id. Once a human denies an action,
106    /// the model can re-emit the SAME logical call with a fresh call-id; a
107    /// call-id-only check would re-pause and re-prompt for something already
108    /// rejected. The resume pre-pass seeds this and the in-loop batch records
109    /// into it, so a matching re-emit is auto-denied (synthetic result) without
110    /// ever pausing again.
111    pub denied_sigs: HashSet<(String, String)>,
112    /// Occurrence-ordered decisions still awaiting one exact matching call.
113    /// Arguments are canonicalized when the turn starts. A consumed entry is
114    /// removed, so an identical later occurrence cannot inherit it.
115    pub approval_decisions_remaining: Vec<crate::ApprovalDecision>,
116    /// How many loop iterations have resolved a signature-matched terminal
117    /// denial — the model retrying an action a human already denied. The first
118    /// signed denial (by call-id, before any signature is recorded) does not
119    /// count; only re-emits of an already-denied signature do. Persists across
120    /// iterations so the stateless [`CircuitBreaker`] step can increment it and
121    /// end the turn once it reaches `MAX_DENIAL_REPROMPTS`.
122    pub denial_reprompts: usize,
123    /// Whether the step that just resolved handled a signature-matched terminal
124    /// denial. The loop republishes it onto the ctx each iteration before the
125    /// [`CircuitBreaker`] step reads it; the step never touches the working
126    /// state.
127    pub saw_sig_match_denial: bool,
128    /// Gated calls an unattended turn denied fail-closed (`#623`), accumulated
129    /// across the loop iterations. Each entry is a call the capability gate would
130    /// have escalated on a turn with [`RunTurnOptions::unattended`](crate::RunTurnOptions::unattended)
131    /// set; the model saw a legible denial result and the call neither ran nor
132    /// paused. The final
133    /// [`TurnResult::unattended_denials`](crate::TurnResult::unattended_denials)
134    /// carries them out for the control plane to audit. Always empty on an
135    /// attended turn.
136    pub unattended_denials: Vec<crate::UnattendedDenial>,
137    /// Whether the fuzzy-match escape hatch (`#582`, invariant 9) has fired
138    /// this turn. The hatch widens the advertised tool set at most ONCE per
139    /// turn; once set, a later call naming an unadvertised tool resolves to
140    /// the ordinary unknown-tool result again.
141    pub escape_hatch_fired: bool,
142    /// One entry per `__delegate_to` call dispatched this turn (`#872`),
143    /// accumulated across the loop iterations. The final
144    /// [`TurnResult::delegate_records`](crate::TurnResult::delegate_records)
145    /// carries them out for the control plane to append as signed forensic
146    /// events. Empty for every turn that never called `__delegate_to`.
147    pub delegate_records: Vec<crate::DelegateRecord>,
148    /// Questions from an `ask_question` call awaiting an answer (`#1660`).
149    /// Populated only when the turn pauses on the question-pause phase
150    /// (mirroring [`Self::pending_handoff`]) — a sibling pause path to the
151    /// HITL approval gate, not a reuse of it. The final
152    /// [`TurnResult::pending_questions`](crate::TurnResult::pending_questions)
153    /// carries them out. Empty for every turn that never paused on a
154    /// question.
155    pub pending_questions: Vec<crate::question::PendingQuestion>,
156}
157
158impl<P, T> TurnCtx<'_, P, T>
159where
160    P: LlmProvider + ?Sized,
161    T: ToolExecutor + ?Sized,
162{
163    /// Consumes the turn's working state into a [`crate::TurnResult`], moving
164    /// every accumulated audit surface out in one place.
165    ///
166    /// The turn loop's return sites differ only in the approvals they surface
167    /// and whether a handoff rides along; the transcript, folded usage, last
168    /// stop reason, and the `#623` unattended-denial audit surface are always
169    /// whatever the context accumulated. Owning
170    /// that move here makes forgetting an audit surface at a return site
171    /// impossible by construction.
172    #[must_use]
173    pub fn finish(
174        self,
175        pending_approvals: Vec<PendingApproval>,
176        handoff: Option<HandoffRequest>,
177    ) -> crate::TurnResult {
178        // Per-turn prompt-cache effectiveness (#1299): every return site
179        // funnels through here, so this fires exactly once per turn with
180        // the fully folded `Usage` — including the cache counters the two
181        // fold sites (`lib.rs`'s loop, `ForcedCompletion`) accumulate but
182        // never otherwise leave the agent crate.
183        crate::metrics::record_turn(&self.total_usage);
184        crate::TurnResult {
185            messages: self.outputs,
186            usage: self.total_usage,
187            stop: self.last_stop,
188            pending_approvals,
189            handoff,
190            unattended_denials: self.unattended_denials,
191            mid_stream_failure: None,
192            delegate_records: self.delegate_records,
193            grounded: self.grounded,
194            pending_questions: self.pending_questions,
195        }
196    }
197
198    /// Consumes the turn's working state into a [`crate::TurnResult`] that
199    /// reports a mid-turn provider stream failure (`#798`), exactly like
200    /// [`Self::finish`] but with [`crate::TurnResult::mid_stream_failure`] set
201    /// and no pending approvals (a failed stream never paused for HITL).
202    ///
203    /// Whatever the loop already accumulated — executed tool results, produced
204    /// text, folded usage — rides along on the returned [`crate::TurnResult`]
205    /// instead of being discarded, which is the whole point: the caller can
206    /// persist iterations `1..N-1`'s work AND fail the turn with a typed error,
207    /// rather than losing both to a bare `Err` propagated via `?`.
208    #[must_use]
209    pub fn finish_failed(mut self, failure: crate::MidStreamFailure) -> crate::TurnResult {
210        let handoff = self.pending_handoff.take();
211        let mut result = self.finish(Vec::new(), handoff);
212        result.mid_stream_failure = Some(failure);
213        result
214    }
215
216    /// Fold one provider call's [`Usage`] into [`Self::total_usage`], via
217    /// [`Usage`]'s [`AddAssign`](std::ops::AddAssign) impl — the single
218    /// canonical field-by-field fold, never a `..Default::default()` spread
219    /// (which would silently leave a newly-added field at zero instead of
220    /// failing to compile; see `#1241`/`#1238`).
221    ///
222    /// The turn loop calls this once per provider call it makes (the main
223    /// loop and [`ForcedCompletion`] are the two call sites, one provider
224    /// call each), so `total_usage` always reflects every call the turn's
225    /// tool-calling loop actually made, however many iterations that took.
226    pub(crate) fn fold_usage(&mut self, delta: Usage) {
227        self.total_usage += delta;
228    }
229}
230
231/// What a [`TurnStep`] reports after running.
232pub enum StepOutcome {
233    /// Continue to the next step in the list.
234    Continue,
235    /// Pause the turn for human approval, surfacing the given calls.
236    Pause(Vec<PendingApproval>),
237    /// Pause the turn on one or more still-unanswered `ask_question`
238    /// questions (`#1660`) — the question-pause SIBLING of [`Self::Pause`],
239    /// not a reuse of it.
240    PauseQuestions(Vec<crate::question::PendingQuestion>),
241    /// End the step-driving phase early; skip any remaining steps.
242    Done,
243}
244
245/// One cohesive slice of turn behavior the turn loop drives.
246///
247/// Each step reads and mutates the turn's working state through [`TurnCtx`] and
248/// reports a [`StepOutcome`]. Generic over the provider `P` and tool executor
249/// `T` so a step can dial the provider and use the turn's error type directly.
250#[async_trait]
251pub trait TurnStep<P, T>: Send + Sync
252where
253    P: LlmProvider + ?Sized,
254    T: ToolExecutor + ?Sized,
255{
256    /// Run this step against the turn context.
257    ///
258    /// # Errors
259    ///
260    /// Returns the provider's error type when the step fails in a way that
261    /// should abort the turn. A step that is best-effort swallows its own
262    /// provider failures and returns [`StepOutcome::Continue`] instead.
263    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error>;
264}
265
266/// Last-resort reply when even the forced closing completion (below) comes
267/// back with no text — e.g. a model stuck in a tool-calling groove that keeps
268/// emitting `stop == ToolUse` with empty text even with no tools declared
269/// (`#1317`). Used verbatim only when [`synthesize_forced_completion_fallback`]
270/// finds nothing to name (no tool was ever called this turn); otherwise that
271/// function's output is used instead. Honest about the outcome rather than
272/// fabricating a summary of RESULTS: no machinery here could safely stand in
273/// for the model's own words about what it found, so this states what
274/// happened and what to do next, per the user-facing-copy rules (no
275/// "sorry"/"please"/"unfortunately").
276pub(crate) const FORCED_COMPLETION_FALLBACK_TEXT: &str =
277    "I couldn't put together an answer to that — try asking again or rephrasing.";
278
279/// `#1317` "robust fix": when even the forced closing completion comes back
280/// empty, synthesize the fallback reply from what was actually TRIED this
281/// turn (never a third completion attempt — no further retry exists past
282/// this) instead of the fully generic [`FORCED_COMPLETION_FALLBACK_TEXT`].
283/// Deterministic and honest: it names which tools were called, never
284/// fabricates what they found.
285///
286/// Collects each distinct tool name called anywhere in `messages` (first-seen
287/// order), rendered through [`polyc_proto::humanize_tool_name`] rather than
288/// the raw machine identifier — the same "never hand-write tool-name jargon
289/// into user-facing copy" rule every edge's status text already follows.
290/// Falls back to [`FORCED_COMPLETION_FALLBACK_TEXT`] verbatim when nothing was
291/// ever called (e.g. the very first completion came back structurally empty,
292/// with neither a tool call nor text).
293fn synthesize_forced_completion_fallback(messages: &[LlmMessage]) -> String {
294    let mut seen = HashSet::new();
295    let mut names = Vec::new();
296    for name in messages
297        .iter()
298        .filter(|m| m.role == Role::Assistant)
299        .flat_map(|m| &m.content)
300        .filter_map(|c| match c {
301            LlmContent::ToolUse(call) => Some(call.name.as_str()),
302            _ => None,
303        })
304    {
305        if seen.insert(name) {
306            names.push(polyc_proto::humanize_tool_name(name));
307        }
308    }
309    if names.is_empty() {
310        return FORCED_COMPLETION_FALLBACK_TEXT.to_owned();
311    }
312    format!(
313        "I tried {} but couldn't put together an answer — try asking again, or rephrasing what \
314         you need.",
315        names.join(", ")
316    )
317}
318
319/// `#1317` "cheap fix": collapse a trailing run of pure tool-call/tool-result
320/// turns — the exact pattern that primes a model to keep emitting
321/// `functionCall` instead of answering in text — into one terse text summary,
322/// instead of cloning the raw transcript verbatim into the forced closing
323/// completion's request. Only the TRAILING run collapses; everything before
324/// it (the real conversation) is untouched.
325///
326/// A message counts as "pure tool" when every [`LlmContent`] block in it is a
327/// [`LlmContent::ToolUse`] (an [`Role::Assistant`] turn) or a
328/// [`LlmContent::ToolResult`] (a [`Role::Tool`] turn) — i.e. it carries no
329/// text at all. Returns `messages` unchanged (cloned) when there is no such
330/// trailing run to collapse.
331fn collapse_trailing_tool_only_run(messages: &[LlmMessage]) -> Vec<LlmMessage> {
332    let is_pure_tool_turn = |m: &LlmMessage| -> bool {
333        !m.content.is_empty()
334            && match m.role {
335                Role::Assistant => m
336                    .content
337                    .iter()
338                    .all(|c| matches!(c, LlmContent::ToolUse(_))),
339                Role::Tool => m
340                    .content
341                    .iter()
342                    .all(|c| matches!(c, LlmContent::ToolResult(_))),
343                Role::User | Role::System | _ => false,
344            }
345    };
346    let split = messages
347        .iter()
348        .rposition(|m| !is_pure_tool_turn(m))
349        .map_or(0, |i| i + 1);
350    if split == messages.len() {
351        return messages.to_vec();
352    }
353    let mut collapsed = messages[..split].to_vec();
354    let tool_names: Vec<&str> = messages[split..]
355        .iter()
356        .flat_map(|m| &m.content)
357        .filter_map(|c| match c {
358            LlmContent::ToolUse(call) => Some(call.name.as_str()),
359            LlmContent::ToolResult(_) | LlmContent::Text(_) | LlmContent::Image(_) | _ => None,
360        })
361        .collect();
362    let summary = if tool_names.is_empty() {
363        "Earlier this turn, tool calls were made with no further progress. Answer directly \
364         from what is already known instead of calling another tool."
365            .to_owned()
366    } else {
367        format!(
368            "Earlier this turn, these tools were called with no further progress: {}. \
369             Answer directly from what is already known instead of calling another tool.",
370            tool_names.join(", ")
371        )
372    };
373    collapsed.push(LlmMessage {
374        role: Role::System,
375        content: vec![LlmContent::text(summary)],
376    });
377    collapsed
378}
379
380/// The forced closing completion.
381///
382/// Whenever a turn is about to end with no user-visible text, force one
383/// final text answer. A pending one-way handoff deliberately stays silent.
384pub struct ForcedCompletion;
385
386#[async_trait]
387impl<P, T> TurnStep<P, T> for ForcedCompletion
388where
389    P: LlmProvider + ?Sized,
390    T: ToolExecutor + ?Sized,
391{
392    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
393        // FALLBACK: the turn is about to end with no user-visible text, so
394        // `outputs` carries only tool calls/results (or nothing at all) — the
395        // edge would post nothing (the "agent produced no text" dead-end).
396        // This covers every shape that can reach here with `produced_text ==
397        // false`: the loop exhausting MAX_STEPS while still calling tools; a
398        // resume whose pre-pass executed an approved call and then got an
399        // empty continuation; a MAX_STEPS of 0 (a delegated worker's own step
400        // budget can be configured to zero, meaning the loop body never ran at
401        // all); AND a turn whose very FIRST completion came back with neither
402        // a tool call nor any text (no `tool_use` for `executed_tools` to have
403        // ever been set on) — native search grounding is exactly this shape,
404        // since a failed/empty grounding attempt is invisible to
405        // `executed_tools` (grounding is a request-level flag, never a
406        // `tool_use` call). The old guard required `executed_tools`, which
407        // covered the first three shapes but not the fourth — see the
408        // `__delegate_to` "worker produced no answer" incident this fixes.
409        // Force ONE final completion with tools disabled so the model must
410        // answer in text, summarizing what it did or explaining it couldn't
411        // proceed. An intentional handoff skips this completion because its
412        // parent turn ends at the one-way transfer. Best-effort: a failure
413        // here still yields fallback text rather than leaving the turn silent.
414        if ctx.produced_text || ctx.pending_handoff.is_some() {
415            return Ok(StepOutcome::Continue);
416        }
417        let mut req = CompletionRequest::new(ctx.model);
418        // `#1317` "cheap fix": collapse a trailing tool-call-only run instead
419        // of cloning the raw transcript verbatim — see
420        // `collapse_trailing_tool_only_run`'s doc comment.
421        req.messages = collapse_trailing_tool_only_run(&ctx.messages);
422        // Removing tools is not enough: a model deep in a tool-calling groove
423        // will keep emitting a functionCall (stop == ToolUse) and no text even
424        // with no tools declared. Also disable web-search grounding (another
425        // tool surface) and append an explicit instruction so the model writes a
426        // plain-text final answer from what it already has.
427        // A System instruction (folded into systemInstruction by the provider,
428        // not the visible transcript) so the model follows it without echoing it
429        // into the reply; a User message gets paraphrased back by thinking models.
430        // Kept non-meta for the same reason.
431        req.messages.push(LlmMessage {
432            role: Role::System,
433            content: vec![LlmContent::Text(
434                "No tools are available for the remainder of this turn. Give the \
435                 user a direct, plain-text answer using the information already \
436                 gathered."
437                    .to_owned(),
438            )],
439        });
440        req.tools = Vec::new();
441        req.web_search = false;
442        // Best-effort closing completion: its output is discarded on any error,
443        // so don't spend the retry budget's backoff here — a single attempt
444        // keeps a wedged turn from also paying tens of seconds of backoff.
445        let mut closing_text: Option<String> = None;
446        if let Ok(stream) = ctx.provider.complete(req).await {
447            let turn = if let Some(tx) = ctx.options.stream_tx.clone() {
448                // Bounded (`#251`): see the matching forwarding site in
449                // `lib.rs` — `tx` is cloned once here (not per event) and the
450                // `.await`ed send applies real backpressure.
451                let mut tx = tx;
452                collect_turn_observed(stream, async move |ev| {
453                    let _ = tx.send(ev).await;
454                })
455                .await
456            } else {
457                collect_turn(stream).await
458            };
459            if let Ok(turn) = turn {
460                ctx.fold_usage(turn.usage);
461                push_reasoning(&mut ctx.outputs, &turn.reasoning);
462                ctx.last_stop = turn.stop;
463                if !turn.text.is_empty() {
464                    closing_text = Some(turn.text);
465                }
466            }
467        }
468        if let Some(text) = closing_text {
469            ctx.outputs.push(text_message("model", &text));
470            ctx.produced_text = true;
471            tracing::info!("forced closing completion produced text; turn now yields a reply");
472        } else {
473            // `#1317`: the forced pass itself can also come back empty (a
474            // model stuck in the same tool-calling groove even with no tools
475            // declared, or the completion request failing outright) — log a
476            // distinct WARN so this is greppable from harness logs alone,
477            // then fall back to the honest static reply so the turn NEVER
478            // drops silently.
479            tracing::warn!(
480                "forced closing completion also produced no text — falling back to a synthesized reply so the turn doesn't drop silently"
481            );
482            let fallback = synthesize_forced_completion_fallback(&ctx.messages);
483            ctx.outputs.push(text_message("model", &fallback));
484            ctx.produced_text = true;
485        }
486        Ok(StepOutcome::Continue)
487    }
488}
489
490/// The approval resume pre-pass: resolve the tool calls a human already decided.
491///
492/// Before the turn drives the model, execute the calls the human approved that
493/// are dangling in the resumed transcript, resolve signed or denied calls to
494/// synthetic results, splice those results in after the paused batch, and append
495/// any approver-injected context notes.
496///
497/// On an approval resume the control plane replays the paused turn's assistant
498/// `tool_use` (which has NO paired `tool_result` — the call was paused, never
499/// executed) and forwards the signed decisions via
500/// [`RunTurnOptions::approval_decisions`].
501/// The function-calling loop only executes tool calls the *model emits this
502/// turn*, so without this step an approval takes effect only if the model
503/// happens to RE-EMIT the same call. Resolving the dangling calls
504/// deterministically here makes an approval ALWAYS take effect, independent of
505/// whether the model re-emits.
506///
507/// Classification and the #141 approval binding mirror the in-loop batch so the
508/// two can't drift. It runs only on a resume: a fresh turn carries an empty
509/// decision set, so this step is a no-op and the hot path is unchanged.
510///
511/// A forwarded signed decision must never resolve silently to nothing: whenever
512/// `approval_decisions` is non-empty, [`Self::run`] logs a resolution summary and
513/// `tracing::warn!`s individually for every approved tuple that matches no
514/// unanswered call in the resumed transcript — distinguishing an already-
515/// answered (harmless) re-forward from a call that is missing outright (a
516/// projection loss upstream, e.g. one folded into a compaction summary). This
517/// is purely observational: an approval that resolves to nothing still resolves
518/// to nothing (re-pausing here would loop), but the loss is now loud instead of
519/// surfacing only as an unexplained model refusal downstream.
520///
521/// Borrows the turn's read-only resume inputs — the pinned tool-spec set (for
522/// pause-card titles), the approver edits, and the signed denials — while the
523/// mutable working state (transcript, outputs, the sticky denial set, and the
524/// remaining approvals) rides the [`TurnCtx`].
525pub struct ResumePrePass<'a> {
526    /// The turn's pinned tool-spec set, read once for pause-card titles.
527    pub tool_specs: &'a [ToolSpec],
528}
529
530#[async_trait]
531impl<P, T> TurnStep<P, T> for ResumePrePass<'_>
532where
533    P: LlmProvider + ?Sized,
534    T: ToolExecutor + ?Sized,
535{
536    #[allow(clippy::too_many_lines)] // cohesive resume-resolution pass
537    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
538        // RESUME: execute approved-but-unanswered tool calls ALREADY PRESENT in
539        // the input transcript, before driving the model. When the model instead
540        // reads its own dangling `tool_use` as already-done and narrates
541        // completion (e.g. "OK, I've torn it down"), the approved action silently
542        // never executes and the human's decision is lost — so resolve the
543        // dangling calls deterministically here.
544        //
545        // #1154: this step used to short-circuit here whenever BOTH decision
546        // sets were empty, on the assumption that "no approvals and no
547        // denials" implies "a genuinely fresh turn, no dangling tool_use." A
548        // resume whose only signed decision failed harness-side verification
549        // (e.g. a dropped signed field) breaks that assumption: the wire
550        // carried a real decision, it just verified to nothing, so this step
551        // was skipped and the model was left narrating a dangling call it
552        // never ran. There is no cheaper-but-safe proxy for "this is a fresh
553        // turn" than actually checking for a dangling `tool_use` below, so the
554        // scan always runs; a genuinely fresh turn still exits immediately at
555        // the `unanswered.is_empty()` check just past it.
556
557        // Match each result to ONE preceding same-id tool use. Provider ids can
558        // repeat across turns, so a global answered-id set would let turn A's
559        // result hide turn B's later occurrence.
560        let mut calls: Vec<Option<(usize, ToolCall)>> = Vec::new();
561        let mut open_by_id: HashMap<String, std::collections::VecDeque<usize>> = HashMap::new();
562        let mut answered_ids: HashSet<String> = HashSet::new();
563        for (idx, m) in ctx.messages.iter().enumerate() {
564            for c in &m.content {
565                match c {
566                    LlmContent::ToolUse(tc) => {
567                        let slot = calls.len();
568                        calls.push(Some((idx, tc.clone())));
569                        open_by_id.entry(tc.id.clone()).or_default().push_back(slot);
570                    }
571                    LlmContent::ToolResult(result) => {
572                        answered_ids.insert(result.tool_call_id.clone());
573                        if let Some(slot) = open_by_id
574                            .get_mut(&result.tool_call_id)
575                            .and_then(std::collections::VecDeque::pop_front)
576                        {
577                            calls[slot] = None;
578                        }
579                    }
580                    _ => {}
581                }
582            }
583        }
584        let unanswered: Vec<(usize, ToolCall)> = calls.into_iter().flatten().collect();
585
586        // Observability (hardening after the #699/#700 admin-invite silent-no-op:
587        // an approved resume that resolved to nothing with no trace beyond a
588        // model-generated refusal). A forwarded signed decision must never
589        // resolve silently — WARN individually for every approved tuple that
590        // matches no unanswered call in THIS resumed transcript, distinguishing
591        // "already answered" (its id already carries a live `tool_result` — a
592        // harmless re-forward of a spent decision) from "not found" (no call
593        // anywhere in the transcript carries this id — the call itself is
594        // missing, e.g. folded into a compaction summary or otherwise dropped
595        // between pause and resume) so a silent loss is loud at the exact site
596        // that would otherwise have swallowed it.
597        if !ctx.options.approval_decisions.is_empty() {
598            let unanswered_ids: HashSet<&str> =
599                unanswered.iter().map(|(_, tc)| tc.id.as_str()).collect();
600            for decision in &ctx.options.approval_decisions {
601                let id = &decision.request_id;
602                let name = &decision.tool_name;
603                if unanswered_ids.contains(id.as_str()) {
604                    continue;
605                }
606                if answered_ids.contains(id) {
607                    tracing::info!(
608                        request_id = %id,
609                        tool = %name,
610                        "approved call already answered on this resume; decision is a no-op re-forward"
611                    );
612                } else {
613                    tracing::warn!(
614                        request_id = %id,
615                        tool = %name,
616                        "approved call id matches no tool call in the resumed \
617                         transcript; the signed decision cannot resolve to anything"
618                    );
619                }
620            }
621        }
622        tracing::info!(
623            approved = ctx
624                .options
625                .approval_decisions
626                .iter()
627                .filter(|decision| decision.approved)
628                .count(),
629            denied = ctx
630                .options
631                .approval_decisions
632                .iter()
633                .filter(|decision| !decision.approved)
634                .count(),
635            unanswered = unanswered.len(),
636            "resume pre-pass: resolving forwarded decisions against the resumed transcript"
637        );
638
639        if unanswered.is_empty() {
640            return Ok(StepOutcome::Continue);
641        }
642
643        // Taint state, evaluated against the resumed transcript: any untrusted
644        // tool-result (a prior fetch) already in context, OR the durable seed the
645        // control plane computed over the full event log (untrusted content that
646        // compaction folded out of the projection, or a non-principal
647        // participant's input — neither of which survives as a live `ToolResult`).
648        let untrusted_in_context =
649            untrusted_content_in_context(&ctx.messages) || ctx.options.untrusted_context_seed;
650        // Classify exactly as the in-loop batch does (same #141 binding:
651        // approval/denial bound to the exact (id, name, args) tuple).
652        let unanswered_calls: Vec<ToolCall> =
653            unanswered.iter().map(|(_, call)| call.clone()).collect();
654        let matched_decisions =
655            matching_decision_indexes(&unanswered_calls, &ctx.approval_decisions_remaining);
656        let dispositions: Vec<CallDisposition> = unanswered
657            .iter()
658            .zip(&matched_decisions)
659            .map(|((_, tc), decision_index)| {
660                let gate = gate_decision(
661                    ctx.tools,
662                    ctx.options,
663                    untrusted_in_context,
664                    &tc.name,
665                    &tc.args_json,
666                );
667                let decision = decision_index.map(|index| &ctx.approval_decisions_remaining[index]);
668                let is_denied = decision.is_some_and(|decision| !decision.approved);
669                // A remembered session approval ("don't ask again") satisfies the
670                // gate only when its signed covered set includes everything this
671                // call is currently missing (#595; see the in-loop site for the
672                // rationale). An explicit occurrence decision still runs.
673                let is_approved = decision.is_some_and(|decision| decision.approved)
674                    || session_approves(ctx.options, ctx.tools, &tc.name, gate_missing(&gate));
675                // No sticky-signature denial at pre-pass time (denied_sigs is
676                // empty until the loop runs), so sig_match is always false. A
677                // resume is by definition attended (a human answered an approval),
678                // so `unattended` is false here — the #623 fail-closed denial only
679                // arises on a fresh trigger-originated firing, never on resume.
680                CallDisposition::classify(
681                    gate,
682                    CallContext {
683                        approved: is_approved,
684                        denied: is_denied,
685                        ..CallContext::default()
686                    },
687                )
688            })
689            .collect();
690        // Tally the four disposition classes in a SINGLE traversal rather than
691        // one filter/count pass per class.
692        let mut execute = 0usize;
693        let mut denied = 0usize;
694        let mut policy_denied = 0usize;
695        let mut pending = 0usize;
696        for disposition in &dispositions {
697            match disposition {
698                CallDisposition::Execute => execute += 1,
699                CallDisposition::Denied { .. } => denied += 1,
700                // #623: an unattended fail-closed denial is a non-HITL denial,
701                // tallied with the policy/sandbox class (this count only feeds a
702                // tracing line; an unattended turn never resumes, ADR 0003).
703                CallDisposition::PolicyDenied { .. } | CallDisposition::UnattendedDenied { .. } => {
704                    policy_denied += 1;
705                }
706                // #582 invariant 9: constructed only by the in-loop escape
707                // hatch, never by `classify` — unreachable on a resume, but the
708                // tally stays total so a future refactor can't miscount.
709                CallDisposition::Recovered { .. } => {}
710                CallDisposition::Pending { .. } => pending += 1,
711            }
712        }
713        tracing::info!(
714            execute,
715            denied,
716            policy_denied,
717            pending,
718            "resume pre-pass: classified every unanswered dangling call"
719        );
720
721        // A dangling call that still needs approval (neither approved nor denied)
722        // must NOT be executed — re-pause the turn so the human is re-prompted,
723        // exactly as a fresh gated call would.
724        if dispositions
725            .iter()
726            .any(|d| matches!(d, CallDisposition::Pending { .. }))
727        {
728            let pending = unanswered
729                .iter()
730                .zip(&dispositions)
731                .filter_map(|((_, tc), d)| {
732                    let CallDisposition::Pending { reason, missing } = d else {
733                        return None;
734                    };
735                    let title = self
736                        .tool_specs
737                        .iter()
738                        .find(|s| s.name == tc.name)
739                        .and_then(|s| s.title.clone())
740                        .unwrap_or_default();
741                    Some(PendingApproval {
742                        occurrence_turn_id: tc.approval_turn_id.clone(),
743                        id: tc.id.clone(),
744                        name: tc.name.clone(),
745                        args_json: tc.args_json.clone(),
746                        title,
747                        // Sandbox-unaware here; the harness stamps the mode onto
748                        // the wire payload.
749                        sandbox_mode: String::new(),
750                        // The gate's reason carried on the disposition (empty for
751                        // an ordinary intrinsic/sandbox gate).
752                        reason: reason.clone(),
753                        missing_capabilities: missing
754                            .names()
755                            .iter()
756                            .map(|n| (*n).to_owned())
757                            .collect(),
758                        // Filled in later, control-plane side, for a
759                        // `routine_delete` call (see the field's own doc).
760                        computed_preview: String::new(),
761                    })
762                })
763                .collect::<Vec<_>>();
764            return Ok(StepOutcome::Pause(pending));
765        }
766
767        // Execute approved calls concurrently; denied calls resolve to the
768        // synthetic denial payload (mirrors the in-loop resolution). Resolve each
769        // paused call's approver edit (#67) once: the edited args to execute + any
770        // context to inject. Aligned with `unanswered`.
771        let pre_resolutions: Vec<ResolvedCall> = unanswered
772            .iter()
773            .zip(&matched_decisions)
774            .map(|((_, tc), decision_index)| {
775                let r#override = decision_index
776                    .and_then(|index| ctx.approval_decisions_remaining[index].r#override.as_ref());
777                resolve_approved_call(&tc.args_json, r#override)
778            })
779            .collect();
780        // Resumed calls execute the args the human already approved; the dispatch
781        // policy's INPUT mutations (#539) belong to a fresh dispatch, but
782        // `post_dispatch` result redaction (#540) still applies to their output.
783        let tools = ctx.tools;
784        let recorder = ctx.options.dispatch_recorder.clone();
785        let futures = unanswered
786            .iter()
787            .zip(&dispositions)
788            .zip(&pre_resolutions)
789            .map(|(((_, tc), disposition), resolved)| {
790                if matches!(disposition, CallDisposition::Denied { .. }) {
791                    // Sticky for the loop below: any re-emit of the same action is
792                    // auto-denied without re-prompting.
793                    ctx.denied_sigs
794                        .insert((tc.name.clone(), canon_args(&tc.args_json)));
795                }
796                // A human denial OR a policy veto (#67) resolves to a synthetic
797                // result instead of executing.
798                let forced = forced_result(disposition);
799                let name = tc.name.clone();
800                let args = resolved.args_json.clone();
801                let call_id = tc.id.clone();
802                let approval_turn_id = tc.approval_turn_id.clone();
803                let recorder = recorder.clone();
804                async move {
805                    if let Some(result) = forced {
806                        result
807                    } else {
808                        run_and_redact(
809                            tools,
810                            recorder.as_ref(),
811                            call_id,
812                            approval_turn_id,
813                            name,
814                            args,
815                        )
816                        .await
817                    }
818                }
819            })
820            .collect::<Vec<_>>();
821        let results = futures::future::join_all(futures).await;
822        // The pre-pass resolved dangling calls (executed approvals and/or
823        // synthesized denial results); either way the turn produced tool_results
824        // that need narrating, so guarantee a closing reply.
825        ctx.executed_tools = true;
826
827        // Every matched human decision is now represented by exactly one tool
828        // result (real execution or synthetic denial), so spend it once.
829        consume_decisions(&mut ctx.approval_decisions_remaining, &matched_decisions);
830
831        // Append each result to the persisted `outputs` (so a LATER resume sees
832        // the call as answered) and into the transcript GROUPED after the paused
833        // batch's last tool_use — never interleaved between two calls. A paused
834        // batch can be parallel tool calls, and the function-calling contract
835        // requires a turn's `functionCall`s to be followed by ALL their
836        // `functionResponse`s together: a response spliced between two parallel
837        // calls is rejected (the provider 400s, which would fail the re-drive and
838        // strand the calls unanswered — poisoning the conversation). The in-loop
839        // path groups the same way.
840        let mut result_msgs = Vec::with_capacity(unanswered.len());
841        for ((_, tc), result) in unanswered.iter().zip(results) {
842            let result = cap_tool_result(&result);
843            // Stamp ingestion-time provenance so the durable trifecta tag mirrors
844            // the live-scan predicate: a first-party tool's result does not taint
845            // context (see `output_msg_trust`).
846            let first_party = !ctx.tools.ingests_untrusted_content(&tc.name);
847            ctx.outputs
848                .push(tool_result_message(&tc.id, &result, first_party));
849            // #874 (headline fix): stamp the same verdict onto the in-memory
850            // transcript, mirroring the main dispatch loop's fix. The static
851            // per-tool-name check is sufficient HERE specifically: a
852            // `__delegate_to` call is never gated (`gate_decision` returns
853            // `Allow` unconditionally for it, the same seam this pre-pass and
854            // the in-loop batch share), so it can never be paused and
855            // therefore never appears in `unanswered` — this resume path
856            // structurally never dispatches a delegate call, only ordinary
857            // gated tools whose provenance IS the static per-name check.
858            result_msgs.push(LlmMessage {
859                role: Role::Tool,
860                content: vec![LlmContent::tool_result(
861                    tc.id.clone(),
862                    result,
863                    false,
864                    first_party,
865                )],
866            });
867        }
868        // The paused batch is the tail of the transcript, so its results go after
869        // its last call. `unanswered` is non-empty in this branch.
870        let after = unanswered
871            .iter()
872            .map(|(idx, _)| *idx)
873            .max()
874            .unwrap_or(ctx.messages.len());
875        ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
876        // #67: approver-injected context lands as internal-only system notes after
877        // the spliced results (the paused batch is the transcript tail),
878        // preserving the function-call ⇒ all-responses grouping.
879        append_injected_notes(&mut ctx.outputs, &mut ctx.messages, &pre_resolutions);
880
881        // `#743` change 1b: when at least one dangling call actually EXECUTED
882        // this resume (as opposed to only denials/policy vetoes resolving),
883        // tell the model — as runtime-injected ground truth, not a
884        // suppressible instruction — that the results above are the final,
885        // already-approved outcome. This is what makes the resume's
886        // continuation narrate the real result instead of re-guessing
887        // approval status from a bare tool result.
888        if dispositions
889            .iter()
890            .any(|d| matches!(d, CallDisposition::Execute))
891        {
892            push_internal_note(
893                &mut ctx.outputs,
894                &mut ctx.messages,
895                RESUME_EXECUTED_GROUND_TRUTH_NOTE.as_str(),
896            );
897        }
898
899        Ok(StepOutcome::Continue)
900    }
901}
902
903/// The runtime-injected ground-truth note pushed after a resume applies at
904/// least one `ask_question` answer (`#1660`) — the question-pause SIBLING of
905/// [`RESUME_EXECUTED_GROUND_TRUTH_NOTE`] above, not a reuse of it: a
906/// clarifying-question answer is a decision, not a permission grant, so it
907/// gets its own wording rather than borrowing the approval gate's.
908pub(crate) static QUESTION_ANSWERED_GROUND_TRUTH_NOTE: &str = "The question(s) above have been resolved — each carries its own `state` \
909     (answered/declined/auto_resolved) telling you exactly how. Read each one and act on it \
910     directly; do not re-ask a question that already has a result here.";
911
912/// The ephemeral reminder pushed alongside the invariant-I8 interim splice
913/// below — model-visible only ([`TurnCtx::messages`]), never persisted
914/// ([`TurnCtx::outputs`]) for the same reason the interim result itself
915/// isn't: it describes a fact ("still open") that's only true at this
916/// instant and would go stale the moment a real answer lands.
917pub(crate) static QUESTION_STILL_PENDING_EPHEMERAL_NOTE: &str = "A question you asked earlier is still open — see the still_pending result above. That's \
918     not an answer; it means nobody has responded yet. Handle the message below on its own \
919     terms, and only bring the open question back up if it's still relevant once you have.";
920
921/// True when nothing after message index `after` in `messages` is genuinely
922/// new turn input — i.e. every trailing message is blank/whitespace-only
923/// text, matching how the control plane's edge-facing
924/// `new_inputs_are_blank` recognizes a pure resume redrive (`vec![user_message("")]`).
925/// A non-text block (a real tool result, image, etc.) or any non-blank text
926/// counts as real input. `crates/agent` can't depend on `crates/control-plane`
927/// (the layer rule points inward), so this is the same semantics reimplemented
928/// against [`LlmContent`] rather than shared code.
929fn trailing_input_is_blank(messages: &[LlmMessage], after: usize) -> bool {
930    messages
931        .get(after.saturating_add(1)..)
932        .unwrap_or(&[])
933        .iter()
934        .flat_map(|m| m.content.iter())
935        .all(|c| matches!(c, LlmContent::Text(t) if t.trim().is_empty()))
936}
937
938/// One `tool_result` per dangling call in `resolved`, grouped after the
939/// batch's last call and spliced into [`TurnCtx::messages`] — the shape
940/// [`QuestionResumePrePass`]'s two splice sites share (the invariant-I8
941/// transcript-only interim splice, and the real-answer splice once every
942/// question has a verified answer): same `resolved.iter()` walk, same
943/// `after` computation, same `tool_result` construction, differing only in
944/// which JSON each call renders and whether the result is durable.
945///
946/// `durable: true` ALSO writes to [`TurnCtx::outputs`] — the real-answer
947/// path, where a genuine signed answer must survive as part of the turn's
948/// persisted transcript. `durable: false` writes to `ctx.messages` ONLY —
949/// the I8 interim splice, which must never be persisted (see that call
950/// site's own doc for why).
951fn splice_question_results<P, T>(
952    ctx: &mut TurnCtx<'_, P, T>,
953    resolved: &[(
954        usize,
955        ToolCall,
956        Vec<crate::question::QuestionItem>,
957        Vec<crate::question::VerifiedAnswer>,
958    )],
959    durable: bool,
960    mut result_json: impl FnMut(
961        &ToolCall,
962        &[crate::question::QuestionItem],
963        &[crate::question::VerifiedAnswer],
964    ) -> String,
965) where
966    P: LlmProvider + ?Sized,
967    T: ToolExecutor + ?Sized,
968{
969    let after = resolved
970        .iter()
971        .map(|(idx, ..)| *idx)
972        .max()
973        .unwrap_or(ctx.messages.len());
974    let mut result_msgs = Vec::with_capacity(resolved.len());
975    for (_, tc, items, answers) in resolved {
976        let json = result_json(tc, items, answers);
977        if durable {
978            // `first_party: true` — the result is either a human's own
979            // selection or the control plane's own signed auto-resolution,
980            // never externally-fetched content, so it must not be treated
981            // as untrusted-content-in-context.
982            ctx.outputs.push(tool_result_message(&tc.id, &json, true));
983        }
984        result_msgs.push(LlmMessage {
985            role: Role::Tool,
986            content: vec![LlmContent::tool_result(tc.id.clone(), json, false, true)],
987        });
988    }
989    ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
990}
991
992/// Resolves dangling `ask_question` calls once their answers have arrived
993/// (`#1660`).
994///
995/// The RESUME half of the question-pause phase — the question-pause SIBLING
996/// of [`ResumePrePass`], not a reuse of it. The pause/emit half (recognizing
997/// a fresh `ask_question` call and short-circuiting the batch) lives in the
998/// in-loop QUESTION-PAUSE PHASE (`run_turn_with`) because `CollectedTurn`/
999/// `tool_calls` are loop-body locals, not fields on [`TurnCtx`] — the same
1000/// seam constraint [`ResumePrePass`]'s own module doc notes for the approval
1001/// gate. This step only ever RESOLVES calls already dangling in the input
1002/// transcript.
1003///
1004/// Atomicity mirrors [`ResumePrePass`]: every dangling `ask_question` call's
1005/// disposition is computed FIRST; if ANY question across ANY of them is
1006/// still missing a [`crate::RunTurnOptions::question_answers`] entry, the
1007/// WHOLE resume re-pauses — UNLESS the dispatch also carries genuinely new
1008/// turn input (invariant I8), in which case the whole batch instead gets a
1009/// transcript-only "still pending" interim splice and the turn continues
1010/// (see the branch below for why: a provider requires every `tool_use` in
1011/// one assistant turn to receive a `tool_result` together, so a partial
1012/// splice — real answers for some calls, nothing for others — would corrupt
1013/// the transcript either way, real or interim). Only once every question in
1014/// every dangling call has a verified answer does this step build the
1015/// durable three-state result JSON (invariant I4) and splice it into both
1016/// [`TurnCtx::messages`] and [`TurnCtx::outputs`].
1017pub struct QuestionResumePrePass;
1018
1019#[async_trait]
1020impl<P, T> TurnStep<P, T> for QuestionResumePrePass
1021where
1022    P: LlmProvider + ?Sized,
1023    T: ToolExecutor + ?Sized,
1024{
1025    #[allow(clippy::too_many_lines)] // cohesive resume-resolution pass, mirrors ResumePrePass::run
1026    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
1027        let answered: HashSet<&str> = ctx
1028            .messages
1029            .iter()
1030            .flat_map(|m| m.content.iter())
1031            .filter_map(|c| match c {
1032                LlmContent::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
1033                _ => None,
1034            })
1035            .collect();
1036        let unanswered: Vec<(usize, ToolCall)> = ctx
1037            .messages
1038            .iter()
1039            .enumerate()
1040            .flat_map(|(idx, m)| m.content.iter().map(move |c| (idx, c)))
1041            .filter_map(|(idx, c)| match c {
1042                LlmContent::ToolUse(tc)
1043                    if tc.name == crate::question::ASK_QUESTION_TOOL_NAME
1044                        && !answered.contains(tc.id.as_str()) =>
1045                {
1046                    Some((idx, tc.clone()))
1047                }
1048                _ => None,
1049            })
1050            .collect();
1051
1052        if unanswered.is_empty() {
1053            return Ok(StepOutcome::Continue);
1054        }
1055
1056        // Parse each dangling call's questions once. A parse failure here is
1057        // unreachable in production: the exact same `args_json` bytes
1058        // already passed I5 validation before the call could ever pause
1059        // (the QUESTION-PAUSE PHASE never pauses a malformed call). Fail
1060        // defensively rather than panic — an empty item list contributes no
1061        // questions to resolve or re-pause, so a hypothetical future bug
1062        // here degrades to "this call is silently skipped" rather than a
1063        // crashed turn.
1064        let calls: Vec<(usize, ToolCall, Vec<crate::question::QuestionItem>)> = unanswered
1065            .into_iter()
1066            .map(|(idx, tc)| {
1067                let items = crate::question::parse_ask_question_args(&tc.args_json)
1068                    .inspect_err(|err| {
1069                        tracing::error!(
1070                            call_id = %tc.id,
1071                            %err,
1072                            "dangling ask_question call failed to re-parse on resume \
1073                             (unreachable: already validated before pausing)"
1074                        );
1075                    })
1076                    .unwrap_or_default();
1077                (idx, tc, items)
1078            })
1079            .collect();
1080
1081        // First pass: match every question to a verified answer, or record it
1082        // as still missing. Nothing is mutated yet — the atomicity rule above.
1083        let mut missing: Vec<crate::question::PendingQuestion> = Vec::new();
1084        let mut resolved: Vec<(
1085            usize,
1086            ToolCall,
1087            Vec<crate::question::QuestionItem>,
1088            Vec<crate::question::VerifiedAnswer>,
1089        )> = Vec::with_capacity(calls.len());
1090        for (idx, tc, items) in calls {
1091            let mut matched = Vec::with_capacity(items.len());
1092            for (i, item) in items.iter().enumerate() {
1093                let index = u32::try_from(i).unwrap_or(u32::MAX);
1094                // Occurrence match (`#2523`): a provider re-mints a
1095                // tool-call id across turns, so an answer names the turn its
1096                // question was asked in as well as `(call_id, index)`. The
1097                // comparison is strict equality against the dangling call's
1098                // own stamped turn — the control plane re-binds a durable
1099                // answer that predates occurrence identity to the turn on its
1100                // event kind before forwarding it, so a historical answer
1101                // arrives here already carrying the turn it must match.
1102                let occurrence = tc.approval_turn_id.as_deref().unwrap_or_default();
1103                if let Some(answer) = ctx
1104                    .options
1105                    .question_answers
1106                    .iter()
1107                    .find(|a| a.turn_id == occurrence && a.call_id == tc.id && a.index == index)
1108                {
1109                    matched.push(answer.clone());
1110                } else {
1111                    missing.push(crate::question::PendingQuestion {
1112                        occurrence_turn_id: tc.approval_turn_id.clone(),
1113                        call_id: tc.id.clone(),
1114                        index,
1115                        item: item.clone(),
1116                        args_json: tc.args_json.clone(),
1117                    });
1118                }
1119            }
1120            resolved.push((idx, tc, items, matched));
1121        }
1122
1123        if !missing.is_empty() {
1124            // #1662 follow-up / invariant I8: a hard re-pause here is only
1125            // correct when this dispatch carries no genuinely new turn
1126            // input — a blank redrive (the ordinary "resume after answering
1127            // elsewhere" shape) or a true no-op. When the caller appended
1128            // real new content after the dangling call (an unrelated
1129            // message the model hasn't seen yet), re-pausing identically
1130            // would silently swallow it: the pre-loop gate would return
1131            // before the model is ever dialed this turn, and the edge would
1132            // just re-render the byte-identical pending-question notice —
1133            // exactly the incident this invariant fixes (a user's follow-up
1134            // in the same Slack thread never reached the model; see #1659's
1135            // tracking issue for the root cause).
1136            let after = resolved
1137                .iter()
1138                .map(|(idx, ..)| *idx)
1139                .max()
1140                .unwrap_or(ctx.messages.len());
1141            if trailing_input_is_blank(&ctx.messages, after) {
1142                return Ok(StepOutcome::PauseQuestions(missing));
1143            }
1144
1145            // Transcript-only interim result for every dangling call in this
1146            // batch — `durable: false` writes it to `ctx.messages` ONLY,
1147            // never `ctx.outputs`. `TurnCtx::finish` persists `ctx.outputs`
1148            // verbatim as the durable transcript every future resume
1149            // rebuilds from (`crates/agent/src/step.rs`'s own `finish`) —
1150            // writing this there would make the call look answered forever,
1151            // permanently losing the real question. Left out of
1152            // `ctx.outputs`, the very next dispatch re-parses the same
1153            // still-dangling call and re-splices fresh, so this never goes
1154            // stale and never blocks the real signed answer from resolving
1155            // it later exactly as today.
1156            splice_question_results(ctx, &resolved, false, |_, items, _| {
1157                crate::question::question_still_pending_json(items)
1158            });
1159            // Ephemeral reminder, transcript-only for the same reason as the
1160            // interim results above — appended after the user's new message
1161            // so it's the last thing the model reads before replying.
1162            ctx.messages.push(LlmMessage {
1163                role: Role::System,
1164                content: vec![LlmContent::text(
1165                    QUESTION_STILL_PENDING_EPHEMERAL_NOTE.to_owned(),
1166                )],
1167            });
1168            return Ok(StepOutcome::Continue);
1169        }
1170
1171        // Every question in every dangling call now has a verified answer —
1172        // build the three-state result (I4) and splice it in durably
1173        // (mirrors `ResumePrePass`).
1174        ctx.executed_tools = true;
1175        splice_question_results(ctx, &resolved, true, |_, items, answers| {
1176            crate::question::question_call_result_json(items, answers)
1177        });
1178
1179        push_internal_note(
1180            &mut ctx.outputs,
1181            &mut ctx.messages,
1182            QUESTION_ANSWERED_GROUND_TRUTH_NOTE,
1183        );
1184
1185        Ok(StepOutcome::Continue)
1186    }
1187}
1188
1189/// The denied-action circuit breaker.
1190///
1191/// When the model re-emits an action a human already denied, the turn loop
1192/// auto-denies it (a synthetic result, never executed) and republishes the "saw
1193/// a signature-matching denial" signal onto the ctx. This step counts each such
1194/// re-emit and, once the model has done it `MAX_DENIAL_REPROMPTS` times, reports
1195/// [`StepOutcome::Done`] so the turn ends cleanly with the last stop reason
1196/// instead of burning the rest of the step budget looping the same dead-end.
1197pub struct CircuitBreaker;
1198
1199#[async_trait]
1200impl<P, T> TurnStep<P, T> for CircuitBreaker
1201where
1202    P: LlmProvider + ?Sized,
1203    T: ToolExecutor + ?Sized,
1204{
1205    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
1206        // CIRCUIT BREAKER: if the step that just resolved handled a re-emitted
1207        // denied signature (the model retried an already-denied action), count
1208        // it. Once the model has done this `MAX_DENIAL_REPROMPTS` times, stop
1209        // giving it another chance — end the turn so it closes cleanly with the
1210        // last stop reason instead of burning the rest of the step budget
1211        // looping the same dead-end. The tool_results for the step are already
1212        // appended by the loop, so the transcript stays well-formed.
1213        if ctx.saw_sig_match_denial {
1214            ctx.denial_reprompts += 1;
1215            if ctx.denial_reprompts >= crate::MAX_DENIAL_REPROMPTS {
1216                tracing::warn!(
1217                    denial_reprompts = ctx.denial_reprompts,
1218                    max = crate::MAX_DENIAL_REPROMPTS,
1219                    "HITL circuit breaker: model re-emitted a denied action repeatedly; \
1220                     ending turn instead of re-prompting"
1221                );
1222                return Ok(StepOutcome::Done);
1223            }
1224        }
1225        Ok(StepOutcome::Continue)
1226    }
1227}