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.
- loopshield/__init__.py +25 -0
- loopshield/adapters.py +222 -0
- loopshield/frameworks.py +522 -0
- loopshield/graph_analyzer.py +147 -0
- loopshield/mitigator.py +637 -0
- loopshield/models.py +32 -0
- loopshield/proxy.py +376 -0
- loopshield/py.typed +0 -0
- loopshield/semantic_analyzer.py +142 -0
- loopshield/shield.py +279 -0
- loopshield/transcript.py +205 -0
- loopshield-1.0.0.dist-info/METADATA +229 -0
- loopshield-1.0.0.dist-info/RECORD +15 -0
- loopshield-1.0.0.dist-info/WHEEL +5 -0
- loopshield-1.0.0.dist-info/top_level.txt +1 -0
loopshield/frameworks.py
ADDED
|
@@ -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))]
|