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