loopshield 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,522 @@
1
+ """Native LangChain, CrewAI, and LangGraph integrations for LoopShield.
2
+
3
+ Every framework import is optional. Message objects are read by attribute, so
4
+ the integrations run, and can be tested, against any class with the same shape.
5
+ Use them through :class:`~loopshield.shield.LoopShield`::
6
+
7
+ shield = LoopShield()
8
+ agent = create_agent(model, tools, middleware=[shield.langchain_middleware()])
9
+
10
+ @shield.crewai_agent()
11
+ class GuardedAgent(Agent): ...
12
+
13
+ graph.add_node("agent", shield.langgraph_node(agent_node))
14
+
15
+ None of the hooks records history on the shield: each call judges the
16
+ trajectory it was given, so one shield can serve many concurrent runs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import importlib
23
+ import inspect
24
+ import re
25
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from functools import wraps
28
+ from typing import TYPE_CHECKING, Any, Literal
29
+
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+
32
+ from loopshield.mitigator import estimate_tokens, prune_context
33
+ from loopshield.models import AgentStep
34
+ from loopshield.transcript import (
35
+ argument_dict,
36
+ coerce_messages,
37
+ content_text,
38
+ describe_call,
39
+ is_intervention,
40
+ messages_to_steps,
41
+ nudge_for,
42
+ tool_calls,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from loopshield.shield import LoopShield
47
+
48
+
49
+ def _optional(module: str, attribute: str) -> Any | None:
50
+ try:
51
+ return getattr(importlib.import_module(module), attribute)
52
+ except (ImportError, AttributeError):
53
+ return None
54
+
55
+
56
+ AgentMiddleware: Any | None = _optional("langchain.agents.middleware", "AgentMiddleware")
57
+ wrap_model_call: Any | None = _optional("langchain.agents.middleware", "wrap_model_call")
58
+ wrap_tool_call: Any | None = _optional("langchain.agents.middleware", "wrap_tool_call")
59
+ _AIMessage: Any | None = _optional("langchain_core.messages", "AIMessage")
60
+ _SystemMessage: Any | None = _optional("langchain_core.messages", "SystemMessage")
61
+ _ToolMessage: Any | None = _optional("langchain_core.messages", "ToolMessage")
62
+ _LangGraphCommand: Any | None = _optional("langgraph.types", "Command")
63
+
64
+ LANGCHAIN_AVAILABLE = AgentMiddleware is not None
65
+ LANGGRAPH_AVAILABLE = _LangGraphCommand is not None
66
+
67
+ _INDEX_KEY = "_loopshield_index"
68
+ _MARKER = {"loopshield": True}
69
+
70
+
71
+ class IntegrationConfig(BaseModel):
72
+ """Options shared by the framework integrations.
73
+
74
+ ``max_tokens`` is the budget above which ``prune_context`` runs. It falls
75
+ back to the shield's ``max_tokens_estimate``. ``on_loop`` picks what the
76
+ LangChain model hook does for a flagged trajectory: ``short_circuit``
77
+ answers with the nudge and never calls the model, ``inject`` appends the
78
+ nudge as a system message and lets the model run. ``retry_node`` is the
79
+ LangGraph node to re-enter after a nudge is injected.
80
+ """
81
+
82
+ model_config = ConfigDict(frozen=True, extra="forbid")
83
+
84
+ max_tokens: int | None = Field(default=None, ge=1)
85
+ on_loop: Literal["short_circuit", "inject"] = "short_circuit"
86
+ retry_node: str | None = None
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class FallbackCommand:
91
+ """Stand-in for ``langgraph.types.Command`` when LangGraph is not installed."""
92
+
93
+ update: Any = None
94
+ goto: Any = None
95
+ resume: Any = None
96
+ graph: Any = None
97
+
98
+
99
+ def resolve_config(config: IntegrationConfig | Mapping[str, Any] | None) -> IntegrationConfig:
100
+ """Return ``config`` as an :class:`IntegrationConfig`."""
101
+
102
+ if config is None:
103
+ return IntegrationConfig()
104
+ if isinstance(config, IntegrationConfig):
105
+ return config
106
+ return IntegrationConfig.model_validate(dict(config))
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # Shared screening
111
+ # --------------------------------------------------------------------------
112
+
113
+
114
+ def _loop_nudge(shield: LoopShield, messages: Sequence[Mapping[str, Any]]) -> str | None:
115
+ """Return the nudge for a flagged transcript, or None.
116
+
117
+ A transcript that already ends in a nudge is let through, which is what
118
+ gives the model its turn to change strategy.
119
+ """
120
+
121
+ if not messages or is_intervention(messages[-1]):
122
+ return None
123
+ steps = messages_to_steps(messages)
124
+ if not steps:
125
+ return None
126
+ verdict = shield.evaluate(steps)
127
+ return nudge_for(verdict, steps) if verdict.is_loop else None
128
+
129
+
130
+ def _build_message(
131
+ kind: Literal["system", "ai", "tool"],
132
+ content: str,
133
+ *,
134
+ intervention: bool = True,
135
+ **fields: Any,
136
+ ) -> Any:
137
+ """Build a LangChain message when ``langchain_core`` is present, else a dict."""
138
+
139
+ extras = dict(_MARKER) if intervention else {}
140
+ classes = {"system": _SystemMessage, "ai": _AIMessage, "tool": _ToolMessage}
141
+ message_class = classes[kind]
142
+ if message_class is not None:
143
+ if extras:
144
+ fields["additional_kwargs"] = extras
145
+ return message_class(content=content, **fields)
146
+ roles = {"system": "system", "ai": "assistant", "tool": "tool"}
147
+ message: dict[str, Any] = {"role": roles[kind], "content": content, **fields}
148
+ if extras:
149
+ message["additional_kwargs"] = extras
150
+ return message
151
+
152
+
153
+ def _prune_messages(
154
+ raw: Sequence[Any],
155
+ messages: Sequence[Mapping[str, Any]],
156
+ limit: int,
157
+ ) -> list[Any]:
158
+ """Run ``prune_context`` and map the result back onto the original objects."""
159
+
160
+ tagged = [{**message, _INDEX_KEY: index} for index, message in enumerate(messages)]
161
+ pruned = prune_context(tagged, limit)
162
+ restored: list[Any] = []
163
+ for message in pruned:
164
+ index = message.get(_INDEX_KEY)
165
+ if not isinstance(index, int):
166
+ restored.append(
167
+ _build_message("system", content_text(message.get("content")), intervention=False)
168
+ )
169
+ continue
170
+ if _same_payload(messages[index], message):
171
+ restored.append(raw[index])
172
+ continue
173
+ restored.append(_project_pruned(raw[index], message))
174
+ return restored
175
+
176
+
177
+ def _same_payload(source: Mapping[str, Any], pruned: Mapping[str, Any]) -> bool:
178
+ return all(source.get(key) == pruned.get(key) for key in ("role", "content", "tool_calls", "name", "tool_call_id"))
179
+
180
+
181
+ def _project_pruned(raw: Any, pruned: Mapping[str, Any]) -> Any:
182
+ """Copy ``raw`` with the calls and content ``prune_context`` kept."""
183
+
184
+ if isinstance(raw, Mapping):
185
+ return {key: value for key, value in pruned.items() if key != _INDEX_KEY}
186
+ clone = copy.copy(raw)
187
+ if hasattr(clone, "content") and "content" in pruned:
188
+ clone.content = pruned.get("content")
189
+ if hasattr(clone, "tool_calls"):
190
+ calls = pruned.get("tool_calls")
191
+ clone.tool_calls = list(calls) if isinstance(calls, list) else []
192
+ return clone
193
+
194
+
195
+ # --------------------------------------------------------------------------
196
+ # LangChain
197
+ # --------------------------------------------------------------------------
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class _ModelOutcome:
202
+ request: Any
203
+ reply: Any | None = None
204
+
205
+
206
+ def _with_messages(request: Any, messages: list[Any]) -> Any:
207
+ override = getattr(request, "override", None)
208
+ if callable(override):
209
+ return override(messages=messages)
210
+ clone = copy.copy(request)
211
+ clone.messages = messages
212
+ return clone
213
+
214
+
215
+ def _screen_model_request(shield: LoopShield, config: IntegrationConfig, request: Any) -> _ModelOutcome:
216
+ raw = list(getattr(request, "messages", None) or [])
217
+ messages = coerce_messages(raw)
218
+ nudge = _loop_nudge(shield, messages)
219
+ if nudge is not None and config.on_loop == "short_circuit":
220
+ return _ModelOutcome(request, _build_message("ai", nudge))
221
+
222
+ outgoing = raw
223
+ if nudge is not None:
224
+ outgoing = [*raw, _build_message("system", nudge)]
225
+ messages = coerce_messages(outgoing)
226
+ limit = config.max_tokens if config.max_tokens is not None else shield.max_tokens_estimate
227
+ if limit is not None and estimate_tokens(messages) > limit:
228
+ outgoing = _prune_messages(outgoing, messages, limit)
229
+ if outgoing is raw:
230
+ return _ModelOutcome(request)
231
+ return _ModelOutcome(_with_messages(request, outgoing))
232
+
233
+
234
+ def _call_parts(call: Any) -> tuple[str, dict[str, Any], str | None]:
235
+ if isinstance(call, Mapping):
236
+ name, arguments, call_id = call.get("name"), call.get("args"), call.get("id")
237
+ else:
238
+ name, arguments, call_id = (getattr(call, key, None) for key in ("name", "args", "id"))
239
+ return (
240
+ str(name or "").strip() or "tool",
241
+ argument_dict(arguments),
242
+ str(call_id) if call_id is not None else None,
243
+ )
244
+
245
+
246
+ def _state_messages(state: Any) -> list[Any]:
247
+ messages = state.get("messages") if isinstance(state, Mapping) else getattr(state, "messages", None)
248
+ return list(messages) if isinstance(messages, (list, tuple)) else []
249
+
250
+
251
+ def _before_pending_call(
252
+ messages: Sequence[Mapping[str, Any]],
253
+ call_id: str | None,
254
+ ) -> tuple[list[Mapping[str, Any]], str]:
255
+ """Split a transcript at the assistant message that issued ``call_id``."""
256
+
257
+ for index in range(len(messages) - 1, -1, -1):
258
+ message = messages[index]
259
+ if str(message.get("role", "")) != "assistant":
260
+ continue
261
+ issued = tool_calls(message)
262
+ if not issued:
263
+ continue
264
+ ids = {str(call.get("id")) for call in issued if call.get("id") is not None}
265
+ if call_id is None or call_id in ids:
266
+ return list(messages[:index]), content_text(message.get("content")).strip()
267
+ return list(messages), ""
268
+
269
+
270
+ def _screen_tool_call(shield: LoopShield, request: Any) -> Any | None:
271
+ raw_call = getattr(request, "tool_call", None)
272
+ name, arguments, call_id = _call_parts(raw_call)
273
+ messages = coerce_messages(_state_messages(getattr(request, "state", None)))
274
+ prior, thought = _before_pending_call(messages, call_id)
275
+ pending = AgentStep(
276
+ thought=thought or describe_call(name, arguments),
277
+ tool_name=name,
278
+ tool_args=arguments,
279
+ tokens_used=0,
280
+ )
281
+ steps = [*messages_to_steps(prior), pending]
282
+ verdict = shield.evaluate(steps)
283
+ if not verdict.is_loop:
284
+ return None
285
+ fields: dict[str, Any] = {"name": name, "status": "error"}
286
+ if call_id is not None:
287
+ fields["tool_call_id"] = call_id
288
+ return _build_message("tool", nudge_for(verdict, steps), **fields)
289
+
290
+
291
+ _MiddlewareBase: Any = AgentMiddleware if AgentMiddleware is not None else object
292
+
293
+
294
+ class LoopShieldMiddleware(_MiddlewareBase):
295
+ """LangChain ``AgentMiddleware`` that guards the model and tool calls of an agent.
296
+
297
+ ``wrap_tool_call`` replaces a looping tool call with an error result that
298
+ carries the nudge, so the tool never runs and the model sees why.
299
+ ``wrap_model_call`` prunes an over-budget transcript and, for a loop that
300
+ reaches the model, answers with the nudge (or injects it) before the LLM
301
+ is called. Handlers are invoked unchanged for every clean call.
302
+ """
303
+
304
+ def __init__(self, shield: LoopShield, config: IntegrationConfig | Mapping[str, Any] | None = None) -> None:
305
+ super().__init__()
306
+ self.shield = shield
307
+ self.config = resolve_config(config)
308
+
309
+ def wrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
310
+ outcome = _screen_model_request(self.shield, self.config, request)
311
+ if outcome.reply is not None:
312
+ return outcome.reply
313
+ return handler(outcome.request)
314
+
315
+ async def awrap_model_call(self, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
316
+ outcome = _screen_model_request(self.shield, self.config, request)
317
+ if outcome.reply is not None:
318
+ return outcome.reply
319
+ return await handler(outcome.request)
320
+
321
+ def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
322
+ blocked = _screen_tool_call(self.shield, request)
323
+ if blocked is not None:
324
+ return blocked
325
+ return handler(request)
326
+
327
+ async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
328
+ blocked = _screen_tool_call(self.shield, request)
329
+ if blocked is not None:
330
+ return blocked
331
+ return await handler(request)
332
+
333
+
334
+ def langchain_hooks(
335
+ shield: LoopShield,
336
+ config: IntegrationConfig | Mapping[str, Any] | None = None,
337
+ ) -> list[Any]:
338
+ """Return the guards as ``wrap_model_call`` / ``wrap_tool_call`` decorated middleware.
339
+
340
+ Falls back to the single :class:`LoopShieldMiddleware` when LangChain's
341
+ decorators cannot be imported.
342
+ """
343
+
344
+ middleware = LoopShieldMiddleware(shield, config)
345
+ if wrap_model_call is None or wrap_tool_call is None:
346
+ return [middleware]
347
+
348
+ def guard_model(request: Any, handler: Callable[[Any], Any]) -> Any:
349
+ return middleware.wrap_model_call(request, handler)
350
+
351
+ def guard_tool(request: Any, handler: Callable[[Any], Any]) -> Any:
352
+ return middleware.wrap_tool_call(request, handler)
353
+
354
+ return [wrap_model_call(guard_model), wrap_tool_call(guard_tool)]
355
+
356
+
357
+ # --------------------------------------------------------------------------
358
+ # CrewAI
359
+ # --------------------------------------------------------------------------
360
+
361
+ _CREW_ACTION = re.compile(
362
+ r"Action:\s*(?P<name>[^\n]+?)\s*\n\s*Action Input:\s*(?P<input>.*?)(?=\n\s*(?:Observation|Thought|Action):|\Z)",
363
+ re.DOTALL,
364
+ )
365
+ _NAME_KEYS = ("tool_name", "name", "tool")
366
+ _ARGUMENT_KEYS = ("tool_args", "args", "input", "arguments")
367
+
368
+
369
+ def _field(item: Any, keys: Sequence[str]) -> Any:
370
+ for key in keys:
371
+ value = item.get(key) if isinstance(item, Mapping) else getattr(item, key, None)
372
+ if value is not None:
373
+ return value
374
+ return None
375
+
376
+
377
+ def _crew_tool_steps(agent: Any, trace: str) -> list[AgentStep]:
378
+ """Rebuild the tool-use trajectory from the agent's results, else its string trace."""
379
+
380
+ uses: list[tuple[str, dict[str, Any]]] = []
381
+ results = getattr(agent, "tools_results", None)
382
+ if isinstance(results, (list, tuple)) and results:
383
+ for item in results:
384
+ name = _field(item, _NAME_KEYS)
385
+ if name is not None and str(name).strip():
386
+ uses.append((str(name).strip(), argument_dict(_field(item, _ARGUMENT_KEYS))))
387
+ else:
388
+ for match in _CREW_ACTION.finditer(trace):
389
+ uses.append((match.group("name").strip(), argument_dict(match.group("input").strip())))
390
+ return [
391
+ AgentStep(thought=describe_call(name, arguments), tool_name=name, tool_args=arguments, tokens_used=0)
392
+ for name, arguments in uses
393
+ ]
394
+
395
+
396
+ def _crew_context(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, bool]:
397
+ if "context" in kwargs:
398
+ return kwargs["context"], True
399
+ if args:
400
+ return args[0], False
401
+ return None, True
402
+
403
+
404
+ def _nudged_call(
405
+ shield: LoopShield,
406
+ agent: Any,
407
+ args: tuple[Any, ...],
408
+ kwargs: dict[str, Any],
409
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
410
+ context, by_keyword = _crew_context(args, kwargs)
411
+ trace = context if isinstance(context, str) else ""
412
+ steps = _crew_tool_steps(agent, trace)
413
+ if not steps:
414
+ return args, kwargs
415
+ verdict = shield.evaluate(steps)
416
+ if not verdict.is_loop:
417
+ return args, kwargs
418
+ nudge = nudge_for(verdict, steps)
419
+ if trace.startswith(nudge):
420
+ return args, kwargs
421
+ updated = f"{nudge}\n\n{trace}" if trace else nudge
422
+ if by_keyword:
423
+ return args, {**kwargs, "context": updated}
424
+ return (updated, *args[1:]), kwargs
425
+
426
+
427
+ def _wrap_execute_task(shield: LoopShield, execute: Callable[..., Any]) -> Callable[..., Any]:
428
+ if inspect.iscoroutinefunction(execute):
429
+
430
+ @wraps(execute)
431
+ async def async_wrapper(agent: Any, task: Any, *args: Any, **kwargs: Any) -> Any:
432
+ call_args, call_kwargs = _nudged_call(shield, agent, args, kwargs)
433
+ return await execute(agent, task, *call_args, **call_kwargs)
434
+
435
+ return async_wrapper
436
+
437
+ @wraps(execute)
438
+ def wrapper(agent: Any, task: Any, *args: Any, **kwargs: Any) -> Any:
439
+ call_args, call_kwargs = _nudged_call(shield, agent, args, kwargs)
440
+ return execute(agent, task, *call_args, **call_kwargs)
441
+
442
+ return wrapper
443
+
444
+
445
+ def crewai_agent(shield: LoopShield) -> Callable[[Any], Any]:
446
+ """Return a decorator for ``execute_task`` or for a CrewAI ``Agent`` class.
447
+
448
+ Before the wrapped step runs, the agent's tool results (or the ReAct
449
+ ``Action`` / ``Action Input`` lines in the string context) are rebuilt
450
+ into a tool trajectory. A loop prepends the nudge to the ``context``
451
+ string, which is the execution trace the next step reads.
452
+ """
453
+
454
+ def decorate(target: Any) -> Any:
455
+ if inspect.isclass(target):
456
+ target.execute_task = _wrap_execute_task(shield, target.execute_task)
457
+ return target
458
+ return _wrap_execute_task(shield, target)
459
+
460
+ return decorate
461
+
462
+
463
+ # --------------------------------------------------------------------------
464
+ # LangGraph
465
+ # --------------------------------------------------------------------------
466
+
467
+
468
+ def _graph_update(
469
+ shield: LoopShield,
470
+ config: IntegrationConfig,
471
+ state: Any,
472
+ ) -> Any | None:
473
+ nudge = _loop_nudge(shield, coerce_messages(_state_messages(state)))
474
+ if nudge is None:
475
+ return None
476
+ update = {"messages": [_build_message("system", nudge)]}
477
+ if config.retry_node is None:
478
+ return update
479
+ command = _LangGraphCommand if _LangGraphCommand is not None else FallbackCommand
480
+ return command(update=update, goto=config.retry_node)
481
+
482
+
483
+ def langgraph_node(
484
+ shield: LoopShield,
485
+ node: Callable[..., Any] | None = None,
486
+ config: IntegrationConfig | Mapping[str, Any] | None = None,
487
+ ) -> Any:
488
+ """Wrap a LangGraph node, or return a decorator when ``node`` is omitted.
489
+
490
+ The wrapper reads ``state["messages"]`` and, when the trajectory is a loop
491
+ that has not been nudged yet, returns the system nudge as a state update
492
+ without running the node. With ``retry_node`` set it returns
493
+ ``Command(update=..., goto=retry_node)`` so the graph re-enters that node
494
+ with the nudge in context. Otherwise the node runs exactly as written.
495
+ The update needs a ``messages`` channel with an appending reducer such as
496
+ ``add_messages``.
497
+ """
498
+
499
+ resolved = resolve_config(config)
500
+
501
+ def decorate(function: Callable[..., Any]) -> Callable[..., Any]:
502
+ if inspect.iscoroutinefunction(function):
503
+
504
+ @wraps(function)
505
+ async def async_wrapper(state: Any, *args: Any, **kwargs: Any) -> Any:
506
+ intervention = _graph_update(shield, resolved, state)
507
+ if intervention is not None:
508
+ return intervention
509
+ return await function(state, *args, **kwargs)
510
+
511
+ return async_wrapper
512
+
513
+ @wraps(function)
514
+ def wrapper(state: Any, *args: Any, **kwargs: Any) -> Any:
515
+ intervention = _graph_update(shield, resolved, state)
516
+ if intervention is not None:
517
+ return intervention
518
+ return function(state, *args, **kwargs)
519
+
520
+ return wrapper
521
+
522
+ return decorate if node is None else decorate(node)
@@ -0,0 +1,147 @@
1
+ """Directed-graph detection of repeating tool-call cycles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ import networkx as nx
10
+
11
+ from loopshield.models import AgentStep
12
+
13
+
14
+ def tool_signature(tool_name: str, tool_args: dict[str, Any]) -> str:
15
+ """Identify a tool call by its name and normalized arguments."""
16
+
17
+ return f"{tool_name.strip()}|{normalize_tool_args(tool_args)}"
18
+
19
+
20
+ def normalize_tool_args(tool_args: dict[str, Any]) -> str:
21
+ """Serialize arguments so key order and string formatting do not matter."""
22
+
23
+ return json.dumps(
24
+ _normalize(dict(tool_args)),
25
+ sort_keys=True,
26
+ separators=(",", ":"),
27
+ default=str,
28
+ )
29
+
30
+
31
+ class GraphAnalyzer:
32
+ """Track sequential tool calls and flag a cycle that repeats too often.
33
+
34
+ Each step becomes a node labeled ``tool_name`` plus normalized ``tool_args``.
35
+ Consecutive steps become directed edges. A cycle counts once each time that
36
+ exact tool sequence is walked and returns to its start. ``detect`` returns
37
+ True when that count is greater than ``max_cycles``.
38
+
39
+ With the default ``max_cycles=1``, one completed lap is allowed and the
40
+ second lap is a loop. ``max_cycles=0`` flags the first time a cycle closes.
41
+ """
42
+
43
+ def __init__(self, max_cycles: int = 1) -> None:
44
+ if max_cycles < 0:
45
+ raise ValueError("max_cycles must be zero or greater")
46
+ self.max_cycles = max_cycles
47
+
48
+ def build_graph(self, steps: Sequence[AgentStep]) -> nx.DiGraph:
49
+ """Return a directed graph of successive tool calls.
50
+
51
+ Edge ``weight`` is how many times that transition occurred.
52
+ """
53
+
54
+ graph: nx.DiGraph = nx.DiGraph()
55
+ signatures = [_signature(step) for step in steps]
56
+ for signature in signatures:
57
+ graph.add_node(signature)
58
+ for source, target in zip(signatures, signatures[1:], strict=False):
59
+ if graph.has_edge(source, target):
60
+ graph[source][target]["weight"] += 1
61
+ else:
62
+ graph.add_edge(source, target, weight=1)
63
+ return graph
64
+
65
+ def repeat_count(self, steps: Sequence[AgentStep]) -> int:
66
+ """Return how many times the most-repeated exact tool cycle completed."""
67
+
68
+ if len(steps) < 2:
69
+ return 0
70
+ graph = self.build_graph(steps)
71
+ signatures = [_signature(step) for step in steps]
72
+ counts = [
73
+ _completion_count(signatures, cycle) for cycle in _directed_cycles(graph)
74
+ ]
75
+ return max(counts, default=0)
76
+
77
+ def detect(self, steps: Sequence[AgentStep]) -> bool:
78
+ """Return True when one exact tool cycle repeats more than ``max_cycles`` times."""
79
+
80
+ return self.repeat_count(steps) > self.max_cycles
81
+
82
+ def repeated_signatures(self, steps: Sequence[AgentStep]) -> set[str]:
83
+ """Return tool signatures that sit on a cycle repeated more than ``max_cycles`` times."""
84
+
85
+ if len(steps) < 2:
86
+ return set()
87
+ graph = self.build_graph(steps)
88
+ signatures = [_signature(step) for step in steps]
89
+ locked: set[str] = set()
90
+ for cycle in _directed_cycles(graph):
91
+ if _completion_count(signatures, cycle) > self.max_cycles:
92
+ locked.update(cycle)
93
+ return locked
94
+
95
+
96
+ def _signature(step: AgentStep) -> str:
97
+ return tool_signature(step.tool_name, step.tool_args)
98
+
99
+
100
+ def _normalize(value: Any) -> Any:
101
+ if isinstance(value, str):
102
+ return " ".join(value.split()).casefold()
103
+ if isinstance(value, dict):
104
+ return {str(key): _normalize(item) for key, item in value.items()}
105
+ if isinstance(value, (list, tuple)):
106
+ return [_normalize(item) for item in value]
107
+ return value
108
+
109
+
110
+ def _directed_cycles(graph: nx.DiGraph) -> list[tuple[str, ...]]:
111
+ """Yield simple directed cycles, including self-loops."""
112
+
113
+ cycles = [tuple(str(node) for node in cycle) for cycle in nx.simple_cycles(graph)]
114
+ for node in graph.nodes:
115
+ if graph.has_edge(node, node):
116
+ loop = (str(node),)
117
+ if loop not in cycles:
118
+ cycles.append(loop)
119
+ return cycles
120
+
121
+
122
+ def _completion_count(signatures: Sequence[str], cycle: tuple[str, ...]) -> int:
123
+ """Count contiguous walks of ``cycle`` that return to the start node.
124
+
125
+ Rotations describe the same directed cycle. The maximum is used so one lap
126
+ is not counted once per starting node.
127
+ """
128
+
129
+ if not cycle:
130
+ return 0
131
+ best = 0
132
+ for rotation in _rotations(cycle):
133
+ needed = (*rotation, rotation[0])
134
+ width = len(needed)
135
+ matches = sum(
136
+ 1
137
+ for index in range(len(signatures) - width + 1)
138
+ if tuple(signatures[index : index + width]) == needed
139
+ )
140
+ best = max(best, matches)
141
+ return best
142
+
143
+
144
+ def _rotations(cycle: tuple[str, ...]) -> list[tuple[str, ...]]:
145
+ if len(cycle) <= 1:
146
+ return [cycle]
147
+ return [cycle[index:] + cycle[:index] for index in range(len(cycle))]