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.
48pub(crate) const RESUME_EXECUTED_GROUND_TRUTH_NOTE: &str = "A person approved this request and the tool has \
49    already run — the results above are final. Tell the user what happened; do not describe \
50    approval status, the system already showed it.";
51
52/// The turn's working state, threaded through each [`TurnStep`].
53///
54/// Owns what were locals in the turn function — the working transcript, the
55/// accumulated wire outputs, the folded usage, the last stop reason, and the
56/// loop-control flags — and borrows the turn's immutable inputs (the provider,
57/// tool executor, model, and options) for the lifetime `'a` so a step can dial
58/// the provider without re-plumbing them.
59// Independent working flags a step reads/sets separately; folding them into an
60// enum would force artificial combinations (a turn that executed tools also
61// produced text, and either can coexist with a fired escape hatch).
62#[allow(clippy::struct_excessive_bools)]
63pub struct TurnCtx<'a, P, T>
64where
65    P: LlmProvider + ?Sized,
66    T: ToolExecutor + ?Sized,
67{
68    /// The LLM provider the turn dials.
69    pub provider: &'a P,
70    /// The executor that advertises and runs this turn's tools.
71    pub tools: &'a T,
72    /// The model identifier for provider requests.
73    pub model: &'a str,
74    /// The options the turn was invoked with (streaming channel, decisions).
75    pub options: &'a RunTurnOptions,
76    /// The working transcript driven through the loop and any post-steps.
77    pub messages: Vec<LlmMessage>,
78    /// The wire messages produced so far — assistant text and tool results.
79    pub outputs: Vec<Message>,
80    /// Usage folded across every provider call this turn has made.
81    pub total_usage: Usage,
82    /// Stop reason of the most recent provider step.
83    pub last_stop: Option<StopReason>,
84    /// Whether any tool ran this turn (resume pre-pass or the loop).
85    pub executed_tools: bool,
86    /// Whether the model ever emitted user-visible text this turn.
87    pub produced_text: bool,
88    /// The pending handoff request, if the model asked to hand off.
89    pub pending_handoff: Option<HandoffRequest>,
90    /// STICKY/TERMINAL denials keyed to the tool *signature* (name + canonical
91    /// args) rather than the provider call-id. Once a human denies an action,
92    /// the model can re-emit the SAME logical call with a fresh call-id; a
93    /// call-id-only check would re-pause and re-prompt for something already
94    /// rejected. The resume pre-pass seeds this and the in-loop batch records
95    /// into it, so a matching re-emit is auto-denied (synthetic result) without
96    /// ever pausing again.
97    pub denied_sigs: HashSet<(String, String)>,
98    /// Approvals still awaiting execution, keyed by the canonicalized
99    /// `(id, name, args)` identity (#141). Seeded from
100    /// [`RunTurnOptions::approved_call_ids`]; the resume pre-pass removes each
101    /// entry it spends so neither the pre-pass nor the loop re-executes an
102    /// approval the model re-emits. `args` is canonicalized through `canon_args`
103    /// so a re-emit with reordered keys still matches by value.
104    pub approved_remaining: HashSet<(String, String, String)>,
105    /// How many loop iterations have resolved a signature-matched terminal
106    /// denial — the model retrying an action a human already denied. The first
107    /// signed denial (by call-id, before any signature is recorded) does not
108    /// count; only re-emits of an already-denied signature do. Persists across
109    /// iterations so the stateless [`CircuitBreaker`] step can increment it and
110    /// end the turn once it reaches `MAX_DENIAL_REPROMPTS`.
111    pub denial_reprompts: usize,
112    /// Whether the step that just resolved handled a signature-matched terminal
113    /// denial. The loop republishes it onto the ctx each iteration before the
114    /// [`CircuitBreaker`] step reads it; the step never touches the working
115    /// state.
116    pub saw_sig_match_denial: bool,
117    /// Gate clears a remembered passkey grant was solely responsible for
118    /// (`#594`), accumulated across the turn's loop iterations. Each entry is an
119    /// executed tool call that ran only because a grant kept a capability
120    /// untrusted content in context would have revoked; the final
121    /// [`TurnResult::grant_replays`](crate::TurnResult::grant_replays) carries
122    /// them out for the control plane to audit.
123    pub grant_replays: Vec<crate::GrantReplayClear>,
124    /// Gated calls an unattended turn denied fail-closed (`#623`), accumulated
125    /// across the loop iterations. Each entry is a call the capability gate would
126    /// have escalated on a turn with [`RunTurnOptions::unattended`](crate::RunTurnOptions::unattended)
127    /// set, where no live grant covered the shape; the model saw a legible denial
128    /// result and the call neither ran nor paused. The final
129    /// [`TurnResult::unattended_denials`](crate::TurnResult::unattended_denials)
130    /// carries them out for the control plane to audit. Always empty on an
131    /// attended turn.
132    pub unattended_denials: Vec<crate::UnattendedDenial>,
133    /// Whether the fuzzy-match escape hatch (`#582`, invariant 9) has fired
134    /// this turn. The hatch widens the advertised tool set at most ONCE per
135    /// turn; once set, a later call naming an unadvertised tool resolves to
136    /// the ordinary unknown-tool result again.
137    pub escape_hatch_fired: bool,
138    /// One entry per `__delegate_to` call dispatched this turn (`#872`),
139    /// accumulated across the loop iterations. The final
140    /// [`TurnResult::delegate_records`](crate::TurnResult::delegate_records)
141    /// carries them out for the control plane to append as signed forensic
142    /// events. Empty for every turn that never called `__delegate_to`.
143    pub delegate_records: Vec<crate::DelegateRecord>,
144}
145
146impl<P, T> TurnCtx<'_, P, T>
147where
148    P: LlmProvider + ?Sized,
149    T: ToolExecutor + ?Sized,
150{
151    /// Consumes the turn's working state into a [`crate::TurnResult`], moving
152    /// every accumulated audit surface out in one place.
153    ///
154    /// The turn loop's return sites differ only in the approvals they surface
155    /// and whether a handoff rides along; the transcript, folded usage, last
156    /// stop reason, and the audit surfaces (`#594` grant replays, `#623`
157    /// unattended denials) are always whatever the context accumulated. Owning
158    /// that move here makes forgetting an audit surface at a return site
159    /// impossible by construction.
160    #[must_use]
161    pub fn finish(
162        self,
163        pending_approvals: Vec<PendingApproval>,
164        handoff: Option<HandoffRequest>,
165    ) -> crate::TurnResult {
166        crate::TurnResult {
167            messages: self.outputs,
168            usage: self.total_usage,
169            stop: self.last_stop,
170            pending_approvals,
171            handoff,
172            grant_replays: self.grant_replays,
173            unattended_denials: self.unattended_denials,
174            mid_stream_failure: None,
175            delegate_records: self.delegate_records,
176        }
177    }
178
179    /// Consumes the turn's working state into a [`crate::TurnResult`] that
180    /// reports a mid-turn provider stream failure (`#798`), exactly like
181    /// [`Self::finish`] but with [`crate::TurnResult::mid_stream_failure`] set
182    /// and no pending approvals (a failed stream never paused for HITL).
183    ///
184    /// Whatever the loop already accumulated — executed tool results, produced
185    /// text, folded usage — rides along on the returned [`crate::TurnResult`]
186    /// instead of being discarded, which is the whole point: the caller can
187    /// persist iterations `1..N-1`'s work AND fail the turn with a typed error,
188    /// rather than losing both to a bare `Err` propagated via `?`.
189    #[must_use]
190    pub fn finish_failed(mut self, failure: crate::MidStreamFailure) -> crate::TurnResult {
191        let handoff = self.pending_handoff.take();
192        let mut result = self.finish(Vec::new(), handoff);
193        result.mid_stream_failure = Some(failure);
194        result
195    }
196}
197
198/// What a [`TurnStep`] reports after running.
199pub enum StepOutcome {
200    /// Continue to the next step in the list.
201    Continue,
202    /// Pause the turn for human approval, surfacing the given calls.
203    Pause(Vec<PendingApproval>),
204    /// End the step-driving phase early; skip any remaining steps.
205    Done,
206}
207
208/// One cohesive slice of turn behavior the turn loop drives.
209///
210/// Each step reads and mutates the turn's working state through [`TurnCtx`] and
211/// reports a [`StepOutcome`]. Generic over the provider `P` and tool executor
212/// `T` so a step can dial the provider and use the turn's error type directly.
213#[async_trait]
214pub trait TurnStep<P, T>: Send + Sync
215where
216    P: LlmProvider + ?Sized,
217    T: ToolExecutor + ?Sized,
218{
219    /// Run this step against the turn context.
220    ///
221    /// # Errors
222    ///
223    /// Returns the provider's error type when the step fails in a way that
224    /// should abort the turn. A step that is best-effort swallows its own
225    /// provider failures and returns [`StepOutcome::Continue`] instead.
226    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error>;
227}
228
229/// The forced closing completion: when a turn executed tools but the model
230/// never produced any user-visible text, force one final text answer so the
231/// turn always yields a reply.
232pub struct ForcedCompletion;
233
234#[async_trait]
235impl<P, T> TurnStep<P, T> for ForcedCompletion
236where
237    P: LlmProvider + ?Sized,
238    T: ToolExecutor + ?Sized,
239{
240    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
241        // FALLBACK: the turn executed tools but the model never produced any
242        // user-visible text, so `outputs` carries only tool calls/results — the
243        // edge would post nothing (the "agent produced no text" dead-end). This
244        // covers two shapes: the loop exhausting MAX_STEPS while still calling
245        // tools, AND a resume whose pre-pass executed an approved call in one step
246        // and then got an empty continuation (which breaks the loop far short of
247        // MAX_STEPS, so the old `steps_used >= MAX_STEPS` guard let it fall through
248        // silent — the approved action ran but the human saw no reply). Force ONE
249        // final completion with tools disabled so the model must answer in text,
250        // summarizing what it did or explaining it couldn't proceed. Skipped for an
251        // intentional handoff (the parent resumes with the child's result).
252        // Best-effort: a failure here leaves the turn as-is rather than erroring.
253        if !(ctx.executed_tools && !ctx.produced_text && ctx.pending_handoff.is_none()) {
254            return Ok(StepOutcome::Continue);
255        }
256        let mut req = CompletionRequest::new(ctx.model);
257        req.messages.clone_from(&ctx.messages);
258        // Removing tools is not enough: a model deep in a tool-calling groove
259        // will keep emitting a functionCall (stop == ToolUse) and no text even
260        // with no tools declared. Also disable web-search grounding (another
261        // tool surface) and append an explicit instruction so the model writes a
262        // plain-text final answer from what it already has.
263        // A System instruction (folded into systemInstruction by the provider,
264        // not the visible transcript) so the model follows it without echoing it
265        // into the reply; a User message gets paraphrased back by thinking models.
266        // Kept non-meta for the same reason.
267        req.messages.push(LlmMessage {
268            role: Role::System,
269            content: vec![LlmContent::Text(
270                "No tools are available for the remainder of this turn. Give the \
271                 user a direct, plain-text answer using the information already \
272                 gathered."
273                    .to_owned(),
274            )],
275        });
276        req.tools = Vec::new();
277        req.web_search = false;
278        // Best-effort closing completion: its output is discarded on any error,
279        // so don't spend the retry budget's backoff here — a single attempt
280        // keeps a wedged turn from also paying tens of seconds of backoff.
281        if let Ok(stream) = ctx.provider.complete(req).await {
282            let turn = if let Some(tx) = ctx.options.stream_tx.clone() {
283                // Bounded (`#251`): see the matching forwarding site in
284                // `lib.rs` — `tx` is cloned once here (not per event) and the
285                // `.await`ed send applies real backpressure.
286                let mut tx = tx;
287                collect_turn_observed(stream, async move |ev| {
288                    let _ = tx.send(ev).await;
289                })
290                .await
291            } else {
292                collect_turn(stream).await
293            };
294            if let Ok(turn) = turn {
295                ctx.total_usage.input_tokens += turn.usage.input_tokens;
296                ctx.total_usage.output_tokens += turn.usage.output_tokens;
297                push_reasoning(&mut ctx.outputs, &turn.reasoning);
298                if !turn.text.is_empty() {
299                    ctx.outputs.push(text_message("model", &turn.text));
300                }
301                ctx.last_stop = turn.stop;
302                tracing::info!(
303                    "forced closing completion (tool loop produced no text); turn now yields a reply"
304                );
305            }
306        }
307        Ok(StepOutcome::Continue)
308    }
309}
310
311/// The approval resume pre-pass: resolve the tool calls a human already decided.
312///
313/// Before the turn drives the model, execute the calls the human approved that
314/// are dangling in the resumed transcript, resolve signed or denied calls to
315/// synthetic results, splice those results in after the paused batch, and append
316/// any approver-injected context notes.
317///
318/// On an approval resume the control plane replays the paused turn's assistant
319/// `tool_use` (which has NO paired `tool_result` — the call was paused, never
320/// executed) and forwards the signed decisions via
321/// [`RunTurnOptions::approved_call_ids`] / [`RunTurnOptions::denied_call_ids`].
322/// The function-calling loop only executes tool calls the *model emits this
323/// turn*, so without this step an approval takes effect only if the model
324/// happens to RE-EMIT the same call. Resolving the dangling calls
325/// deterministically here makes an approval ALWAYS take effect, independent of
326/// whether the model re-emits.
327///
328/// Classification and the #141 approval binding mirror the in-loop batch so the
329/// two can't drift. It runs only on a resume: a fresh turn carries an empty
330/// decision set, so this step is a no-op and the hot path is unchanged.
331///
332/// A forwarded signed decision must never resolve silently to nothing: whenever
333/// `approved_call_ids` is non-empty, [`Self::run`] logs a resolution summary and
334/// `tracing::warn!`s individually for every approved tuple that matches no
335/// unanswered call in the resumed transcript — distinguishing an already-
336/// answered (harmless) re-forward from a call that is missing outright (a
337/// projection loss upstream, e.g. one folded into a compaction summary). This
338/// is purely observational: an approval that resolves to nothing still resolves
339/// to nothing (re-pausing here would loop), but the loss is now loud instead of
340/// surfacing only as an unexplained model refusal downstream.
341///
342/// Borrows the turn's read-only resume inputs — the pinned tool-spec set (for
343/// pause-card titles), the approver edits, and the signed denials — while the
344/// mutable working state (transcript, outputs, the sticky denial set, and the
345/// remaining approvals) rides the [`TurnCtx`].
346pub struct ResumePrePass<'a> {
347    /// The turn's pinned tool-spec set, read once for pause-card titles.
348    pub tool_specs: &'a [ToolSpec],
349    /// Approver edits (#67), keyed by the canonicalized approval identity.
350    pub approved_overrides: &'a HashMap<(String, String, String), ApprovalOverride>,
351    /// Verified signed denials as canonicalized `(id, name, args)` tuples.
352    pub denied_call_ids: &'a HashSet<(String, String, String)>,
353}
354
355#[async_trait]
356impl<P, T> TurnStep<P, T> for ResumePrePass<'_>
357where
358    P: LlmProvider + ?Sized,
359    T: ToolExecutor + ?Sized,
360{
361    #[allow(clippy::too_many_lines)] // cohesive resume-resolution pass
362    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
363        // RESUME: execute approved-but-unanswered tool calls ALREADY PRESENT in
364        // the input transcript, before driving the model. When the model instead
365        // reads its own dangling `tool_use` as already-done and narrates
366        // completion (e.g. "OK, I've torn it down"), the approved action silently
367        // never executes and the human's decision is lost — so resolve the
368        // dangling calls deterministically here.
369        //
370        // #1154: this step used to short-circuit here whenever BOTH decision
371        // sets were empty, on the assumption that "no approvals and no
372        // denials" implies "a genuinely fresh turn, no dangling tool_use." A
373        // resume whose only signed decision failed harness-side verification
374        // (e.g. a dropped signed field) breaks that assumption: the wire
375        // carried a real decision, it just verified to nothing, so this step
376        // was skipped and the model was left narrating a dangling call it
377        // never ran. There is no cheaper-but-safe proxy for "this is a fresh
378        // turn" than actually checking for a dangling `tool_use` below, so the
379        // scan always runs; a genuinely fresh turn still exits immediately at
380        // the `unanswered.is_empty()` check just past it.
381
382        // Every tool_call id that already has a tool_result somewhere in the
383        // transcript is "answered" and must not be re-executed.
384        let answered: HashSet<&str> = ctx
385            .messages
386            .iter()
387            .flat_map(|m| m.content.iter())
388            .filter_map(|c| match c {
389                LlmContent::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
390                _ => None,
391            })
392            .collect();
393        // Unanswered assistant tool_use blocks, paired with the index of the
394        // message they live in so each synthesized result can be inserted
395        // directly after its `tool_use` (preserving provider ordering).
396        let mut unanswered: Vec<(usize, ToolCall)> = Vec::new();
397        for (idx, m) in ctx.messages.iter().enumerate() {
398            for c in &m.content {
399                if let LlmContent::ToolUse(tc) = c
400                    && !answered.contains(tc.id.as_str())
401                {
402                    unanswered.push((idx, tc.clone()));
403                }
404            }
405        }
406
407        // Observability (hardening after the #699/#700 admin-invite silent-no-op:
408        // an approved resume that resolved to nothing with no trace beyond a
409        // model-generated refusal). A forwarded signed decision must never
410        // resolve silently — WARN individually for every approved tuple that
411        // matches no unanswered call in THIS resumed transcript, distinguishing
412        // "already answered" (its id already carries a live `tool_result` — a
413        // harmless re-forward of a spent decision) from "not found" (no call
414        // anywhere in the transcript carries this id — the call itself is
415        // missing, e.g. folded into a compaction summary or otherwise dropped
416        // between pause and resume) so a silent loss is loud at the exact site
417        // that would otherwise have swallowed it.
418        if !ctx.options.approved_call_ids.is_empty() {
419            let unanswered_ids: HashSet<&str> =
420                unanswered.iter().map(|(_, tc)| tc.id.as_str()).collect();
421            for (id, name, _args) in &ctx.options.approved_call_ids {
422                if unanswered_ids.contains(id.as_str()) {
423                    continue;
424                }
425                if answered.contains(id.as_str()) {
426                    tracing::info!(
427                        request_id = %id,
428                        tool = %name,
429                        "approved call already answered on this resume; decision is a no-op re-forward"
430                    );
431                } else {
432                    tracing::warn!(
433                        request_id = %id,
434                        tool = %name,
435                        "approved call id matches no tool call in the resumed \
436                         transcript; the signed decision cannot resolve to anything"
437                    );
438                }
439            }
440        }
441        tracing::info!(
442            approved = ctx.options.approved_call_ids.len(),
443            denied = ctx.options.denied_call_ids.len(),
444            unanswered = unanswered.len(),
445            "resume pre-pass: resolving forwarded decisions against the resumed transcript"
446        );
447
448        if unanswered.is_empty() {
449            return Ok(StepOutcome::Continue);
450        }
451
452        // Taint state, evaluated against the resumed transcript: any untrusted
453        // tool-result (a prior fetch) already in context, OR the durable seed the
454        // control plane computed over the full event log (untrusted content that
455        // compaction folded out of the projection, or a non-principal
456        // participant's input — neither of which survives as a live `ToolResult`).
457        let untrusted_in_context =
458            untrusted_content_in_context(&ctx.messages) || ctx.options.untrusted_context_seed;
459        // Classify exactly as the in-loop batch does (same #141 binding:
460        // approval/denial bound to the exact (id, name, args) tuple).
461        let dispositions: Vec<CallDisposition> = unanswered
462            .iter()
463            .map(|(_, tc)| {
464                let gate = gate_decision(
465                    ctx.tools,
466                    ctx.options,
467                    untrusted_in_context,
468                    &tc.name,
469                    &tc.args_json,
470                );
471                let key = (tc.id.clone(), tc.name.clone(), canon_args(&tc.args_json));
472                let is_denied = self.denied_call_ids.contains(&key);
473                // A remembered session approval ("don't ask again") satisfies the
474                // gate only when its signed covered set includes everything this
475                // call is currently missing (#595; see the in-loop site for the
476                // rationale). An explicit `approved_remaining` entry still runs.
477                let is_approved = ctx.approved_remaining.contains(&key)
478                    || session_approves(ctx.options, ctx.tools, &tc.name, gate_missing(&gate));
479                // No sticky-signature denial at pre-pass time (denied_sigs is
480                // empty until the loop runs), so sig_match is always false. A
481                // resume is by definition attended (a human answered an approval),
482                // so `unattended` is false here — the #623 fail-closed denial only
483                // arises on a fresh trigger-originated firing, never on resume.
484                CallDisposition::classify(
485                    gate,
486                    CallContext {
487                        approved: is_approved,
488                        denied: is_denied,
489                        ..CallContext::default()
490                    },
491                )
492            })
493            .collect();
494        // Tally the four disposition classes in a SINGLE traversal rather than
495        // one filter/count pass per class.
496        let mut execute = 0usize;
497        let mut denied = 0usize;
498        let mut policy_denied = 0usize;
499        let mut pending = 0usize;
500        for disposition in &dispositions {
501            match disposition {
502                CallDisposition::Execute => execute += 1,
503                CallDisposition::Denied { .. } => denied += 1,
504                // #623: an unattended fail-closed denial is a non-HITL denial,
505                // tallied with the policy/sandbox class (this count only feeds a
506                // tracing line; an unattended turn never resumes, ADR 0003).
507                CallDisposition::PolicyDenied { .. } | CallDisposition::UnattendedDenied { .. } => {
508                    policy_denied += 1;
509                }
510                // #582 invariant 9: constructed only by the in-loop escape
511                // hatch, never by `classify` — unreachable on a resume, but the
512                // tally stays total so a future refactor can't miscount.
513                CallDisposition::Recovered { .. } => {}
514                CallDisposition::Pending { .. } => pending += 1,
515            }
516        }
517        tracing::info!(
518            execute,
519            denied,
520            policy_denied,
521            pending,
522            "resume pre-pass: classified every unanswered dangling call"
523        );
524
525        // A dangling call that still needs approval (neither approved nor denied)
526        // must NOT be executed — re-pause the turn so the human is re-prompted,
527        // exactly as a fresh gated call would.
528        if dispositions
529            .iter()
530            .any(|d| matches!(d, CallDisposition::Pending { .. }))
531        {
532            let pending = unanswered
533                .iter()
534                .zip(&dispositions)
535                .filter_map(|((_, tc), d)| {
536                    let CallDisposition::Pending { reason, missing } = d else {
537                        return None;
538                    };
539                    let title = self
540                        .tool_specs
541                        .iter()
542                        .find(|s| s.name == tc.name)
543                        .and_then(|s| s.title.clone())
544                        .unwrap_or_default();
545                    Some(PendingApproval {
546                        id: tc.id.clone(),
547                        name: tc.name.clone(),
548                        args_json: tc.args_json.clone(),
549                        title,
550                        // Sandbox-unaware here; the harness stamps the mode onto
551                        // the wire payload.
552                        sandbox_mode: String::new(),
553                        // The gate's reason carried on the disposition (empty for
554                        // an ordinary intrinsic/sandbox gate).
555                        reason: reason.clone(),
556                        missing_capabilities: missing
557                            .names()
558                            .iter()
559                            .map(|n| (*n).to_owned())
560                            .collect(),
561                    })
562                })
563                .collect::<Vec<_>>();
564            return Ok(StepOutcome::Pause(pending));
565        }
566
567        // Execute approved calls concurrently; denied calls resolve to the
568        // synthetic denial payload (mirrors the in-loop resolution). Resolve each
569        // paused call's approver edit (#67) once: the edited args to execute + any
570        // context to inject. Aligned with `unanswered`.
571        let pre_resolutions: Vec<ResolvedCall> = unanswered
572            .iter()
573            .map(|(_, tc)| {
574                let key = (tc.id.clone(), tc.name.clone(), canon_args(&tc.args_json));
575                resolve_approved_call(&tc.args_json, self.approved_overrides.get(&key))
576            })
577            .collect();
578        // Resumed calls execute the args the human already approved; the dispatch
579        // policy's INPUT mutations (#539) belong to a fresh dispatch, but
580        // `post_dispatch` result redaction (#540) still applies to their output.
581        let tools = ctx.tools;
582        let recorder = ctx.options.dispatch_recorder.clone();
583        let futures = unanswered
584            .iter()
585            .zip(&dispositions)
586            .zip(&pre_resolutions)
587            .map(|(((_, tc), disposition), resolved)| {
588                if matches!(disposition, CallDisposition::Denied { .. }) {
589                    // Sticky for the loop below: any re-emit of the same action is
590                    // auto-denied without re-prompting.
591                    ctx.denied_sigs
592                        .insert((tc.name.clone(), canon_args(&tc.args_json)));
593                }
594                // A human denial OR a policy veto (#67) resolves to a synthetic
595                // result instead of executing.
596                let forced = forced_result(disposition);
597                let name = tc.name.clone();
598                let args = resolved.args_json.clone();
599                let call_id = tc.id.clone();
600                let recorder = recorder.clone();
601                async move {
602                    if let Some(result) = forced {
603                        result
604                    } else {
605                        run_and_redact(tools, recorder.as_ref(), call_id, name, args).await
606                    }
607                }
608            })
609            .collect::<Vec<_>>();
610        let results = futures::future::join_all(futures).await;
611        // The pre-pass resolved dangling calls (executed approvals and/or
612        // synthesized denial results); either way the turn produced tool_results
613        // that need narrating, so guarantee a closing reply.
614        ctx.executed_tools = true;
615
616        // Mark each EXECUTED approval as spent so the loop below cannot re-execute
617        // it if the model re-emits the same call. Only Execute consumes an
618        // approval — a denial or a policy veto (#67) ran no tool.
619        for ((_, tc), disposition) in unanswered.iter().zip(&dispositions) {
620            if matches!(disposition, CallDisposition::Execute) {
621                ctx.approved_remaining.remove(&(
622                    tc.id.clone(),
623                    tc.name.clone(),
624                    canon_args(&tc.args_json),
625                ));
626            }
627        }
628
629        // Append each result to the persisted `outputs` (so a LATER resume sees
630        // the call as answered) and into the transcript GROUPED after the paused
631        // batch's last tool_use — never interleaved between two calls. A paused
632        // batch can be parallel tool calls, and the function-calling contract
633        // requires a turn's `functionCall`s to be followed by ALL their
634        // `functionResponse`s together: a response spliced between two parallel
635        // calls is rejected (the provider 400s, which would fail the re-drive and
636        // strand the calls unanswered — poisoning the conversation). The in-loop
637        // path groups the same way.
638        let mut result_msgs = Vec::with_capacity(unanswered.len());
639        for ((_, tc), result) in unanswered.iter().zip(results) {
640            let result = cap_tool_result(&result);
641            // Stamp ingestion-time provenance so the durable trifecta tag mirrors
642            // the live-scan predicate: a first-party tool's result does not taint
643            // context (see `output_msg_trust`).
644            let first_party = !ctx.tools.ingests_untrusted_content(&tc.name);
645            ctx.outputs
646                .push(tool_result_message(&tc.id, &result, first_party));
647            // #874 (headline fix): stamp the same verdict onto the in-memory
648            // transcript, mirroring the main dispatch loop's fix. The static
649            // per-tool-name check is sufficient HERE specifically: a
650            // `__delegate_to` call is never gated (`gate_decision` returns
651            // `Allow` unconditionally for it, the same seam this pre-pass and
652            // the in-loop batch share), so it can never be paused and
653            // therefore never appears in `unanswered` — this resume path
654            // structurally never dispatches a delegate call, only ordinary
655            // gated tools whose provenance IS the static per-name check.
656            result_msgs.push(LlmMessage {
657                role: Role::Tool,
658                content: vec![LlmContent::tool_result(
659                    tc.id.clone(),
660                    result,
661                    false,
662                    first_party,
663                )],
664            });
665        }
666        // The paused batch is the tail of the transcript, so its results go after
667        // its last call. `unanswered` is non-empty in this branch.
668        let after = unanswered
669            .iter()
670            .map(|(idx, _)| *idx)
671            .max()
672            .unwrap_or(ctx.messages.len());
673        ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
674        // #67: approver-injected context lands as internal-only system notes after
675        // the spliced results (the paused batch is the transcript tail),
676        // preserving the function-call ⇒ all-responses grouping.
677        append_injected_notes(&mut ctx.outputs, &mut ctx.messages, &pre_resolutions);
678
679        // `#743` change 1b: when at least one dangling call actually EXECUTED
680        // this resume (as opposed to only denials/policy vetoes resolving),
681        // tell the model — as runtime-injected ground truth, not a
682        // suppressible instruction — that the results above are the final,
683        // already-approved outcome. This is what makes the resume's
684        // continuation narrate the real result instead of re-guessing
685        // approval status from a bare tool result.
686        if dispositions
687            .iter()
688            .any(|d| matches!(d, CallDisposition::Execute))
689        {
690            push_internal_note(
691                &mut ctx.outputs,
692                &mut ctx.messages,
693                RESUME_EXECUTED_GROUND_TRUTH_NOTE,
694            );
695        }
696
697        Ok(StepOutcome::Continue)
698    }
699}
700
701/// The denied-action circuit breaker.
702///
703/// When the model re-emits an action a human already denied, the turn loop
704/// auto-denies it (a synthetic result, never executed) and republishes the "saw
705/// a signature-matching denial" signal onto the ctx. This step counts each such
706/// re-emit and, once the model has done it `MAX_DENIAL_REPROMPTS` times, reports
707/// [`StepOutcome::Done`] so the turn ends cleanly with the last stop reason
708/// instead of burning the rest of the step budget looping the same dead-end.
709pub struct CircuitBreaker;
710
711#[async_trait]
712impl<P, T> TurnStep<P, T> for CircuitBreaker
713where
714    P: LlmProvider + ?Sized,
715    T: ToolExecutor + ?Sized,
716{
717    async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
718        // CIRCUIT BREAKER: if the step that just resolved handled a re-emitted
719        // denied signature (the model retried an already-denied action), count
720        // it. Once the model has done this `MAX_DENIAL_REPROMPTS` times, stop
721        // giving it another chance — end the turn so it closes cleanly with the
722        // last stop reason instead of burning the rest of the step budget
723        // looping the same dead-end. The tool_results for the step are already
724        // appended by the loop, so the transcript stays well-formed.
725        if ctx.saw_sig_match_denial {
726            ctx.denial_reprompts += 1;
727            if ctx.denial_reprompts >= crate::MAX_DENIAL_REPROMPTS {
728                tracing::warn!(
729                    denial_reprompts = ctx.denial_reprompts,
730                    max = crate::MAX_DENIAL_REPROMPTS,
731                    "HITL circuit breaker: model re-emitted a denied action repeatedly; \
732                     ending turn instead of re-prompting"
733                );
734                return Ok(StepOutcome::Done);
735            }
736        }
737        Ok(StepOutcome::Continue)
738    }
739}