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