Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4d20323
feat(config): add agent loop detection settings
Oct 14, 2026
7255863
feat(agent-runtime): add loop detection tier policy
Oct 14, 2026
0fde3b3
feat(agent-stream): add pure L1 loop detection core
Oct 14, 2026
73931bb
test(agent-stream): add synthetic loop corpus and detector behavior t…
Oct 14, 2026
6678600
feat(core): add loop detection ladder, arbiter port, and tier transla…
Oct 14, 2026
1b0981c
feat(core): add model-backed L2 loop judge arbiter
Oct 14, 2026
6872030
feat(core): wire loop detection observation and the intervention ladder
Oct 14, 2026
b7cd0cb
docs(core): record the loop detection ladder contract and test command
Oct 14, 2026
7074b96
feat(core): honor the loop detection session toggle within a running …
Oct 14, 2026
ec79966
Merge the model-backed L2 loop judge
Oct 14, 2026
d273775
feat(core): route loop arbitration through the model judge
Oct 14, 2026
38dcb46
feat(events): add the loop detection noted event
Oct 14, 2026
3d33b16
feat(web): surface loop detection notices
Oct 14, 2026
df3855b
feat(cli): report loop detection interventions
Oct 14, 2026
7e5fac2
feat(relay): publish the loop detection note as a control record
Oct 14, 2026
40a34bf
docs: describe the agent loop detection feature
Oct 14, 2026
bff51cc
feat(core): gate judge-confirmed escalation on the configured confidence
Oct 14, 2026
7f204bf
test(core): drive loop detection through a real turn
Oct 14, 2026
a625d90
docs(agent-stream): correct the predictable drift reporting note
Oct 14, 2026
0a81e18
docs: align loop detection docs with the reviewed behavior
Oct 14, 2026
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
466 changes: 466 additions & 0 deletions docs/features/agent-loop-detection.md

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1290,7 +1290,7 @@ impl ChatState {
parent_tool_id: &str,
event: &openbitfun_events::AgenticEvent,
) {
use openbitfun_events::AgenticEvent;
use openbitfun_events::{AgenticEvent, LoopDetectionEscalation};

match event {
AgenticEvent::ToolEvent { tool_event, .. } => match tool_event {
Expand Down Expand Up @@ -1356,6 +1356,28 @@ impl ChatState {
});
self.rebuild_streaming_message();
}
// A subagent's loop-detection intervention belongs to the parent Task
// card the user is actually watching: the child session is hidden, and
// the event only carries its own session id, so the forwarding that
// routes child events here is what bubbles the signal up.
AgenticEvent::LoopDetectionNoted {
kind, escalation, ..
} if *escalation != LoopDetectionEscalation::Noted => {
let title = match escalation {
LoopDetectionEscalation::Stopped => {
format!("Loop detected ({kind}); stopped the subagent turn")
}
_ => format!("Loop detected ({kind}); asked the subagent to change course"),
};
self.update_tool(parent_tool_id, |tool| {
let progress = tool
.subagent_progress
.get_or_insert_with(SubagentProgress::default);
progress.current_tool_name = None;
progress.current_tool_title = Some(title);
});
self.rebuild_streaming_message();
}
_ => {}
}
}
Expand Down
43 changes: 42 additions & 1 deletion src/apps/cli/src/modes/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::sync::{
use std::time::{Duration, Instant};
use tokio::sync::broadcast::error::TryRecvError;

use openbitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity};
use openbitfun_events::{AgenticEvent, LoopDetectionEscalation, ToolEventData, ToolEventIdentity};
use openbitfun_product_domains::account::AccountSnapshotProjection;
use openbitfun_product_domains::agent_catalog::{SkillSummary, SubagentSummary};
use openbitfun_product_domains::native_hooks::{
Expand Down Expand Up @@ -146,6 +146,31 @@ enum TranscriptTerminalOutcome {
SystemError(String),
}

/// Transcript status line for one loop-detection intervention.
///
/// Only a steer is reported from the event: a hard stop is reported from the
/// turn's `loop_detected` finalization reason instead, so one stopped turn never
/// renders two lines for the same decision. Report-only decisions
/// (`ai.loop_detection.mode` calibration) stay in the log.
fn loop_detection_status_line(
kind: &str,
confidence: f32,
escalation: LoopDetectionEscalation,
) -> Option<String> {
if escalation != LoopDetectionEscalation::Steered {
return None;
}
Some(format!(
"[loop detection] The agent started repeating itself ({kind} signal, confidence {confidence:.2}). A correction reminder was injected and the turn continues."
))
}

/// Transcript status line for a turn ended by the loop-detection fallback.
fn loop_detected_turn_status_line() -> String {
"[loop detection] The turn was stopped because the agent kept repeating itself after correction attempts."
.to_string()
}

/// Shared event-to-transcript projection used by the root conversation and a
/// read-only inspected descendant. Host notifications and git refresh remain
/// root-only side effects in the caller.
Expand Down Expand Up @@ -214,13 +239,29 @@ fn project_transcript_event(
outcome.changed = true;
}
}
AgenticEvent::LoopDetectionNoted {
turn_id,
kind,
confidence,
escalation,
..
} if chat_state.current_turn_id() == Some(turn_id.as_str()) => {
if let Some(status_line) = loop_detection_status_line(kind, *confidence, *escalation) {
chat_state.add_system_message(status_line);
outcome.changed = true;
}
}
AgenticEvent::DialogTurnCompleted {
turn_id,
total_rounds,
total_tools,
finish_reason,
..
} if chat_state.current_turn_id() == Some(turn_id.as_str()) => {
chat_state.handle_turn_completed(*total_rounds, *total_tools);
if finish_reason.as_deref() == Some("loop_detected") {
chat_state.add_system_message(loop_detected_turn_status_line());
}
outcome.changed = true;
outcome.terminal = Some(TranscriptTerminalOutcome::Completed);
}
Expand Down
26 changes: 24 additions & 2 deletions src/apps/cli/src/modes/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ mod tests {
external_hook_help_text, external_integration_policy_lines,
external_operation_error_status, external_tool_mutation_result_label,
external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text,
external_tool_run_location_label, mark_active_turn_failed,
external_tool_run_location_label, loop_detected_turn_status_line,
loop_detection_status_line, mark_active_turn_failed,
merge_external_agent_mutation_snapshot, native_command_choice_is_active,
native_command_reconfirmation_is_required, native_hook_help_text,
parse_external_agent_review_action, parse_external_control_action,
Expand All @@ -42,7 +43,7 @@ mod tests {
use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection};
use crate::ui::theme::Theme;
use crossterm::event::Event;
use openbitfun_events::{AgenticEvent, ToolEventData};
use openbitfun_events::{AgenticEvent, LoopDetectionEscalation, ToolEventData};
use openbitfun_product_domains::external_hook_catalog::{
ExternalHookCatalogEntry, ExternalHookCatalogSnapshotV1, ExternalHookHandlerKind,
ExternalHookMatcherSummary, ExternalHookNativeActivation, ExternalHookProjectionStatus,
Expand Down Expand Up @@ -73,6 +74,27 @@ mod tests {
snapshot.into()
}

#[test]
fn loop_detection_reports_one_line_per_intervention() {
let steered =
loop_detection_status_line("verbatim_block", 0.91, LoopDetectionEscalation::Steered)
.expect("a steer is user visible");
assert!(steered.contains("verbatim_block"));
assert!(steered.contains("0.91"));

// Report-only decisions stay in the log, and a hard stop is reported from
// the turn's finalization reason so the stopped turn never prints twice.
assert!(
loop_detection_status_line("phrase_stuck", 0.8, LoopDetectionEscalation::Noted)
.is_none()
);
assert!(
loop_detection_status_line("phrase_stuck", 0.8, LoopDetectionEscalation::Stopped)
.is_none()
);
assert!(loop_detected_turn_status_line().contains("stopped"));
}

#[test]
fn goal_prompts_use_normal_runtime_submission_and_pending_guards() {
use crate::actions::ActionContext;
Expand Down
23 changes: 22 additions & 1 deletion src/apps/cli/src/modes/exec/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use openbitfun_agent_runtime::sdk::{
};
use openbitfun_agent_runtime::thread_goal::{ThreadGoalRunDisposition, ThreadGoalRunTracker};
use openbitfun_agent_tools::effective_tool_invocation;
use openbitfun_events::{AgenticEvent, ToolEventIdentity};
use openbitfun_events::{AgenticEvent, LoopDetectionEscalation, ToolEventIdentity};
use tokio::time::Instant;

use crate::agent::runtime_client::CliAgentRuntimeClient;
Expand Down Expand Up @@ -116,6 +116,7 @@ pub(super) fn event_turn_id(event: &AgenticEvent) -> Option<&str> {
| AgenticEvent::ThinkingChunk { turn_id, .. }
| AgenticEvent::ToolEvent { turn_id, .. }
| AgenticEvent::DeepReviewQueueStateChanged { turn_id, .. }
| AgenticEvent::LoopDetectionNoted { turn_id, .. }
| AgenticEvent::UserSteeringInjected { turn_id, .. } => Some(turn_id),
_ => None,
}
Expand Down Expand Up @@ -1387,6 +1388,26 @@ impl ExecMode {
_ => {}
}
}
// Report-only loop-detection decisions are calibration telemetry and
// stay out of the terminal; an intervention is worth one status line.
AgenticEvent::LoopDetectionNoted {
turn_id: event_turn_id,
escalation,
kind,
confidence,
..
} if event_turn_id == turn_id && *escalation != LoopDetectionEscalation::Noted => {
let next_action = match escalation {
LoopDetectionEscalation::Stopped => "stopping this turn",
_ => "injected a correction reminder",
};
self.print_text(|| {
eprintln!(
"\n [!] Loop detection: repeated {} signal (confidence {:.2}); {}",
kind, confidence, next_action
)
});
}
_ => {}
}
Ok(())
Expand Down
27 changes: 26 additions & 1 deletion src/apps/cli/src/modes/exec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use openbitfun_agent_runtime::sdk::{
PermissionRequestSourceKind, PortError, PortErrorKind, RuntimeError,
};
use openbitfun_events::{
AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity,
AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, LoopDetectionEscalation,
ToolEventIdentity,
};
use serde_json::json;
use std::collections::BTreeSet;
Expand Down Expand Up @@ -532,6 +533,30 @@ fn exec_turn_filter_accepts_session_correlated_system_errors() {
));
}

#[test]
fn exec_turn_filter_keeps_loop_detection_notes_on_their_own_turn() {
let event = AgenticEvent::LoopDetectionNoted {
session_id: "session-1".to_string(),
turn_id: "turn-current".to_string(),
kind: "verbatim_block".to_string(),
confidence: 0.9,
escalation: LoopDetectionEscalation::Steered,
judge_arbitrated: false,
};

assert_eq!(event_turn_id(&event), Some("turn-current"));
assert!(event_belongs_to_exec_turn(
&event,
"session-1",
"turn-current"
));
assert!(!event_belongs_to_exec_turn(
&event,
"session-1",
"turn-other"
));
}

#[tokio::test]
async fn interrupted_turn_returns_a_delayed_terminal_without_emitting_it() {
let (event_tx, mut event_rx) = tokio::sync::broadcast::channel(4);
Expand Down
10 changes: 10 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,10 @@ async fn init_agentic_system() -> anyhow::Result<(
));

let execution_config = execution::execution_engine_config_from_global_config().await;
// The L2 loop-detection judge is installed from the same configuration
// snapshot the engine itself reads, so a disabled judge never builds an
// arbiter the engine would never consult.
let loop_judge_enabled = execution_config.loop_detection.judge_enabled;

let execution_engine = Arc::new(execution::ExecutionEngine::new(
round_executor,
Expand All @@ -2048,6 +2052,12 @@ async fn init_agentic_system() -> anyhow::Result<(
execution_config,
));

if let Some(arbiter) =
execution::loop_detection::configured_model_loop_arbiter(loop_judge_enabled)
{
execution_engine.install_loop_arbiter(arbiter);
}

let runtime_ownership = Arc::new(
openbitfun_core::runtime_ownership::CoreRuntimeOwnership::embedded(
path_manager.as_ref(),
Expand Down
11 changes: 11 additions & 0 deletions src/crates/assembly/core/src/agentic/core/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ pub enum InternalReminderKind {
SessionMessageReply,
LoopRecovery,
PeriodicLoopRecovery,
/// Repeated internal reasoning was detected; the agent is asked to abandon
/// the line it is repeating and act on its conclusion.
ThinkingLoopRecovery,
/// Repeated user-visible output was detected; the agent is asked to stop
/// restating itself and finish or continue from new ground.
OutputLoopRecovery,
UserSteering,
BackgroundResult,
InterruptedContinue,
Expand Down Expand Up @@ -158,6 +164,11 @@ impl InternalReminderKind {
| Self::AgentListingDiff
| Self::LoopRecovery
| Self::PeriodicLoopRecovery
// Loop-detection steers join their recovery siblings: they
// describe a situation that has passed once the context they
// were injected into is compacted away.
| Self::ThinkingLoopRecovery
| Self::OutputLoopRecovery
| Self::InterruptedContinue
| Self::ThinkingOnlyRescue
| Self::FinalizeCacheAnchor
Expand Down
4 changes: 2 additions & 2 deletions src/crates/assembly/core/src/agentic/events/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ pub use openbitfun_events::agentic::ErrorCategory;
pub use openbitfun_events::{
AgenticEvent as BaseAgenticEvent, AgenticEventEnvelope as EventEnvelope,
AgenticEventPriority as EventPriority, DeepReviewQueueReason, DeepReviewQueueState,
DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, ModelRoundAttemptToolDiagnostic,
SubagentParentInfo, ToolEventData,
DeepReviewQueueStatus, LoopDetectionEscalation, ModelRoundAttemptDiagnostic,
ModelRoundAttemptToolDiagnostic, SubagentParentInfo, ToolEventData,
};

// ============ Core layer AgenticEvent extension ============
Expand Down
39 changes: 39 additions & 0 deletions src/crates/assembly/core/src/agentic/execution/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,45 @@ For model retry admission and recovery, use:
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::execution::round_executor::tests
```

For agent loop detection (tier-to-detector translation, the intervention ladder,
the L2 arbitration port, and the model-backed judge adapter), use:

```bash
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::execution::loop_detection
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::execution::loop_arbiter
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib loop_detection_turn_tests
```

The last command drives the engine-level mounting point: real scripted rounds
through the provider fixture in `round_executor::tests`, the production
round-boundary entry point, a real session manager, and a real event queue. It is
the regression net for the observe-steer-hard-stop chain; add a case there (not a
ladder-only unit test) whenever that chain changes.

Detection is observation-first: every verdict is logged in every mode, a steer is
injected at the round boundary like the existing failed-tool recovery reminder,
and the hard fallback reuses the three-attempt pattern with
`finalization_reason=loop_detected`. Only high-confidence kinds may hard stop; a
judge-confirmed confidence-capped kind is the single exception, and that
confirmation counts only at or above
`ai.loop_detection.thresholds.judge_verdict_confidence_min` (baseline 0.9, absent
confidence is not a pass). Detector state belongs to the turn
(`LoopInterventionLadder` owns it), and `mode=off` or a closed session escape
hatch must never feed the detectors.

The arbitration port stays model-free and the judge stays session-free;
`loop_detection/model_arbiter.rs` is the only place they meet, and it owns the
whole conversion: the observed round's model becomes the judge's avoided model,
and `loop`/`not_loop`/`unclear` map to confirmed/rejected/indeterminate — the
confirmation carries the judge's stated confidence, which the ladder gates on
`ai.loop_detection.thresholds.judge_verdict_confidence_min` — with every unusable
judge outcome (disabled, no eligible model, budget spent, timeout, failure,
malformed reply) keeping the heuristic verdict. Session hosts install
the judge's process-wide arbiter at assembly only when
`ai.loop_detection.judge.enabled` is set in the same configuration snapshot the
engine reads; otherwise the engine keeps its no-arbiter default. Do not add a
second arbitration trait or install the arbiter per session.

For complete shell constraint checks, use:

```bash
Expand Down
Loading
Loading