feat(observability): persist loop detection events (#5127)

* feat(observability): persist loop detection events

* fix(observability): persist subagent loop events

* fix(observability): narrow subagent loop event bridge

* fix(observability): attribute subagent loop events

* fix(tests): isolate subagent executor imports
This commit is contained in:
AoHanBei 2026-09-02 10:25:37 +08:00 committed by GitHub
parent 5860423eb6
commit 9b32b5d841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 583 additions and 28 deletions

View File

@ -76,10 +76,24 @@ through run-event or specialized APIs:
| `context:memory` | `context` | `record_memory_context()` |
| `middleware:{tag}` | `middleware` | `record_middleware()` |
Current middleware tags are `guardrail`, `safety_termination`,
`skill_activation`, and `skill_secrets`. The pattern is intentionally open so
new middleware tags are additive. Because the full event type is limited to 32
characters and `middleware:` uses 11, a tag must contain 1-21 characters.
Current middleware tags are `guardrail`, `loop_detection`,
`safety_termination`, `skill_activation`, and `skill_secrets`. The pattern is
intentionally open so new middleware tags are additive. Because the full event
type is limited to 32 characters and `middleware:` uses 11, a tag must contain
1-21 characters.
`middleware:loop_detection` records transitions into the warned state (first
per call hash or per tool-frequency burst) and each hard stop produced by
`LoopDetectionMiddleware` in lead-agent and ordinary task-tool subagent runs.
Task-tool subagents forward the append to the parent loop because `RunJournal`
and its event store must not cross the isolated-loop boundary. Durable batch
subagents have no parent run journal and do not emit these events. The event's
`action` is `warn` or
`hard_stop`. The `changes` object identifies the detection layer, affected tool
names, observed count, effective threshold, whether the producer was a
subagent, and its agent id when applicable. Tool arguments, prompts, message
content, tool results, and argument-derived hashes are not persisted in this
event.
### Opaque Run Outputs
@ -176,7 +190,7 @@ be used by new producers.
- Nested non-JSON values in `run.end.content` have backend-dependent
representations: memory retains Python values, while JSONL and database
stores read them back as strings.
- Loop detection and deferred-tool promotion do not currently emit middleware
events.
- Durable batch subagent loop detection and deferred-tool promotion do not
currently emit middleware events.
- Journal attribution, token accounting, and external tracing metadata still
depend on manual instrumentation at several LLM call sites.

View File

@ -95,7 +95,7 @@ Before changing a later authorization phase, read the [authorization RFC](../../
25. **DeferredToolFilterMiddleware** - *(optional, if `tool_search.enabled`)* Hides deferred (MCP) tool schemas from the bound model until `tool_search` or `McpRoutingMiddleware` promotes them (reads per-thread promotions from `ThreadState.promoted`, hash-scoped)
26. **SystemMessageCoalescingMiddleware** - Merges every SystemMessage into a single leading SystemMessage per request; provider-agnostic fix for strict backends (vLLM/SGLang/Qwen/Anthropic) that reject non-leading system messages. Touches the per-request payload only (checkpoint state unchanged); on midnight crossings only the latest `dynamic_context_reminder` SystemMessage survives. The subagent builder places its date-only context middleware immediately before this coalescer, so the built-in subagent prompt and hidden date reminder still reach providers as one leading system block
27. **SubagentLimitMiddleware** - *(optional, if `subagent_enabled`)* Truncates excess ordinary `task` tool calls to enforce both the per-response concurrency limit (`max_concurrent_subagents`, resolved against startup `subagent_runtime.max_running` and the 1-64 safety range before construction) and the per-run total delegation cap (`max_total_subagents` runtime override or `subagents.max_total_per_run`, default 6, clamped to 1-50). The total cap counts current-run entries in the durable delegation ledger (entries are tagged with `run_id` when captured), so repeated planning checkpoints in one run cannot keep launching legal-sized batches indefinitely, while later user turns in the same thread get a fresh run budget. Explicit durable `batch_task` calls are a separate mode with persisted total/live/running limits and are not rewritten into ordinary ledger entries. If the ordinary cap is exhausted, the middleware strips remaining `task` calls, forces `finish_reason="stop"`, and appends a visible limit note so the run can synthesize existing results instead of ending with an empty tool-call response.
28. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`
28. **LoopDetectionMiddleware** - *(optional, if `loop_detection.enabled`)* Detects repeated tool-call loops; hard-stop clears both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer; stamps `loop_capped` via `consume_stop_reason` (#3875 Phase 2), symmetric to `TokenBudgetMiddleware`; persists warned-state transitions (first per call hash or per tool-frequency burst) and hard stops as `middleware:loop_detection`, attributed with `is_subagent` and the optional `agent_id`, without tool arguments, message content, tool results, or argument-derived hashes. Ordinary task-tool subagents receive a loop-detection-only recorder proxy that forwards the append to the parent run loop; never pass the loop-bound `RunJournal` itself into their isolated event loop. Durable batch subagents have no parent run journal and do not persist these transitions
29. **TokenBudgetMiddleware** - *(optional, if `token_budget.enabled`)* Enforces per-run token limits
30. **Custom middlewares** - *(optional)* Any `custom_middlewares` passed to `build_middlewares` are injected here, before config-declared extensions and the terminal-response/safety/clarification tail
31. **Configured extension middlewares** - *(optional, if `extensions.middlewares` is set in `config.yaml` or `extensions_config.json`)* Zero-argument `AgentMiddleware` classes loaded from `module.path:ClassName` entries via `deerflow.reflection.resolve_class`. Missing packages, invalid classes, and broken modules fail loudly at agent creation. These run after built-ins/programmatic custom middleware and after the lead/subagent loop/token guards, but before the terminal-response/safety/clarification tail; subagents receive the same configured extension middleware class list before their safety tail. Treat these files as trusted operator config because middleware paths instantiate arbitrary code. Gateway skill/MCP toggle endpoints preserve this field through `to_file_dict()` but must not add a write path for `extensions.middlewares` without an explicit trust-boundary review. Lead-only vs subagent-only middleware lists and per-context constructor parameters are not expressible in this MVP.

View File

@ -0,0 +1,3 @@
"""Private runtime-context keys for narrowly scoped middleware audit recorders."""
LOOP_DETECTION_RECORDER_CONTEXT_KEY = "__run_loop_detection_recorder"

View File

@ -58,7 +58,8 @@ import threading
from collections import Counter, OrderedDict, defaultdict, deque
from collections.abc import Awaitable, Callable
from copy import deepcopy
from typing import TYPE_CHECKING, override
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
@ -67,6 +68,8 @@ from langchain_core.messages import HumanMessage
from langgraph.runtime import Runtime
from deerflow.agents.middlewares._bounded_dict import BoundedDict
from deerflow.agents.middlewares.audit_context import LOOP_DETECTION_RECORDER_CONTEXT_KEY
from deerflow.runtime.events.catalog import MIDDLEWARE_LOOP_DETECTION_TAG
if TYPE_CHECKING:
from deerflow.config.loop_detection_config import LoopDetectionConfig
@ -184,6 +187,22 @@ _HARD_STOP_MSG = "[FORCED STOP] Repeated tool calls exceeded the safety limit. P
_TOOL_FREQ_HARD_STOP_MSG = "[FORCED STOP] Tool {tool_name} called {count} times — exceeded the per-tool safety limit. Producing final answer with results collected so far."
@dataclass(frozen=True)
class _LoopDecision:
"""A loop-detection transition that may be persisted for audit."""
message: str
action: Literal["warn", "hard_stop"]
detection_layer: Literal["identical_call_set", "tool_frequency"]
tool_names: tuple[str, ...]
count: int
threshold: int
@property
def hard_stop(self) -> bool:
return self.action == "hard_stop"
class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
"""Detects and breaks repetitive tool call loops.
@ -416,7 +435,11 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
self._touch_pending_warning_key_locked(pending_key)
self._prune_pending_warning_state_locked(protected_key=pending_key)
def _track_and_check(self, state: AgentState, runtime: Runtime) -> tuple[str | None, bool]:
def _track_and_check(
self,
state: AgentState,
runtime: Runtime,
) -> _LoopDecision | None:
"""Track tool calls and check for loops.
Two detection layers:
@ -426,19 +449,19 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
on 40 different files).
Returns:
(warning_message_or_none, should_hard_stop)
A structured decision when a warning or hard stop is triggered.
"""
messages = state.get("messages", [])
if not messages:
return None, False
return None
last_msg = messages[-1]
if getattr(last_msg, "type", None) != "ai":
return None, False
return None
tool_calls = getattr(last_msg, "tool_calls", None)
if not tool_calls:
return None, False
return None
thread_id = self._get_thread_id(runtime)
call_hash = _hash_tool_calls(tool_calls)
@ -463,7 +486,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
self._warned.pop(thread_id, None)
count = history.count(call_hash)
tool_names = [tc.get("name", "?") for tc in tool_calls]
tool_names = [str(tc.get("name") or "?") for tc in tool_calls]
# --- Layer 1: hash-based (identical call sets) ---
if count >= self.hard_limit:
@ -476,7 +499,14 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
"tools": tool_names,
},
)
return _HARD_STOP_MSG, True
return _LoopDecision(
message=_HARD_STOP_MSG,
action="hard_stop",
detection_layer="identical_call_set",
tool_names=tuple(tool_names),
count=count,
threshold=self.hard_limit,
)
if count >= self.warn_threshold:
warned = self._warned[thread_id]
@ -491,7 +521,14 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
"tools": tool_names,
},
)
return _WARNING_MSG, False
return _LoopDecision(
message=_WARNING_MSG,
action="warn",
detection_layer="identical_call_set",
tool_names=tuple(tool_names),
count=count,
threshold=self.warn_threshold,
)
# --- Layer 2: per-tool-type frequency (windowed) ---
tool_name_history = self._tool_name_history[thread_id]
@ -530,7 +567,14 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
"count": freq_count,
},
)
return _TOOL_FREQ_HARD_STOP_MSG.format(tool_name=name, count=freq_count), True
return _LoopDecision(
message=_TOOL_FREQ_HARD_STOP_MSG.format(tool_name=name, count=freq_count),
action="hard_stop",
detection_layer="tool_frequency",
tool_names=(name,),
count=freq_count,
threshold=eff_hard,
)
if freq_count >= eff_warn:
freq_warned = self._tool_freq_warned[thread_id]
@ -544,13 +588,20 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
"count": freq_count,
},
)
return _TOOL_FREQ_WARNING_MSG.format(tool_name=name, count=freq_count), False
return _LoopDecision(
message=_TOOL_FREQ_WARNING_MSG.format(tool_name=name, count=freq_count),
action="warn",
detection_layer="tool_frequency",
tool_names=(name,),
count=freq_count,
threshold=eff_warn,
)
else:
# Windowed count decayed below the warn threshold; allow a
# future burst of this tool to warn again.
self._tool_freq_warned[thread_id].discard(name)
return None, False
return None
@staticmethod
def _append_text(content: str | list | None, text: str) -> str | list:
@ -589,10 +640,55 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
return update
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
warning, hard_stop = self._track_and_check(state, runtime)
def _record_audit_event(
self,
decision: _LoopDecision,
runtime: Runtime,
) -> None:
"""Persist a loop-detection transition without sensitive tool data."""
context = getattr(runtime, "context", None)
is_subagent = isinstance(context, dict) and context.get("is_subagent") is True
recorder = context.get(LOOP_DETECTION_RECORDER_CONTEXT_KEY) if isinstance(context, dict) else None
if recorder is None and isinstance(context, dict):
# Lead-agent runs expose the ordinary RunJournal. Native task-tool
# subagents receive only the narrow, loop-safe recorder key above.
recorder = context.get("__run_journal")
if recorder is None:
return
if hard_stop:
try:
recorder.record_middleware(
tag=MIDDLEWARE_LOOP_DETECTION_TAG,
name=type(self).__name__,
hook="after_model",
action=decision.action,
changes={
"is_subagent": is_subagent,
"agent_id": context.get("agent_id") if is_subagent else None,
"detection_layer": decision.detection_layer,
"tool_names": list(decision.tool_names),
"count": decision.count,
"threshold": decision.threshold,
},
)
except Exception: # noqa: BLE001
# Audit persistence must never break the agent run.
logger.warning(
"Failed to record middleware:loop_detection event",
exc_info=True,
)
def _apply(self, state: AgentState, runtime: Runtime) -> dict | None:
decision = self._track_and_check(state, runtime)
if decision is None:
return None
# Keep the shared loop-detection lock's critical section bounded; the
# journal append is cheap and non-blocking but does not belong under it.
self._record_audit_event(decision, runtime)
warning = decision.message
if decision.hard_stop:
# Record the stop reason so the executor can surface
# ``stop_reason=loop_capped`` after the run returns (#3875 Phase 2).
# The hard stop does not raise — it strips tool_calls and lets the
@ -619,7 +715,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]):
stripped_msg = last_msg.model_copy(update=self._build_hard_stop_update(last_msg, content))
return {"messages": [stripped_msg]}
if warning:
if decision.action == "warn":
# Defer injection to the next model call. We must NOT alter the
# AIMessage(tool_calls=...) here (would put framework words in
# the model's mouth, polluting downstream consumers like

View File

@ -77,11 +77,13 @@ MIDDLEWARE_EVENT_PATTERN = RunEventPattern(
)
MIDDLEWARE_EVENT_TAG_MAX_LENGTH = RUN_EVENT_TYPE_MAX_LENGTH - len(MIDDLEWARE_EVENT_PATTERN.prefix)
MIDDLEWARE_GUARDRAIL_TAG = "guardrail"
MIDDLEWARE_LOOP_DETECTION_TAG = "loop_detection"
MIDDLEWARE_SAFETY_TERMINATION_TAG = "safety_termination"
MIDDLEWARE_SKILL_ACTIVATION_TAG = "skill_activation"
MIDDLEWARE_SKILL_SECRETS_TAG = "skill_secrets"
MIDDLEWARE_EVENT_TAGS = (
MIDDLEWARE_GUARDRAIL_TAG,
MIDDLEWARE_LOOP_DETECTION_TAG,
MIDDLEWARE_SAFETY_TERMINATION_TAG,
MIDDLEWARE_SKILL_ACTIVATION_TAG,
MIDDLEWARE_SKILL_SECRETS_TAG,

View File

@ -21,4 +21,6 @@
**Isolated-loop callback boundary**: sync delegation from an active event loop and `execute_async()` copy the ambient ContextVars into the persistent subagent loop so checkpoint lineage, user identity, tracing context, tags, metadata, and LangGraph's namespaced message-stream handler survive. Before submission, `_copy_isolated_subagent_context()` copies the callback manager/list and removes only handlers marked `deerflow_loop_bound`; `RunJournal` carries that marker because it owns parent-loop tasks and a SQL store/pool. LangGraph merges inherited callbacks with the child run's explicit `SubagentTokenCollector`/tracing callbacks, so letting `RunJournal` cross loops causes duplicate accounting and `Future attached to a different loop` failures, while dropping the whole callback chain silently removes child token frames. Do not replace the boundary with a blank `Context`; the inherited checkpoint namespace and framework stream callback are required by the stream-isolation contract above.
Loop-detection audit records cross this boundary through a deliberately narrow exception: `task_tool` captures the parent loop and passes `_ParentLoopMiddlewareRecorderProxy` to `SubagentExecutor`, which installs it under the loop-detection-only context key and schedules the real journal append back onto its owner loop with `call_soon_threadsafe`. Other subagent middleware consumers still do not see `__run_journal`. The task tool closes the proxy before returning: close fences later child events and yields once on the owner loop so every previously accepted append reaches the journal before the parent run captures completion data. The proxy, not `RunJournal`, crosses into the isolated loop; do not broaden it into a generic journal facade or call the event store from the subagent loop. Durable batch subagents have no parent run journal and therefore do not use this bridge.
**Reverse direction of the loop boundary — deferred cleanup & final usage delivery (#5069)**: when a task-tool poller exits unexpectedly, the registry cleanup is pinned **to** the persistent subagent loop via the public `run_on_isolated_subagent_loop()` (executor) so it survives caller-loop teardown — `asyncio.run()` cancels caller-loop tasks on exit, so a caller-loop `asyncio.create_task` would be cancelled before running. The final usage report crosses the boundary the **other way**: `_schedule_deferred_subagent_cleanup` captures the parent run's loop at unwind time (alive in every path that continues the run — the polling-timeout branch returns normally, and a generic poller error becomes an error `ToolMessage`), and `_deliver_final_usage_report` hands the report back onto that loop with `call_soon_threadsafe`. `record_external_llm_usage_records` must never be invoked from the persistent loop or a worker thread (`to_thread`): the journal's accumulators are unlocked read-modify-write fields and `get_completion_data()` iterates `_tokens_by_model`, so a cross-thread write silently loses token updates or breaks iteration mid-run — calling `_report_subagent_usage` directly inside `_deferred_cleanup_subagent_task` would reintroduce exactly this race. The deferred cleaner captures only the resolved usage recorder (plus ids and the captured report loop) — never the whole `runtime`: the strongly-referenced cleanup task lives for up to the full poll budget, and through `runtime` it would pin the parent run's journal and event store for that entire window. When the captured parent loop is already closed (synchronous `asyncio.run` teardown), the report is dropped on purpose — the run has persisted its completion data and nothing reads the counters back — and logged at info with the execution id and unaccounted record count, because the registry entry is removed right after and those records exist nowhere else.

View File

@ -25,6 +25,7 @@ from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.config import var_child_runnable_config
from langgraph.errors import GraphRecursionError
from deerflow.agents.middlewares.audit_context import LOOP_DETECTION_RECORDER_CONTEXT_KEY
from deerflow.agents.thread_state import SandboxState, ThreadDataState, ThreadState
from deerflow.authz.principal import normalize_authz_attributes
from deerflow.config import get_app_config
@ -774,6 +775,7 @@ class SubagentExecutor:
extensions: Any | None = None,
execution_capacity: SubagentExecutionCapacity | None = None,
acceptance_criteria: list[str] | None = None,
loop_detection_recorder: Any | None = None,
):
"""Initialize the executor.
@ -814,6 +816,10 @@ class SubagentExecutor:
``HumanMessage`` (the channel ``InputSanitizationMiddleware``
sanitizes and boundary-frames); the subagent's ``SystemMessage``
carries only the framework-owned pointer note.
loop_detection_recorder: Optional loop-safe recorder supplied by the
parent task tool. Native subagents execute on a separate event
loop, so this must be a proxy rather than the parent
``RunJournal`` itself.
"""
self.config = config
self.app_config = app_config
@ -860,6 +866,7 @@ class SubagentExecutor:
# Raw lead-supplied criteria; stripping/capping happens at render time
# in report_contract.render_acceptance_criteria_block.
self.acceptance_criteria = acceptance_criteria
self.loop_detection_recorder = loop_detection_recorder
self._base_tools = _filter_tools(
tools,
@ -1458,6 +1465,9 @@ class SubagentExecutor:
context["authz_attributes"] = dict(self.authz_attributes)
context[DEERFLOW_TRACE_METADATA_KEY] = self.deerflow_trace_id
context["is_subagent"] = True
context["agent_id"] = self.config.name
if self.loop_detection_recorder is not None:
context[LOOP_DETECTION_RECORDER_CONTEXT_KEY] = self.loop_detection_recorder
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} starting async execution with max_turns={self.config.max_turns}")

View File

@ -3,6 +3,7 @@
import asyncio
import concurrent.futures
import logging
import threading
import time
import uuid
from contextvars import ContextVar
@ -75,6 +76,63 @@ _explicit_app_config: ContextVar[Any | None] = ContextVar(
)
def _record_middleware_on_parent_loop(journal: Any, kwargs: dict[str, Any]) -> None:
"""Run one subagent middleware-journal append on the journal owner's loop."""
try:
journal.record_middleware(**kwargs)
except Exception:
logger.warning("Failed to record subagent middleware event", exc_info=True)
class _ParentLoopMiddlewareRecorderProxy:
"""Forward subagent loop-detection events to the parent run's event loop.
``RunJournal`` owns parent-loop tasks and may wrap an event store backed by
a loop-bound SQL pool. Subagents execute on a persistent isolated loop, so
the journal object itself must never be called there.
"""
def __init__(self, journal: Any, loop: asyncio.AbstractEventLoop) -> None:
self._journal = journal
self._loop = loop
self._state_lock = threading.Lock()
self._closed = False
def record_middleware(self, **kwargs: Any) -> None:
with self._state_lock:
if self._closed or self._loop.is_closed():
logger.debug("Dropping subagent middleware event after parent loop shutdown")
return
try:
self._loop.call_soon_threadsafe(
_record_middleware_on_parent_loop,
self._journal,
dict(kwargs),
)
except RuntimeError:
# The loop may close between is_closed() and scheduling.
logger.debug("Dropping subagent middleware event after parent loop shutdown")
@property
def is_closed(self) -> bool:
"""Whether the task-tool boundary has fenced new child events."""
with self._state_lock:
return self._closed
async def aclose(self) -> None:
"""Fence late child events and drain every append accepted before it."""
if asyncio.get_running_loop() is not self._loop:
logger.warning("Cannot drain subagent middleware recorder from a non-owner loop")
return
with self._state_lock:
self._closed = True
if self._loop.is_closed():
return
# record_middleware holds _state_lock through call_soon_threadsafe, so
# all accepted callbacks are already ahead of this continuation.
await asyncio.sleep(0)
def _is_subagent_terminal(result: Any) -> bool:
"""Return whether a background subagent result is safe to clean up."""
return result.status in {SubagentStatus.COMPLETED, SubagentStatus.FAILED, SubagentStatus.CANCELLED, SubagentStatus.TIMED_OUT} or getattr(result, "completed_at", None) is not None
@ -815,6 +873,17 @@ async def task_tool(
# system-channel authority over framework instructions.
"acceptance_criteria": acceptance_criteria,
}
loop_detection_recorder = None
parent_journal = parent_context.get("__run_journal")
if parent_journal is not None:
# The task tool runs on the parent run's loop. Pass only a proxy across
# the isolated-subagent boundary so middleware persistence is delivered
# on the loop that owns the RunJournal and its event store.
loop_detection_recorder = _ParentLoopMiddlewareRecorderProxy(
parent_journal,
asyncio.get_running_loop(),
)
executor_kwargs["loop_detection_recorder"] = loop_detection_recorder
if resolved_app_config is not None:
executor_kwargs["app_config"] = resolved_app_config
if run_extensions is not None:
@ -1105,3 +1174,6 @@ async def task_tool(
# must end as an interrupted run, not a failed tool call.
raise asyncio.CancelledError
raise
finally:
if loop_detection_recorder is not None:
await loop_detection_recorder.aclose()

View File

@ -652,6 +652,217 @@ class TestLoopDetection:
assert "default" in mw._history
class TestLoopDetectionRunEvents:
@staticmethod
def _runtime_with_journal(journal):
runtime = _make_runtime()
runtime.context["__run_journal"] = journal
return runtime
def test_identical_call_warning_records_once_without_arguments(self):
journal = MagicMock()
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=2,
hard_limit=10,
tool_freq_warn=100,
tool_freq_hard_limit=200,
)
call = [_bash_call("SUPER_SECRET_COMMAND")]
# The second identical call reaches the warning threshold.
for _ in range(2):
assert mw._apply(_make_state(tool_calls=call), runtime) is None
# A subsequent occurrence must not duplicate the warning event.
assert mw._apply(_make_state(tool_calls=call), runtime) is None
journal.record_middleware.assert_called_once()
recorded = journal.record_middleware.call_args
assert recorded.kwargs["tag"] == "loop_detection"
assert recorded.kwargs["name"] == "LoopDetectionMiddleware"
assert recorded.kwargs["hook"] == "after_model"
assert recorded.kwargs["action"] == "warn"
assert recorded.kwargs["changes"] == {
"is_subagent": False,
"agent_id": None,
"detection_layer": "identical_call_set",
"tool_names": ["bash"],
"count": 2,
"threshold": 2,
}
# Tool arguments and argument-derived values must not be persisted.
assert "SUPER_SECRET_COMMAND" not in repr(recorded)
assert "args" not in recorded.kwargs["changes"]
def test_narrow_subagent_recorder_key_records_without_shared_journal(self):
recorder = MagicMock()
runtime = _make_runtime()
runtime.context["__run_loop_detection_recorder"] = recorder
runtime.context["is_subagent"] = True
runtime.context["agent_id"] = "general-purpose"
assert "__run_journal" not in runtime.context
mw = LoopDetectionMiddleware(
warn_threshold=2,
hard_limit=10,
tool_freq_warn=100,
tool_freq_hard_limit=200,
)
call = [_bash_call("ls")]
assert mw._apply(_make_state(tool_calls=call), runtime) is None
assert mw._apply(_make_state(tool_calls=call), runtime) is None
recorder.record_middleware.assert_called_once()
assert recorder.record_middleware.call_args.kwargs["action"] == "warn"
assert recorder.record_middleware.call_args.kwargs["changes"]["is_subagent"] is True
assert recorder.record_middleware.call_args.kwargs["changes"]["agent_id"] == "general-purpose"
def test_identical_call_hard_stop_records_event(self):
journal = MagicMock()
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=2,
hard_limit=3,
tool_freq_warn=100,
tool_freq_hard_limit=200,
)
call = [_bash_call("ls")]
for _ in range(2):
assert mw._apply(_make_state(tool_calls=call), runtime) is None
result = mw._apply(_make_state(tool_calls=call), runtime)
assert result is not None
assert result["messages"][0].tool_calls == []
# One warning transition followed by one hard-stop transition.
assert journal.record_middleware.call_count == 2
recorded = journal.record_middleware.call_args_list[-1]
assert recorded.kwargs["tag"] == "loop_detection"
assert recorded.kwargs["action"] == "hard_stop"
assert recorded.kwargs["changes"] == {
"is_subagent": False,
"agent_id": None,
"detection_layer": "identical_call_set",
"tool_names": ["bash"],
"count": 3,
"threshold": 3,
}
def test_tool_frequency_warning_records_once(self):
journal = MagicMock()
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=100,
hard_limit=200,
tool_freq_warn=2,
tool_freq_hard_limit=10,
)
# Vary the arguments so the identical-call detector cannot fire.
for index in range(3):
result = mw._apply(
_make_state(tool_calls=[_bash_call(f"command-{index}")]),
runtime,
)
assert result is None
journal.record_middleware.assert_called_once()
recorded = journal.record_middleware.call_args
assert recorded.kwargs["action"] == "warn"
assert recorded.kwargs["changes"] == {
"is_subagent": False,
"agent_id": None,
"detection_layer": "tool_frequency",
"tool_names": ["bash"],
"count": 2,
"threshold": 2,
}
def test_tool_frequency_hard_stop_records_event(self):
journal = MagicMock()
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=100,
hard_limit=200,
tool_freq_warn=2,
tool_freq_hard_limit=3,
)
for index in range(2):
assert (
mw._apply(
_make_state(tool_calls=[_bash_call(f"command-{index}")]),
runtime,
)
is None
)
result = mw._apply(
_make_state(tool_calls=[_bash_call("command-2")]),
runtime,
)
assert result is not None
assert journal.record_middleware.call_count == 2
recorded = journal.record_middleware.call_args_list[-1]
assert recorded.kwargs["action"] == "hard_stop"
assert recorded.kwargs["changes"] == {
"is_subagent": False,
"agent_id": None,
"detection_layer": "tool_frequency",
"tool_names": ["bash"],
"count": 3,
"threshold": 3,
}
def test_journal_failure_warns_without_breaking_detection(self, caplog):
journal = MagicMock()
journal.record_middleware.side_effect = RuntimeError("db down")
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=2,
hard_limit=10,
tool_freq_warn=100,
tool_freq_hard_limit=200,
)
call = [_bash_call("ls")]
assert mw._apply(_make_state(tool_calls=call), runtime) is None
with caplog.at_level("WARNING"):
result = mw._apply(_make_state(tool_calls=call), runtime)
assert result is None
assert mw._pending_warnings[_pending_key()]
assert "Failed to record middleware:loop_detection event" in caplog.text
@pytest.mark.asyncio
async def test_async_after_model_records_warning_event(self):
journal = MagicMock()
runtime = self._runtime_with_journal(journal)
mw = LoopDetectionMiddleware(
warn_threshold=2,
hard_limit=10,
tool_freq_warn=100,
tool_freq_hard_limit=200,
)
call = [_bash_call("ls")]
assert await mw.aafter_model(_make_state(tool_calls=call), runtime) is None
assert await mw.aafter_model(_make_state(tool_calls=call), runtime) is None
journal.record_middleware.assert_called_once()
assert journal.record_middleware.call_args.kwargs["action"] == "warn"
class TestLoopDetectionAgentGraphIntegration:
def test_loop_warning_is_transient_in_real_agent_graph(self):
"""after_model queues the warning; wrap_model_call injects it request-only."""

View File

@ -82,11 +82,13 @@ def _setup_executor_classes():
# Save original modules
original_modules = {name: sys.modules.get(name) for name in _MOCKED_MODULE_NAMES}
original_executor = sys.modules.get("deerflow.subagents.executor")
original_audit_context = sys.modules.get("deerflow.agents.middlewares.audit_context")
original_tool_search = sys.modules.get("deerflow.tools.builtins.tool_search")
# Preload the real deferred-tool helpers before replacing the parent agent
# packages with cycle-breaking test doubles. Executor imports this module
# lazily while building initial state.
# Preload real executor dependencies before replacing their parent packages
# with cycle-breaking test doubles. Keeping the concrete leaf modules in
# sys.modules makes this fixture independent of test collection order.
audit_context_module = importlib.import_module("deerflow.agents.middlewares.audit_context")
tool_search_module = importlib.import_module("deerflow.tools.builtins.tool_search")
# Remove mocked executor if exists (from conftest.py)
@ -101,6 +103,7 @@ def _setup_executor_classes():
storage_module.get_or_new_skill_storage = lambda **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [])
storage_module.get_or_new_user_skill_storage = lambda user_id, **kwargs: SimpleNamespace(load_skills=lambda *, enabled_only: [])
sys.modules["deerflow.skills.storage"] = storage_module
sys.modules["deerflow.agents.middlewares.audit_context"] = audit_context_module
sys.modules["deerflow.tools.builtins.tool_search"] = tool_search_module
# Import real classes inside fixture
@ -146,6 +149,10 @@ def _setup_executor_classes():
sys.modules["deerflow.subagents.executor"] = original_executor
elif "deerflow.subagents.executor" in sys.modules:
del sys.modules["deerflow.subagents.executor"]
if original_audit_context is not None:
sys.modules["deerflow.agents.middlewares.audit_context"] = original_audit_context
else:
sys.modules.pop("deerflow.agents.middlewares.audit_context", None)
if original_tool_search is not None:
sys.modules["deerflow.tools.builtins.tool_search"] = original_tool_search
else:
@ -3641,6 +3648,7 @@ class TestSubagentGuardrailAttribution:
oauth_provider=None,
oauth_id=None,
run_id=None,
loop_detection_recorder=None,
name="general-purpose",
parent_model="test-model",
):
@ -3664,6 +3672,7 @@ class TestSubagentGuardrailAttribution:
oauth_provider=oauth_provider,
oauth_id=oauth_id,
run_id=run_id,
loop_detection_recorder=loop_detection_recorder,
)
@pytest.mark.anyio
@ -3700,6 +3709,32 @@ class TestSubagentGuardrailAttribution:
assert context.get("run_id") == "run-42"
assert context.get("is_subagent") is True
@pytest.mark.anyio
async def test_aexecute_propagates_narrow_loop_detection_recorder(
self,
classes,
executor_module,
monkeypatch,
):
"""The child context receives the loop-safe proxy, never the raw journal."""
recorder = object()
executor = self._make_executor(
classes,
run_id="run-42",
loop_detection_recorder=recorder,
)
fake_agent = _FakeStreamAgent()
monkeypatch.setattr(executor, "_build_initial_state", self._noop_build_initial_state)
monkeypatch.setattr(executor, "_create_agent", lambda *a, **kw: fake_agent)
await executor._aexecute("do something")
context = fake_agent.captured_context
assert context is not None
assert context.get("__run_loop_detection_recorder") is recorder
assert "__run_journal" not in context
assert context.get("agent_id") == "general-purpose"
@pytest.mark.anyio
async def test_aexecute_propagates_channel_user_id_to_subagent_context(
self,

View File

@ -36,6 +36,82 @@ from deerflow.subagents.status_contract import (
task_tool_module = importlib.import_module("deerflow.tools.builtins.task_tool")
def test_parent_loop_middleware_recorder_proxy_delivers_on_owner_loop():
"""Subagent middleware events must never call RunJournal from the child loop."""
calls: list[tuple[object, dict]] = []
delivered = threading.Event()
class LoopPinnedJournal:
def record_middleware(self, **kwargs):
calls.append((asyncio.get_running_loop(), dict(kwargs)))
delivered.set()
parent_loop = asyncio.new_event_loop()
parent_thread = threading.Thread(
target=parent_loop.run_forever,
name="test-middleware-journal-parent-loop",
daemon=True,
)
parent_thread.start()
try:
proxy = task_tool_module._ParentLoopMiddlewareRecorderProxy(
LoopPinnedJournal(),
parent_loop,
)
proxy.record_middleware(
tag="loop_detection",
name="LoopDetectionMiddleware",
hook="after_model",
action="warn",
changes={"detection_layer": "identical_call_set"},
)
assert delivered.wait(timeout=5)
assert len(calls) == 1
observed_loop, kwargs = calls[0]
assert observed_loop is parent_loop
assert kwargs["tag"] == "loop_detection"
assert kwargs["action"] == "warn"
asyncio.run_coroutine_threadsafe(proxy.aclose(), parent_loop).result(timeout=5)
proxy.record_middleware(tag="loop_detection", name="LoopDetectionMiddleware", hook="after_model", action="hard_stop", changes={})
time.sleep(0.05)
assert len(calls) == 1, "events emitted after the parent task boundary must be dropped"
finally:
parent_loop.call_soon_threadsafe(parent_loop.stop)
parent_thread.join(timeout=5)
parent_loop.close()
def test_parent_loop_middleware_recorder_proxy_drops_after_loop_closed():
"""A child event after asyncio.run teardown is a quiet no-op."""
loop = asyncio.new_event_loop()
loop.close()
proxy = task_tool_module._ParentLoopMiddlewareRecorderProxy(MagicMock(), loop)
proxy.record_middleware(
tag="loop_detection",
name="LoopDetectionMiddleware",
hook="after_model",
action="warn",
changes={},
)
def test_parent_loop_middleware_recorder_close_is_fail_open_off_owner_loop(caplog):
"""A bad close caller must not replace the task tool's original outcome."""
owner_loop = asyncio.new_event_loop()
proxy = task_tool_module._ParentLoopMiddlewareRecorderProxy(MagicMock(), owner_loop)
try:
with caplog.at_level("WARNING"):
asyncio.run(proxy.aclose())
assert proxy.is_closed is False
assert "Cannot drain subagent middleware recorder from a non-owner loop" in caplog.text
finally:
owner_loop.close()
class FakeSubagentStatus(Enum):
# Match production enum values so branch comparisons behave identically.
PENDING = "pending"
@ -353,6 +429,40 @@ def test_task_tool_forwards_the_run_extension_snapshot_to_executor(monkeypatch):
assert captured["executor_kwargs"]["extensions"] is loaded
def test_task_tool_installs_and_closes_narrow_loop_detection_recorder(monkeypatch):
journal = MagicMock()
runtime = _make_runtime()
runtime.context["__run_journal"] = journal
captured = {}
class DummyExecutor:
def __init__(self, **kwargs):
captured["executor_kwargs"] = kwargs
def execute_async(self, prompt, task_id=None):
return task_id or "generated-task-id"
monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus)
monkeypatch.setattr(task_tool_module, "SubagentExecutor", DummyExecutor)
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
monkeypatch.setattr(
task_tool_module,
"get_background_task_result",
lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done"),
)
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
_run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-journal")
kwargs = captured["executor_kwargs"]
proxy = kwargs["loop_detection_recorder"]
assert proxy.is_closed is True
proxy.record_middleware(tag="loop_detection", name="LoopDetectionMiddleware", hook="after_model", action="warn", changes={})
journal.record_middleware.assert_not_called()
def test_task_tool_omits_extensions_without_a_run_snapshot(monkeypatch):
"""Callers outside the Gateway run path (embedded client, standalone
LangGraph Server) install no snapshot; the executor must keep its existing

View File

@ -397,7 +397,7 @@
"pattern": "middleware:{tag}",
"category": "middleware",
"producer": "RunJournal.record_middleware(tag, ...)",
"known_tags": ["guardrail", "safety_termination", "skill_activation", "skill_secrets"],
"known_tags": ["guardrail", "loop_detection", "safety_termination", "skill_activation", "skill_secrets"],
"event_type_schema": {
"type": "string",
"pattern": "^middleware:",
@ -444,7 +444,7 @@
{
"id": "middleware-coverage",
"status": "partial",
"notes": "Loop detection and deferred-tool promotion do not currently emit middleware events."
"notes": "Loop-detection events cover lead-agent and ordinary task-tool subagent runs; durable batch subagent loop detection and deferred-tool promotion do not currently emit middleware events."
},
{
"id": "run-scoped-observation-context",