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/mitigator.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""Self-healing nudges and context pruning for looping agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from loopshield.graph_analyzer import tool_signature
|
|
10
|
+
from loopshield.models import AgentStep, LoopVerdict
|
|
11
|
+
|
|
12
|
+
_LOOP_LABELS: dict[str, str] = {
|
|
13
|
+
"graph_cycle": "repeated tool cycle",
|
|
14
|
+
"semantic": "repeated line of thought",
|
|
15
|
+
"both": "repeated tool cycle and a repeated line of thought",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_ERROR_TEXT = re.compile(r"\b(error|exception|traceback|failed|failure|timeout|timed out)\b", re.I)
|
|
19
|
+
_TURN_STARTS = {"user", "assistant"}
|
|
20
|
+
_TOOL_ROLES = {"tool", "function", "tool_result"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_nudge(verdict: LoopVerdict, blocked_tool: str) -> dict[str, str]:
|
|
24
|
+
"""Return a system message that locks ``blocked_tool`` and demands a new strategy."""
|
|
25
|
+
|
|
26
|
+
tool = blocked_tool.strip()
|
|
27
|
+
if not tool:
|
|
28
|
+
raise ValueError("blocked_tool must name the locked tool")
|
|
29
|
+
label = _LOOP_LABELS.get(verdict.loop_type or "", "detected loop")
|
|
30
|
+
content = (
|
|
31
|
+
f"The tool `{tool}` is temporarily locked due to a {label}. "
|
|
32
|
+
f"You must not call `{tool}` again. "
|
|
33
|
+
"Choose a different strategy immediately, using only the evidence you already have, "
|
|
34
|
+
"or give the final answer."
|
|
35
|
+
)
|
|
36
|
+
return {"role": "system", "content": content}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prune_context(messages: list[dict[str, Any]], max_tokens_estimate: int) -> list[dict[str, Any]]:
|
|
40
|
+
"""Drop repeated tool errors from the middle of a chat transcript.
|
|
41
|
+
|
|
42
|
+
The original leading system prompt and the latest two turns are kept.
|
|
43
|
+
When :func:`estimate_tokens` is above ``max_tokens_estimate``, repetitive
|
|
44
|
+
tool failures between those anchors collapse into one system summary.
|
|
45
|
+
OpenAI ``tool_calls`` and Anthropic ``tool_use`` blocks that produced
|
|
46
|
+
those failures are removed with them, so a call is never left without its
|
|
47
|
+
result. The input list is not mutated.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
if max_tokens_estimate < 0:
|
|
51
|
+
raise ValueError("max_tokens_estimate must be zero or greater")
|
|
52
|
+
if any(not isinstance(message, dict) for message in messages):
|
|
53
|
+
raise TypeError("messages must be a list of dictionaries")
|
|
54
|
+
|
|
55
|
+
copied = [dict(message) for message in messages]
|
|
56
|
+
if estimate_tokens(copied) <= max_tokens_estimate:
|
|
57
|
+
return copied
|
|
58
|
+
|
|
59
|
+
system, body = _split_system_prompt(copied)
|
|
60
|
+
turns = _split_turns(body)
|
|
61
|
+
if len(turns) <= 2:
|
|
62
|
+
return system + _flatten(turns)
|
|
63
|
+
|
|
64
|
+
middle_turns, tail_turns = turns[:-2], turns[-2:]
|
|
65
|
+
middle = _flatten(middle_turns)
|
|
66
|
+
tail = _flatten(tail_turns)
|
|
67
|
+
collapsed_middle, summary = _collapse_repetitive_tool_errors(middle, _collected_result_ids(tail))
|
|
68
|
+
collapsed_messages = system + collapsed_middle + tail
|
|
69
|
+
collapsed = _legalize_tool_exchanges(
|
|
70
|
+
collapsed_messages if summary is None else _place_summary(collapsed_messages, summary)
|
|
71
|
+
)
|
|
72
|
+
summarized = _legalize_tool_exchanges(system + [_summarize_middle(middle)] + tail)
|
|
73
|
+
anchors = _legalize_tool_exchanges(system + tail)
|
|
74
|
+
for candidate in (collapsed, summarized, anchors):
|
|
75
|
+
if estimate_tokens(candidate) <= max_tokens_estimate:
|
|
76
|
+
return candidate
|
|
77
|
+
return anchors
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def estimate_tokens(messages: Sequence[Mapping[str, Any]]) -> int:
|
|
81
|
+
"""Estimate the token cost of OpenAI-style messages.
|
|
82
|
+
|
|
83
|
+
An explicit ``token_count``, ``tokens``, or ``usage.total_tokens`` value
|
|
84
|
+
is used when a message carries one. Otherwise each message is estimated
|
|
85
|
+
from its character count at about four characters per token.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
return sum(_estimate_message(message) for message in messages)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Mitigator:
|
|
92
|
+
"""Author loop nudges and keep agent transcripts from growing without bound."""
|
|
93
|
+
|
|
94
|
+
def generate_nudge(self, verdict: LoopVerdict, blocked_tool: str) -> dict[str, str]:
|
|
95
|
+
"""Return the system message from :func:`generate_nudge`."""
|
|
96
|
+
|
|
97
|
+
return generate_nudge(verdict, blocked_tool)
|
|
98
|
+
|
|
99
|
+
def prune_context(
|
|
100
|
+
self,
|
|
101
|
+
messages: list[dict[str, Any]],
|
|
102
|
+
max_tokens_estimate: int,
|
|
103
|
+
) -> list[dict[str, Any]]:
|
|
104
|
+
"""Return the pruned transcript from :func:`prune_context`."""
|
|
105
|
+
|
|
106
|
+
return prune_context(messages, max_tokens_estimate)
|
|
107
|
+
|
|
108
|
+
def prune(self, steps: Sequence[AgentStep]) -> list[AgentStep]:
|
|
109
|
+
"""Return the trajectory with earlier exact tool repeats removed.
|
|
110
|
+
|
|
111
|
+
A repeat is the same tool name and normalized arguments. The original
|
|
112
|
+
sequence is not mutated.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
last_index: dict[str, int] = {}
|
|
116
|
+
for index, step in enumerate(steps):
|
|
117
|
+
last_index[tool_signature(step.tool_name, step.tool_args)] = index
|
|
118
|
+
return [
|
|
119
|
+
step
|
|
120
|
+
for index, step in enumerate(steps)
|
|
121
|
+
if last_index[tool_signature(step.tool_name, step.tool_args)] == index
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _estimate_message(message: Mapping[str, Any]) -> int:
|
|
126
|
+
explicit = _explicit_token_count(message)
|
|
127
|
+
if explicit is not None:
|
|
128
|
+
return explicit
|
|
129
|
+
return _characters_to_tokens(_message_characters(message))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _explicit_token_count(message: Mapping[str, Any]) -> int | None:
|
|
133
|
+
for key in ("token_count", "tokens"):
|
|
134
|
+
value = message.get(key)
|
|
135
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
136
|
+
return value
|
|
137
|
+
usage = message.get("usage")
|
|
138
|
+
if isinstance(usage, Mapping):
|
|
139
|
+
total = usage.get("total_tokens")
|
|
140
|
+
if isinstance(total, int) and not isinstance(total, bool) and total >= 0:
|
|
141
|
+
return total
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _characters_to_tokens(characters: int) -> int:
|
|
146
|
+
if characters <= 0:
|
|
147
|
+
return 0
|
|
148
|
+
return max(1, (characters + 3) // 4)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _message_characters(message: Mapping[str, Any]) -> int:
|
|
152
|
+
chunks = [_role(message), _content_text(message.get("content"))]
|
|
153
|
+
for key in ("name", "tool_name", "tool_call_id"):
|
|
154
|
+
value = message.get(key)
|
|
155
|
+
if isinstance(value, str):
|
|
156
|
+
chunks.append(value)
|
|
157
|
+
calls = message.get("tool_calls")
|
|
158
|
+
if isinstance(calls, list):
|
|
159
|
+
chunks.extend(_tool_call_text(call) for call in calls)
|
|
160
|
+
return sum(len(chunk) for chunk in chunks)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _content_text(content: Any) -> str:
|
|
164
|
+
"""Read plain text or OpenAI content parts."""
|
|
165
|
+
|
|
166
|
+
if content is None:
|
|
167
|
+
return ""
|
|
168
|
+
if isinstance(content, str):
|
|
169
|
+
return content
|
|
170
|
+
if isinstance(content, list):
|
|
171
|
+
parts = [_content_text(part) if not isinstance(part, Mapping) else _part_text(part) for part in content]
|
|
172
|
+
return "\n".join(part for part in parts if part)
|
|
173
|
+
if isinstance(content, Mapping):
|
|
174
|
+
return _part_text(content)
|
|
175
|
+
return str(content)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _part_text(part: Mapping[str, Any]) -> str:
|
|
179
|
+
for key in ("text", "content", "refusal"):
|
|
180
|
+
value = part.get(key)
|
|
181
|
+
if isinstance(value, str):
|
|
182
|
+
return value
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _tool_call_text(call: Any) -> str:
|
|
187
|
+
if not isinstance(call, Mapping):
|
|
188
|
+
return str(call)
|
|
189
|
+
function = call.get("function")
|
|
190
|
+
payload = function if isinstance(function, Mapping) else call
|
|
191
|
+
name = payload.get("name") if isinstance(payload, Mapping) else ""
|
|
192
|
+
arguments = payload.get("arguments") if isinstance(payload, Mapping) else ""
|
|
193
|
+
return f"{name if isinstance(name, str) else ''}{arguments if isinstance(arguments, str) else ''}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _collapse_repetitive_tool_errors(
|
|
197
|
+
messages: Sequence[dict[str, Any]],
|
|
198
|
+
preserved_ids: set[str] | None = None,
|
|
199
|
+
) -> tuple[list[dict[str, Any]], dict[str, str] | None]:
|
|
200
|
+
"""Replace repeated tool failures with a summary slot and strip their calls.
|
|
201
|
+
|
|
202
|
+
The returned summary is placed by the caller across the whole transcript,
|
|
203
|
+
so it can move past a result that survived in a later turn. ``preserved_ids``
|
|
204
|
+
are those surviving results: their assistant calls stay.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
preserved = preserved_ids or set()
|
|
208
|
+
names = _tool_call_names(messages)
|
|
209
|
+
failures = _error_results(messages, names)
|
|
210
|
+
counts: dict[str, int] = {}
|
|
211
|
+
for _index, _block, _call_id, tool, _reason in failures:
|
|
212
|
+
counts[tool] = counts.get(tool, 0) + 1
|
|
213
|
+
repetitive = {tool for tool, count in counts.items() if count >= 2}
|
|
214
|
+
if not repetitive:
|
|
215
|
+
return [dict(message) for message in messages], None
|
|
216
|
+
|
|
217
|
+
dropped = [failure for failure in failures if failure[3] in repetitive]
|
|
218
|
+
dropped_whole = {failure[0] for failure in dropped if failure[1] is None}
|
|
219
|
+
dropped_blocks: dict[int, set[int]] = {}
|
|
220
|
+
samples: dict[str, list[str]] = {}
|
|
221
|
+
for index, block_index, _call_id, tool, reason in dropped:
|
|
222
|
+
if block_index is not None:
|
|
223
|
+
dropped_blocks.setdefault(index, set()).add(block_index)
|
|
224
|
+
bucket = samples.setdefault(tool, [])
|
|
225
|
+
if reason and reason not in bucket:
|
|
226
|
+
bucket.append(reason)
|
|
227
|
+
still_answered = _remaining_result_ids(messages, dropped_whole, dropped_blocks) | preserved
|
|
228
|
+
strip_ids = {
|
|
229
|
+
call_id
|
|
230
|
+
for _index, _block, call_id, _tool, _reason in dropped
|
|
231
|
+
if call_id and call_id not in still_answered
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
slot = {"role": "system", "content": "", "_loopshield_summary_slot": True}
|
|
235
|
+
rebuilt: list[dict[str, Any]] = []
|
|
236
|
+
placed = False
|
|
237
|
+
for index, message in enumerate(messages):
|
|
238
|
+
if index in dropped_whole:
|
|
239
|
+
if not placed:
|
|
240
|
+
rebuilt.append(slot)
|
|
241
|
+
placed = True
|
|
242
|
+
continue
|
|
243
|
+
updated = dict(message)
|
|
244
|
+
blocks = dropped_blocks.get(index)
|
|
245
|
+
if blocks and isinstance(message.get("content"), list):
|
|
246
|
+
if not placed:
|
|
247
|
+
rebuilt.append(slot)
|
|
248
|
+
placed = True
|
|
249
|
+
updated["content"] = [
|
|
250
|
+
part for block_index, part in enumerate(message["content"]) if block_index not in blocks
|
|
251
|
+
]
|
|
252
|
+
if _role(updated) == "user" and _message_is_blank(updated):
|
|
253
|
+
continue
|
|
254
|
+
rebuilt.append(updated)
|
|
255
|
+
if not placed:
|
|
256
|
+
rebuilt.append(slot)
|
|
257
|
+
return _strip_call_ids_backward(rebuilt, strip_ids), _repetitive_summary(counts, samples)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _repetitive_summary(counts: Mapping[str, int], samples: Mapping[str, list[str]]) -> dict[str, str]:
|
|
261
|
+
parts: list[str] = []
|
|
262
|
+
total = 0
|
|
263
|
+
for tool, reasons in samples.items():
|
|
264
|
+
total += counts[tool]
|
|
265
|
+
shown = "; ".join(reasons[:2])
|
|
266
|
+
label = f"{tool} x{counts[tool]}"
|
|
267
|
+
parts.append(f"{label} ({shown})" if shown else label)
|
|
268
|
+
listed = "; ".join(parts)
|
|
269
|
+
if len(listed) > 240:
|
|
270
|
+
listed = listed[:237] + "..."
|
|
271
|
+
noun = "message" if total == 1 else "messages"
|
|
272
|
+
return {
|
|
273
|
+
"role": "system",
|
|
274
|
+
"content": (
|
|
275
|
+
f"Compressed {total} repetitive tool-error {noun}: {listed}. "
|
|
276
|
+
"Do not repeat those failed calls."
|
|
277
|
+
),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _split_system_prompt(
|
|
282
|
+
messages: list[dict[str, Any]],
|
|
283
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
284
|
+
index = 0
|
|
285
|
+
while index < len(messages) and _role(messages[index]) == "system":
|
|
286
|
+
index += 1
|
|
287
|
+
return messages[:index], messages[index:]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _split_turns(messages: Sequence[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
|
291
|
+
turns: list[list[dict[str, Any]]] = []
|
|
292
|
+
current: list[dict[str, Any]] = []
|
|
293
|
+
for message in messages:
|
|
294
|
+
if _role(message) in _TURN_STARTS and current:
|
|
295
|
+
turns.append(current)
|
|
296
|
+
current = [message]
|
|
297
|
+
else:
|
|
298
|
+
current.append(message)
|
|
299
|
+
if current:
|
|
300
|
+
turns.append(current)
|
|
301
|
+
return turns
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _flatten(turns: Sequence[Sequence[dict[str, Any]]]) -> list[dict[str, Any]]:
|
|
305
|
+
return [message for turn in turns for message in turn]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _summarize_middle(messages: Sequence[Mapping[str, Any]]) -> dict[str, str]:
|
|
309
|
+
snippets: list[str] = []
|
|
310
|
+
for message in messages:
|
|
311
|
+
role = _role(message) or "message"
|
|
312
|
+
text = " ".join(_content_text(message.get("content")).split())
|
|
313
|
+
if len(text) > 80:
|
|
314
|
+
text = text[:77] + "..."
|
|
315
|
+
snippets.append(f"{role}: {text}" if text else role)
|
|
316
|
+
body = " | ".join(snippets)
|
|
317
|
+
if len(body) > 300:
|
|
318
|
+
body = body[:297] + "..."
|
|
319
|
+
return {"role": "system", "content": f"Compressed {len(messages)} earlier messages: {body}"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_tool_error(message: Mapping[str, Any]) -> bool:
|
|
323
|
+
if _role(message) not in _TOOL_ROLES:
|
|
324
|
+
return False
|
|
325
|
+
if message.get("is_error") is True:
|
|
326
|
+
return True
|
|
327
|
+
if str(message.get("status", "")).casefold() in {"error", "failed", "failure"}:
|
|
328
|
+
return True
|
|
329
|
+
text = _content_text(message.get("content"))
|
|
330
|
+
return _ERROR_TEXT.search(text) is not None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _tool_call_names(messages: Sequence[Mapping[str, Any]]) -> dict[str, str]:
|
|
334
|
+
"""Map a tool call id to its name, for OpenAI calls and Anthropic tool_use blocks."""
|
|
335
|
+
|
|
336
|
+
names: dict[str, str] = {}
|
|
337
|
+
for message in messages:
|
|
338
|
+
calls = message.get("tool_calls")
|
|
339
|
+
if isinstance(calls, list):
|
|
340
|
+
for call in calls:
|
|
341
|
+
_remember_call_name(names, call)
|
|
342
|
+
content = message.get("content")
|
|
343
|
+
if isinstance(content, list):
|
|
344
|
+
for part in content:
|
|
345
|
+
if isinstance(part, Mapping) and part.get("type") == "tool_use":
|
|
346
|
+
_remember_call_name(names, part)
|
|
347
|
+
return names
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _remember_call_name(names: dict[str, str], call: Any) -> None:
|
|
351
|
+
if not isinstance(call, Mapping):
|
|
352
|
+
return
|
|
353
|
+
function = call.get("function")
|
|
354
|
+
payload = function if isinstance(function, Mapping) else call
|
|
355
|
+
call_id = call.get("id")
|
|
356
|
+
name = payload.get("name") if isinstance(payload, Mapping) else None
|
|
357
|
+
if isinstance(call_id, str) and call_id and isinstance(name, str) and name.strip():
|
|
358
|
+
names[call_id] = name.strip()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _error_results(
|
|
362
|
+
messages: Sequence[Mapping[str, Any]],
|
|
363
|
+
names: Mapping[str, str],
|
|
364
|
+
) -> list[tuple[int, int | None, str | None, str, str]]:
|
|
365
|
+
"""Return tool failures as ``(message, block or None, call id, tool, reason)``.
|
|
366
|
+
|
|
367
|
+
A whole OpenAI ``tool`` message has no block index. An Anthropic
|
|
368
|
+
``tool_result`` block does, so a user message that also carries text can
|
|
369
|
+
lose the failed result and keep the text.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
found: list[tuple[int, int | None, str | None, str, str]] = []
|
|
373
|
+
for index, message in enumerate(messages):
|
|
374
|
+
if _role(message) in _TOOL_ROLES:
|
|
375
|
+
if _is_tool_error(message):
|
|
376
|
+
found.append(
|
|
377
|
+
(
|
|
378
|
+
index,
|
|
379
|
+
None,
|
|
380
|
+
_text_id(message.get("tool_call_id")) or _text_id(message.get("tool_use_id")),
|
|
381
|
+
_message_tool_name(message, names),
|
|
382
|
+
_short_reason(message),
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
continue
|
|
386
|
+
content = message.get("content")
|
|
387
|
+
if _role(message) != "user" or not isinstance(content, list):
|
|
388
|
+
continue
|
|
389
|
+
for block_index, part in enumerate(content):
|
|
390
|
+
if not isinstance(part, Mapping) or part.get("type") != "tool_result" or not _part_is_error(part):
|
|
391
|
+
continue
|
|
392
|
+
call_id = _text_id(part.get("tool_use_id")) or _text_id(part.get("tool_call_id"))
|
|
393
|
+
explicit = part.get("name")
|
|
394
|
+
tool = explicit.strip() if isinstance(explicit, str) and explicit.strip() else names.get(call_id or "", "tool")
|
|
395
|
+
found.append((index, block_index, call_id, tool, _clip_text(_content_text(part.get("content")))))
|
|
396
|
+
return found
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _part_is_error(part: Mapping[str, Any]) -> bool:
|
|
400
|
+
if part.get("is_error") is True:
|
|
401
|
+
return True
|
|
402
|
+
if str(part.get("status", "")).casefold() in {"error", "failed", "failure"}:
|
|
403
|
+
return True
|
|
404
|
+
return _ERROR_TEXT.search(_content_text(part.get("content"))) is not None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _text_id(value: Any) -> str | None:
|
|
408
|
+
if isinstance(value, str) and value:
|
|
409
|
+
return value
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _remaining_result_ids(
|
|
414
|
+
messages: Sequence[Mapping[str, Any]],
|
|
415
|
+
dropped_whole: set[int],
|
|
416
|
+
dropped_blocks: Mapping[int, set[int]],
|
|
417
|
+
) -> set[str]:
|
|
418
|
+
found: set[str] = set()
|
|
419
|
+
for index, message in enumerate(messages):
|
|
420
|
+
if index in dropped_whole:
|
|
421
|
+
continue
|
|
422
|
+
blocks = dropped_blocks.get(index)
|
|
423
|
+
if not blocks or not isinstance(message.get("content"), list):
|
|
424
|
+
found.update(_result_ids(message))
|
|
425
|
+
continue
|
|
426
|
+
trimmed = dict(message)
|
|
427
|
+
trimmed["content"] = [
|
|
428
|
+
part for block_index, part in enumerate(message["content"]) if block_index not in blocks
|
|
429
|
+
]
|
|
430
|
+
found.update(_result_ids(trimmed))
|
|
431
|
+
return found
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _collected_result_ids(messages: Sequence[Mapping[str, Any]]) -> set[str]:
|
|
435
|
+
found: set[str] = set()
|
|
436
|
+
for message in messages:
|
|
437
|
+
found.update(_result_ids(message))
|
|
438
|
+
return found
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _result_ids(message: Mapping[str, Any]) -> list[str]:
|
|
442
|
+
"""Return ids of OpenAI tool results and Anthropic tool_result blocks."""
|
|
443
|
+
|
|
444
|
+
found: list[str] = []
|
|
445
|
+
if _role(message) in _TOOL_ROLES:
|
|
446
|
+
call_id = _text_id(message.get("tool_call_id")) or _text_id(message.get("tool_use_id"))
|
|
447
|
+
if call_id:
|
|
448
|
+
found.append(call_id)
|
|
449
|
+
content = message.get("content")
|
|
450
|
+
if isinstance(content, list):
|
|
451
|
+
for part in content:
|
|
452
|
+
if not isinstance(part, Mapping) or part.get("type") != "tool_result":
|
|
453
|
+
continue
|
|
454
|
+
call_id = _text_id(part.get("tool_use_id")) or _text_id(part.get("tool_call_id"))
|
|
455
|
+
if call_id:
|
|
456
|
+
found.append(call_id)
|
|
457
|
+
return found
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _call_ids(message: Mapping[str, Any]) -> list[str]:
|
|
461
|
+
"""Return ids of OpenAI tool_calls and Anthropic tool_use blocks."""
|
|
462
|
+
|
|
463
|
+
if _role(message) != "assistant":
|
|
464
|
+
return []
|
|
465
|
+
found: list[str] = []
|
|
466
|
+
calls = message.get("tool_calls")
|
|
467
|
+
if isinstance(calls, list):
|
|
468
|
+
for call in calls:
|
|
469
|
+
if isinstance(call, Mapping):
|
|
470
|
+
call_id = _text_id(call.get("id"))
|
|
471
|
+
if call_id:
|
|
472
|
+
found.append(call_id)
|
|
473
|
+
content = message.get("content")
|
|
474
|
+
if isinstance(content, list):
|
|
475
|
+
for part in content:
|
|
476
|
+
if isinstance(part, Mapping) and part.get("type") == "tool_use":
|
|
477
|
+
call_id = _text_id(part.get("id"))
|
|
478
|
+
if call_id:
|
|
479
|
+
found.append(call_id)
|
|
480
|
+
return found
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _strip_call_ids_backward(messages: Sequence[dict[str, Any]], strip_ids: set[str]) -> list[dict[str, Any]]:
|
|
484
|
+
"""Drop ``strip_ids`` from assistant calls, walking from the latest message backward."""
|
|
485
|
+
|
|
486
|
+
if not strip_ids:
|
|
487
|
+
return [dict(message) for message in messages]
|
|
488
|
+
kept: list[dict[str, Any]] = []
|
|
489
|
+
for message in reversed(messages):
|
|
490
|
+
if message.get("_loopshield_summary_slot") is True:
|
|
491
|
+
kept.append(message)
|
|
492
|
+
continue
|
|
493
|
+
if _role(message) == "assistant":
|
|
494
|
+
message = _without_call_ids(message, strip_ids)
|
|
495
|
+
if _assistant_is_empty(message):
|
|
496
|
+
continue
|
|
497
|
+
kept.append(dict(message))
|
|
498
|
+
kept.reverse()
|
|
499
|
+
return kept
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _without_call_ids(message: Mapping[str, Any], strip_ids: set[str]) -> dict[str, Any]:
|
|
503
|
+
updated = dict(message)
|
|
504
|
+
calls = message.get("tool_calls")
|
|
505
|
+
if isinstance(calls, list):
|
|
506
|
+
kept_calls = [
|
|
507
|
+
dict(call)
|
|
508
|
+
for call in calls
|
|
509
|
+
if not (isinstance(call, Mapping) and _text_id(call.get("id")) in strip_ids)
|
|
510
|
+
]
|
|
511
|
+
if kept_calls:
|
|
512
|
+
updated["tool_calls"] = kept_calls
|
|
513
|
+
else:
|
|
514
|
+
updated.pop("tool_calls", None)
|
|
515
|
+
content = message.get("content")
|
|
516
|
+
if isinstance(content, list):
|
|
517
|
+
updated["content"] = [
|
|
518
|
+
part
|
|
519
|
+
for part in content
|
|
520
|
+
if not (
|
|
521
|
+
isinstance(part, Mapping)
|
|
522
|
+
and part.get("type") == "tool_use"
|
|
523
|
+
and _text_id(part.get("id")) in strip_ids
|
|
524
|
+
)
|
|
525
|
+
]
|
|
526
|
+
return updated
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _assistant_is_empty(message: Mapping[str, Any]) -> bool:
|
|
530
|
+
if _role(message) != "assistant" or _call_ids(message):
|
|
531
|
+
return False
|
|
532
|
+
return _message_is_blank(message)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _message_is_blank(message: Mapping[str, Any]) -> bool:
|
|
536
|
+
if _call_ids(message) or _result_ids(message):
|
|
537
|
+
return False
|
|
538
|
+
return not _content_text(message.get("content")).strip()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _place_summary(messages: Sequence[dict[str, Any]], summary: dict[str, str]) -> list[dict[str, Any]]:
|
|
542
|
+
"""Insert the summary where it does not split a tool call from its results."""
|
|
543
|
+
|
|
544
|
+
slot = next(
|
|
545
|
+
(index for index, message in enumerate(messages) if message.get("_loopshield_summary_slot") is True),
|
|
546
|
+
len(messages),
|
|
547
|
+
)
|
|
548
|
+
body = [dict(message) for message in messages if message.get("_loopshield_summary_slot") is not True]
|
|
549
|
+
preferred = sum(1 for message in messages[:slot] if message.get("_loopshield_summary_slot") is not True)
|
|
550
|
+
while preferred < len(body) and _insertion_splits_exchange(body, preferred):
|
|
551
|
+
preferred += 1
|
|
552
|
+
return [*body[:preferred], summary, *body[preferred:]]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _insertion_splits_exchange(messages: Sequence[Mapping[str, Any]], index: int) -> bool:
|
|
556
|
+
pending: list[str] = []
|
|
557
|
+
for message in messages[:index]:
|
|
558
|
+
if _role(message) == "assistant":
|
|
559
|
+
pending.extend(_call_ids(message))
|
|
560
|
+
for result_id in _result_ids(message):
|
|
561
|
+
if result_id in pending:
|
|
562
|
+
pending.remove(result_id)
|
|
563
|
+
if not pending:
|
|
564
|
+
return False
|
|
565
|
+
later: list[str] = []
|
|
566
|
+
for message in messages[index:]:
|
|
567
|
+
later.extend(_result_ids(message))
|
|
568
|
+
return any(call_id in later for call_id in pending)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _legalize_tool_exchanges(messages: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
572
|
+
"""Drop calls with no result and id-bearing results whose call is gone."""
|
|
573
|
+
|
|
574
|
+
call_ids: set[str] = set()
|
|
575
|
+
result_ids: set[str] = set()
|
|
576
|
+
for message in messages:
|
|
577
|
+
if _role(message) == "assistant":
|
|
578
|
+
call_ids.update(_call_ids(message))
|
|
579
|
+
result_ids.update(_result_ids(message))
|
|
580
|
+
stripped = _strip_call_ids_backward(messages, call_ids - result_ids)
|
|
581
|
+
return _drop_result_ids(stripped, result_ids - call_ids)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _drop_result_ids(messages: Sequence[dict[str, Any]], drop_ids: set[str]) -> list[dict[str, Any]]:
|
|
585
|
+
if not drop_ids:
|
|
586
|
+
return [dict(message) for message in messages]
|
|
587
|
+
kept: list[dict[str, Any]] = []
|
|
588
|
+
for message in messages:
|
|
589
|
+
updated = dict(message)
|
|
590
|
+
if _role(updated) in _TOOL_ROLES:
|
|
591
|
+
call_id = _text_id(updated.get("tool_call_id")) or _text_id(updated.get("tool_use_id"))
|
|
592
|
+
if call_id in drop_ids:
|
|
593
|
+
continue
|
|
594
|
+
content = updated.get("content")
|
|
595
|
+
if isinstance(content, list):
|
|
596
|
+
updated["content"] = [
|
|
597
|
+
part
|
|
598
|
+
for part in content
|
|
599
|
+
if not (
|
|
600
|
+
isinstance(part, Mapping)
|
|
601
|
+
and part.get("type") == "tool_result"
|
|
602
|
+
and (
|
|
603
|
+
_text_id(part.get("tool_use_id")) in drop_ids
|
|
604
|
+
or _text_id(part.get("tool_call_id")) in drop_ids
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
]
|
|
608
|
+
if _role(updated) in {"user", "assistant"} and _message_is_blank(updated):
|
|
609
|
+
continue
|
|
610
|
+
kept.append(updated)
|
|
611
|
+
return kept
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _message_tool_name(message: Mapping[str, Any], names: Mapping[str, str]) -> str:
|
|
615
|
+
for key in ("name", "tool_name", "tool"):
|
|
616
|
+
value = message.get(key)
|
|
617
|
+
if isinstance(value, str) and value.strip():
|
|
618
|
+
return value.strip()
|
|
619
|
+
call_id = message.get("tool_call_id")
|
|
620
|
+
if isinstance(call_id, str) and call_id in names:
|
|
621
|
+
return names[call_id]
|
|
622
|
+
return "tool"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _short_reason(message: Mapping[str, Any]) -> str:
|
|
626
|
+
return _clip_text(_content_text(message.get("content")))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _clip_text(text: str) -> str:
|
|
630
|
+
collapsed = " ".join(text.split())
|
|
631
|
+
if len(collapsed) > 60:
|
|
632
|
+
return collapsed[:57] + "..."
|
|
633
|
+
return collapsed
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _role(message: Mapping[str, Any]) -> str:
|
|
637
|
+
return str(message.get("role", "")).casefold()
|
loopshield/models.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Pydantic schemas for agent steps and loop verdicts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentStep(BaseModel):
|
|
11
|
+
"""One tool-using step in an agent trajectory."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
14
|
+
|
|
15
|
+
thought: str
|
|
16
|
+
tool_name: str = Field(min_length=1)
|
|
17
|
+
tool_args: dict[str, Any]
|
|
18
|
+
tokens_used: int = Field(ge=0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoopVerdict(BaseModel):
|
|
22
|
+
"""Result of checking a trajectory for a repeating loop.
|
|
23
|
+
|
|
24
|
+
``loop_type`` is ``"graph_cycle"``, ``"semantic"``, or ``"both"`` when a
|
|
25
|
+
loop is present. ``intervention_prompt`` is the nudge to send next.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
29
|
+
|
|
30
|
+
is_loop: bool
|
|
31
|
+
loop_type: str | None = None
|
|
32
|
+
intervention_prompt: str | None = None
|