|
49 | 49 | ) |
50 | 50 | from core.agent_runtime.tools.base import ToolResult |
51 | 51 | 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 |
52 | 54 | from core.providers.base import LLMProvider, LLMResponse, ToolCallRequest |
53 | 55 | from core.providers.timeouts import ( |
54 | 56 | resolve_request_timeout_s, |
@@ -190,6 +192,20 @@ class AgentRunSpec: |
190 | 192 | # so a well-behaved hook stops blocking after its first continuation. |
191 | 193 | stop_hook: Any | None = None |
192 | 194 |
|
| 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 | + |
193 | 209 | def allowed_tool_names(self) -> frozenset[str] | None: |
194 | 210 | if self.tool_filter is None: |
195 | 211 | return None |
@@ -400,6 +416,9 @@ def _injection_note_source(item: Any) -> str: |
400 | 416 | return "injection" |
401 | 417 |
|
402 | 418 | 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) |
403 | 422 | hook = spec.hook or AgentHook() |
404 | 423 | messages = list(spec.initial_messages) |
405 | 424 | final_content: str | None = None |
@@ -568,11 +587,33 @@ async def record_compaction_response(response: LLMResponse) -> None: |
568 | 587 |
|
569 | 588 | await hook.before_execute_tools(context) |
570 | 589 |
|
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 | + ) |
576 | 617 | tool_events.extend(new_events) |
577 | 618 | context.tool_results = list(results) |
578 | 619 | context.tool_events = list(new_events) |
@@ -657,6 +698,31 @@ async def record_compaction_response(response: LLMResponse) -> None: |
657 | 698 | if drained: |
658 | 699 | had_injections = True |
659 | 700 | 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 | + |
660 | 726 | await hook.after_iteration(context) |
661 | 727 | continue |
662 | 728 |
|
@@ -1167,6 +1233,34 @@ async def _run_tool( |
1167 | 1233 | ) |
1168 | 1234 | return result, event, None |
1169 | 1235 |
|
| 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 | + |
1170 | 1264 | prepare_call = getattr(spec.tools, "prepare_call", None) |
1171 | 1265 | tool, params, prep_error = None, tool_call.arguments, None |
1172 | 1266 | if callable(prepare_call): |
@@ -1299,6 +1393,15 @@ async def _run_tool( |
1299 | 1393 | elif len(detail) > 120: |
1300 | 1394 | detail = detail[:120] + "..." |
1301 | 1395 | 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) |
1302 | 1405 | return await self._finish_tool( |
1303 | 1406 | spec, tool_call, result, event, None, pre_contexts |
1304 | 1407 | ) |
|
0 commit comments