Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions agent-engine/src/executor/agent_loop/meta_tools/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ impl CompletionHandoff {
next_actions: None,
}
}

pub(crate) fn is_blocked(&self) -> bool {
self.blockers.as_ref().is_some_and(|value| match value {
Value::Array(items) => !items.is_empty(),
Value::Object(fields) => !fields.is_empty(),
Value::String(text) => !text.trim().is_empty(),
Value::Bool(value) => *value,
Value::Number(_) => true,
Value::Null => false,
})
}
}

impl AgentExecutor {
Expand Down Expand Up @@ -135,15 +146,30 @@ impl AgentExecutor {
ConversationContent::Steer {
message: final_message,
further_actions_required: false,
reasoning: "Run completed — terminal handoff recorded.".to_string(),
reasoning: "Run terminal handoff recorded.".to_string(),
attachments: None,
},
ConversationMessageType::Steer,
)
.await?;

let execution_state = {
let mut state = self.build_execution_state_snapshot(None);
if handoff.is_blocked() && self.can_abort_current_assignment_execution() {
state.assignment_outcome_override = Some(models::ThreadAssignmentOutcomeOverride {
assignment_status: models::project_task_board::assignment_status::BLOCKED
.to_string(),
result_status: Some(
models::project_task_board::assignment_result_status::BLOCKED.to_string(),
),
note: Some(handoff.summary.clone()),
});
}
state
};

UpdateAgentThreadStateCommand::new(self.ctx.thread_id, self.ctx.agent.deployment_id)
.with_execution_state(self.build_execution_state_snapshot(None))
.with_execution_state(execution_state)
.with_status(AgentThreadStatus::Idle)
.execute_with_deps(&common::deps::from_app(&self.ctx.app_state).db().nats().id())
.await?;
Expand Down
4 changes: 2 additions & 2 deletions agent-engine/src/executor/agent_loop/meta_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ pub fn complete_tool() -> NativeToolDefinition {
Before calling, self-review the turn: every action you said you'd take must already be done \
(tool call visible in history), journal updated for service work, user feedback resolved. \
If anything is still pending, do it first and call `terminate_loop` on a later turn. \
If the work is genuinely blocked rather than done, don't fake success — set `update_project_task` \
status `blocked` with a result summary, then call `terminate_loop`. \
If the work is genuinely blocked rather than done, don't fake success — include the concrete blocker(s) in `blockers` and the handoff in `next_actions`, then call `terminate_loop`; the runtime records the assignment as blocked. \
Use `abort_task` only when the loop cannot exit cleanly. \
Comment on lines +215 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remove the conflicting blocked-work instruction.

Lines 215-216 direct routine blocked work to terminate_loop with blockers and next_actions. However, abort_tool at Lines 260-266 still directs the agent to call update_project_task with status blocked before terminate_loop. This conflict reduces deterministic tool selection and can reintroduce the obsolete completion flow.

Update abort_tool to describe terminate_loop with blockers and next_actions as the routine blocked exit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent-engine/src/executor/agent_loop/meta_tools/mod.rs` around lines 215 -
216, Update the `abort_tool` instructions to make `terminate_loop` with concrete
`blockers` and a handoff in `next_actions` the routine exit for blocked work,
removing the conflicting requirement to call `update_project_task` with status
`blocked`; retain `abort_task` only for loops that cannot exit cleanly.

Must be the only tool call in its response; text alongside it is delivered as your final reply/log. \
`summary` is the durable handoff — the next lane or reviewer reads it cold, so ground it in what \
actually happened this run. Pull `artifacts` from real tool results, never from intention."
Expand Down
82 changes: 77 additions & 5 deletions agent-engine/src/executor/agent_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,28 @@ impl AgentExecutor {
return Ok(false);
}

// Soft escalation: emit each turn while tool-failure or unproductive-turn
// counters remain elevated. This fires BEFORE the hard abort at
// MAX_UNPRODUCTIVE_TURNS, giving the model a chance to self-correct.
// Role-aware: checks whether `ask_user` is available so the message can
// steer the model toward the right recovery path.
if self.consecutive_tool_failure_count >= 2
|| (self.consecutive_unproductive_turns >= 2
&& self.consecutive_unproductive_turns < MAX_UNPRODUCTIVE_TURNS)
{
let ask_user_available = !self
.ctx
.agent
.disabled_internal_tools
.iter()
.any(|t| t == "ask_user");
self.signal(core::RuntimeSignal::ToolFailureEscalation {
ask_user_available,
failure_count: self.consecutive_tool_failure_count,
unproductive_count: self.consecutive_unproductive_turns,
});
}

if self.consecutive_unproductive_turns >= MAX_UNPRODUCTIVE_TURNS {
if self.can_abort_current_assignment_execution() {
self.abort_current_assignment_execution(&AbortDirective {
Expand Down Expand Up @@ -1017,9 +1039,16 @@ impl AgentExecutor {
serde_json::Value::Null,
)
.await;
let mut output = llm
let mut output = match llm
.generate_tool_calls(request, native_tools, cache_request)
.await?;
.await
{
Ok(output) => output,
Err(error) => {
self.signal(crate::executor::core::RuntimeSignal::LlmRequestFailed);
return Err(error);
}
};
self.run_hooks(
super::hooks::LifecyclePhase::AfterLlm,
serde_json::Value::Null,
Expand Down Expand Up @@ -1357,6 +1386,12 @@ impl AgentExecutor {
),
)
.await?;
// Transient signal: surfaces in the next prompt's
// [runtime_signals] block once, then is cleared by
// discriminant dedup on the next signal or by drain.
self.signal(core::RuntimeSignal::UnknownToolCall {
tool_name: call.tool_name.clone(),
});
continue;
}
};
Expand Down Expand Up @@ -1407,6 +1442,8 @@ impl AgentExecutor {
}

let signature = Self::tool_call_signature(&tool_requests);

// --- Adjacent-match: same signature as the immediately previous turn ---
if self
.last_tool_call_signature
.as_deref()
Expand All @@ -1417,8 +1454,38 @@ impl AgentExecutor {
} else {
self.repeated_tool_call_count = 0;
}
self.last_tool_call_signature = Some(signature);
self.last_tool_call_signature = Some(signature.clone());

// --- Non-adjacent cycle detection: bounded window of recent signatures ---
// Push the current signature into the bounded window. Then scan the window
// (excluding the most-recent entry, which is the adjacent case above) for
// any earlier occurrence of the same signature. This catches alternating
// patterns like A→B→A→B or A→B→C→A without firing when the agent is
// making genuine progress (different arguments ⇒ different signature).
const TOOL_CALL_CYCLE_WINDOW: usize = 6;
self.recent_tool_call_signatures
.push_back(signature.clone());
while self.recent_tool_call_signatures.len() > TOOL_CALL_CYCLE_WINDOW {
self.recent_tool_call_signatures.pop_front();
}
if self.repeated_tool_call_count < 2 {
// The adjacent case already fires at count >= 2; only scan the wider
// window when the adjacent check did NOT trigger.
let window_len = self.recent_tool_call_signatures.len();
if window_len >= 3 {
// Check all entries except the last one (current turn).
for earlier_idx in (0..window_len - 1).rev() {
if self.recent_tool_call_signatures[earlier_idx] == signature {
// Compute cycle window length (turns since first occurrence).
let cycle_len = window_len - earlier_idx;
self.signal(core::RuntimeSignal::ToolCallCycle { cycle_len });
break;
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// --- Adjacent-match sustained signal (unchanged) ---
if self.repeated_tool_call_count >= 2 {
self.signal(core::RuntimeSignal::ToolCallLoop {
count: self.repeated_tool_call_count + 1,
Expand Down Expand Up @@ -1585,13 +1652,18 @@ impl AgentExecutor {
}
};

let outcome = if handoff.is_blocked() {
"blocked"
} else {
"completed"
};
let mut cmd = commands::CreateTaskHandoffSummaryCommand::new(
handoff_id,
self.ctx.agent.deployment_id,
board_item_id,
self.ctx.thread_id,
role_str,
"completed",
outcome,
summary.clone(),
)
.with_execution_run_id(self.ctx.execution_run_id);
Expand Down Expand Up @@ -1650,7 +1722,7 @@ impl AgentExecutor {
source_thread_id: self.ctx.thread_id,
board_item_id,
source_role: role_str.to_string(),
outcome: "completed".to_string(),
outcome: outcome.to_string(),
summary,
artifacts,
blockers,
Expand Down
81 changes: 74 additions & 7 deletions agent-engine/src/executor/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,55 @@ pub enum ResumeContext {
/// Wording, key, and log severity live here — call sites only pick a variant.
#[derive(Debug, Clone)]
pub(crate) enum RuntimeSignal {
NoteLoop { count: usize },
NoteLoop {
count: usize,
},
EmptyResponse,
ResponseTruncated,
ShellDiscipline { message: String },
ShellDisciplineEscalated { count: usize },
ToolCallLoop { count: usize },
BatchBackpressure { batch_size: usize },
ShellDiscipline {
message: String,
},
ShellDisciplineEscalated {
count: usize,
},
ToolCallLoop {
count: usize,
},
/// Transient signal emitted when the same tool-call signature recurs within
/// the bounded recent-turn window without being adjacent.
ToolCallCycle {
cycle_len: usize,
},
/// Transient signal emitted when the LLM invokes a tool name absent from the
/// current turn's allowed set. Cleared after one prompt render (transient by
/// discriminant dedup).
UnknownToolCall {
tool_name: String,
},
/// Sustained escalation emitted each turn while tool-failure or
/// unproductive-turn counters remain elevated. Re-emitted every iteration so
/// the signal persists until the condition resolves or the loop aborts.
/// Role-aware: carries `ask_user_available` so the message can guide the
/// model toward the right recovery path.
ToolFailureEscalation {
ask_user_available: bool,
failure_count: usize,
unproductive_count: usize,
},
BatchBackpressure {
batch_size: usize,
},
CompleteRequired,
CompleteBlocked { reason: String },
AskUserBlocked { reason: String },
CompleteBlocked {
reason: String,
},
AskUserBlocked {
reason: String,
},
UserVisibilityLapse,
CoordinatorBriefMissing,
StateIntent,
LlmRequestFailed,
}

impl RuntimeSignal {
Expand All @@ -44,13 +80,17 @@ impl RuntimeSignal {
"shell_discipline"
}
Self::ToolCallLoop { .. } => "tool_call_loop",
Self::ToolCallCycle { .. } => "tool_call_cycle",
Self::UnknownToolCall { .. } => "unknown_tool_call",
Self::ToolFailureEscalation { .. } => "tool_failure_escalation",
Self::BatchBackpressure { .. } => "batch_backpressure",
Self::CompleteRequired => "complete_required",
Self::CompleteBlocked { .. } => "complete_blocked",
Self::AskUserBlocked { .. } => "ask_user_blocked",
Self::UserVisibilityLapse => "user_visibility",
Self::CoordinatorBriefMissing => "coordinator_brief_missing",
Self::StateIntent => "state_intent",
Self::LlmRequestFailed => "llm_request_failed",
}
}

Expand All @@ -68,6 +108,23 @@ impl RuntimeSignal {
Self::ToolCallLoop { count } => format!(
"identical tool call repeated {count} turns in a row; the result will not change — change inputs, change tool, or report blocked"
),
Self::ToolCallCycle { cycle_len } => format!(
"the same tool-call signature recurred after {cycle_len} turns; change inputs, change tool, or report blocked"
),
Self::UnknownToolCall { tool_name } => format!(
"tool '{tool_name}' is not in this turn's allowed set; pick a tool from the available list by exact name, or respond with text if none fit"
),
Self::ToolFailureEscalation { ask_user_available, failure_count, unproductive_count } => {
if *ask_user_available {
format!(
"{failure_count} consecutive tool failures and {unproductive_count} unproductive turns — stop retrying the same approach. Either ask the user for clarification via `ask_user`, change strategy completely, or call `terminate_loop` with a status report"
)
} else {
format!(
"{failure_count} consecutive tool failures and {unproductive_count} unproductive turns — stop retrying the same approach. Change strategy completely, or call `terminate_loop` to report the blocker"
)
}
},
Self::BatchBackpressure { batch_size } => format!(
"{batch_size} tool calls in one turn; read those results and let them choose the next narrow step before fanning out further"
),
Expand All @@ -77,6 +134,7 @@ impl RuntimeSignal {
Self::UserVisibilityLapse => "no user-visible message in the last 4 visible steps; add one short progress line beside the next tool call unless it is a tiny read".to_string(),
Self::CoordinatorBriefMissing => "the task brief `/task/TASK.md` isn't ready yet — write a complete brief there (objective, scope, acceptance criteria) before routing work to a lane, so the executor has one to read".to_string(),
Self::StateIntent => "a new user message just arrived — call `note` once stating your intent: one or two sentences covering any work you were mid-way through (so it survives the interruption) and what you will do next for this message, then proceed (you may batch it with your first real step)".to_string(),
Self::LlmRequestFailed => "the previous model request failed before producing output; emit a simpler, shorter response this turn — fewer tool calls, narrower scope, or split the work into smaller steps".to_string(),
}
}

Expand All @@ -87,7 +145,11 @@ impl RuntimeSignal {
| Self::EmptyResponse
| Self::ResponseTruncated
| Self::ToolCallLoop { .. }
| Self::ToolCallCycle { .. }
| Self::UnknownToolCall { .. }
| Self::ToolFailureEscalation { .. }
| Self::CompleteBlocked { .. }
| Self::LlmRequestFailed
)
}

Expand Down Expand Up @@ -149,6 +211,10 @@ pub struct AgentExecutor {
pub(crate) complete_nudge_count: usize,
pub(crate) terminate_loop_guard_rejections: usize,
pub(crate) pending_runtime_signals: Vec<RuntimeSignal>,
/// Bounded window of recent tool-call signatures for non-adjacent cycle
/// detection. Only the most recent N entries are kept; each signature is a
/// sorted concatenation of `name:args` for the turn's non-meta tool calls.
pub(crate) recent_tool_call_signatures: std::collections::VecDeque<String>,
pub(crate) audit_run_header_written: bool,
pub(crate) preloaded_immediate_context: Option<ImmediateContext>,
pub(crate) budget: super::budget::BudgetCounter,
Expand Down Expand Up @@ -472,6 +538,7 @@ impl AgentExecutorBuilder {
complete_nudge_count: 0,
terminate_loop_guard_rejections: 0,
pending_runtime_signals: Vec::new(),
recent_tool_call_signatures: std::collections::VecDeque::new(),
audit_run_header_written: false,
preloaded_immediate_context: Some(immediate_context),
budget: super::budget::BudgetCounter::new(run_token_budget),
Expand Down
Loading