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/proxy.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""OpenAI- and Anthropic-compatible proxy that screens agent loops before upstream calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import AsyncIterator, Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from loopshield.mitigator import estimate_tokens, prune_context
|
|
18
|
+
from loopshield.shield import LoopShield
|
|
19
|
+
from loopshield.transcript import content_text as _content_text
|
|
20
|
+
from loopshield.transcript import messages_to_steps as _messages_to_steps
|
|
21
|
+
from loopshield.transcript import nudge_for as _nudge_for
|
|
22
|
+
|
|
23
|
+
_FORWARD_HEADERS = (
|
|
24
|
+
"authorization",
|
|
25
|
+
"x-api-key",
|
|
26
|
+
"anthropic-version",
|
|
27
|
+
"anthropic-beta",
|
|
28
|
+
"openai-organization",
|
|
29
|
+
"openai-project",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProxySettings(BaseModel):
|
|
34
|
+
"""Runtime configuration loaded from the process environment."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True)
|
|
37
|
+
|
|
38
|
+
upstream_api_base: str = "https://api.openai.com"
|
|
39
|
+
similarity_threshold: float = Field(default=0.92, ge=0.0, le=1.0)
|
|
40
|
+
max_tokens: int = Field(default=8000, ge=1)
|
|
41
|
+
max_cycles: int = Field(default=1, ge=0)
|
|
42
|
+
max_semantic_repeats: int = Field(default=2, ge=1)
|
|
43
|
+
timeout_seconds: float = Field(default=60.0, gt=0)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_env(cls) -> ProxySettings:
|
|
47
|
+
"""Read the proxy settings from environment variables."""
|
|
48
|
+
|
|
49
|
+
return cls(
|
|
50
|
+
upstream_api_base=os.environ.get("UPSTREAM_API_BASE", "https://api.openai.com"),
|
|
51
|
+
similarity_threshold=float(os.environ.get("SHIELD_SIMILARITY_THRESHOLD", "0.92")),
|
|
52
|
+
max_tokens=int(os.environ.get("SHIELD_MAX_TOKENS", "8000")),
|
|
53
|
+
max_cycles=int(os.environ.get("SHIELD_MAX_CYCLES", "1")),
|
|
54
|
+
max_semantic_repeats=int(os.environ.get("SHIELD_MAX_SEMANTIC_REPEATS", "2")),
|
|
55
|
+
timeout_seconds=float(os.environ.get("SHIELD_UPSTREAM_TIMEOUT", "60")),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(
|
|
60
|
+
settings: ProxySettings | None = None,
|
|
61
|
+
transport: httpx.BaseTransport | None = None,
|
|
62
|
+
) -> FastAPI:
|
|
63
|
+
"""Build the proxy application.
|
|
64
|
+
|
|
65
|
+
``transport`` is for tests that need to capture the upstream request.
|
|
66
|
+
Uvicorn serves the module-level ``app`` created from the environment.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
resolved = settings or ProxySettings.from_env()
|
|
70
|
+
application = FastAPI(title="LoopShield Proxy", version="0.1.0")
|
|
71
|
+
application.state.settings = resolved
|
|
72
|
+
application.state.transport = transport
|
|
73
|
+
application.state.shield = LoopShield(
|
|
74
|
+
similarity_threshold=resolved.similarity_threshold,
|
|
75
|
+
max_cycles=resolved.max_cycles,
|
|
76
|
+
max_semantic_repeats=resolved.max_semantic_repeats,
|
|
77
|
+
max_tokens_estimate=resolved.max_tokens,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@application.get("/health")
|
|
81
|
+
async def health() -> dict[str, str]:
|
|
82
|
+
return {"status": "ok"}
|
|
83
|
+
|
|
84
|
+
@application.post("/v1/chat/completions", response_model=None)
|
|
85
|
+
async def chat_completions(request: Request) -> JSONResponse | StreamingResponse:
|
|
86
|
+
return await _handle_completion(request, application)
|
|
87
|
+
|
|
88
|
+
return application
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
app = create_app()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _handle_completion(request: Request, application: FastAPI) -> JSONResponse | StreamingResponse:
|
|
95
|
+
settings: ProxySettings = application.state.settings
|
|
96
|
+
payload = await _read_payload(request)
|
|
97
|
+
if isinstance(payload, JSONResponse):
|
|
98
|
+
return payload
|
|
99
|
+
|
|
100
|
+
style = _api_style(request, payload)
|
|
101
|
+
messages = _normalize_messages(payload, style)
|
|
102
|
+
if isinstance(messages, JSONResponse):
|
|
103
|
+
return messages
|
|
104
|
+
|
|
105
|
+
steps = _messages_to_steps(messages)
|
|
106
|
+
verdict = application.state.shield.inspect(steps)
|
|
107
|
+
if verdict.is_loop:
|
|
108
|
+
nudge = _nudge_for(verdict, steps)
|
|
109
|
+
if bool(payload.get("stream")):
|
|
110
|
+
return StreamingResponse(
|
|
111
|
+
_intercepted_stream(style, str(payload.get("model") or "loopshield"), nudge, verdict.loop_type),
|
|
112
|
+
media_type="text/event-stream",
|
|
113
|
+
)
|
|
114
|
+
body = _intercepted_body(style, str(payload.get("model") or "loopshield"), nudge, verdict.loop_type, messages)
|
|
115
|
+
return JSONResponse(body)
|
|
116
|
+
|
|
117
|
+
screened = _screen_messages(messages, settings.max_tokens)
|
|
118
|
+
outbound = _outbound_payload(payload, screened, style)
|
|
119
|
+
return await _forward(request, settings, application.state.transport, outbound, style)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _read_payload(request: Request) -> dict[str, Any] | JSONResponse:
|
|
123
|
+
try:
|
|
124
|
+
payload = await request.json()
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
return _error("Request body must be valid JSON.", anthropic=False, status_code=400)
|
|
127
|
+
if not isinstance(payload, dict):
|
|
128
|
+
return _error("Request body must be a JSON object.", anthropic=False, status_code=400)
|
|
129
|
+
return payload
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _api_style(request: Request, payload: Mapping[str, Any]) -> str:
|
|
133
|
+
if request.headers.get("anthropic-version"):
|
|
134
|
+
return "anthropic"
|
|
135
|
+
if "system" in payload and "max_tokens" in payload:
|
|
136
|
+
return "anthropic"
|
|
137
|
+
for message in payload.get("messages") or []:
|
|
138
|
+
content = message.get("content") if isinstance(message, Mapping) else None
|
|
139
|
+
if isinstance(content, list) and any(
|
|
140
|
+
isinstance(part, Mapping) and part.get("type") in {"tool_use", "tool_result"}
|
|
141
|
+
for part in content
|
|
142
|
+
):
|
|
143
|
+
return "anthropic"
|
|
144
|
+
return "openai"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalize_messages(
|
|
148
|
+
payload: Mapping[str, Any],
|
|
149
|
+
style: str,
|
|
150
|
+
) -> list[dict[str, Any]] | JSONResponse:
|
|
151
|
+
raw_messages = payload.get("messages")
|
|
152
|
+
if not isinstance(raw_messages, list) or not raw_messages:
|
|
153
|
+
return _error("messages must be a non-empty list.", anthropic=style == "anthropic", status_code=400)
|
|
154
|
+
if any(not isinstance(message, dict) for message in raw_messages):
|
|
155
|
+
return _error("every message must be an object.", anthropic=style == "anthropic", status_code=400)
|
|
156
|
+
|
|
157
|
+
messages = [dict(message) for message in raw_messages]
|
|
158
|
+
if style == "anthropic":
|
|
159
|
+
system = payload.get("system")
|
|
160
|
+
if isinstance(system, str) and system.strip():
|
|
161
|
+
messages.insert(0, {"role": "system", "content": system})
|
|
162
|
+
elif isinstance(system, list) and system:
|
|
163
|
+
messages.insert(0, {"role": "system", "content": system})
|
|
164
|
+
if not str(payload.get("model") or "").strip():
|
|
165
|
+
return _error("model is required.", anthropic=style == "anthropic", status_code=400)
|
|
166
|
+
return messages
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _screen_messages(messages: list[dict[str, Any]], max_tokens: int) -> list[dict[str, Any]]:
|
|
170
|
+
if estimate_tokens(messages) <= max_tokens:
|
|
171
|
+
return messages
|
|
172
|
+
return prune_context(messages, max_tokens)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _outbound_payload(
|
|
176
|
+
payload: Mapping[str, Any],
|
|
177
|
+
messages: list[dict[str, Any]],
|
|
178
|
+
style: str,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
outbound = dict(payload)
|
|
181
|
+
if style == "openai":
|
|
182
|
+
outbound["messages"] = messages
|
|
183
|
+
return outbound
|
|
184
|
+
system, rest = _split_leading_system(messages)
|
|
185
|
+
if system:
|
|
186
|
+
contents = [_content_text(message.get("content")) for message in system]
|
|
187
|
+
outbound["system"] = "\n\n".join(part for part in contents if part)
|
|
188
|
+
else:
|
|
189
|
+
outbound.pop("system", None)
|
|
190
|
+
outbound["messages"] = [_anthropic_message(message) for message in rest]
|
|
191
|
+
return outbound
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
if str(message.get("role", "")).casefold() != "system":
|
|
196
|
+
return message
|
|
197
|
+
summary = _content_text(message.get("content"))
|
|
198
|
+
return {"role": "user", "content": f"LoopShield context summary: {summary}"}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _split_leading_system(
|
|
202
|
+
messages: list[dict[str, Any]],
|
|
203
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
204
|
+
index = 0
|
|
205
|
+
while index < len(messages) and str(messages[index].get("role", "")).casefold() == "system":
|
|
206
|
+
index += 1
|
|
207
|
+
return messages[:index], messages[index:]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _forward(
|
|
211
|
+
request: Request,
|
|
212
|
+
settings: ProxySettings,
|
|
213
|
+
transport: httpx.BaseTransport | None,
|
|
214
|
+
payload: dict[str, Any],
|
|
215
|
+
style: str,
|
|
216
|
+
) -> JSONResponse | StreamingResponse:
|
|
217
|
+
path = "/v1/messages" if style == "anthropic" else "/v1/chat/completions"
|
|
218
|
+
url = _upstream_url(settings.upstream_api_base, path)
|
|
219
|
+
headers = _forward_headers(request)
|
|
220
|
+
if payload.get("stream") is True:
|
|
221
|
+
return StreamingResponse(
|
|
222
|
+
_stream_upstream(url, payload, headers, settings, transport),
|
|
223
|
+
media_type="text/event-stream",
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
async with httpx.AsyncClient(transport=transport, timeout=settings.timeout_seconds) as client:
|
|
227
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
228
|
+
except httpx.HTTPError as exc:
|
|
229
|
+
return _error(f"Upstream request failed: {exc}", anthropic=style == "anthropic", status_code=502)
|
|
230
|
+
return _upstream_json(response, style)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _upstream_json(response: httpx.Response, style: str) -> JSONResponse:
|
|
234
|
+
try:
|
|
235
|
+
body = response.json()
|
|
236
|
+
except json.JSONDecodeError:
|
|
237
|
+
body = {"error": {"message": response.text, "type": "upstream_error"}}
|
|
238
|
+
return JSONResponse(body, status_code=response.status_code)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _stream_upstream(
|
|
242
|
+
url: str,
|
|
243
|
+
payload: dict[str, Any],
|
|
244
|
+
headers: dict[str, str],
|
|
245
|
+
settings: ProxySettings,
|
|
246
|
+
transport: httpx.BaseTransport | None,
|
|
247
|
+
) -> AsyncIterator[bytes]:
|
|
248
|
+
client = httpx.AsyncClient(transport=transport, timeout=settings.timeout_seconds)
|
|
249
|
+
try:
|
|
250
|
+
async with client.stream("POST", url, json=payload, headers=headers) as response:
|
|
251
|
+
async for chunk in response.aiter_raw():
|
|
252
|
+
yield chunk
|
|
253
|
+
except httpx.HTTPError as exc:
|
|
254
|
+
message = json.dumps(_error_body(f"Upstream request failed: {exc}", anthropic=False))
|
|
255
|
+
yield f"data: {message}\n\n".encode()
|
|
256
|
+
finally:
|
|
257
|
+
await client.aclose()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _forward_headers(request: Request) -> dict[str, str]:
|
|
261
|
+
headers: dict[str, str] = {}
|
|
262
|
+
for name in _FORWARD_HEADERS:
|
|
263
|
+
value = request.headers.get(name)
|
|
264
|
+
if value:
|
|
265
|
+
headers[name] = value
|
|
266
|
+
return headers
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _upstream_url(base: str, path: str) -> str:
|
|
270
|
+
root = base.strip().rstrip("/")
|
|
271
|
+
if not root:
|
|
272
|
+
raise ValueError("UPSTREAM_API_BASE is required")
|
|
273
|
+
if root.endswith("/v1") and path.startswith("/v1/"):
|
|
274
|
+
return f"{root}{path[len('/v1'):]}"
|
|
275
|
+
return f"{root}{path}"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _intercepted_body(
|
|
279
|
+
style: str,
|
|
280
|
+
model: str,
|
|
281
|
+
nudge: str,
|
|
282
|
+
loop_type: str | None,
|
|
283
|
+
messages: list[dict[str, Any]],
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
prompt_tokens = estimate_tokens(messages)
|
|
286
|
+
completion_tokens = max(1, (len(nudge) + 3) // 4)
|
|
287
|
+
marker = {
|
|
288
|
+
"intercepted": True,
|
|
289
|
+
"loop_type": loop_type,
|
|
290
|
+
"intervention_prompt": nudge,
|
|
291
|
+
}
|
|
292
|
+
if style == "anthropic":
|
|
293
|
+
return {
|
|
294
|
+
"id": f"msg_loopshield_{uuid.uuid4().hex[:24]}",
|
|
295
|
+
"type": "message",
|
|
296
|
+
"role": "assistant",
|
|
297
|
+
"model": model,
|
|
298
|
+
"content": [{"type": "text", "text": nudge}],
|
|
299
|
+
"stop_reason": "end_turn",
|
|
300
|
+
"stop_sequence": None,
|
|
301
|
+
"usage": {"input_tokens": prompt_tokens, "output_tokens": completion_tokens},
|
|
302
|
+
"loopshield": marker,
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
"id": f"chatcmpl-loopshield-{uuid.uuid4().hex[:24]}",
|
|
306
|
+
"object": "chat.completion",
|
|
307
|
+
"created": int(time.time()),
|
|
308
|
+
"model": model,
|
|
309
|
+
"choices": [
|
|
310
|
+
{
|
|
311
|
+
"index": 0,
|
|
312
|
+
"message": {"role": "assistant", "content": nudge},
|
|
313
|
+
"finish_reason": "stop",
|
|
314
|
+
}
|
|
315
|
+
],
|
|
316
|
+
"usage": {
|
|
317
|
+
"prompt_tokens": prompt_tokens,
|
|
318
|
+
"completion_tokens": completion_tokens,
|
|
319
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
320
|
+
},
|
|
321
|
+
"loopshield": marker,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def _intercepted_stream(
|
|
326
|
+
style: str,
|
|
327
|
+
model: str,
|
|
328
|
+
nudge: str,
|
|
329
|
+
loop_type: str | None,
|
|
330
|
+
) -> AsyncIterator[bytes]:
|
|
331
|
+
if style == "anthropic":
|
|
332
|
+
events = [
|
|
333
|
+
{"type": "message_start", "message": {"id": "msg_loopshield", "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "usage": {"input_tokens": 0, "output_tokens": 0}}},
|
|
334
|
+
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
335
|
+
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": nudge}},
|
|
336
|
+
{"type": "content_block_stop", "index": 0},
|
|
337
|
+
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": max(1, (len(nudge) + 3) // 4)}},
|
|
338
|
+
{"type": "message_stop"},
|
|
339
|
+
]
|
|
340
|
+
for event in events:
|
|
341
|
+
name = str(event["type"])
|
|
342
|
+
yield f"event: {name}\ndata: {json.dumps(event)}\n\n".encode()
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
created = int(time.time())
|
|
346
|
+
completion_id = f"chatcmpl-loopshield-{uuid.uuid4().hex[:24]}"
|
|
347
|
+
chunks = [
|
|
348
|
+
{
|
|
349
|
+
"id": completion_id,
|
|
350
|
+
"object": "chat.completion.chunk",
|
|
351
|
+
"created": created,
|
|
352
|
+
"model": model,
|
|
353
|
+
"choices": [{"index": 0, "delta": {"role": "assistant", "content": nudge}, "finish_reason": None}],
|
|
354
|
+
"loopshield": {"intercepted": True, "loop_type": loop_type, "intervention_prompt": nudge},
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
"id": completion_id,
|
|
358
|
+
"object": "chat.completion.chunk",
|
|
359
|
+
"created": created,
|
|
360
|
+
"model": model,
|
|
361
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
362
|
+
},
|
|
363
|
+
]
|
|
364
|
+
for chunk in chunks:
|
|
365
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode()
|
|
366
|
+
yield b"data: [DONE]\n\n"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _error(message: str, *, anthropic: bool, status_code: int) -> JSONResponse:
|
|
370
|
+
return JSONResponse(_error_body(message, anthropic=anthropic), status_code=status_code)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _error_body(message: str, *, anthropic: bool) -> dict[str, Any]:
|
|
374
|
+
if anthropic:
|
|
375
|
+
return {"type": "error", "error": {"type": "invalid_request_error", "message": message}}
|
|
376
|
+
return {"error": {"message": message, "type": "invalid_request_error", "code": "invalid_request_error"}}
|
loopshield/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Cosine-similarity detection of repeating agent thoughts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from loopshield.models import AgentStep
|
|
13
|
+
|
|
14
|
+
EmbedFn = Callable[[str], list[float]]
|
|
15
|
+
|
|
16
|
+
_TOKEN = re.compile(r"[a-z0-9]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tokenize(text: str) -> list[str]:
|
|
20
|
+
"""Split text into lowercase word tokens."""
|
|
21
|
+
|
|
22
|
+
return _TOKEN.findall(text.casefold())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tfidf_embed(texts: Sequence[str]) -> list[list[float]]:
|
|
26
|
+
"""Embed texts with a shared bag-of-words TF-IDF vocabulary.
|
|
27
|
+
|
|
28
|
+
This is the lightweight fallback used when no embedding model is injected.
|
|
29
|
+
Identical thoughts land on the same vector. A text with no tokens becomes
|
|
30
|
+
a one-dimensional zero vector.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
documents = [tokenize(text) for text in texts]
|
|
34
|
+
vocabulary: dict[str, int] = {}
|
|
35
|
+
for tokens in documents:
|
|
36
|
+
for token in tokens:
|
|
37
|
+
if token not in vocabulary:
|
|
38
|
+
vocabulary[token] = len(vocabulary)
|
|
39
|
+
if not vocabulary:
|
|
40
|
+
return [[0.0] for _ in texts]
|
|
41
|
+
|
|
42
|
+
document_frequency = [0] * len(vocabulary)
|
|
43
|
+
for tokens in documents:
|
|
44
|
+
for token in set(tokens):
|
|
45
|
+
document_frequency[vocabulary[token]] += 1
|
|
46
|
+
|
|
47
|
+
document_count = len(documents)
|
|
48
|
+
vectors: list[list[float]] = []
|
|
49
|
+
for tokens in documents:
|
|
50
|
+
counts = Counter(tokens)
|
|
51
|
+
length = len(tokens) or 1
|
|
52
|
+
vector = [0.0] * len(vocabulary)
|
|
53
|
+
for token, count in counts.items():
|
|
54
|
+
term_frequency = count / length
|
|
55
|
+
inverse_document_frequency = math.log(
|
|
56
|
+
(1.0 + document_count) / (1.0 + document_frequency[vocabulary[token]])
|
|
57
|
+
) + 1.0
|
|
58
|
+
vector[vocabulary[token]] = term_frequency * inverse_document_frequency
|
|
59
|
+
vectors.append(vector)
|
|
60
|
+
return vectors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
|
|
64
|
+
"""Return the cosine similarity of two vectors, clipped to [-1, 1]."""
|
|
65
|
+
|
|
66
|
+
left_vector = np.asarray(left, dtype=np.float64).reshape(-1)
|
|
67
|
+
right_vector = np.asarray(right, dtype=np.float64).reshape(-1)
|
|
68
|
+
if left_vector.size == 0 or right_vector.size == 0:
|
|
69
|
+
raise ValueError("Embedding must contain at least one dimension.")
|
|
70
|
+
if left_vector.shape != right_vector.shape:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"Embedding dimensions differ: "
|
|
73
|
+
f"{left_vector.shape[0]} and {right_vector.shape[0]}"
|
|
74
|
+
)
|
|
75
|
+
denominator = float(np.linalg.norm(left_vector) * np.linalg.norm(right_vector))
|
|
76
|
+
if denominator == 0.0:
|
|
77
|
+
return 0.0
|
|
78
|
+
score = float(np.dot(left_vector, right_vector) / denominator)
|
|
79
|
+
if not math.isfinite(score):
|
|
80
|
+
return 0.0
|
|
81
|
+
return float(np.clip(score, -1.0, 1.0))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SemanticAnalyzer:
|
|
85
|
+
"""Flag a thought that keeps matching the consecutive thoughts before it.
|
|
86
|
+
|
|
87
|
+
Pass ``embed_fn`` to plug in any model that maps text to a vector. When it
|
|
88
|
+
is omitted, thoughts in the call are embedded together with TF-IDF
|
|
89
|
+
bag-of-words.
|
|
90
|
+
|
|
91
|
+
Walking backward from the current thought, each recent thought whose cosine
|
|
92
|
+
similarity exceeds ``similarity_threshold`` extends a streak. The streak
|
|
93
|
+
stops at the first thought that does not exceed it. A semantic loop is
|
|
94
|
+
flagged when that streak is at least ``max_semantic_repeats`` long.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
embed_fn: EmbedFn | None = None,
|
|
100
|
+
*,
|
|
101
|
+
similarity_threshold: float = 0.92,
|
|
102
|
+
max_semantic_repeats: int = 2,
|
|
103
|
+
) -> None:
|
|
104
|
+
if not 0.0 <= similarity_threshold <= 1.0:
|
|
105
|
+
raise ValueError("similarity_threshold must be between 0 and 1")
|
|
106
|
+
if max_semantic_repeats < 1:
|
|
107
|
+
raise ValueError("max_semantic_repeats must be at least 1")
|
|
108
|
+
self.embed_fn = embed_fn
|
|
109
|
+
self.similarity_threshold = similarity_threshold
|
|
110
|
+
self.max_semantic_repeats = max_semantic_repeats
|
|
111
|
+
|
|
112
|
+
def similarity_streak(self, steps: Sequence[AgentStep]) -> int:
|
|
113
|
+
"""Return how many consecutive thoughts before the latest one exceed the threshold.
|
|
114
|
+
|
|
115
|
+
Comparison starts at the current thought and walks backward. The first
|
|
116
|
+
recent thought that does not exceed the threshold ends the streak.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
if len(steps) < 2:
|
|
120
|
+
return 0
|
|
121
|
+
vectors = self._embed([step.thought for step in steps])
|
|
122
|
+
streak = 0
|
|
123
|
+
current = vectors[-1]
|
|
124
|
+
for previous in range(len(vectors) - 2, -1, -1):
|
|
125
|
+
similarity = cosine_similarity(current, vectors[previous])
|
|
126
|
+
if similarity > self.similarity_threshold:
|
|
127
|
+
streak += 1
|
|
128
|
+
else:
|
|
129
|
+
break
|
|
130
|
+
return streak
|
|
131
|
+
|
|
132
|
+
def detect(self, steps: Sequence[AgentStep]) -> bool:
|
|
133
|
+
"""Return True when a similar streak reaches ``max_semantic_repeats``."""
|
|
134
|
+
|
|
135
|
+
return self.similarity_streak(steps) >= self.max_semantic_repeats
|
|
136
|
+
|
|
137
|
+
def _embed(self, thoughts: Sequence[str]) -> list[np.ndarray]:
|
|
138
|
+
if self.embed_fn is None:
|
|
139
|
+
embedded = tfidf_embed(thoughts)
|
|
140
|
+
else:
|
|
141
|
+
embedded = [self.embed_fn(thought) for thought in thoughts]
|
|
142
|
+
return [np.asarray(vector, dtype=np.float64).reshape(-1) for vector in embedded]
|