use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::agent::{post_task, LearningContext, TurnContext};
use crate::traits::{Task, ToolTargetHint};
use super::execution_state::{ExecutionState, LinearIntentPlan, OutcomeEntry, TargetScope};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutedAction {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ValidationFailure {
MissingEvidence,
ContradictoryEvidence,
VerificationPending,
BudgetExhausted,
ApprovalRequired,
ScopeViolation,
PlanRejected,
CritiqueRejected,
SuccessCriteriaUnmatched,
NonrecoverableFailure,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoopRepetitionReason {
MissingEvidence,
ContradictoryEvidence,
VerificationPending,
PlanRejected,
CritiqueRejected,
RetryStep,
ReplanRequired,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ValidationState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_action: Option<ExecutedAction>,
#[serde(default)]
pub last_result_had_evidence: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub matched_success_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed_checks: Vec<ValidationFailure>,
#[serde(default)]
pub replan_count: usize,
#[serde(default)]
pub retry_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_repetition_reason: Option<LoopRepetitionReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_plan_version: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub active_success_criteria: Vec<String>,
}
impl ValidationState {
pub fn set_plan(&mut self, plan_version: u32, success_criteria: &[String]) {
if plan_version > 0 {
self.active_plan_version = Some(plan_version);
}
self.active_success_criteria = success_criteria.to_vec();
self.refresh_success_criteria_matches("");
}
pub fn record_action(
&mut self,
tool_name: Option<&str>,
target: Option<String>,
last_result_had_evidence: bool,
) {
self.last_action = Some(ExecutedAction {
tool_name: tool_name.map(str::to_string),
target,
});
self.last_result_had_evidence = last_result_had_evidence;
}
pub fn record_failure(&mut self, failure: ValidationFailure) {
if !self.failed_checks.contains(&failure) {
self.failed_checks.push(failure);
}
}
pub fn note_retry(&mut self, reason: LoopRepetitionReason) {
self.retry_count = self.retry_count.saturating_add(1);
self.loop_repetition_reason = Some(reason);
}
pub fn note_replan(&mut self) {
self.replan_count = self.replan_count.saturating_add(1);
self.loop_repetition_reason = Some(LoopRepetitionReason::ReplanRequired);
}
pub fn note_replan_for(&mut self, reason: LoopRepetitionReason) {
self.replan_count = self.replan_count.saturating_add(1);
self.loop_repetition_reason = Some(reason);
}
pub fn clear_loop_repetition_reason(&mut self) {
self.loop_repetition_reason = None;
}
pub fn refresh_success_criteria_matches(&mut self, text: &str) {
let normalized_text = normalize_success_text(text);
self.matched_success_criteria = self
.active_success_criteria
.iter()
.filter(|criterion| !criterion.trim().is_empty())
.filter(|criterion| {
let normalized = normalize_success_text(criterion);
!normalized.is_empty() && normalized_text.contains(&normalized)
})
.cloned()
.collect();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ValidationOutcome {
StepDone,
VerifyAgain,
NeedsApproval,
PartialDoneBlocked,
ReduceScope,
Abandon,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalState {
NotNeeded,
Required,
Requested,
Granted,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PartialResult {
pub completed_work_summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
pub blocker: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remaining_work: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HumanInterventionRequest {
pub outcome: ValidationOutcome,
pub approval_state: ApprovalState,
pub action_requested: String,
pub target: Option<String>,
pub reason: String,
pub exact_need: String,
pub next_step: String,
pub consequence_if_not_provided: Option<String>,
pub partial_result: Option<PartialResult>,
}
impl HumanInterventionRequest {
pub fn render_user_message(&self) -> String {
let mut lines = Vec::new();
lines.push("I'm blocked from safely finishing this request.".to_string());
if let Some(partial_result) = &self.partial_result {
lines.push(String::new());
lines.push(format!(
"Completed work so far: {}",
partial_result.completed_work_summary
));
if !partial_result.artifacts.is_empty() {
lines.push(format!(
"Relevant target: {}",
partial_result.artifacts.join(", ")
));
}
lines.push(format!("Current blocker: {}", partial_result.blocker));
if !partial_result.remaining_work.is_empty() {
lines.push(format!(
"Remaining work: {}",
partial_result.remaining_work.join("; ")
));
}
}
lines.push(String::new());
lines.push(format!("What I need from you: {}", self.exact_need));
lines.push(format!("What I will do next: {}", self.next_step));
if let Some(consequence) = &self.consequence_if_not_provided {
lines.push(format!("If not provided: {}", consequence));
}
lines.join("\n")
}
pub fn to_inline_approval_prompt(&self) -> (String, Vec<String>) {
let mut description = format!("Approval needed: {}", self.action_requested);
if let Some(target) = &self.target {
description.push_str(&format!(" (target: {})", target));
}
let mut warnings = vec![self.reason.clone(), self.exact_need.clone()];
if let Some(partial_result) = &self.partial_result {
warnings.push(format!(
"Completed work so far: {}",
partial_result.completed_work_summary
));
}
warnings.push(format!("Next step after approval: {}", self.next_step));
if let Some(consequence) = &self.consequence_if_not_provided {
warnings.push(consequence.clone());
}
(description, warnings)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StepValidationOutcome {
StepDone,
RetryStep,
UseFallbackTool,
VerifyAgain,
ReplanTask,
NeedsApproval,
PartialDoneBlocked,
ReduceScope,
Abandon,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskValidationOutcome {
TaskDone,
ContinueWithNextStep,
VerifyAgain,
ReplanTask,
NeedsApproval,
PartialDoneBlocked,
ReduceScope,
Abandon,
Blocked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutorHandoff {
pub task_id: String,
pub mission: String,
pub task_description: String,
pub target_scope: TargetScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expected_targets: Vec<ToolTargetHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
}
impl ExecutorHandoff {
pub fn render_prompt_section(&self) -> String {
let mut lines = vec![
"## Task Contract".to_string(),
format!("- task_id: {}", self.task_id),
];
if !self.target_scope.allowed_targets.is_empty() {
let allowed = self
.target_scope
.allowed_targets
.iter()
.map(|target| target.value.as_str())
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("- allowed targets (hard boundary): {allowed}"));
}
if !self.expected_targets.is_empty() {
let expected = self
.expected_targets
.iter()
.map(|target| target.value.as_str())
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("- expected targets (execution hint): {expected}"));
}
if let Some(allowed_tools) = &self.allowed_tools {
if !allowed_tools.is_empty() {
lines.push(format!(
"- allowed tools for this executor: {}",
allowed_tools.join(", ")
));
}
}
lines.push(
"- If finishing requires acting outside the allowed targets, stop and use report_blocker."
.to_string(),
);
lines.push(
"- You may complete this single task only. Do not claim the overall goal is done."
.to_string(),
);
lines.join("\n")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExecutorStepResult {
pub task_id: String,
pub step_outcome: StepValidationOutcome,
pub task_outcome: TaskValidationOutcome,
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact_need: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_request: Option<HumanInterventionRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partial_result: Option<PartialResult>,
}
impl ExecutorStepResult {
pub fn render_task_lead_summary(&self) -> String {
let mut lines = vec![
format!("Executor outcome: {:?}", self.task_outcome)
.replace("TaskDone", "task_done")
.replace("ContinueWithNextStep", "continue_with_next_step")
.replace("VerifyAgain", "verify_again")
.replace("ReplanTask", "replan_task")
.replace("NeedsApproval", "needs_approval")
.replace("PartialDoneBlocked", "partial_done_blocked")
.replace("ReduceScope", "reduce_scope")
.replace("Abandon", "abandon")
.replace("Blocked", "blocked"),
format!("Summary: {}", self.summary),
];
if !self.artifacts.is_empty() {
lines.push(format!("Artifacts: {}", self.artifacts.join(", ")));
}
if let Some(blocker) = &self.blocker {
lines.push(format!("Blocker: {}", blocker));
}
if let Some(exact_need) = &self.exact_need {
lines.push(format!("Need: {}", exact_need));
}
if let Some(next_step) = &self.next_step {
lines.push(format!("Next step: {}", next_step));
}
if let Some(partial) = &self.partial_result {
lines.push(format!(
"Completed work so far: {}",
partial.completed_work_summary
));
}
lines.join("\n")
}
}
pub fn extract_executor_handoff_context(context: Option<&str>) -> Option<ExecutorHandoff> {
context
.and_then(parse_context_object)
.and_then(|context| context.get("executor_handoff").cloned())
.and_then(|value| serde_json::from_value(value).ok())
}
pub fn extract_executor_result_context(context: Option<&str>) -> Option<ExecutorStepResult> {
context
.and_then(parse_context_object)
.and_then(|context| context.get("executor_result").cloned())
.and_then(|value| serde_json::from_value(value).ok())
}
pub fn persist_executor_handoff_context(
existing_context: Option<&str>,
handoff: &ExecutorHandoff,
) -> Result<String, serde_json::Error> {
merge_context_entry(
existing_context,
"executor_handoff",
serde_json::to_value(handoff)?,
)
}
pub fn persist_executor_result_context(
existing_context: Option<&str>,
result: &ExecutorStepResult,
) -> Result<String, serde_json::Error> {
merge_context_entry(
existing_context,
"executor_result",
serde_json::to_value(result)?,
)
}
pub fn derive_executor_step_result(
task_id: &str,
task: Option<&Task>,
response: Option<&str>,
error: Option<&str>,
) -> ExecutorStepResult {
if let Some(task) = task {
if let Some(existing) = extract_executor_result_context(task.context.as_deref()) {
return existing;
}
}
let handoff = task.and_then(|task| extract_executor_handoff_context(task.context.as_deref()));
let artifacts = handoff
.as_ref()
.map(|handoff| {
handoff
.expected_targets
.iter()
.map(|target| target.value.clone())
.collect::<Vec<_>>()
})
.filter(|targets| !targets.is_empty())
.unwrap_or_default();
let task_response = response
.map(str::trim)
.filter(|response| !response.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
task.and_then(|task| {
task.result
.as_deref()
.map(str::trim)
.filter(|result| !result.is_empty())
.map(ToOwned::to_owned)
})
});
if let Some(error) = error {
let partial_summary = task
.and_then(|task| task.result.clone())
.filter(|result| !result.trim().is_empty());
return ExecutorStepResult {
task_id: task_id.to_string(),
step_outcome: if partial_summary.is_some() {
StepValidationOutcome::PartialDoneBlocked
} else {
StepValidationOutcome::Abandon
},
task_outcome: if partial_summary.is_some() {
TaskValidationOutcome::PartialDoneBlocked
} else {
TaskValidationOutcome::Abandon
},
summary: format!("Executor run failed: {error}"),
artifacts,
blocker: Some(error.to_string()),
exact_need: Some(if partial_summary.is_some() {
"Resolve the blocker or narrow the task before retrying.".to_string()
} else {
"A new plan or a narrower replacement task before trying again.".to_string()
}),
next_step: Some(if partial_summary.is_some() {
"Resume from the partial progress with a narrower follow-up step.".to_string()
} else {
"Abandon this execution path and create a fresh replacement task.".to_string()
}),
approval_request: None,
partial_result: partial_summary.map(|completed_work_summary| PartialResult {
completed_work_summary,
artifacts: Vec::new(),
blocker: error.to_string(),
remaining_work: vec![
"Resolve the blocker and resume with a narrower task.".to_string()
],
}),
};
}
let blocker = task
.and_then(|task| task.blocker.clone())
.or_else(|| task_response.clone().filter(|text| mentions_blocker(text)));
let summary = task_response
.clone()
.or_else(|| blocker.clone())
.unwrap_or_else(|| "Executor finished without a detailed summary.".to_string());
if let Some(blocker) = blocker {
let partial_summary = task
.and_then(|task| task.result.clone())
.filter(|result| !result.trim().is_empty());
let partial_result = partial_summary
.as_ref()
.map(|partial_summary| PartialResult {
completed_work_summary: partial_summary.clone(),
artifacts: artifacts.clone(),
blocker: blocker.clone(),
remaining_work: vec!["Resolve the blocker and resume the task.".to_string()],
});
let needs_approval =
mentions_approval(&blocker) || task_response.as_deref().is_some_and(mentions_approval);
let exact_need = if needs_approval {
"Explicit approval or permission to perform the blocked action.".to_string()
} else if let Some(partial_result) = &partial_result {
format!(
"Resolve the blocker after '{}'.",
partial_result.completed_work_summary
)
} else {
"Clarify the blocker or provide the missing dependency.".to_string()
};
let next_step = if needs_approval {
"Resume the blocked action once approval is granted.".to_string()
} else {
"Continue the task after the blocker is resolved.".to_string()
};
let approval_request = needs_approval.then(|| HumanInterventionRequest {
outcome: ValidationOutcome::NeedsApproval,
approval_state: ApprovalState::Required,
action_requested: blocker.clone(),
target: artifacts.first().cloned(),
reason: blocker.clone(),
exact_need: exact_need.clone(),
next_step: next_step.clone(),
consequence_if_not_provided: Some(
"The task will remain blocked and the executor will stop without taking the gated action."
.to_string(),
),
partial_result: partial_result.clone(),
});
return ExecutorStepResult {
task_id: task_id.to_string(),
step_outcome: if needs_approval {
StepValidationOutcome::NeedsApproval
} else if partial_result.is_some() {
StepValidationOutcome::PartialDoneBlocked
} else {
StepValidationOutcome::Blocked
},
task_outcome: if needs_approval {
TaskValidationOutcome::NeedsApproval
} else if partial_result.is_some() {
TaskValidationOutcome::PartialDoneBlocked
} else {
TaskValidationOutcome::Blocked
},
summary,
artifacts,
blocker: Some(blocker),
exact_need: Some(exact_need),
next_step: Some(next_step),
approval_request,
partial_result,
};
}
if task_response
.as_deref()
.is_some_and(super::goal_completion_response_indicates_incomplete_work)
{
let blocker =
"The executor reported progress but did not verify the final outcome.".to_string();
return ExecutorStepResult {
task_id: task_id.to_string(),
step_outcome: StepValidationOutcome::VerifyAgain,
task_outcome: TaskValidationOutcome::PartialDoneBlocked,
summary,
artifacts: artifacts.clone(),
blocker: Some(blocker.clone()),
exact_need: Some(
"A fresh verification step before treating the task as complete.".to_string(),
),
next_step: Some(
"Run the remaining verification or follow-up check, then update the task."
.to_string(),
),
approval_request: None,
partial_result: Some(PartialResult {
completed_work_summary: task_response
.unwrap_or_else(|| "The executor reported partial progress.".to_string()),
artifacts,
blocker,
remaining_work: vec![
"Verify the final state before marking the task complete.".to_string()
],
}),
};
}
ExecutorStepResult {
task_id: task_id.to_string(),
step_outcome: StepValidationOutcome::StepDone,
task_outcome: TaskValidationOutcome::TaskDone,
summary,
artifacts,
blocker: None,
exact_need: None,
next_step: None,
approval_request: None,
partial_result: None,
}
}
fn mentions_blocker(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
super::contains_keyword_as_words(&lower, "blocked")
|| super::contains_keyword_as_words(&lower, "blocker")
|| super::contains_keyword_as_words(&lower, "cannot proceed")
}
fn mentions_approval(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
super::contains_keyword_as_words(&lower, "approval")
|| super::contains_keyword_as_words(&lower, "permission")
|| super::contains_keyword_as_words(&lower, "approve")
}
fn normalize_success_text(text: &str) -> String {
text.to_ascii_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn parse_context_object(existing_context: &str) -> Option<Value> {
let parsed = serde_json::from_str::<Value>(existing_context).ok()?;
parsed.is_object().then_some(parsed)
}
fn merge_context_entry(
existing_context: Option<&str>,
key: &str,
value: Value,
) -> Result<String, serde_json::Error> {
let mut context = existing_context
.and_then(parse_context_object)
.unwrap_or_else(|| {
existing_context
.filter(|context| !context.trim().is_empty())
.map(|context| json!({ "prior_context_raw": context }))
.unwrap_or_else(|| json!({}))
});
if let Some(object) = context.as_object_mut() {
object.insert(key.to_string(), value);
}
serde_json::to_string(&context)
}
pub fn build_needs_approval_request(
action_requested: impl Into<String>,
target: Option<String>,
reason: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
partial_result: Option<PartialResult>,
) -> HumanInterventionRequest {
HumanInterventionRequest {
outcome: ValidationOutcome::NeedsApproval,
approval_state: ApprovalState::Required,
action_requested: action_requested.into(),
target,
reason: reason.into(),
exact_need: exact_need.into(),
next_step: next_step.into(),
consequence_if_not_provided: Some(
"I will stop here and return the current best progress without taking the requested action."
.to_string(),
),
partial_result,
}
}
#[allow(dead_code)]
pub fn build_partial_done_blocked_request(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
blocker: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
) -> HumanInterventionRequest {
build_partial_done_blocked_request_with_plan(
turn_context,
learning_ctx,
None,
blocker,
exact_need,
next_step,
)
}
pub fn build_partial_done_blocked_request_with_plan(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
execution_state: Option<&ExecutionState>,
blocker: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
) -> HumanInterventionRequest {
let blocker = blocker.into();
let partial_result = summarize_partial_result_with_plan(
turn_context,
learning_ctx,
execution_state,
blocker.clone(),
);
HumanInterventionRequest {
outcome: ValidationOutcome::PartialDoneBlocked,
approval_state: ApprovalState::NotNeeded,
action_requested: "provide the missing verification input so I can finish safely"
.to_string(),
target: turn_context.completion_contract.primary_target_hint(),
reason: blocker.clone(),
exact_need: exact_need.into(),
next_step: next_step.into(),
consequence_if_not_provided: Some(
"I will stop short of claiming success because the outcome is not yet verified."
.to_string(),
),
partial_result: Some(partial_result),
}
}
#[allow(dead_code)]
pub fn build_reduce_scope_request(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
blocker: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
) -> HumanInterventionRequest {
build_reduce_scope_request_with_plan(
turn_context,
learning_ctx,
None,
blocker,
exact_need,
next_step,
)
}
pub fn build_reduce_scope_request_with_plan(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
execution_state: Option<&ExecutionState>,
blocker: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
) -> HumanInterventionRequest {
let blocker = blocker.into();
HumanInterventionRequest {
outcome: ValidationOutcome::ReduceScope,
approval_state: ApprovalState::NotNeeded,
action_requested: "confirm a narrower scope so I can finish safely".to_string(),
target: turn_context.completion_contract.primary_target_hint(),
reason: blocker.clone(),
exact_need: exact_need.into(),
next_step: next_step.into(),
consequence_if_not_provided: Some(
"I will stop here instead of silently continuing beyond the current safe scope."
.to_string(),
),
partial_result: Some(summarize_partial_result_with_plan(
turn_context,
learning_ctx,
execution_state,
blocker,
)),
}
}
pub fn build_abandon_request(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
blocker: impl Into<String>,
exact_need: impl Into<String>,
next_step: impl Into<String>,
) -> HumanInterventionRequest {
let blocker = blocker.into();
HumanInterventionRequest {
outcome: ValidationOutcome::Abandon,
approval_state: ApprovalState::NotNeeded,
action_requested: "replace the current approach with a new plan".to_string(),
target: turn_context.completion_contract.primary_target_hint(),
reason: blocker.clone(),
exact_need: exact_need.into(),
next_step: next_step.into(),
consequence_if_not_provided: Some(
"I will abandon this execution path rather than repeatedly retrying a broken approach."
.to_string(),
),
partial_result: (!learning_ctx.tool_calls.is_empty())
.then(|| summarize_partial_result(turn_context, learning_ctx, blocker)),
}
}
fn summarize_partial_result(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
blocker: String,
) -> PartialResult {
summarize_partial_result_with_plan(turn_context, learning_ctx, None, blocker)
}
pub(crate) fn summarize_partial_result_with_plan(
turn_context: &TurnContext,
learning_ctx: &LearningContext,
execution_state: Option<&ExecutionState>,
blocker: String,
) -> PartialResult {
let completed_work_summary =
if let Some(plan) = execution_state.and_then(|es| es.active_linear_intent_plan.as_ref()) {
format_plan_for_blocked_message(plan, execution_state.unwrap())
} else if let Some(reconciliation) =
execution_state.and_then(|es| es.build_reconciliation_overview())
{
reconciliation.summary
} else if !learning_ctx.tool_calls.is_empty() {
let summary = post_task::categorize_tool_calls(&learning_ctx.tool_calls);
if summary.trim().is_empty() {
format!(
"Completed {} tool action(s).",
learning_ctx.tool_calls.len()
)
} else {
summary.trim().to_string()
}
} else {
"No executable work completed yet.".to_string()
};
let artifacts = turn_context
.completion_contract
.primary_target_hint()
.into_iter()
.collect();
let remaining_work =
if let Some(plan) = execution_state.and_then(|es| es.active_linear_intent_plan.as_ref()) {
plan.steps
.iter()
.filter(|s| s.step_index > plan.current_step_cursor)
.map(|s| s.description.clone())
.take(3)
.collect()
} else if turn_context.completion_contract.requires_observation {
vec!["Run the final verification step and confirm the result.".to_string()]
} else if learning_ctx.tool_calls.is_empty() {
vec!["Resume the next concrete step once the blocker is resolved.".to_string()]
} else {
vec!["Synthesize the observed result into the final user-facing answer.".to_string()]
};
PartialResult {
completed_work_summary,
artifacts,
blocker,
remaining_work,
}
}
fn format_plan_for_blocked_message(
plan: &LinearIntentPlan,
execution_state: &ExecutionState,
) -> String {
let has_outcomes = execution_state
.outcome_ledger
.iter()
.any(|e| e.planned_step_id.is_some());
let mut lines = Vec::new();
if has_outcomes {
for step in &plan.steps {
let outcomes: Vec<&OutcomeEntry> = execution_state
.outcome_ledger
.iter()
.filter(|e| e.planned_step_id.as_deref() == Some(&step.step_id))
.collect();
if outcomes.is_empty() {
if step.step_index <= plan.current_step_cursor {
lines.push(format!(
"\u{2705} Step {}: {}",
step.step_index, step.description
));
} else {
lines.push(format!(
"\u{2b1c} Step {}: {}",
step.step_index, step.description
));
}
} else if let Some(last) = outcomes.last() {
if last.success {
lines.push(format!(
"\u{2705} Step {}: {}",
step.step_index, step.description
));
} else {
let err = last.error_summary.as_deref().unwrap_or("unknown error");
let err = crate::utils::truncate_str(err, 100);
let attempts = outcomes.len();
lines.push(format!(
"\u{274c} Step {}: {} \u{2014} {} ({} attempt{})",
step.step_index,
step.description,
err,
attempts,
if attempts == 1 { "" } else { "s" }
));
}
}
}
} else {
lines.push("Plan:".to_string());
for step in &plan.steps {
lines.push(format!("{}. {}", step.step_index, step.description));
}
let total = execution_state.outcome_ledger.len();
let succeeded = execution_state
.outcome_ledger
.iter()
.filter(|e| e.success)
.count();
if total > 0 {
lines.push(format!(
"\nTool results: {} tool calls completed ({} succeeded, {} failed).",
total,
succeeded,
total - succeeded
));
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{
history::CompletionTaskKind, CompletionContract, VerificationTarget, VerificationTargetKind,
};
use chrono::Utc;
#[test]
fn partial_done_blocked_request_is_specific() {
let turn_context = TurnContext {
completion_contract: CompletionContract {
task_kind: CompletionTaskKind::Diagnose,
requires_observation: true,
verification_targets: vec![VerificationTarget {
kind: VerificationTargetKind::Url,
value: "https://example.com/health".to_string(),
}],
..CompletionContract::default()
},
..TurnContext::default()
};
let learning_ctx = LearningContext {
user_text: "deploy it".to_string(),
intent_domains: Vec::new(),
tool_calls: vec![
"terminal(npm run build)".to_string(),
"terminal(cloudflare deploy)".to_string(),
],
errors: Vec::new(),
first_error: None,
recovery_actions: Vec::new(),
start_time: Utc::now(),
completed_naturally: false,
explicit_positive_signals: 0,
explicit_negative_signals: 0,
task_outcome: None,
replay_notes: Vec::new(),
};
let request = build_partial_done_blocked_request(
&turn_context,
&learning_ctx,
"I still need a live verification check.",
"A fresh read-only verification against the deployed URL.",
"I will run the final verification check and then confirm the deployment state.",
);
let rendered = request.render_user_message();
assert_eq!(request.outcome, ValidationOutcome::PartialDoneBlocked);
assert!(rendered.contains("Completed work so far:"));
assert!(rendered.contains("https://example.com/health"));
assert!(rendered.contains("What I need from you:"));
assert!(rendered.contains("What I will do next:"));
}
#[test]
fn needs_approval_request_formats_inline_prompt() {
let request = build_needs_approval_request(
"extend the task budget from 10_000 to 20_000 tokens",
Some("task budget".to_string()),
"The current task budget is exhausted.",
"Explicit owner approval to continue spending tokens for this run.",
"Continue the current task inside the extended budget.",
None,
);
let (description, warnings) = request.to_inline_approval_prompt();
assert_eq!(request.outcome, ValidationOutcome::NeedsApproval);
assert_eq!(request.approval_state, ApprovalState::Required);
assert!(description.contains("Approval needed: extend the task budget"));
assert!(warnings
.iter()
.any(|warning| warning.contains("Explicit owner approval")));
assert!(warnings
.iter()
.any(|warning| warning.contains("Next step after approval")));
}
#[test]
fn reduce_scope_summary_for_non_observation_work_does_not_claim_verification() {
let turn_context = TurnContext {
completion_contract: CompletionContract {
task_kind: CompletionTaskKind::Answer,
requires_observation: false,
..CompletionContract::default()
},
..TurnContext::default()
};
let learning_ctx = LearningContext {
user_text: "find trials".to_string(),
intent_domains: Vec::new(),
tool_calls: vec![
"http_request(GET https://clinicaltrials.gov/api/v2/studies)".to_string(),
],
errors: Vec::new(),
first_error: None,
recovery_actions: Vec::new(),
start_time: Utc::now(),
completed_naturally: false,
explicit_positive_signals: 0,
explicit_negative_signals: 0,
task_outcome: None,
replay_notes: Vec::new(),
};
let request = build_reduce_scope_request(
&turn_context,
&learning_ctx,
"Budget exhausted before I could finish the summary.",
"A narrower target.",
"I will continue on the narrowed scope.",
);
let partial = request.partial_result.expect("expected partial result");
assert_eq!(
partial.remaining_work,
vec!["Synthesize the observed result into the final user-facing answer.".to_string()]
);
}
}