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/shield.py ADDED
@@ -0,0 +1,279 @@
1
+ """Middleware wrapper that inspects each agent step and blocks looping tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from functools import wraps
7
+ from typing import Any, ParamSpec, TypeVar
8
+
9
+ from loopshield.adapters import as_embed_fn
10
+ from loopshield.graph_analyzer import GraphAnalyzer, tool_signature
11
+ from loopshield.mitigator import Mitigator
12
+ from loopshield.models import AgentStep, LoopVerdict
13
+ from loopshield.semantic_analyzer import EmbedFn, SemanticAnalyzer
14
+
15
+ P = ParamSpec("P")
16
+ R = TypeVar("R")
17
+
18
+ _INTERVENTIONS: dict[str, str] = {
19
+ "graph_cycle": (
20
+ "LoopShield detected a repeated tool cycle. "
21
+ "Do not call the same tools with the same arguments again. "
22
+ "Use the observations you already have, try a different approach, "
23
+ "or give the final answer."
24
+ ),
25
+ "semantic": (
26
+ "LoopShield detected a repeated line of thought. "
27
+ "Stop rephrasing the same idea. "
28
+ "Use the observations you already have, try a different approach, "
29
+ "or give the final answer."
30
+ ),
31
+ "both": (
32
+ "LoopShield detected a repeated tool cycle and a repeated line of thought. "
33
+ "Do not repeat the same action or reasoning. "
34
+ "Use the observations you already have, try a different approach, "
35
+ "or give the final answer."
36
+ ),
37
+ }
38
+
39
+
40
+ class LoopShield:
41
+ """Watch an agent loop, block a tool that starts repeating, and prune context.
42
+
43
+ ``step`` records one :class:`AgentStep` and returns the verdict for the
44
+ trajectory so far. ``guard`` wraps the function an agent calls on each
45
+ iteration: a looping call is not executed and the verdict is returned
46
+ instead. A guarded function that receives the whole ``list[AgentStep]``
47
+ is refused before it runs when any step in that list closes a loop.
48
+
49
+ Pass an initialized client straight through ``embedding``::
50
+
51
+ LoopShield(embedding=OpenAI())
52
+ LoopShield(embedding=Client(host="http://127.0.0.1:11434"))
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ max_cycles: int = 1,
59
+ similarity_threshold: float = 0.92,
60
+ max_semantic_repeats: int = 2,
61
+ embed_fn: EmbedFn | None = None,
62
+ embedding: Any | None = None,
63
+ embedding_model: str | None = None,
64
+ max_tokens_estimate: int | None = None,
65
+ ) -> None:
66
+ if max_tokens_estimate is not None and max_tokens_estimate < 0:
67
+ raise ValueError("max_tokens_estimate must be zero or greater")
68
+ if embed_fn is not None and embedding is not None:
69
+ raise ValueError("Pass either embed_fn or embedding, not both.")
70
+ if embedding is None and embedding_model is not None:
71
+ raise ValueError("embedding_model is only used with an OpenAI or Ollama client.")
72
+ resolved_embed_fn = embed_fn if embedding is None else as_embed_fn(embedding, model=embedding_model)
73
+ self.graph_analyzer = GraphAnalyzer(max_cycles=max_cycles)
74
+ self.semantic_analyzer = SemanticAnalyzer(
75
+ resolved_embed_fn,
76
+ similarity_threshold=similarity_threshold,
77
+ max_semantic_repeats=max_semantic_repeats,
78
+ )
79
+ self.mitigator = Mitigator()
80
+ self.max_tokens_estimate = max_tokens_estimate
81
+ self._history: list[AgentStep] = []
82
+
83
+ @property
84
+ def history(self) -> tuple[AgentStep, ...]:
85
+ """Steps recorded by :meth:`step` and :meth:`guard`, in call order."""
86
+
87
+ return tuple(self._history)
88
+
89
+ def step(self, agent_step: AgentStep) -> LoopVerdict:
90
+ """Record ``agent_step`` and judge the trajectory collected so far.
91
+
92
+ A loop locks ``agent_step.tool_name``. The verdict's
93
+ ``intervention_prompt`` is the system nudge that tells the model to
94
+ change strategy.
95
+ """
96
+
97
+ self._history.append(agent_step)
98
+ verdict = self._verdict_for_latest(self._history)
99
+ if not verdict.is_loop:
100
+ return verdict
101
+ nudge = self.mitigator.generate_nudge(verdict, agent_step.tool_name)
102
+ return verdict.model_copy(update={"intervention_prompt": nudge["content"]})
103
+
104
+ def guard(self, func: Callable[P, R]) -> Callable[P, R | LoopVerdict]:
105
+ """Decorate an agent step or an agent loop so a detected loop is not executed.
106
+
107
+ Per-step form::
108
+
109
+ @shield.guard
110
+ def execute(agent_step: AgentStep) -> str:
111
+ return run_tool(agent_step)
112
+
113
+ Loop form::
114
+
115
+ @shield.guard
116
+ def run(steps: list[AgentStep]) -> list[str]:
117
+ return [run_tool(step) for step in steps]
118
+ """
119
+
120
+ @wraps(func)
121
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | LoopVerdict:
122
+ agent_step = _find_step(args, kwargs)
123
+ if agent_step is not None:
124
+ verdict = self.step(agent_step)
125
+ if verdict.is_loop:
126
+ return verdict
127
+ return self._maybe_prune(func(*args, **kwargs))
128
+
129
+ sequence = _find_step_sequence(args, kwargs)
130
+ if sequence is not None:
131
+ for item in sequence:
132
+ verdict = self.step(item)
133
+ if verdict.is_loop:
134
+ return verdict
135
+ return self._maybe_prune(func(*args, **kwargs))
136
+
137
+ result = func(*args, **kwargs)
138
+ if isinstance(result, AgentStep):
139
+ verdict = self.step(result)
140
+ if verdict.is_loop:
141
+ return verdict
142
+ return self._maybe_prune(result)
143
+
144
+ return wrapper
145
+
146
+ def inspect(self, steps: Sequence[AgentStep]) -> LoopVerdict:
147
+ """Return a verdict for ``steps``. The given trajectory is not modified."""
148
+
149
+ return _assemble(
150
+ self.graph_analyzer.detect(steps),
151
+ self.semantic_analyzer.detect(steps),
152
+ )
153
+
154
+ def _verdict_for_latest(self, steps: Sequence[AgentStep]) -> LoopVerdict:
155
+ """Flag a loop only when the newest step is still inside it.
156
+
157
+ A tool that is not part of the repeated cycle can run after a nudge,
158
+ which is what lets the agent leave the loop and finish the task.
159
+ """
160
+
161
+ if not steps:
162
+ return _assemble(False, False)
163
+ latest = tool_signature(steps[-1].tool_name, steps[-1].tool_args)
164
+ graph_loop = latest in self.graph_analyzer.repeated_signatures(steps)
165
+ semantic_loop = self.semantic_analyzer.detect(steps)
166
+ return _assemble(graph_loop, semantic_loop)
167
+
168
+ def protect(
169
+ self,
170
+ steps: Sequence[AgentStep],
171
+ ) -> tuple[LoopVerdict, list[AgentStep]]:
172
+ """Inspect ``steps`` and drop older copies of a repeated tool call.
173
+
174
+ When a loop is present, ``intervention_prompt`` is the tool-lock nudge
175
+ for the latest step.
176
+ """
177
+
178
+ verdict = self.inspect(steps)
179
+ if not verdict.is_loop or not steps:
180
+ return verdict, list(steps)
181
+ nudge = self.mitigator.generate_nudge(verdict, steps[-1].tool_name)
182
+ verdict = verdict.model_copy(update={"intervention_prompt": nudge["content"]})
183
+ return verdict, self.mitigator.prune(steps)
184
+
185
+ def __call__(
186
+ self,
187
+ steps: Sequence[AgentStep],
188
+ ) -> tuple[LoopVerdict, list[AgentStep]]:
189
+ """Run :meth:`protect` so the shield can be used as middleware."""
190
+
191
+ return self.protect(steps)
192
+
193
+ def evaluate(self, steps: Sequence[AgentStep]) -> LoopVerdict:
194
+ """Judge a complete trajectory by its newest step, without recording it.
195
+
196
+ A loop is reported only while the newest tool call is still inside the
197
+ repeated cycle, so an agent that switches tools after a nudge is let
198
+ through. Framework integrations call this on every message pipeline pass.
199
+ """
200
+
201
+ return self._verdict_for_latest(steps)
202
+
203
+ def langchain_middleware(self, config: Any | None = None) -> Any:
204
+ """Return a LangChain ``AgentMiddleware`` that screens model and tool calls.
205
+
206
+ ``config`` is a :class:`loopshield.frameworks.IntegrationConfig` or a
207
+ mapping of its fields. Pass the result in ``create_agent(middleware=[...])``.
208
+ """
209
+
210
+ from loopshield.frameworks import LoopShieldMiddleware
211
+
212
+ return LoopShieldMiddleware(self, config)
213
+
214
+ def crewai_agent(self) -> Callable[[Any], Any]:
215
+ """Return a decorator for CrewAI's ``Agent.execute_task`` or an ``Agent`` class."""
216
+
217
+ from loopshield.frameworks import crewai_agent
218
+
219
+ return crewai_agent(self)
220
+
221
+ def langgraph_node(
222
+ self,
223
+ node: Callable[..., Any] | None = None,
224
+ config: Any | None = None,
225
+ ) -> Any:
226
+ """Wrap a LangGraph node so a flagged ``messages`` state gets the system nudge.
227
+
228
+ Use ``@shield.langgraph_node()`` or ``shield.langgraph_node(node)``.
229
+ """
230
+
231
+ from loopshield.frameworks import langgraph_node
232
+
233
+ return langgraph_node(self, node, config)
234
+
235
+ def _maybe_prune(self, result: R) -> R:
236
+ if self.max_tokens_estimate is None or not _is_message_list(result):
237
+ return result
238
+ return self.mitigator.prune_context(result, self.max_tokens_estimate) # type: ignore[return-value]
239
+
240
+
241
+ def _assemble(graph_loop: bool, semantic_loop: bool) -> LoopVerdict:
242
+ if graph_loop and semantic_loop:
243
+ loop_type = "both"
244
+ elif graph_loop:
245
+ loop_type = "graph_cycle"
246
+ elif semantic_loop:
247
+ loop_type = "semantic"
248
+ else:
249
+ return LoopVerdict(is_loop=False, loop_type=None, intervention_prompt=None)
250
+ return LoopVerdict(
251
+ is_loop=True,
252
+ loop_type=loop_type,
253
+ intervention_prompt=_INTERVENTIONS[loop_type],
254
+ )
255
+
256
+
257
+ def _find_step(args: tuple[Any, ...], kwargs: dict[str, Any]) -> AgentStep | None:
258
+ for value in (*args, *kwargs.values()):
259
+ if isinstance(value, AgentStep):
260
+ return value
261
+ return None
262
+
263
+
264
+ def _find_step_sequence(
265
+ args: tuple[Any, ...],
266
+ kwargs: dict[str, Any],
267
+ ) -> list[AgentStep] | None:
268
+ for value in (*args, *kwargs.values()):
269
+ if (
270
+ isinstance(value, (list, tuple))
271
+ and value
272
+ and all(isinstance(item, AgentStep) for item in value)
273
+ ):
274
+ return list(value)
275
+ return None
276
+
277
+
278
+ def _is_message_list(value: object) -> bool:
279
+ return isinstance(value, list) and bool(value) and all(isinstance(item, dict) for item in value)
@@ -0,0 +1,205 @@
1
+ """Convert chat transcripts from any framework into :class:`AgentStep` trajectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from loopshield.mitigator import estimate_tokens, generate_nudge
10
+ from loopshield.models import AgentStep, LoopVerdict
11
+
12
+ REPLY_TOOL = "reply"
13
+
14
+ _ROLE_ALIASES = {
15
+ "human": "user",
16
+ "ai": "assistant",
17
+ "chat": "assistant",
18
+ "model": "assistant",
19
+ }
20
+
21
+
22
+ def content_text(content: Any) -> str:
23
+ """Read plain text or a list of content parts as one string."""
24
+
25
+ if content is None:
26
+ return ""
27
+ if isinstance(content, str):
28
+ return content
29
+ if isinstance(content, list):
30
+ parts = [
31
+ content_text(part.get("text", part.get("content", "")))
32
+ if isinstance(part, Mapping)
33
+ else content_text(part)
34
+ for part in content
35
+ ]
36
+ return "\n".join(part for part in parts if part)
37
+ return str(content)
38
+
39
+
40
+ def argument_dict(arguments: Any) -> dict[str, Any]:
41
+ """Return tool arguments as a dict, parsing a JSON string when needed."""
42
+
43
+ if isinstance(arguments, str):
44
+ try:
45
+ parsed = json.loads(arguments) if arguments.strip() else {}
46
+ except json.JSONDecodeError:
47
+ return {"raw": arguments}
48
+ arguments = parsed
49
+ if isinstance(arguments, dict):
50
+ return arguments
51
+ if arguments is None:
52
+ return {}
53
+ return {"value": arguments}
54
+
55
+
56
+ def describe_call(name: str, arguments: Mapping[str, Any]) -> str:
57
+ """Describe a tool call as text for steps that carry no written thought."""
58
+
59
+ return f"{name} {json.dumps(dict(arguments), sort_keys=True, default=str)}"
60
+
61
+
62
+ def coerce_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
63
+ """Return OpenAI-style message dicts for dicts, role tuples, or message objects.
64
+
65
+ LangChain-style objects (``type``, ``content``, ``tool_calls``) are read by
66
+ attribute, so no framework import is needed.
67
+ """
68
+
69
+ return [coerce_message(item) for item in raw]
70
+
71
+
72
+ def coerce_message(item: Any) -> dict[str, Any]:
73
+ """Return one message as an OpenAI-style dict. The input is not modified."""
74
+
75
+ if isinstance(item, Mapping):
76
+ data = dict(item)
77
+ data["role"] = _role(data.get("role") or data.get("type"))
78
+ _carry_marker(data, item.get("additional_kwargs"))
79
+ return data
80
+ if isinstance(item, str):
81
+ return {"role": "user", "content": item}
82
+ if isinstance(item, (tuple, list)) and len(item) == 2:
83
+ return {"role": _role(item[0]), "content": item[1]}
84
+
85
+ data = {
86
+ "role": _role(getattr(item, "role", None) or getattr(item, "type", None)),
87
+ "content": getattr(item, "content", ""),
88
+ }
89
+ for key in ("name", "tool_call_id", "status"):
90
+ value = getattr(item, key, None)
91
+ if isinstance(value, str):
92
+ data[key] = value
93
+ calls = getattr(item, "tool_calls", None)
94
+ if isinstance(calls, list) and calls:
95
+ data["tool_calls"] = [_openai_call(call) for call in calls]
96
+ _carry_marker(data, getattr(item, "additional_kwargs", None))
97
+ return data
98
+
99
+
100
+ def messages_to_steps(messages: Sequence[Mapping[str, Any]]) -> list[AgentStep]:
101
+ """Turn assistant turns into steps: one per tool call, or one text reply.
102
+
103
+ A text-only reply is a ``reply`` step whose arguments carry its position,
104
+ so replies never form a tool cycle on their own.
105
+ """
106
+
107
+ steps: list[AgentStep] = []
108
+ for index, message in enumerate(messages):
109
+ if str(message.get("role", "")).casefold() != "assistant":
110
+ continue
111
+ thought = content_text(message.get("content")).strip()
112
+ calls = tool_calls(message)
113
+ tokens_used = estimate_tokens([message])
114
+ if calls:
115
+ for call in calls:
116
+ name, arguments = parse_call(call)
117
+ steps.append(
118
+ AgentStep(
119
+ thought=thought or describe_call(name, arguments),
120
+ tool_name=name,
121
+ tool_args=arguments,
122
+ tokens_used=tokens_used,
123
+ )
124
+ )
125
+ elif thought:
126
+ steps.append(
127
+ AgentStep(
128
+ thought=thought,
129
+ tool_name=REPLY_TOOL,
130
+ tool_args={"index": index},
131
+ tokens_used=tokens_used,
132
+ )
133
+ )
134
+ return steps
135
+
136
+
137
+ def tool_calls(message: Mapping[str, Any]) -> list[Mapping[str, Any]]:
138
+ """Return OpenAI ``tool_calls`` and Anthropic ``tool_use`` blocks of a message."""
139
+
140
+ calls: list[Mapping[str, Any]] = []
141
+ raw_calls = message.get("tool_calls")
142
+ if isinstance(raw_calls, list):
143
+ calls.extend(call for call in raw_calls if isinstance(call, Mapping))
144
+ content = message.get("content")
145
+ if isinstance(content, list):
146
+ calls.extend(
147
+ part for part in content if isinstance(part, Mapping) and part.get("type") == "tool_use"
148
+ )
149
+ return calls
150
+
151
+
152
+ def parse_call(call: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
153
+ """Return the tool name and argument dict of one call in any supported shape."""
154
+
155
+ function = call.get("function")
156
+ payload = function if isinstance(function, Mapping) else call
157
+ name = str(payload.get("name") or "").strip() or "tool"
158
+ arguments = payload.get("arguments", payload.get("input", payload.get("args", {})))
159
+ return name, argument_dict(arguments)
160
+
161
+
162
+ def nudge_for(verdict: LoopVerdict, steps: Sequence[AgentStep]) -> str:
163
+ """Return the intervention text for a flagged trajectory."""
164
+
165
+ tool = steps[-1].tool_name if steps else "tool"
166
+ if tool != REPLY_TOOL:
167
+ return str(generate_nudge(verdict, tool)["content"])
168
+ if verdict.intervention_prompt:
169
+ return str(verdict.intervention_prompt)
170
+ return "LoopShield detected a loop. Choose a different strategy immediately."
171
+
172
+
173
+ def is_intervention(message: Mapping[str, Any]) -> bool:
174
+ """Return True for a message that already carries a LoopShield nudge."""
175
+
176
+ if message.get("loopshield_intervention") is True:
177
+ return True
178
+ text = content_text(message.get("content"))
179
+ return text.startswith("LoopShield detected") or (
180
+ "is temporarily locked" in text and "different strategy" in text
181
+ )
182
+
183
+
184
+ def _role(value: Any) -> str:
185
+ role = str(value or "").strip().casefold()
186
+ return _ROLE_ALIASES.get(role, role)
187
+
188
+
189
+ def _carry_marker(data: dict[str, Any], extras: Any) -> None:
190
+ if isinstance(extras, Mapping) and extras.get("loopshield") is True:
191
+ data["loopshield_intervention"] = True
192
+
193
+
194
+ def _openai_call(call: Any) -> Any:
195
+ if not isinstance(call, Mapping) or isinstance(call.get("function"), Mapping):
196
+ return call
197
+ arguments = call.get("args", call.get("arguments", call.get("input", {})))
198
+ return {
199
+ "id": call.get("id"),
200
+ "type": "function",
201
+ "function": {
202
+ "name": call.get("name"),
203
+ "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments, default=str),
204
+ },
205
+ }