loopshield 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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