Skip to content

Commit eac5244

Browse files
committed
feat(loop): add REASONIX anti-wandering loop guards
Port the REASONIX anti-wandering guard family (evidence ledger, progress guard, storm breaker, delegation admission) into the agent runtime loop: - core/loop/guards.py: guard engine (stdlib-only, no internal deps) - core/loop/guard_telemetry.py: optional telemetry seam - runner.py: blocked short-circuit, observe_batch injection, per-tool check_tool gate, success-path observation wiring - session.py: thread LoopGuards instance through AgentSession/AgentRunSpec - spawn_agent.py: delegation admission gate (REASONIX delegationAdmission) - loop/__init__.py: public exports Origin: Python port of the applyBatchGuards component family from DeepSeek-Reasonix (https://github.com/esengine/DeepSeek-Reasonix, MIT licensed), adapted to DeepCode's run-loop conventions. Implementation is a fresh Python rewrite, not a code copy.
1 parent 287510f commit eac5244

8 files changed

Lines changed: 1568 additions & 7 deletions

File tree

core/agent_runtime/runner.py

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
)
5050
from core.agent_runtime.tools.base import ToolResult
5151
from core.agent_runtime.tools.registry import ToolRegistry
52+
from core.loop.guard_telemetry import install_guard_telemetry
53+
from core.loop.guards import LoopGuards
5254
from core.providers.base import LLMProvider, LLMResponse, ToolCallRequest
5355
from core.providers.timeouts import (
5456
resolve_request_timeout_s,
@@ -190,6 +192,20 @@ class AgentRunSpec:
190192
# so a well-behaved hook stops blocking after its first continuation.
191193
stop_hook: Any | None = None
192194

195+
# Loop guards (REASONIX §5/§6 port, P3.5): anti-wandering circuit breakers.
196+
# ``LoopGuards.observe_batch`` runs after each tool batch; triggered
197+
# interventions are injected as user messages. Once the progress guard
198+
# forces a final answer (N consecutive zero-evidence rounds), further tool
199+
# execution is short-circuited. Absent (None) means zero cost.
200+
guards: LoopGuards | None = None
201+
202+
# Guard-event telemetry seam (REASONIX P1): optional callable invoked when a
203+
# loop guard triggers (blocked short-circuit / observe_batch injection /
204+
# check_tool block). Receives a structured dict
205+
# ``{"kind", "tool", "level", "streak", "message"}``; default None keeps the
206+
# existing logger.warning behavior.
207+
guard_event_callback: Callable[[dict], None] | None = None
208+
193209
def allowed_tool_names(self) -> frozenset[str] | None:
194210
if self.tool_filter is None:
195211
return None
@@ -400,6 +416,9 @@ def _injection_note_source(item: Any) -> str:
400416
return "injection"
401417

402418
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
419+
# P5: optional guard telemetry wiring (DEEPCODE_GUARD_TELEMETRY=1
420+
# auto-connects; zero overhead by default).
421+
install_guard_telemetry(spec)
403422
hook = spec.hook or AgentHook()
404423
messages = list(spec.initial_messages)
405424
final_content: str | None = None
@@ -568,11 +587,33 @@ async def record_compaction_response(response: LLMResponse) -> None:
568587

569588
await hook.before_execute_tools(context)
570589

571-
results, new_events, fatal_error = await self._execute_tools(
572-
spec,
573-
response.tool_calls,
574-
external_lookup_counts,
575-
)
590+
# Progress guard forced a final answer (REASONIX §2.2 level 3):
591+
# short-circuit tool execution and feed errors-as-data instead,
592+
# so the model reads the block reason and wraps up cleanly.
593+
if spec.guards is not None and spec.guards.blocked:
594+
block_reason = (
595+
"Error: blocked by loop guard — no new evidence for "
596+
f"{spec.guards.streak} consecutive tool rounds. "
597+
"Produce your final answer now."
598+
)
599+
blocked_results = [block_reason for _ in response.tool_calls]
600+
results, new_events, fatal_error = blocked_results, [], None
601+
if spec.guard_event_callback is not None:
602+
spec.guard_event_callback(
603+
{
604+
"kind": "blocked",
605+
"tool": None,
606+
"level": 3,
607+
"streak": spec.guards.streak,
608+
"message": block_reason,
609+
}
610+
)
611+
else:
612+
results, new_events, fatal_error = await self._execute_tools(
613+
spec,
614+
response.tool_calls,
615+
external_lookup_counts,
616+
)
576617
tool_events.extend(new_events)
577618
context.tool_results = list(results)
578619
context.tool_events = list(new_events)
@@ -657,6 +698,31 @@ async def record_compaction_response(response: LLMResponse) -> None:
657698
if drained:
658699
had_injections = True
659700
sampling_limit.reset()
701+
702+
if spec.guards is not None:
703+
guard_injections = spec.guards.observe_batch(
704+
response.tool_calls, results, new_events
705+
)
706+
if guard_injections:
707+
self._append_injected_messages(messages, guard_injections)
708+
for injection in guard_injections:
709+
logger.warning(
710+
"Loop guard on turn {} for {}: {}",
711+
current_iteration,
712+
spec.session_key or "default",
713+
injection["content"][:120],
714+
)
715+
if spec.guard_event_callback is not None:
716+
spec.guard_event_callback(
717+
{
718+
"kind": "injection",
719+
"tool": None,
720+
"level": None,
721+
"streak": spec.guards.streak,
722+
"message": injection["content"],
723+
}
724+
)
725+
660726
await hook.after_iteration(context)
661727
continue
662728

@@ -1167,6 +1233,34 @@ async def _run_tool(
11671233
)
11681234
return result, event, None
11691235

1236+
# Loop guards (REASONIX §1.2–1.8 port, P3.6) — per-tool governance gate.
1237+
# Permission is the security layer (checked first); guards are the
1238+
# loop-governance layer (checked second). Blocks are errors-as-data.
1239+
if spec.guards is not None:
1240+
guard_message = spec.guards.check_tool(
1241+
tool_call.name, tool_call.arguments
1242+
)
1243+
if guard_message is not None:
1244+
event = {
1245+
"name": tool_call.name,
1246+
"status": "denied",
1247+
"detail": guard_message.replace("\n", " ").strip()[:120],
1248+
}
1249+
if spec.guard_event_callback is not None:
1250+
spec.guard_event_callback(
1251+
{
1252+
"kind": "tool_block",
1253+
"tool": tool_call.name,
1254+
"level": None,
1255+
"streak": spec.guards.streak,
1256+
"message": guard_message,
1257+
}
1258+
)
1259+
result = self._compose_hook_context(
1260+
f"Error: {guard_message}" + _HINT, pre_contexts
1261+
)
1262+
return result, event, None
1263+
11701264
prepare_call = getattr(spec.tools, "prepare_call", None)
11711265
tool, params, prep_error = None, tool_call.arguments, None
11721266
if callable(prepare_call):
@@ -1299,6 +1393,15 @@ async def _run_tool(
12991393
elif len(detail) > 120:
13001394
detail = detail[:120] + "..."
13011395
event = {"name": tool_call.name, "status": "ok", "detail": detail}
1396+
if spec.guards is not None:
1397+
# Post-execution guard observation (REASONIX P3.6 port): result
1398+
# fingerprint maintenance + mutation dependency tracking. Only the
1399+
# success path reaches here, so pending-state updates are accurate.
1400+
spec.guards.observe_tool_result(
1401+
tool_call.name, tool_call.arguments, result
1402+
)
1403+
spec.guards.observe_tool_mutation(tool_call.name, tool_call.arguments)
1404+
spec.guards.observe_tool_verify(tool_call.name, tool_call.arguments)
13021405
return await self._finish_tool(
13031406
spec, tool_call, result, event, None, pre_contexts
13041407
)

core/events/session.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
)
6464
from core.mcp.models import McpStartupError
6565
from core.mcp.runtime import McpSessionRuntime
66+
from core.loop.guards import LoopGuards
6667
from core.providers.base import LLMProvider
6768
from core.providers.catalog import context_window_for
6869
from core.reasoning import ReasoningAvailability, ReasoningChannel
@@ -398,6 +399,7 @@ def __init__(
398399
tool_filter: Any | None = None,
399400
closure_callback: Any | None = None,
400401
mcp_runtime: McpSessionRuntime | None = None,
402+
guards: LoopGuards | None = None,
401403
) -> None:
402404
self._runner = AgentRunner(provider)
403405
self._provider = provider
@@ -457,6 +459,11 @@ def __init__(
457459
# ordinary Turns leave it unset.
458460
self._closure_callback = closure_callback
459461
self._mcp_runtime = mcp_runtime
462+
# Loop guards (REASONIX P3.5/P3.6 port): anti-wandering circuit
463+
# breakers observed across tool batches; None keeps the feature dormant
464+
# at zero cost. LoopTask passes the same instance every round so
465+
# ProgressGuard/StormBreaker state survives across rounds.
466+
self._guards = guards
460467
# Secret-free immutable selection used by persistence/frontends.
461468
self.execution_profile = execution_profile
462469

@@ -931,6 +938,7 @@ def visible_tool_names() -> tuple[str, ...] | None:
931938
stop_hook=stop_hook,
932939
pre_compact_hook=pre_compact_hook,
933940
post_compact_hook=post_compact_hook,
941+
guards=self._guards,
934942
tool_filter=(
935943
visible_tool_names
936944
if self._skill_runtime is not None or self._tool_filter is not None

core/harness/tools/spawn_agent.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from core.agent_runtime.tools.base import Tool, tool_parameters
1919
from core.harness.agents.control import AgentControl, AgentLimitError
20+
from core.loop.guards import delegation_admission
2021

2122

2223
def _parse_fork_turns(value: Any) -> str | int:
@@ -154,6 +155,22 @@ async def execute(self, **kwargs: Any) -> Any:
154155
output_schema = kwargs.get("output_schema")
155156
if output_schema is not None and not isinstance(output_schema, dict):
156157
return "Error: 'output_schema' must be a JSON object."
158+
# Delegation admission (REASONIX §1.10 delegationAdmission adaptation):
159+
# spawn_agent is a local concurrent delegation (C2); self-contained
160+
# tasks are allowed by default. Only tasks that reference the parent
161+
# conversation but would not inherit it are rejected (the sub-agent
162+
# cannot see your messages).
163+
decision, reason = delegation_admission(task, fork_turns=str(fork_turns))
164+
if decision == "deny":
165+
return (
166+
"Error: delegation denied (local_fix_no_external_need). "
167+
f"The subtask references the parent conversation but "
168+
f"fork_turns is '{fork_turns}', so the sub-agent inherits no "
169+
"context and cannot act on those references. Either do the "
170+
"work yourself, or rewrite the task to be self-contained, or "
171+
"pass fork_turns='all'/'<N>' to inherit the needed context. "
172+
f"(reason: {reason})"
173+
)
157174
try:
158175
agent_id = self._control.spawn(
159176
task,

core/loop/__init__.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
11
"""Optional maintenance utilities outside the interactive Agent loop."""
22

3-
from core.loop.autodream import AutodreamResult, consolidate_memory
3+
from core.loop.guards import (
4+
EvidenceLedger,
5+
GuardIntervention,
6+
LoopGuards,
7+
ProgressGuard,
8+
StormBreaker,
9+
delegation_admission,
10+
)
411

5-
__all__ = ["AutodreamResult", "consolidate_memory"]
12+
__all__ = [
13+
"AutodreamResult",
14+
"consolidate_memory",
15+
# REASONIX anti-wandering guards (P3.5)
16+
"EvidenceLedger",
17+
"GuardIntervention",
18+
"ProgressGuard",
19+
"StormBreaker",
20+
"LoopGuards",
21+
"delegation_admission",
22+
]
23+
24+
25+
def __getattr__(name: str):
26+
"""Lazily expose the autodream API.
27+
28+
``core.loop.autodream`` imports ``core.agent_setup`` (and transitively
29+
``core.compat.agent`` -> ``core.agent_runtime.runner``), so eagerly
30+
importing it here would create a cycle when ``runner`` itself imports this
31+
package (e.g. for the REASONIX loop guards). Load it only on first access
32+
-- by then package init has completed and the import chain is safe.
33+
"""
34+
if name in ("AutodreamResult", "consolidate_memory"):
35+
from core.loop.autodream import AutodreamResult, consolidate_memory
36+
37+
value = (
38+
AutodreamResult if name == "AutodreamResult" else consolidate_memory
39+
)
40+
globals()[name] = value
41+
return value
42+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

core/loop/guard_telemetry.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Guard-event telemetry adapter (P5) — guard_event_callback → deepcode-telemetry.
2+
3+
REASONIX 守卫事件(``blocked`` / ``injection`` / ``tool_block``)的可选遥测接入。
4+
默认零开销:未设置 ``DEEPCODE_GUARD_TELEMETRY=1`` 时不做任何事;遥测 skill 缺失或
5+
调用失败时静默降级,绝不影响 agent 主循环。
6+
7+
设计要点
8+
--------
9+
- 纯 stdlib,不依赖 loguru / core 内部(避免循环导入与回归风险)。
10+
- 通过 ``importlib.util.spec_from_file_location`` 动态加载
11+
``.deepcode/skills/deepcode-telemetry/telemetry.py``,不污染 ``sys.path``。
12+
- 模块级缓存:每个进程只尝试加载一次(``_attempted`` / ``_callbacks_cache``)。
13+
- 事件映射(kind → 遥测调用):
14+
- ``blocked`` → increment_counter("guard.blocked") + record_metric("guard.blocked.streak")
15+
- ``injection`` → increment_counter("guard.injections")
16+
- ``tool_block`` → increment_counter("guard.tool_block") + 按工具细分计数器
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import importlib.util
22+
import os
23+
from pathlib import Path
24+
from typing import Any, Callable
25+
26+
ENV_GUARD_TELEMETRY = "DEEPCODE_GUARD_TELEMETRY"
27+
28+
# core/loop/guard_telemetry.py → parents[2] = F:/DEEPCODE
29+
_TELEMETRY_SKILL_REL = Path(".deepcode") / "skills" / "deepcode-telemetry" / "telemetry.py"
30+
31+
_attempted = False
32+
_callbacks_cache: Callable[[dict], None] | None = None
33+
34+
35+
def _load_telemetry_module() -> Any | None:
36+
"""动态加载 deepcode-telemetry skill 模块;失败返回 None(静默降级)。"""
37+
skill_py = Path(__file__).resolve().parents[2] / _TELEMETRY_SKILL_REL
38+
if not skill_py.is_file():
39+
return None
40+
try:
41+
spec = importlib.util.spec_from_file_location("_deepcode_guard_telemetry", skill_py)
42+
if spec is None or spec.loader is None:
43+
return None
44+
module = importlib.util.module_from_spec(spec)
45+
spec.loader.exec_module(module)
46+
return module
47+
except Exception:
48+
return None
49+
50+
51+
def _build_callback(engine: Any) -> Callable[[dict], None]:
52+
"""构造守卫事件 → 遥测调用回调。所有遥测调用异常静默吞掉。"""
53+
54+
def on_guard_event(event: dict) -> None:
55+
try:
56+
kind = event.get("kind")
57+
if kind == "blocked":
58+
engine.increment_counter("guard.blocked", 1)
59+
streak = event.get("streak")
60+
if streak is not None:
61+
engine.record_metric("guard.blocked.streak", float(streak))
62+
elif kind == "injection":
63+
engine.increment_counter("guard.injections", 1)
64+
elif kind == "tool_block":
65+
engine.increment_counter("guard.tool_block", 1)
66+
tool = event.get("tool")
67+
if tool:
68+
engine.increment_counter(f"guard.tool_block.{tool}", 1)
69+
except Exception:
70+
pass # telemetry 失败静默降级,绝不影响 agent 主循环
71+
72+
return on_guard_event
73+
74+
75+
def make_telemetry_guard_callback() -> Callable[[dict], None] | None:
76+
"""惰性构造遥测守卫回调(进程级缓存,只尝试一次)。"""
77+
global _attempted, _callbacks_cache
78+
if _attempted:
79+
return _callbacks_cache
80+
_attempted = True
81+
try:
82+
module = _load_telemetry_module()
83+
if module is None or not hasattr(module, "get_telemetry"):
84+
_callbacks_cache = None
85+
return None
86+
_callbacks_cache = _build_callback(module.get_telemetry())
87+
except Exception:
88+
_callbacks_cache = None
89+
return _callbacks_cache
90+
91+
92+
def install_guard_telemetry(spec: Any) -> None:
93+
"""runner ``run()`` 开头调用:可选装配遥测回调,默认零开销。
94+
95+
- 已有 ``guard_event_callback`` → 不覆盖。
96+
- ``DEEPCODE_GUARD_TELEMETRY`` 未设为 "1" → 不装配。
97+
- 遥测 skill 不可用 → 静默保持 None。
98+
"""
99+
if spec.guard_event_callback is not None:
100+
return
101+
if os.environ.get(ENV_GUARD_TELEMETRY, "0") != "1":
102+
return
103+
callback = make_telemetry_guard_callback()
104+
if callback is not None:
105+
spec.guard_event_callback = callback
106+
107+
108+
__all__ = [
109+
"ENV_GUARD_TELEMETRY",
110+
"make_telemetry_guard_callback",
111+
"install_guard_telemetry",
112+
]

0 commit comments

Comments
 (0)