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/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)
|
loopshield/transcript.py
ADDED
|
@@ -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
|
+
}
|