use std::collections::{HashMap, HashSet};
use async_trait::async_trait;
use futures::SinkExt as _;
use polyc_crypto::canon::canon_args;
use polyc_llm::{
CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role, StopReason,
ToolSpec, Usage,
request::ToolCall,
turn::{collect_turn, collect_turn_observed},
};
use polyc_proto::proto::polychrome::agent::v1::Message;
use crate::{
CallContext, CallDisposition, HandoffRequest, PendingApproval, ResolvedCall, RunTurnOptions,
ToolExecutor, append_injected_notes, cap_tool_result, consume_decisions, forced_result,
gate_decision, gate_missing, matching_decision_indexes, push_internal_note, push_reasoning,
resolve_approved_call, run_and_redact, session_approves, splice_results_after, text_message,
tool_result_message, untrusted_content_in_context,
};
pub(crate) static RESUME_EXECUTED_GROUND_TRUTH_NOTE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
format!(
"A person approved this request and the tool has already run — the results above \
are final. Tell the user what happened. {}",
polyc_llm::APPROVAL_STATUS_GROUND_RULE
)
});
#[allow(clippy::struct_excessive_bools)]
pub struct TurnCtx<'a, P, T>
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
pub provider: &'a P,
pub tools: &'a T,
pub model: &'a str,
pub options: &'a RunTurnOptions,
pub messages: Vec<LlmMessage>,
pub outputs: Vec<Message>,
pub total_usage: Usage,
pub last_stop: Option<StopReason>,
pub executed_tools: bool,
pub produced_text: bool,
pub grounded: bool,
pub pending_handoff: Option<HandoffRequest>,
pub denied_sigs: HashSet<(String, String)>,
pub approval_decisions_remaining: Vec<crate::ApprovalDecision>,
pub denial_reprompts: usize,
pub saw_sig_match_denial: bool,
pub unattended_denials: Vec<crate::UnattendedDenial>,
pub escape_hatch_fired: bool,
pub delegate_records: Vec<crate::DelegateRecord>,
pub pending_questions: Vec<crate::question::PendingQuestion>,
}
impl<P, T> TurnCtx<'_, P, T>
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
#[must_use]
pub fn finish(
self,
pending_approvals: Vec<PendingApproval>,
handoff: Option<HandoffRequest>,
) -> crate::TurnResult {
crate::metrics::record_turn(&self.total_usage);
crate::TurnResult {
messages: self.outputs,
usage: self.total_usage,
stop: self.last_stop,
pending_approvals,
handoff,
unattended_denials: self.unattended_denials,
mid_stream_failure: None,
delegate_records: self.delegate_records,
grounded: self.grounded,
pending_questions: self.pending_questions,
}
}
#[must_use]
pub fn finish_failed(mut self, failure: crate::MidStreamFailure) -> crate::TurnResult {
let handoff = self.pending_handoff.take();
let mut result = self.finish(Vec::new(), handoff);
result.mid_stream_failure = Some(failure);
result
}
pub(crate) fn fold_usage(&mut self, delta: Usage) {
self.total_usage += delta;
}
}
pub enum StepOutcome {
Continue,
Pause(Vec<PendingApproval>),
PauseQuestions(Vec<crate::question::PendingQuestion>),
Done,
}
#[async_trait]
pub trait TurnStep<P, T>: Send + Sync
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error>;
}
pub(crate) const FORCED_COMPLETION_FALLBACK_TEXT: &str =
"I couldn't put together an answer to that — try asking again or rephrasing.";
fn synthesize_forced_completion_fallback(messages: &[LlmMessage]) -> String {
let mut seen = HashSet::new();
let mut names = Vec::new();
for name in messages
.iter()
.filter(|m| m.role == Role::Assistant)
.flat_map(|m| &m.content)
.filter_map(|c| match c {
LlmContent::ToolUse(call) => Some(call.name.as_str()),
_ => None,
})
{
if seen.insert(name) {
names.push(polyc_proto::humanize_tool_name(name));
}
}
if names.is_empty() {
return FORCED_COMPLETION_FALLBACK_TEXT.to_owned();
}
format!(
"I tried {} but couldn't put together an answer — try asking again, or rephrasing what \
you need.",
names.join(", ")
)
}
fn collapse_trailing_tool_only_run(messages: &[LlmMessage]) -> Vec<LlmMessage> {
let is_pure_tool_turn = |m: &LlmMessage| -> bool {
!m.content.is_empty()
&& match m.role {
Role::Assistant => m
.content
.iter()
.all(|c| matches!(c, LlmContent::ToolUse(_))),
Role::Tool => m
.content
.iter()
.all(|c| matches!(c, LlmContent::ToolResult(_))),
Role::User | Role::System | _ => false,
}
};
let split = messages
.iter()
.rposition(|m| !is_pure_tool_turn(m))
.map_or(0, |i| i + 1);
if split == messages.len() {
return messages.to_vec();
}
let mut collapsed = messages[..split].to_vec();
let tool_names: Vec<&str> = messages[split..]
.iter()
.flat_map(|m| &m.content)
.filter_map(|c| match c {
LlmContent::ToolUse(call) => Some(call.name.as_str()),
LlmContent::ToolResult(_) | LlmContent::Text(_) | LlmContent::Image(_) | _ => None,
})
.collect();
let summary = if tool_names.is_empty() {
"Earlier this turn, tool calls were made with no further progress. Answer directly \
from what is already known instead of calling another tool."
.to_owned()
} else {
format!(
"Earlier this turn, these tools were called with no further progress: {}. \
Answer directly from what is already known instead of calling another tool.",
tool_names.join(", ")
)
};
collapsed.push(LlmMessage {
role: Role::System,
content: vec![LlmContent::text(summary)],
});
collapsed
}
pub struct ForcedCompletion;
#[async_trait]
impl<P, T> TurnStep<P, T> for ForcedCompletion
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
if ctx.produced_text || ctx.pending_handoff.is_some() {
return Ok(StepOutcome::Continue);
}
let mut req = CompletionRequest::new(ctx.model);
req.messages = collapse_trailing_tool_only_run(&ctx.messages);
req.messages.push(LlmMessage {
role: Role::System,
content: vec![LlmContent::Text(
"No tools are available for the remainder of this turn. Give the \
user a direct, plain-text answer using the information already \
gathered."
.to_owned(),
)],
});
req.tools = Vec::new();
req.web_search = false;
let mut closing_text: Option<String> = None;
if let Ok(stream) = ctx.provider.complete(req).await {
let turn = if let Some(tx) = ctx.options.stream_tx.clone() {
let mut tx = tx;
collect_turn_observed(stream, async move |ev| {
let _ = tx.send(ev).await;
})
.await
} else {
collect_turn(stream).await
};
if let Ok(turn) = turn {
ctx.fold_usage(turn.usage);
push_reasoning(&mut ctx.outputs, &turn.reasoning);
ctx.last_stop = turn.stop;
if !turn.text.is_empty() {
closing_text = Some(turn.text);
}
}
}
if let Some(text) = closing_text {
ctx.outputs.push(text_message("model", &text));
ctx.produced_text = true;
tracing::info!("forced closing completion produced text; turn now yields a reply");
} else {
tracing::warn!(
"forced closing completion also produced no text — falling back to a synthesized reply so the turn doesn't drop silently"
);
let fallback = synthesize_forced_completion_fallback(&ctx.messages);
ctx.outputs.push(text_message("model", &fallback));
ctx.produced_text = true;
}
Ok(StepOutcome::Continue)
}
}
pub struct ResumePrePass<'a> {
pub tool_specs: &'a [ToolSpec],
}
#[async_trait]
impl<P, T> TurnStep<P, T> for ResumePrePass<'_>
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
#[allow(clippy::too_many_lines)] async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
let mut calls: Vec<Option<(usize, ToolCall)>> = Vec::new();
let mut open_by_id: HashMap<String, std::collections::VecDeque<usize>> = HashMap::new();
let mut answered_ids: HashSet<String> = HashSet::new();
for (idx, m) in ctx.messages.iter().enumerate() {
for c in &m.content {
match c {
LlmContent::ToolUse(tc) => {
let slot = calls.len();
calls.push(Some((idx, tc.clone())));
open_by_id.entry(tc.id.clone()).or_default().push_back(slot);
}
LlmContent::ToolResult(result) => {
answered_ids.insert(result.tool_call_id.clone());
if let Some(slot) = open_by_id
.get_mut(&result.tool_call_id)
.and_then(std::collections::VecDeque::pop_front)
{
calls[slot] = None;
}
}
_ => {}
}
}
}
let unanswered: Vec<(usize, ToolCall)> = calls.into_iter().flatten().collect();
if !ctx.options.approval_decisions.is_empty() {
let unanswered_ids: HashSet<&str> =
unanswered.iter().map(|(_, tc)| tc.id.as_str()).collect();
for decision in &ctx.options.approval_decisions {
let id = &decision.request_id;
let name = &decision.tool_name;
if unanswered_ids.contains(id.as_str()) {
continue;
}
if answered_ids.contains(id) {
tracing::info!(
request_id = %id,
tool = %name,
"approved call already answered on this resume; decision is a no-op re-forward"
);
} else {
tracing::warn!(
request_id = %id,
tool = %name,
"approved call id matches no tool call in the resumed \
transcript; the signed decision cannot resolve to anything"
);
}
}
}
tracing::info!(
approved = ctx
.options
.approval_decisions
.iter()
.filter(|decision| decision.approved)
.count(),
denied = ctx
.options
.approval_decisions
.iter()
.filter(|decision| !decision.approved)
.count(),
unanswered = unanswered.len(),
"resume pre-pass: resolving forwarded decisions against the resumed transcript"
);
if unanswered.is_empty() {
return Ok(StepOutcome::Continue);
}
let untrusted_in_context =
untrusted_content_in_context(&ctx.messages) || ctx.options.untrusted_context_seed;
let unanswered_calls: Vec<ToolCall> =
unanswered.iter().map(|(_, call)| call.clone()).collect();
let matched_decisions =
matching_decision_indexes(&unanswered_calls, &ctx.approval_decisions_remaining);
let dispositions: Vec<CallDisposition> = unanswered
.iter()
.zip(&matched_decisions)
.map(|((_, tc), decision_index)| {
let gate = gate_decision(
ctx.tools,
ctx.options,
untrusted_in_context,
&tc.name,
&tc.args_json,
);
let decision = decision_index.map(|index| &ctx.approval_decisions_remaining[index]);
let is_denied = decision.is_some_and(|decision| !decision.approved);
let is_approved = decision.is_some_and(|decision| decision.approved)
|| session_approves(ctx.options, ctx.tools, &tc.name, gate_missing(&gate));
CallDisposition::classify(
gate,
CallContext {
approved: is_approved,
denied: is_denied,
..CallContext::default()
},
)
})
.collect();
let mut execute = 0usize;
let mut denied = 0usize;
let mut policy_denied = 0usize;
let mut pending = 0usize;
for disposition in &dispositions {
match disposition {
CallDisposition::Execute => execute += 1,
CallDisposition::Denied { .. } => denied += 1,
CallDisposition::PolicyDenied { .. } | CallDisposition::UnattendedDenied { .. } => {
policy_denied += 1;
}
CallDisposition::Recovered { .. } => {}
CallDisposition::Pending { .. } => pending += 1,
}
}
tracing::info!(
execute,
denied,
policy_denied,
pending,
"resume pre-pass: classified every unanswered dangling call"
);
if dispositions
.iter()
.any(|d| matches!(d, CallDisposition::Pending { .. }))
{
let pending = unanswered
.iter()
.zip(&dispositions)
.filter_map(|((_, tc), d)| {
let CallDisposition::Pending { reason, missing } = d else {
return None;
};
let title = self
.tool_specs
.iter()
.find(|s| s.name == tc.name)
.and_then(|s| s.title.clone())
.unwrap_or_default();
Some(PendingApproval {
occurrence_turn_id: tc.approval_turn_id.clone(),
id: tc.id.clone(),
name: tc.name.clone(),
args_json: tc.args_json.clone(),
title,
sandbox_mode: String::new(),
reason: reason.clone(),
missing_capabilities: missing
.names()
.iter()
.map(|n| (*n).to_owned())
.collect(),
computed_preview: String::new(),
})
})
.collect::<Vec<_>>();
return Ok(StepOutcome::Pause(pending));
}
let pre_resolutions: Vec<ResolvedCall> = unanswered
.iter()
.zip(&matched_decisions)
.map(|((_, tc), decision_index)| {
let r#override = decision_index
.and_then(|index| ctx.approval_decisions_remaining[index].r#override.as_ref());
resolve_approved_call(&tc.args_json, r#override)
})
.collect();
let tools = ctx.tools;
let recorder = ctx.options.dispatch_recorder.clone();
let futures = unanswered
.iter()
.zip(&dispositions)
.zip(&pre_resolutions)
.map(|(((_, tc), disposition), resolved)| {
if matches!(disposition, CallDisposition::Denied { .. }) {
ctx.denied_sigs
.insert((tc.name.clone(), canon_args(&tc.args_json)));
}
let forced = forced_result(disposition);
let name = tc.name.clone();
let args = resolved.args_json.clone();
let call_id = tc.id.clone();
let approval_turn_id = tc.approval_turn_id.clone();
let recorder = recorder.clone();
async move {
if let Some(result) = forced {
result
} else {
run_and_redact(
tools,
recorder.as_ref(),
call_id,
approval_turn_id,
name,
args,
)
.await
}
}
})
.collect::<Vec<_>>();
let results = futures::future::join_all(futures).await;
ctx.executed_tools = true;
consume_decisions(&mut ctx.approval_decisions_remaining, &matched_decisions);
let mut result_msgs = Vec::with_capacity(unanswered.len());
for ((_, tc), result) in unanswered.iter().zip(results) {
let result = cap_tool_result(&result);
let first_party = !ctx.tools.ingests_untrusted_content(&tc.name);
ctx.outputs
.push(tool_result_message(&tc.id, &result, first_party));
result_msgs.push(LlmMessage {
role: Role::Tool,
content: vec![LlmContent::tool_result(
tc.id.clone(),
result,
false,
first_party,
)],
});
}
let after = unanswered
.iter()
.map(|(idx, _)| *idx)
.max()
.unwrap_or(ctx.messages.len());
ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
append_injected_notes(&mut ctx.outputs, &mut ctx.messages, &pre_resolutions);
if dispositions
.iter()
.any(|d| matches!(d, CallDisposition::Execute))
{
push_internal_note(
&mut ctx.outputs,
&mut ctx.messages,
RESUME_EXECUTED_GROUND_TRUTH_NOTE.as_str(),
);
}
Ok(StepOutcome::Continue)
}
}
pub(crate) static QUESTION_ANSWERED_GROUND_TRUTH_NOTE: &str = "The question(s) above have been resolved — each carries its own `state` \
(answered/declined/auto_resolved) telling you exactly how. Read each one and act on it \
directly; do not re-ask a question that already has a result here.";
pub(crate) static QUESTION_STILL_PENDING_EPHEMERAL_NOTE: &str = "A question you asked earlier is still open — see the still_pending result above. That's \
not an answer; it means nobody has responded yet. Handle the message below on its own \
terms, and only bring the open question back up if it's still relevant once you have.";
fn trailing_input_is_blank(messages: &[LlmMessage], after: usize) -> bool {
messages
.get(after.saturating_add(1)..)
.unwrap_or(&[])
.iter()
.flat_map(|m| m.content.iter())
.all(|c| matches!(c, LlmContent::Text(t) if t.trim().is_empty()))
}
fn splice_question_results<P, T>(
ctx: &mut TurnCtx<'_, P, T>,
resolved: &[(
usize,
ToolCall,
Vec<crate::question::QuestionItem>,
Vec<crate::question::VerifiedAnswer>,
)],
durable: bool,
mut result_json: impl FnMut(
&ToolCall,
&[crate::question::QuestionItem],
&[crate::question::VerifiedAnswer],
) -> String,
) where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
let after = resolved
.iter()
.map(|(idx, ..)| *idx)
.max()
.unwrap_or(ctx.messages.len());
let mut result_msgs = Vec::with_capacity(resolved.len());
for (_, tc, items, answers) in resolved {
let json = result_json(tc, items, answers);
if durable {
ctx.outputs.push(tool_result_message(&tc.id, &json, true));
}
result_msgs.push(LlmMessage {
role: Role::Tool,
content: vec![LlmContent::tool_result(tc.id.clone(), json, false, true)],
});
}
ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
}
pub struct QuestionResumePrePass;
#[async_trait]
impl<P, T> TurnStep<P, T> for QuestionResumePrePass
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
#[allow(clippy::too_many_lines)] async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
let answered: HashSet<&str> = ctx
.messages
.iter()
.flat_map(|m| m.content.iter())
.filter_map(|c| match c {
LlmContent::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
_ => None,
})
.collect();
let unanswered: Vec<(usize, ToolCall)> = ctx
.messages
.iter()
.enumerate()
.flat_map(|(idx, m)| m.content.iter().map(move |c| (idx, c)))
.filter_map(|(idx, c)| match c {
LlmContent::ToolUse(tc)
if tc.name == crate::question::ASK_QUESTION_TOOL_NAME
&& !answered.contains(tc.id.as_str()) =>
{
Some((idx, tc.clone()))
}
_ => None,
})
.collect();
if unanswered.is_empty() {
return Ok(StepOutcome::Continue);
}
let calls: Vec<(usize, ToolCall, Vec<crate::question::QuestionItem>)> = unanswered
.into_iter()
.map(|(idx, tc)| {
let items = crate::question::parse_ask_question_args(&tc.args_json)
.inspect_err(|err| {
tracing::error!(
call_id = %tc.id,
%err,
"dangling ask_question call failed to re-parse on resume \
(unreachable: already validated before pausing)"
);
})
.unwrap_or_default();
(idx, tc, items)
})
.collect();
let mut missing: Vec<crate::question::PendingQuestion> = Vec::new();
let mut resolved: Vec<(
usize,
ToolCall,
Vec<crate::question::QuestionItem>,
Vec<crate::question::VerifiedAnswer>,
)> = Vec::with_capacity(calls.len());
for (idx, tc, items) in calls {
let mut matched = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let index = u32::try_from(i).unwrap_or(u32::MAX);
let occurrence = tc.approval_turn_id.as_deref().unwrap_or_default();
if let Some(answer) = ctx
.options
.question_answers
.iter()
.find(|a| a.turn_id == occurrence && a.call_id == tc.id && a.index == index)
{
matched.push(answer.clone());
} else {
missing.push(crate::question::PendingQuestion {
occurrence_turn_id: tc.approval_turn_id.clone(),
call_id: tc.id.clone(),
index,
item: item.clone(),
args_json: tc.args_json.clone(),
});
}
}
resolved.push((idx, tc, items, matched));
}
if !missing.is_empty() {
let after = resolved
.iter()
.map(|(idx, ..)| *idx)
.max()
.unwrap_or(ctx.messages.len());
if trailing_input_is_blank(&ctx.messages, after) {
return Ok(StepOutcome::PauseQuestions(missing));
}
splice_question_results(ctx, &resolved, false, |_, items, _| {
crate::question::question_still_pending_json(items)
});
ctx.messages.push(LlmMessage {
role: Role::System,
content: vec![LlmContent::text(
QUESTION_STILL_PENDING_EPHEMERAL_NOTE.to_owned(),
)],
});
return Ok(StepOutcome::Continue);
}
ctx.executed_tools = true;
splice_question_results(ctx, &resolved, true, |_, items, answers| {
crate::question::question_call_result_json(items, answers)
});
push_internal_note(
&mut ctx.outputs,
&mut ctx.messages,
QUESTION_ANSWERED_GROUND_TRUTH_NOTE,
);
Ok(StepOutcome::Continue)
}
}
pub struct CircuitBreaker;
#[async_trait]
impl<P, T> TurnStep<P, T> for CircuitBreaker
where
P: LlmProvider + ?Sized,
T: ToolExecutor + ?Sized,
{
async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
if ctx.saw_sig_match_denial {
ctx.denial_reprompts += 1;
if ctx.denial_reprompts >= crate::MAX_DENIAL_REPROMPTS {
tracing::warn!(
denial_reprompts = ctx.denial_reprompts,
max = crate::MAX_DENIAL_REPROMPTS,
"HITL circuit breaker: model re-emitted a denied action repeatedly; \
ending turn instead of re-prompting"
);
return Ok(StepOutcome::Done);
}
}
Ok(StepOutcome::Continue)
}
}