use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::agent::{contains_keyword_as_words, FollowupMode, TurnContext};
use crate::traits::{
AgentRole, ToolCallEffect, ToolCallSemantics, ToolCapabilities, ToolTargetHint,
ToolTargetHintKind,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutcomeEntry {
pub tool_name: String,
pub success: bool,
pub http_status: Option<u16>,
pub is_external_mutation: bool,
pub error_summary: Option<String>,
pub iteration: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planned_step_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planned_step_index: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub planned_step_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_step_count: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReconciliationMode {
AttemptLevel,
PlannedStepLevel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReconciliationOverview {
pub mode: ReconciliationMode,
pub total: usize,
pub succeeded: usize,
pub failed: usize,
pub failed_step_indices: Vec<usize>,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinearIntentStep {
pub step_id: String,
pub step_index: usize, pub tool: String,
pub target: String,
pub description: String,
#[serde(default)]
pub tool_calls_on_step: usize,
#[serde(default)]
pub completed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_evidence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_evaluated_at: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LinearIntentPlan {
pub plan_version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<LinearIntentStep>,
#[serde(default)]
pub current_step_cursor: usize, }
impl LinearIntentPlan {
pub fn record_tool_calls_on_current(&mut self, count: usize) {
if let Some(step) = self.steps.get_mut(self.current_step_cursor) {
step.tool_calls_on_step += count;
}
}
pub fn current_step_needs_replan(&self) -> bool {
const REPLAN_INTERVAL: usize = 2;
let Some(step) = self.steps.get(self.current_step_cursor) else {
return false;
};
if step.completed || step.tool_calls_on_step < 2 {
return false;
}
let last_eval = step.last_evaluated_at.unwrap_or(0);
step.tool_calls_on_step >= last_eval + REPLAN_INTERVAL
}
pub fn mark_current_step_evaluated(&mut self) {
if let Some(step) = self.steps.get_mut(self.current_step_cursor) {
step.last_evaluated_at = Some(step.tool_calls_on_step);
}
}
pub fn complete_current_step_with_evidence(&mut self, evidence: String) {
if let Some(step) = self.steps.get_mut(self.current_step_cursor) {
step.completed = true;
step.completion_evidence = Some(evidence);
}
if self.current_step_cursor < self.steps.len() {
self.current_step_cursor += 1;
}
}
#[allow(dead_code)]
pub fn all_steps_complete(&self) -> bool {
self.current_step_cursor >= self.steps.len()
}
pub fn format_with_progress(&self) -> String {
let mut result = String::from("## Task Plan\n");
for step in &self.steps {
let marker = if step.completed {
"[DONE]"
} else if step.step_index == self.current_step_cursor + 1 {
"[CURRENT]"
} else {
""
};
if marker.is_empty() {
result.push_str(&format!("{}. {}\n", step.step_index, step.description));
} else {
result.push_str(&format!(
"{}. {} {}",
step.step_index, marker, step.description
));
if let Some(ref evidence) = step.completion_evidence {
let ev = crate::utils::truncate_str(evidence, 100);
result.push_str(&format!(" \u{2014} {}", ev));
}
result.push('\n');
}
}
if result.len() > 2000 {
result.truncate(crate::utils::floor_char_boundary(&result, 2000));
result.push_str("\n...");
}
result
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetTier {
None,
Small,
Standard,
Extended,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionPersistence {
Ephemeral,
Durable,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionBudgetLimit {
Steps,
Tokens,
LlmCalls,
ToolCalls,
ValidationRounds,
WallClock,
}
impl ExecutionBudgetLimit {
pub fn as_str(self) -> &'static str {
match self {
Self::Steps => "max_steps",
Self::Tokens => "max_tokens",
Self::LlmCalls => "max_llm_calls",
Self::ToolCalls => "max_tool_calls",
Self::ValidationRounds => "max_validation_rounds",
Self::WallClock => "max_wall_clock_ms",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TargetScope {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_targets: Vec<ToolTargetHint>,
#[serde(default)]
pub hard_fail_outside_scope: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: usize,
#[serde(default)]
pub allow_tool_invocation_retry: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalRequirement {
NotNeeded,
Required { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StepExecutionPlan {
pub step_id: String,
pub description: String,
pub plan_version: u32,
pub primary_tool: Option<String>,
pub expected_effect: ToolCallEffect,
pub target_scope: TargetScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expected_targets: Vec<ToolTargetHint>,
pub retry_policy: RetryPolicy,
pub approval_requirement: ApprovalRequirement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StepExecutionOutcome {
Progress,
NoProgress,
RecoverableFailure,
NonrecoverableFailure,
BackgroundDetached,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutionBudget {
pub max_steps: usize,
pub max_tokens: usize,
pub max_llm_calls: usize,
pub max_tool_calls: usize,
pub max_validation_rounds: usize,
pub max_wall_clock_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutionState {
pub execution_id: String,
pub current_step: Option<StepExecutionPlan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_plan_version: Option<u32>,
pub attempt_count: usize,
pub last_tool_name: Option<String>,
pub last_outcome: Option<StepExecutionOutcome>,
pub background_handoff_active: bool,
pub persisted_at: Option<DateTime<Utc>>,
pub budget_tier: BudgetTier,
pub budget: ExecutionBudget,
pub persistence: ExecutionPersistence,
#[serde(default)]
pub budget_envelope_active: bool,
#[serde(default)]
pub final_response_closeout_active: bool,
#[serde(default)]
pub budget_started_task_tokens: u64,
#[serde(default)]
pub budget_started_elapsed_ms: u64,
pub llm_calls_used: usize,
pub tool_calls_used: usize,
pub validation_rounds_used: usize,
pub steps_used: usize,
#[serde(default)]
pub outcome_ledger: Vec<OutcomeEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_linear_intent_plan: Option<LinearIntentPlan>,
#[serde(default)]
pub provider_timeout_ms: u64,
}
impl ExecutionState {
pub fn new(
budget_tier: BudgetTier,
budget: ExecutionBudget,
persistence: ExecutionPersistence,
) -> Self {
Self {
execution_id: Uuid::new_v4().to_string(),
current_step: None,
current_plan_version: None,
attempt_count: 0,
last_tool_name: None,
last_outcome: None,
background_handoff_active: false,
persisted_at: None,
budget_tier,
budget,
persistence,
budget_envelope_active: false,
final_response_closeout_active: false,
budget_started_task_tokens: 0,
budget_started_elapsed_ms: 0,
llm_calls_used: 0,
tool_calls_used: 0,
validation_rounds_used: 0,
steps_used: 0,
outcome_ledger: Vec::new(),
active_linear_intent_plan: None,
provider_timeout_ms: 0,
}
}
pub fn promote_persistence(&mut self, persistence: ExecutionPersistence) {
if matches!(persistence, ExecutionPersistence::Durable) {
self.persistence = ExecutionPersistence::Durable;
}
}
pub fn mark_persisted_now(&mut self) {
self.persisted_at = Some(Utc::now());
}
pub fn set_plan_version(&mut self, plan_version: u32) {
if plan_version > 0 {
self.current_plan_version = Some(plan_version);
}
}
pub fn activate_budget_envelope(&mut self, task_tokens_used: u64, elapsed: Duration) {
if self.budget_envelope_active {
return;
}
self.budget_envelope_active = true;
self.final_response_closeout_active = false;
self.budget_started_task_tokens = task_tokens_used;
self.budget_started_elapsed_ms = elapsed.as_millis().min(u64::MAX as u128) as u64;
}
pub fn execution_budget_applies(&self) -> bool {
self.budget_envelope_active
}
pub fn suspend_budget_for_final_response(&mut self) {
self.budget_envelope_active = false;
self.final_response_closeout_active = true;
}
pub fn record_llm_call(&mut self) {
self.llm_calls_used = self.llm_calls_used.saturating_add(1);
}
pub fn begin_step(&mut self, plan: StepExecutionPlan) {
self.steps_used = self.steps_used.saturating_add(1);
self.attempt_count = self.attempt_count.saturating_add(1);
self.last_tool_name = plan.primary_tool.clone();
self.current_step = Some(plan);
}
pub fn record_tool_call(&mut self) {
self.tool_calls_used = self.tool_calls_used.saturating_add(1);
}
pub fn record_validation_round(&mut self) {
self.validation_rounds_used = self.validation_rounds_used.saturating_add(1);
}
pub fn complete_current_step(&mut self, outcome: StepExecutionOutcome) {
self.background_handoff_active =
matches!(outcome, StepExecutionOutcome::BackgroundDetached);
self.last_outcome = Some(outcome);
}
pub fn record_outcome(&mut self, entry: OutcomeEntry) {
self.outcome_ledger.push(entry);
}
pub fn install_linear_intent_plan(&mut self, plan_version: u32, steps: Vec<LinearIntentStep>) {
if steps.is_empty() {
self.active_linear_intent_plan = None;
return;
}
self.active_linear_intent_plan = Some(LinearIntentPlan {
plan_version,
steps,
current_step_cursor: 0,
});
}
pub fn current_linear_intent_step(&self) -> Option<&LinearIntentStep> {
let plan = self.active_linear_intent_plan.as_ref()?;
plan.steps.get(plan.current_step_cursor)
}
pub fn advance_linear_intent_step_after_external_success(&mut self) {
let Some(plan) = self.active_linear_intent_plan.as_mut() else {
return;
};
if plan.current_step_cursor < plan.steps.len() {
plan.current_step_cursor += 1;
}
}
pub fn successful_external_mutation_count(&self) -> usize {
self.outcome_ledger
.iter()
.filter(|e| e.is_external_mutation && e.success)
.count()
}
pub fn uncorrected_failed_mutations(&self) -> Vec<&OutcomeEntry> {
self.uncorrected_failed_entries(true)
}
pub fn has_uncorrected_failed_external_mutations(&self) -> bool {
!self.uncorrected_failed_mutations().is_empty()
}
fn uncorrected_failed_entries(&self, external_mutation: bool) -> Vec<&OutcomeEntry> {
let failures: Vec<&OutcomeEntry> = self
.outcome_ledger
.iter()
.filter(|e| e.is_external_mutation == external_mutation && !e.success)
.collect();
if failures.is_empty() {
return Vec::new();
}
let failures_with_summary: Vec<&OutcomeEntry> = failures
.into_iter()
.filter(|e| e.error_summary.is_some())
.collect();
if failures_with_summary.is_empty() {
return Vec::new();
}
let uncorrected: Vec<&OutcomeEntry> = failures_with_summary
.into_iter()
.filter(|fail| {
!self.outcome_ledger.iter().any(|e| {
e.is_external_mutation == external_mutation
&& e.success
&& e.tool_name == fail.tool_name
&& e.iteration > fail.iteration
})
})
.collect();
if uncorrected.is_empty() {
return Vec::new();
}
let max_success_iter = self
.outcome_ledger
.iter()
.filter(|e| e.is_external_mutation == external_mutation && e.success)
.map(|e| e.iteration)
.max();
if let Some(max_success) = max_success_iter {
return uncorrected
.into_iter()
.filter(|e| e.iteration > max_success)
.collect();
}
uncorrected
}
pub fn uncorrected_failed_observations(&self) -> Vec<&OutcomeEntry> {
self.uncorrected_failed_entries(false)
}
pub fn uncorrected_failed_required_observations(&self) -> Vec<&OutcomeEntry> {
let has_incomplete_steps = self
.active_linear_intent_plan
.as_ref()
.is_some_and(|plan| plan.steps.iter().any(|step| !step.completed));
self.uncorrected_failed_observations()
.into_iter()
.filter(|entry| {
has_incomplete_steps
|| entry.planned_step_id.is_some()
|| entry.planned_step_index.is_some()
})
.collect()
}
pub(crate) fn build_attempt_reconciliation_overview(&self) -> Option<ReconciliationOverview> {
let uncorrected = self.uncorrected_failed_mutations();
if uncorrected.is_empty() {
return None;
}
let total = self
.outcome_ledger
.iter()
.filter(|e| e.is_external_mutation)
.count();
let succeeded = self.successful_external_mutation_count();
let failed = uncorrected.len();
let mut summary = format!(
"[SYSTEM] External mutation attempt reconciliation: {} of {} attempts succeeded, {} failed.",
succeeded, total, failed,
);
for entry in &uncorrected {
let status = entry
.http_status
.map(|s| format!(" (HTTP {})", s))
.unwrap_or_default();
let error = entry.error_summary.as_deref().unwrap_or("unknown error");
summary.push_str(&format!(
"\n - {} at iteration {}{}: {}",
entry.tool_name, entry.iteration, status, error,
));
}
Some(ReconciliationOverview {
mode: ReconciliationMode::AttemptLevel,
total,
succeeded,
failed,
failed_step_indices: Vec::new(),
summary,
})
}
pub fn build_attempt_reconciliation_summary(&self) -> Option<String> {
self.build_attempt_reconciliation_overview()
.map(|overview| overview.summary)
}
pub(crate) fn build_reconciliation_overview(&self) -> Option<ReconciliationOverview> {
use std::collections::{BTreeMap, BTreeSet};
let latest_plan_version = self
.active_linear_intent_plan
.as_ref()
.map(|plan| plan.plan_version)
.or_else(|| {
self.outcome_ledger
.iter()
.filter(|entry| entry.is_external_mutation)
.filter_map(|entry| entry.plan_version)
.max()
});
let Some(latest_plan_version) = latest_plan_version else {
return self.build_attempt_reconciliation_overview();
};
let planned_entries: Vec<&OutcomeEntry> = self
.outcome_ledger
.iter()
.filter(|entry| {
entry.is_external_mutation
&& entry.planned_step_id.is_some()
&& entry.plan_version == Some(latest_plan_version)
})
.collect();
if planned_entries.is_empty() {
return self.build_attempt_reconciliation_overview();
}
let active_plan = self
.active_linear_intent_plan
.as_ref()
.filter(|plan| plan.plan_version == latest_plan_version);
let mut by_step: BTreeMap<(usize, String), Vec<&OutcomeEntry>> = BTreeMap::new();
for entry in planned_entries {
let Some(step_id) = entry.planned_step_id.clone() else {
continue;
};
let step_index = entry.planned_step_index.unwrap_or(usize::MAX);
by_step
.entry((step_index, step_id))
.or_default()
.push(entry);
}
if by_step.is_empty() {
return self.build_attempt_reconciliation_overview();
}
let expected_steps = active_plan
.map(|plan| plan.steps.len())
.or_else(|| {
by_step
.values()
.flat_map(|entries| {
entries.iter().filter_map(|entry| entry.expected_step_count)
})
.max()
})
.unwrap_or(by_step.len());
let succeeded = by_step
.values()
.filter(|entries| entries.iter().any(|entry| entry.success))
.count();
let mut failed_step_indices = BTreeSet::new();
let mut summary = format!(
"[SYSTEM] Planned-step reconciliation: {} of {} planned steps completed.",
succeeded, expected_steps
);
if let Some(plan) = active_plan {
for step in &plan.steps {
let key = (step.step_index, step.step_id.clone());
if let Some(entries) = by_step.get(&key) {
let attempts = entries.len();
let succeeded_this_step = entries.iter().any(|entry| entry.success);
let final_failure = entries.iter().rev().find(|entry| !entry.success);
if succeeded_this_step && attempts > 1 {
summary.push_str(&format!(
"\n - Step {} ({}) succeeded after {} attempts.",
step.step_index, step.description, attempts
));
} else if succeeded_this_step {
summary.push_str(&format!(
"\n - Step {} ({}) succeeded.",
step.step_index, step.description
));
} else if let Some(failure) = final_failure {
failed_step_indices.insert(step.step_index);
let status = failure
.http_status
.map(|code| format!(" (HTTP {})", code))
.unwrap_or_default();
let error = failure.error_summary.as_deref().unwrap_or("unknown error");
summary.push_str(&format!(
"\n - Step {} ({}) failed after {} attempts{}: {}",
step.step_index, step.description, attempts, status, error
));
} else {
failed_step_indices.insert(step.step_index);
summary.push_str(&format!(
"\n - Step {} ({}) was not completed.",
step.step_index, step.description
));
}
} else {
failed_step_indices.insert(step.step_index);
summary.push_str(&format!(
"\n - Step {} ({}) was not completed.",
step.step_index, step.description
));
}
}
} else {
for ((step_index, _step_id), entries) in &by_step {
let attempts = entries.len();
let description = entries
.iter()
.find_map(|entry| entry.planned_step_description.as_deref())
.unwrap_or("unnamed step");
let succeeded_this_step = entries.iter().any(|entry| entry.success);
let final_failure = entries.iter().rev().find(|entry| !entry.success);
if succeeded_this_step && attempts > 1 {
summary.push_str(&format!(
"\n - Step {} ({}) succeeded after {} attempts.",
step_index, description, attempts
));
} else if succeeded_this_step {
summary.push_str(&format!(
"\n - Step {} ({}) succeeded.",
step_index, description
));
} else if let Some(failure) = final_failure {
failed_step_indices.insert(*step_index);
let status = failure
.http_status
.map(|code| format!(" (HTTP {})", code))
.unwrap_or_default();
let error = failure.error_summary.as_deref().unwrap_or("unknown error");
summary.push_str(&format!(
"\n - Step {} ({}) failed after {} attempts{}: {}",
step_index, description, attempts, status, error
));
}
}
for step_index in 1..=expected_steps {
let seen = by_step.keys().any(|(idx, _)| *idx == step_index);
if !seen {
failed_step_indices.insert(step_index);
}
}
if expected_steps > by_step.len() {
summary.push_str(&format!(
"\n - {} planned step(s) were not completed.",
expected_steps - by_step.len()
));
}
}
let failed = expected_steps.saturating_sub(succeeded);
Some(ReconciliationOverview {
mode: ReconciliationMode::PlannedStepLevel,
total: expected_steps,
succeeded,
failed,
failed_step_indices: failed_step_indices.into_iter().collect(),
summary,
})
}
pub fn extend_budget_on_progress(&mut self) {
if !self.budget_envelope_active {
return;
}
const PROGRESS_EXTENSION: usize = 6;
const WALL_CLOCK_EXTENSION_MS: u64 = 30_000;
const VALIDATION_EXTENSION: usize = 1;
if self.budget.max_llm_calls > 0 {
self.budget.max_llm_calls =
self.budget.max_llm_calls.saturating_add(PROGRESS_EXTENSION);
}
if self.budget.max_tool_calls > 0 {
self.budget.max_tool_calls = self
.budget
.max_tool_calls
.saturating_add(PROGRESS_EXTENSION);
}
if self.budget.max_steps > 0 {
self.budget.max_steps = self.budget.max_steps.saturating_add(PROGRESS_EXTENSION);
}
if self.budget.max_wall_clock_ms > 0 {
self.budget.max_wall_clock_ms = self
.budget
.max_wall_clock_ms
.saturating_add(WALL_CLOCK_EXTENSION_MS);
}
if self.budget.max_validation_rounds > 0 {
self.budget.max_validation_rounds = self
.budget
.max_validation_rounds
.saturating_add(VALIDATION_EXTENSION);
}
}
pub fn promote_budget_for_plan(&mut self, step_count: usize) {
if step_count < 3 {
return;
}
if !matches!(self.budget_tier, BudgetTier::None | BudgetTier::Small) {
return;
}
let promoted = default_execution_budget(BudgetTier::Standard);
self.budget.max_llm_calls = self.budget.max_llm_calls.max(promoted.max_llm_calls);
self.budget.max_tool_calls = self.budget.max_tool_calls.max(promoted.max_tool_calls);
self.budget.max_steps = self.budget.max_steps.max(promoted.max_steps);
self.budget.max_wall_clock_ms = self
.budget
.max_wall_clock_ms
.max(promoted.max_wall_clock_ms);
tracing::info!(
step_count,
new_max_tool_calls = self.budget.max_tool_calls,
new_max_llm_calls = self.budget.max_llm_calls,
"Budget promoted for multi-step task plan"
);
}
pub fn exhausted_limit(
&self,
task_tokens_used: u64,
elapsed: Duration,
) -> Option<ExecutionBudgetLimit> {
if !self.budget_envelope_active {
return None;
}
let execution_tokens_used =
task_tokens_used.saturating_sub(self.budget_started_task_tokens);
let execution_elapsed_ms = (elapsed.as_millis().min(u64::MAX as u128) as u64)
.saturating_sub(self.budget_started_elapsed_ms);
if self.budget.max_steps > 0 && self.steps_used >= self.budget.max_steps {
return Some(ExecutionBudgetLimit::Steps);
}
if self.budget.max_tokens > 0 && execution_tokens_used as usize >= self.budget.max_tokens {
return Some(ExecutionBudgetLimit::Tokens);
}
if self.budget.max_llm_calls > 0 && self.llm_calls_used >= self.budget.max_llm_calls {
return Some(ExecutionBudgetLimit::LlmCalls);
}
if self.budget.max_tool_calls > 0 && self.tool_calls_used >= self.budget.max_tool_calls {
return Some(ExecutionBudgetLimit::ToolCalls);
}
if self.budget.max_validation_rounds > 0
&& self.validation_rounds_used >= self.budget.max_validation_rounds
{
return Some(ExecutionBudgetLimit::ValidationRounds);
}
if self.budget.max_wall_clock_ms > 0 {
let effective_elapsed = execution_elapsed_ms.saturating_sub(self.provider_timeout_ms);
if effective_elapsed >= self.budget.max_wall_clock_ms {
return Some(ExecutionBudgetLimit::WallClock);
}
}
None
}
}
pub fn default_execution_budget(tier: BudgetTier) -> ExecutionBudget {
match tier {
BudgetTier::None => ExecutionBudget {
max_steps: 24,
max_tokens: 0,
max_llm_calls: 14,
max_tool_calls: 24,
max_validation_rounds: 3,
max_wall_clock_ms: 300_000,
},
BudgetTier::Small => ExecutionBudget {
max_steps: 16,
max_tokens: 0,
max_llm_calls: 14,
max_tool_calls: 14,
max_validation_rounds: 3,
max_wall_clock_ms: 300_000,
},
BudgetTier::Standard => ExecutionBudget {
max_steps: 24,
max_tokens: 0,
max_llm_calls: 18,
max_tool_calls: 24,
max_validation_rounds: 3,
max_wall_clock_ms: 900_000,
},
BudgetTier::Extended => ExecutionBudget {
max_steps: 16,
max_tokens: 0,
max_llm_calls: 24,
max_tool_calls: 18,
max_validation_rounds: 5,
max_wall_clock_ms: 1_800_000,
},
}
}
pub fn select_initial_execution_budget(
user_text: &str,
turn_context: &TurnContext,
depth: usize,
role: AgentRole,
) -> (BudgetTier, &'static str, ExecutionBudget) {
fn promote_contextual_followup_budget(
tier: BudgetTier,
route_kind: &'static str,
turn_context: &TurnContext,
) -> (BudgetTier, &'static str, ExecutionBudget) {
let carries_followup_context = matches!(
turn_context.followup_mode,
Some(FollowupMode::Followup | FollowupMode::ClarificationAnswer)
) && !turn_context.recent_messages.is_empty();
if carries_followup_context && matches!(tier, BudgetTier::None | BudgetTier::Small) {
let promoted = BudgetTier::Standard;
return (
promoted,
"contextual_followup",
default_execution_budget(promoted),
);
}
(tier, route_kind, default_execution_budget(tier))
}
let lower = user_text.trim().to_ascii_lowercase();
let authoring_only_content = turn_context
.completion_contract
.connected_content_mode
.is_authoring_only();
let auth_or_integration_management =
crate::agent::intent_routing::user_text_requests_auth_or_integration_management(user_text);
if depth > 0 || matches!(role, AgentRole::Executor | AgentRole::TaskLead) {
let tier = BudgetTier::Extended;
return (tier, "delegated_multi_step", default_execution_budget(tier));
}
let has_scoped_target = turn_context.primary_project_scope.is_some()
|| turn_context
.completion_contract
.primary_target_hint()
.is_some()
|| lower.contains('/')
|| lower.contains(".rs")
|| lower.contains(".md")
|| lower.contains(".toml");
let has_scheduled_action = [
"schedule",
"scheduled",
"cron",
"every day",
"every week",
"every month",
"tomorrow",
"next week",
"daily",
"weekly",
"monthly",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
let has_deployment_or_external_write = auth_or_integration_management
|| (!authoring_only_content
&& [
"deploy", "publish", "release", "restart", "schedule", "webhook", "post", "put",
"patch", "delete", "send",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw)));
let has_mutation_request = auth_or_integration_management
|| (!authoring_only_content
&& [
"edit",
"write",
"update",
"change",
"fix",
"fixing",
"implement",
"refactor",
"create",
"add",
"remove",
"delete",
"rename",
"remember",
"commit",
"deploy",
"restart",
"send",
"schedule",
"retry",
"redo",
"rerun",
"try again",
"do it again",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw)));
let has_read_only_api_lookup = contains_keyword_as_words(&lower, "api")
&& !has_mutation_request
&& !has_deployment_or_external_write
&& !has_scheduled_action;
let has_read_only_investigation = turn_context.completion_contract.requires_observation
|| has_read_only_api_lookup
|| [
"inspect",
"check",
"verify",
"read",
"search",
"find",
"list",
"show",
"look up",
"investigate",
"diagnose",
"status",
"logs",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw))
&& !has_mutation_request
&& !has_deployment_or_external_write;
if has_scheduled_action {
let tier = BudgetTier::Standard;
return promote_contextual_followup_budget(tier, "scheduled_action", turn_context);
}
if has_deployment_or_external_write {
let tier = BudgetTier::Standard;
return promote_contextual_followup_budget(
tier,
"deployment_or_external_write",
turn_context,
);
}
if has_mutation_request {
let has_verification_step = [
"test",
"run",
"verify",
"execute",
"demonstrate",
"show me",
"show the results",
"check",
]
.iter()
.any(|kw| contains_keyword_as_words(&lower, kw));
let has_research_or_multi_phase = [
"search the web",
"web search",
"search online",
"look up online",
"then create",
"then write",
"then make",
"and create",
"and write",
"and make",
]
.iter()
.any(|phrase| lower.contains(phrase))
|| (contains_keyword_as_words(&lower, "search")
&& contains_keyword_as_words(&lower, "create"));
let needs_standard = has_verification_step || has_research_or_multi_phase;
let tier = if has_scoped_target && !needs_standard {
BudgetTier::Small
} else {
BudgetTier::Standard
};
let route_kind = if has_scoped_target && !needs_standard {
"scoped_modification"
} else if has_scoped_target {
"scoped_modification_with_verification"
} else {
"unscoped_modification"
};
return promote_contextual_followup_budget(tier, route_kind, turn_context);
}
if has_read_only_api_lookup {
let tier = BudgetTier::Standard;
return promote_contextual_followup_budget(tier, "api_lookup", turn_context);
}
if has_read_only_investigation {
let tier = BudgetTier::Standard;
return promote_contextual_followup_budget(tier, "read_only_investigation", turn_context);
}
let tier = BudgetTier::Standard;
let route_kind = if turn_context.completion_contract.requires_observation {
"read_only_investigation"
} else {
"knowledge"
};
promote_contextual_followup_budget(tier, route_kind, turn_context)
}
#[allow(clippy::too_many_arguments)]
pub fn compile_step_execution_plan(
execution_id: &str,
plan_version: u32,
iteration: usize,
tool_call_id: &str,
tool_name: &str,
effective_arguments: &str,
semantics: &ToolCallSemantics,
capabilities: ToolCapabilities,
allowed_project_scope: Option<&str>,
) -> StepExecutionPlan {
let expected_targets = if semantics.target_hints.is_empty() {
extract_target_hints_from_arguments(effective_arguments)
} else {
semantics.target_hints.clone()
};
let scope_applies_to_expected_targets = expected_targets.is_empty()
|| expected_targets.iter().all(|target| {
matches!(
target.kind,
ToolTargetHintKind::Path | ToolTargetHintKind::ProjectScope
)
});
let allowed_targets =
if let Some(scope) = allowed_project_scope.filter(|_| scope_applies_to_expected_targets) {
ToolTargetHint::new(ToolTargetHintKind::ProjectScope, scope)
.into_iter()
.collect()
} else {
expected_targets.clone()
};
let target_label = expected_targets
.first()
.map(|target| target.value.as_str())
.or_else(|| allowed_targets.first().map(|target| target.value.as_str()))
.unwrap_or("the requested target");
let approval_requirement = if capabilities.needs_approval || capabilities.high_impact_write {
ApprovalRequirement::Required {
reason: format!("{} is approval-gated or high impact", tool_name),
}
} else {
ApprovalRequirement::NotNeeded
};
let needs_idempotency =
semantics.mutates_state() || capabilities.external_side_effect || !capabilities.idempotent;
StepExecutionPlan {
step_id: format!("step-{iteration}-{tool_call_id}"),
description: format!("Run `{}` against {}", tool_name, target_label),
plan_version: plan_version.max(1),
primary_tool: Some(tool_name.to_string()),
expected_effect: semantics.effect,
target_scope: TargetScope {
allowed_targets,
hard_fail_outside_scope: semantics.mutates_state()
|| (capabilities.external_side_effect && scope_applies_to_expected_targets),
},
expected_targets,
retry_policy: RetryPolicy {
max_attempts: if capabilities.idempotent { 2 } else { 1 },
allow_tool_invocation_retry: capabilities.idempotent,
},
approval_requirement,
idempotency_key: needs_idempotency.then(|| {
format!(
"exec:{}:{}:{}:{}",
execution_id, iteration, tool_name, tool_call_id
)
}),
}
}
pub fn classify_step_execution_outcome(
is_error: bool,
background_detached: bool,
) -> StepExecutionOutcome {
if background_detached {
StepExecutionOutcome::BackgroundDetached
} else if is_error {
StepExecutionOutcome::RecoverableFailure
} else {
StepExecutionOutcome::Progress
}
}
pub(crate) fn extract_target_hints_from_arguments(arguments: &str) -> Vec<ToolTargetHint> {
let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(serde_json::Value::Object(map)) => map,
_ => return Vec::new(),
};
let mut targets = Vec::new();
for key in [
"path",
"file_path",
"file",
"filename",
"project_path",
"project_dir",
"repo_path",
"repo_dir",
"working_dir",
"directory",
"dir",
"url",
"target",
"target_url",
] {
let Some(value) = parsed.get(key).and_then(|value| value.as_str()) else {
continue;
};
let candidate = match key {
"url" | "target_url" => ToolTargetHint::new(ToolTargetHintKind::Url, value),
"project_path" | "project_dir" | "repo_path" | "repo_dir" => {
ToolTargetHint::new(ToolTargetHintKind::ProjectScope, value)
}
_ => ToolTargetHint::new(ToolTargetHintKind::Path, value),
};
if let Some(candidate) = candidate {
if !targets.iter().any(|existing| existing == &candidate) {
targets.push(candidate);
}
}
}
targets
}
#[cfg(test)]
#[path = "execution_state_tests.rs"]
mod tests;