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