loopshield 1.0.0__tar.gz
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-1.0.0/PKG-INFO +229 -0
- loopshield-1.0.0/README.md +201 -0
- loopshield-1.0.0/loopshield/__init__.py +25 -0
- loopshield-1.0.0/loopshield/adapters.py +222 -0
- loopshield-1.0.0/loopshield/frameworks.py +522 -0
- loopshield-1.0.0/loopshield/graph_analyzer.py +147 -0
- loopshield-1.0.0/loopshield/mitigator.py +637 -0
- loopshield-1.0.0/loopshield/models.py +32 -0
- loopshield-1.0.0/loopshield/proxy.py +376 -0
- loopshield-1.0.0/loopshield/py.typed +0 -0
- loopshield-1.0.0/loopshield/semantic_analyzer.py +142 -0
- loopshield-1.0.0/loopshield/shield.py +279 -0
- loopshield-1.0.0/loopshield/transcript.py +205 -0
- loopshield-1.0.0/loopshield.egg-info/PKG-INFO +229 -0
- loopshield-1.0.0/loopshield.egg-info/SOURCES.txt +22 -0
- loopshield-1.0.0/loopshield.egg-info/dependency_links.txt +1 -0
- loopshield-1.0.0/loopshield.egg-info/requires.txt +19 -0
- loopshield-1.0.0/loopshield.egg-info/top_level.txt +1 -0
- loopshield-1.0.0/pyproject.toml +42 -0
- loopshield-1.0.0/setup.cfg +4 -0
- loopshield-1.0.0/tests/test_adapters.py +120 -0
- loopshield-1.0.0/tests/test_frameworks.py +494 -0
- loopshield-1.0.0/tests/test_proxy.py +208 -0
- loopshield-1.0.0/tests/test_simulations.py +865 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loopshield
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lightweight, vendor-agnostic AI agent loop detection and self-healing middleware
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: networkx>=3.0
|
|
14
|
+
Requires-Dist: numpy>=1.24.0
|
|
15
|
+
Requires-Dist: pydantic>=2.0
|
|
16
|
+
Requires-Dist: fastapi>=0.100.0
|
|
17
|
+
Requires-Dist: uvicorn>=0.22.0
|
|
18
|
+
Requires-Dist: httpx>=0.24.0
|
|
19
|
+
Provides-Extra: langchain
|
|
20
|
+
Requires-Dist: langchain>=0.1.0; extra == "langchain"
|
|
21
|
+
Requires-Dist: langgraph>=0.1.0; extra == "langchain"
|
|
22
|
+
Provides-Extra: langgraph
|
|
23
|
+
Requires-Dist: langgraph>=0.1.0; extra == "langgraph"
|
|
24
|
+
Provides-Extra: crewai
|
|
25
|
+
Requires-Dist: crewai>=0.1.0; extra == "crewai"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: loopshield[crewai,langchain,langgraph]; extra == "all"
|
|
28
|
+
|
|
29
|
+
# LoopShield
|
|
30
|
+
|
|
31
|
+
LoopShield is a vendor-agnostic Python SDK and an OpenAI-compatible proxy that stops AI agents from burning tokens on the same tool cycle or the same thought. The proxy sits in front of the model provider. Every `POST /v1/chat/completions` is screened before it is forwarded. A detected loop comes back as a normal assistant message that tells the agent the repeating tool is locked and it must change strategy. The upstream provider is not called.
|
|
32
|
+
|
|
33
|
+
The core package depends only on `networkx`, `numpy`, and Pydantic v2. FastAPI, uvicorn, and httpx are an optional `proxy` extra. Embedding models are injected; the OpenAI and Ollama SDKs are not core dependencies.
|
|
34
|
+
|
|
35
|
+
## How a call is intercepted
|
|
36
|
+
|
|
37
|
+
Clients keep speaking the provider’s chat API. LoopShield accepts an OpenAI Chat Completions body, and it also accepts an Anthropic Messages body on the same route. Anthropic shape is recognized from an `anthropic-version` header, a payload that has both `system` and `max_tokens`, or content blocks of type `tool_use` / `tool_result`.
|
|
38
|
+
|
|
39
|
+
```mermaid
|
|
40
|
+
flowchart TD
|
|
41
|
+
agent["Agent or app"]
|
|
42
|
+
route["POST /v1/chat/completions"]
|
|
43
|
+
style{"Payload style"}
|
|
44
|
+
steps["Assistant turns become AgentSteps"]
|
|
45
|
+
inspect["LoopShield.inspect"]
|
|
46
|
+
graph["GraphAnalyzer<br/>exact tool-call cycle"]
|
|
47
|
+
semantic["SemanticAnalyzer<br/>cosine similarity of thoughts"]
|
|
48
|
+
loop{"Loop?"}
|
|
49
|
+
nudge["Intercepted assistant message<br/>intervention_prompt"]
|
|
50
|
+
budget{"Over SHIELD_MAX_TOKENS?"}
|
|
51
|
+
prune["prune_context<br/>keep system prompt and last 2 turns"]
|
|
52
|
+
openai["Upstream POST /v1/chat/completions"]
|
|
53
|
+
anthropic["Upstream POST /v1/messages"]
|
|
54
|
+
|
|
55
|
+
agent --> route --> style --> steps --> inspect
|
|
56
|
+
inspect --> graph
|
|
57
|
+
inspect --> semantic
|
|
58
|
+
graph --> loop
|
|
59
|
+
semantic --> loop
|
|
60
|
+
loop -->|yes| nudge
|
|
61
|
+
nudge --> agent
|
|
62
|
+
loop -->|no| budget
|
|
63
|
+
budget -->|yes| prune --> forward{"Provider"}
|
|
64
|
+
budget -->|no| forward
|
|
65
|
+
forward -->|OpenAI| openai
|
|
66
|
+
forward -->|Anthropic| anthropic
|
|
67
|
+
openai --> agent
|
|
68
|
+
anthropic --> agent
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A clean request is forwarded with the caller’s `Authorization`, `x-api-key`, `anthropic-version`, `anthropic-beta`, `openai-organization`, and `openai-project` headers. LoopShield does not store those credentials.
|
|
72
|
+
|
|
73
|
+
When a loop is found, the JSON response still looks like a completion:
|
|
74
|
+
|
|
75
|
+
- OpenAI: `object` is `chat.completion`, and `choices[0].message.content` is the nudge.
|
|
76
|
+
- Anthropic: `type` is `message`, `role` is `assistant`, and the text block is the nudge.
|
|
77
|
+
- Both include `loopshield.intercepted`, `loopshield.loop_type` (`graph_cycle`, `semantic`, or `both`), and `loopshield.intervention_prompt`.
|
|
78
|
+
|
|
79
|
+
`stream: true` on an intercepted call is returned as server-sent events with the same nudge. `GET /health` returns `{"status": "ok"}`.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
Build and run the gateway on port 8000. Point `UPSTREAM_API_BASE` at the provider you want clean calls to reach.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
docker build -t loopshield:0.1.0 .
|
|
87
|
+
docker run --rm -p 8000:8000 -e UPSTREAM_API_BASE=https://api.openai.com -e SHIELD_SIMILARITY_THRESHOLD=0.92 loopshield:0.1.0
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Check the process, then send a chat completion through it. The provider key stays on the request; the container only forwards it.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
curl http://127.0.0.1:8000/health
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
curl http://127.0.0.1:8000/v1/chat/completions \
|
|
98
|
+
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
|
99
|
+
-H "Content-Type: application/json" \
|
|
100
|
+
-d "{\"model\":\"gpt-4.1\",\"messages\":[{\"role\":\"user\",\"content\":\"Summarize the deploy log.\"}]}"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
An OpenAI SDK client uses the proxy as its base URL:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from openai import OpenAI
|
|
107
|
+
|
|
108
|
+
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="sk-...")
|
|
109
|
+
completion = client.chat.completions.create(
|
|
110
|
+
model="gpt-4.1",
|
|
111
|
+
messages=[{"role": "user", "content": "Summarize the deploy log."}],
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Anthropic-shaped traffic uses the same path. Set the upstream base to `https://api.anthropic.com` and send the provider headers the proxy already forwards:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
docker run --rm -p 8000:8000 -e UPSTREAM_API_BASE=https://api.anthropic.com loopshield:0.1.0
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
curl http://127.0.0.1:8000/v1/chat/completions \
|
|
123
|
+
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
|
124
|
+
-H "anthropic-version: 2023-06-01" \
|
|
125
|
+
-H "Content-Type: application/json" \
|
|
126
|
+
-d "{\"model\":\"claude-sonnet-4-5\",\"max_tokens\":256,\"system\":\"Be careful.\",\"messages\":[{\"role\":\"user\",\"content\":\"Summarize the deploy log.\"}]}"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The official Anthropic SDK posts to `/v1/messages`. This gateway accepts that payload shape on `/v1/chat/completions`, then forwards a clean call to `{UPSTREAM_API_BASE}/v1/messages`. If the upstream base already ends in `/v1`, that prefix is not doubled.
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Meaning |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `UPSTREAM_API_BASE` | `https://api.openai.com` | Origin for forwarded calls. OpenAI traffic goes to `/v1/chat/completions`. Anthropic traffic goes to `/v1/messages`. |
|
|
136
|
+
| `SHIELD_SIMILARITY_THRESHOLD` | `0.92` | Cosine similarity, from 0 to 1, above which two thoughts count as the same line of reasoning. The comparison is strict `>`. |
|
|
137
|
+
| `SHIELD_MAX_TOKENS` | `8000` | Estimated token budget. Over this, `prune_context` keeps the leading system prompt and the last two turns, and collapses repeated tool errors in the middle. |
|
|
138
|
+
| `SHIELD_MAX_CYCLES` | `1` | A directed tool cycle is a loop when it completes more times than this. `1` allows one lap and flags the second. `0` flags the first time the cycle closes. |
|
|
139
|
+
| `SHIELD_MAX_SEMANTIC_REPEATS` | `2` | Minimum streak of consecutive similar thoughts, measured backward from the latest thought, required to flag a semantic loop. |
|
|
140
|
+
| `SHIELD_UPSTREAM_TIMEOUT` | `60` | Upstream HTTP timeout in seconds. |
|
|
141
|
+
|
|
142
|
+
The image sets `UPSTREAM_API_BASE`, `SHIELD_SIMILARITY_THRESHOLD`, and `SHIELD_MAX_TOKENS`. Override any of them with `docker run -e`.
|
|
143
|
+
|
|
144
|
+
Two detectors run on every request:
|
|
145
|
+
|
|
146
|
+
- **Graph.** Each tool call is a node of `tool_name` plus normalized arguments. Consecutive calls are directed edges. The same cycle walked again, including a tool calling itself with the same arguments, is a graph loop.
|
|
147
|
+
- **Semantic.** Thoughts are compared with cosine similarity. With no embedding model configured, the proxy uses an in-process TF-IDF bag-of-words embedder. Identical text scores 1.0. A streak breaks at the first thought that does not exceed the threshold.
|
|
148
|
+
|
|
149
|
+
The proxy calls `inspect`, which does not keep request history on the shared process. Concurrent requests do not contaminate each other.
|
|
150
|
+
|
|
151
|
+
## Use the SDK in process
|
|
152
|
+
|
|
153
|
+
Install the library when the agent loop lives in your own code and you do not need the gateway:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Record each step. A verdict with `is_loop` carries the system nudge to send back to the model.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from loopshield import AgentStep, LoopShield
|
|
163
|
+
|
|
164
|
+
shield = LoopShield(similarity_threshold=0.92, max_cycles=1)
|
|
165
|
+
|
|
166
|
+
verdict = shield.step(
|
|
167
|
+
AgentStep(
|
|
168
|
+
thought="The config file should be at the project root.",
|
|
169
|
+
tool_name="read_file",
|
|
170
|
+
tool_args={"path": "config.txt"},
|
|
171
|
+
tokens_used=12,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if verdict.is_loop:
|
|
176
|
+
print(verdict.intervention_prompt)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`@shield.guard` wraps a function that takes an `AgentStep`. When the new step closes a loop, the decorator returns the `LoopVerdict` and does not call the function. Pass an initialized OpenAI or Ollama client as `embedding=` to replace the TF-IDF fallback; pass `embed_fn=` for any other model. Do not pass both.
|
|
180
|
+
|
|
181
|
+
`prune_context(messages, max_tokens_estimate)` takes OpenAI-style message dicts. It estimates tokens from `token_count`, `tokens`, or `usage.total_tokens` when present, otherwise from character length at about four characters per token.
|
|
182
|
+
|
|
183
|
+
## Framework integrations
|
|
184
|
+
|
|
185
|
+
`loopshield/frameworks.py` plugs the same engine into LangChain, CrewAI, and LangGraph. Each framework is an optional install (`pip install "loopshield[langchain]"`, `[langgraph]`, `[crewai]`). The hooks keep no history on the shield, so one `LoopShield` can serve concurrent runs.
|
|
186
|
+
|
|
187
|
+
**LangChain.** `shield.langchain_middleware(config)` returns an `AgentMiddleware`:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from langchain.agents import create_agent
|
|
191
|
+
|
|
192
|
+
agent = create_agent(model, tools, middleware=[shield.langchain_middleware({"max_tokens": 6000})])
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `wrap_tool_call` replaces a looping tool call with an error `ToolMessage` that carries the nudge. The tool does not run, and the model reads why on its next turn.
|
|
196
|
+
- `wrap_model_call` runs `prune_context` when the transcript is over budget. For a loop that reaches the model, it answers with the nudge before the LLM is called. Set `on_loop` to `inject` to append the nudge as a system message and let the model run.
|
|
197
|
+
|
|
198
|
+
**CrewAI.** `@shield.crewai_agent()` wraps `Agent.execute_task`, either on a class or on the method. It rebuilds the tool trajectory from `agent.tools_results`, or from the `Action` / `Action Input` lines in the string context. When the tools loop, the nudge is prepended to the `context` string that the next step reads.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
@shield.crewai_agent()
|
|
202
|
+
class GuardedAgent(Agent): ...
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**LangGraph.** `shield.langgraph_node()` wraps a node. A looping `state["messages"]` gets the system nudge as a state update, and the node is skipped for that pass. With `{"retry_node": "agent"}` it returns `Command(update=..., goto="agent")`, so the graph re-enters that node with the nudge in context. The `messages` channel needs an appending reducer such as `add_messages`.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
graph.add_node("agent", shield.langgraph_node(agent_node, config={"retry_node": "agent"}))
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
python -m pytest
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Python 3.11 or newer. Tests live in `tests/` and cover tool-call cycles, semantic streaks, recovery after a nudge, context pruning, embedding adapters, and the proxy with a mocked upstream.
|
|
218
|
+
|
|
219
|
+
| Module | Role |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| `loopshield/models.py` | `AgentStep` and `LoopVerdict` |
|
|
222
|
+
| `loopshield/graph_analyzer.py` | Directed tool-call cycles |
|
|
223
|
+
| `loopshield/semantic_analyzer.py` | Cosine similarity of thoughts |
|
|
224
|
+
| `loopshield/mitigator.py` | Intervention nudge and context pruning |
|
|
225
|
+
| `loopshield/shield.py` | `LoopShield.step`, `inspect`, and `guard` |
|
|
226
|
+
| `loopshield/adapters.py` | Optional OpenAI and Ollama embedding wrappers |
|
|
227
|
+
| `loopshield/proxy.py` | FastAPI gateway served by uvicorn |
|
|
228
|
+
| `loopshield/transcript.py` | Chat messages from any framework to `AgentStep`s |
|
|
229
|
+
| `loopshield/frameworks.py` | LangChain, CrewAI, and LangGraph integrations |
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# LoopShield
|
|
2
|
+
|
|
3
|
+
LoopShield is a vendor-agnostic Python SDK and an OpenAI-compatible proxy that stops AI agents from burning tokens on the same tool cycle or the same thought. The proxy sits in front of the model provider. Every `POST /v1/chat/completions` is screened before it is forwarded. A detected loop comes back as a normal assistant message that tells the agent the repeating tool is locked and it must change strategy. The upstream provider is not called.
|
|
4
|
+
|
|
5
|
+
The core package depends only on `networkx`, `numpy`, and Pydantic v2. FastAPI, uvicorn, and httpx are an optional `proxy` extra. Embedding models are injected; the OpenAI and Ollama SDKs are not core dependencies.
|
|
6
|
+
|
|
7
|
+
## How a call is intercepted
|
|
8
|
+
|
|
9
|
+
Clients keep speaking the provider’s chat API. LoopShield accepts an OpenAI Chat Completions body, and it also accepts an Anthropic Messages body on the same route. Anthropic shape is recognized from an `anthropic-version` header, a payload that has both `system` and `max_tokens`, or content blocks of type `tool_use` / `tool_result`.
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart TD
|
|
13
|
+
agent["Agent or app"]
|
|
14
|
+
route["POST /v1/chat/completions"]
|
|
15
|
+
style{"Payload style"}
|
|
16
|
+
steps["Assistant turns become AgentSteps"]
|
|
17
|
+
inspect["LoopShield.inspect"]
|
|
18
|
+
graph["GraphAnalyzer<br/>exact tool-call cycle"]
|
|
19
|
+
semantic["SemanticAnalyzer<br/>cosine similarity of thoughts"]
|
|
20
|
+
loop{"Loop?"}
|
|
21
|
+
nudge["Intercepted assistant message<br/>intervention_prompt"]
|
|
22
|
+
budget{"Over SHIELD_MAX_TOKENS?"}
|
|
23
|
+
prune["prune_context<br/>keep system prompt and last 2 turns"]
|
|
24
|
+
openai["Upstream POST /v1/chat/completions"]
|
|
25
|
+
anthropic["Upstream POST /v1/messages"]
|
|
26
|
+
|
|
27
|
+
agent --> route --> style --> steps --> inspect
|
|
28
|
+
inspect --> graph
|
|
29
|
+
inspect --> semantic
|
|
30
|
+
graph --> loop
|
|
31
|
+
semantic --> loop
|
|
32
|
+
loop -->|yes| nudge
|
|
33
|
+
nudge --> agent
|
|
34
|
+
loop -->|no| budget
|
|
35
|
+
budget -->|yes| prune --> forward{"Provider"}
|
|
36
|
+
budget -->|no| forward
|
|
37
|
+
forward -->|OpenAI| openai
|
|
38
|
+
forward -->|Anthropic| anthropic
|
|
39
|
+
openai --> agent
|
|
40
|
+
anthropic --> agent
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A clean request is forwarded with the caller’s `Authorization`, `x-api-key`, `anthropic-version`, `anthropic-beta`, `openai-organization`, and `openai-project` headers. LoopShield does not store those credentials.
|
|
44
|
+
|
|
45
|
+
When a loop is found, the JSON response still looks like a completion:
|
|
46
|
+
|
|
47
|
+
- OpenAI: `object` is `chat.completion`, and `choices[0].message.content` is the nudge.
|
|
48
|
+
- Anthropic: `type` is `message`, `role` is `assistant`, and the text block is the nudge.
|
|
49
|
+
- Both include `loopshield.intercepted`, `loopshield.loop_type` (`graph_cycle`, `semantic`, or `both`), and `loopshield.intervention_prompt`.
|
|
50
|
+
|
|
51
|
+
`stream: true` on an intercepted call is returned as server-sent events with the same nudge. `GET /health` returns `{"status": "ok"}`.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
Build and run the gateway on port 8000. Point `UPSTREAM_API_BASE` at the provider you want clean calls to reach.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
docker build -t loopshield:0.1.0 .
|
|
59
|
+
docker run --rm -p 8000:8000 -e UPSTREAM_API_BASE=https://api.openai.com -e SHIELD_SIMILARITY_THRESHOLD=0.92 loopshield:0.1.0
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Check the process, then send a chat completion through it. The provider key stays on the request; the container only forwards it.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
curl http://127.0.0.1:8000/health
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl http://127.0.0.1:8000/v1/chat/completions \
|
|
70
|
+
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
|
71
|
+
-H "Content-Type: application/json" \
|
|
72
|
+
-d "{\"model\":\"gpt-4.1\",\"messages\":[{\"role\":\"user\",\"content\":\"Summarize the deploy log.\"}]}"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An OpenAI SDK client uses the proxy as its base URL:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from openai import OpenAI
|
|
79
|
+
|
|
80
|
+
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="sk-...")
|
|
81
|
+
completion = client.chat.completions.create(
|
|
82
|
+
model="gpt-4.1",
|
|
83
|
+
messages=[{"role": "user", "content": "Summarize the deploy log."}],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Anthropic-shaped traffic uses the same path. Set the upstream base to `https://api.anthropic.com` and send the provider headers the proxy already forwards:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
docker run --rm -p 8000:8000 -e UPSTREAM_API_BASE=https://api.anthropic.com loopshield:0.1.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
curl http://127.0.0.1:8000/v1/chat/completions \
|
|
95
|
+
-H "x-api-key: $ANTHROPIC_API_KEY" \
|
|
96
|
+
-H "anthropic-version: 2023-06-01" \
|
|
97
|
+
-H "Content-Type: application/json" \
|
|
98
|
+
-d "{\"model\":\"claude-sonnet-4-5\",\"max_tokens\":256,\"system\":\"Be careful.\",\"messages\":[{\"role\":\"user\",\"content\":\"Summarize the deploy log.\"}]}"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The official Anthropic SDK posts to `/v1/messages`. This gateway accepts that payload shape on `/v1/chat/completions`, then forwards a clean call to `{UPSTREAM_API_BASE}/v1/messages`. If the upstream base already ends in `/v1`, that prefix is not doubled.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
| Variable | Default | Meaning |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `UPSTREAM_API_BASE` | `https://api.openai.com` | Origin for forwarded calls. OpenAI traffic goes to `/v1/chat/completions`. Anthropic traffic goes to `/v1/messages`. |
|
|
108
|
+
| `SHIELD_SIMILARITY_THRESHOLD` | `0.92` | Cosine similarity, from 0 to 1, above which two thoughts count as the same line of reasoning. The comparison is strict `>`. |
|
|
109
|
+
| `SHIELD_MAX_TOKENS` | `8000` | Estimated token budget. Over this, `prune_context` keeps the leading system prompt and the last two turns, and collapses repeated tool errors in the middle. |
|
|
110
|
+
| `SHIELD_MAX_CYCLES` | `1` | A directed tool cycle is a loop when it completes more times than this. `1` allows one lap and flags the second. `0` flags the first time the cycle closes. |
|
|
111
|
+
| `SHIELD_MAX_SEMANTIC_REPEATS` | `2` | Minimum streak of consecutive similar thoughts, measured backward from the latest thought, required to flag a semantic loop. |
|
|
112
|
+
| `SHIELD_UPSTREAM_TIMEOUT` | `60` | Upstream HTTP timeout in seconds. |
|
|
113
|
+
|
|
114
|
+
The image sets `UPSTREAM_API_BASE`, `SHIELD_SIMILARITY_THRESHOLD`, and `SHIELD_MAX_TOKENS`. Override any of them with `docker run -e`.
|
|
115
|
+
|
|
116
|
+
Two detectors run on every request:
|
|
117
|
+
|
|
118
|
+
- **Graph.** Each tool call is a node of `tool_name` plus normalized arguments. Consecutive calls are directed edges. The same cycle walked again, including a tool calling itself with the same arguments, is a graph loop.
|
|
119
|
+
- **Semantic.** Thoughts are compared with cosine similarity. With no embedding model configured, the proxy uses an in-process TF-IDF bag-of-words embedder. Identical text scores 1.0. A streak breaks at the first thought that does not exceed the threshold.
|
|
120
|
+
|
|
121
|
+
The proxy calls `inspect`, which does not keep request history on the shared process. Concurrent requests do not contaminate each other.
|
|
122
|
+
|
|
123
|
+
## Use the SDK in process
|
|
124
|
+
|
|
125
|
+
Install the library when the agent loop lives in your own code and you do not need the gateway:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e ".[dev]"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Record each step. A verdict with `is_loop` carries the system nudge to send back to the model.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from loopshield import AgentStep, LoopShield
|
|
135
|
+
|
|
136
|
+
shield = LoopShield(similarity_threshold=0.92, max_cycles=1)
|
|
137
|
+
|
|
138
|
+
verdict = shield.step(
|
|
139
|
+
AgentStep(
|
|
140
|
+
thought="The config file should be at the project root.",
|
|
141
|
+
tool_name="read_file",
|
|
142
|
+
tool_args={"path": "config.txt"},
|
|
143
|
+
tokens_used=12,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if verdict.is_loop:
|
|
148
|
+
print(verdict.intervention_prompt)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`@shield.guard` wraps a function that takes an `AgentStep`. When the new step closes a loop, the decorator returns the `LoopVerdict` and does not call the function. Pass an initialized OpenAI or Ollama client as `embedding=` to replace the TF-IDF fallback; pass `embed_fn=` for any other model. Do not pass both.
|
|
152
|
+
|
|
153
|
+
`prune_context(messages, max_tokens_estimate)` takes OpenAI-style message dicts. It estimates tokens from `token_count`, `tokens`, or `usage.total_tokens` when present, otherwise from character length at about four characters per token.
|
|
154
|
+
|
|
155
|
+
## Framework integrations
|
|
156
|
+
|
|
157
|
+
`loopshield/frameworks.py` plugs the same engine into LangChain, CrewAI, and LangGraph. Each framework is an optional install (`pip install "loopshield[langchain]"`, `[langgraph]`, `[crewai]`). The hooks keep no history on the shield, so one `LoopShield` can serve concurrent runs.
|
|
158
|
+
|
|
159
|
+
**LangChain.** `shield.langchain_middleware(config)` returns an `AgentMiddleware`:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from langchain.agents import create_agent
|
|
163
|
+
|
|
164
|
+
agent = create_agent(model, tools, middleware=[shield.langchain_middleware({"max_tokens": 6000})])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `wrap_tool_call` replaces a looping tool call with an error `ToolMessage` that carries the nudge. The tool does not run, and the model reads why on its next turn.
|
|
168
|
+
- `wrap_model_call` runs `prune_context` when the transcript is over budget. For a loop that reaches the model, it answers with the nudge before the LLM is called. Set `on_loop` to `inject` to append the nudge as a system message and let the model run.
|
|
169
|
+
|
|
170
|
+
**CrewAI.** `@shield.crewai_agent()` wraps `Agent.execute_task`, either on a class or on the method. It rebuilds the tool trajectory from `agent.tools_results`, or from the `Action` / `Action Input` lines in the string context. When the tools loop, the nudge is prepended to the `context` string that the next step reads.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
@shield.crewai_agent()
|
|
174
|
+
class GuardedAgent(Agent): ...
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**LangGraph.** `shield.langgraph_node()` wraps a node. A looping `state["messages"]` gets the system nudge as a state update, and the node is skipped for that pass. With `{"retry_node": "agent"}` it returns `Command(update=..., goto="agent")`, so the graph re-enters that node with the nudge in context. The `messages` channel needs an appending reducer such as `add_messages`.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
graph.add_node("agent", shield.langgraph_node(agent_node, config={"retry_node": "agent"}))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python -m pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Python 3.11 or newer. Tests live in `tests/` and cover tool-call cycles, semantic streaks, recovery after a nudge, context pruning, embedding adapters, and the proxy with a mocked upstream.
|
|
190
|
+
|
|
191
|
+
| Module | Role |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `loopshield/models.py` | `AgentStep` and `LoopVerdict` |
|
|
194
|
+
| `loopshield/graph_analyzer.py` | Directed tool-call cycles |
|
|
195
|
+
| `loopshield/semantic_analyzer.py` | Cosine similarity of thoughts |
|
|
196
|
+
| `loopshield/mitigator.py` | Intervention nudge and context pruning |
|
|
197
|
+
| `loopshield/shield.py` | `LoopShield.step`, `inspect`, and `guard` |
|
|
198
|
+
| `loopshield/adapters.py` | Optional OpenAI and Ollama embedding wrappers |
|
|
199
|
+
| `loopshield/proxy.py` | FastAPI gateway served by uvicorn |
|
|
200
|
+
| `loopshield/transcript.py` | Chat messages from any framework to `AgentStep`s |
|
|
201
|
+
| `loopshield/frameworks.py` | LangChain, CrewAI, and LangGraph integrations |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LoopShield: loop detection and self-healing middleware for AI agents."""
|
|
2
|
+
|
|
3
|
+
from loopshield.adapters import BaseEmbeddingAdapter, OllamaEmbeddingAdapter, OpenAIEmbeddingAdapter
|
|
4
|
+
from loopshield.graph_analyzer import GraphAnalyzer
|
|
5
|
+
from loopshield.mitigator import Mitigator, generate_nudge, prune_context
|
|
6
|
+
from loopshield.models import AgentStep, LoopVerdict
|
|
7
|
+
from loopshield.semantic_analyzer import SemanticAnalyzer
|
|
8
|
+
from loopshield.shield import LoopShield
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentStep",
|
|
14
|
+
"BaseEmbeddingAdapter",
|
|
15
|
+
"GraphAnalyzer",
|
|
16
|
+
"LoopShield",
|
|
17
|
+
"LoopVerdict",
|
|
18
|
+
"Mitigator",
|
|
19
|
+
"OllamaEmbeddingAdapter",
|
|
20
|
+
"OpenAIEmbeddingAdapter",
|
|
21
|
+
"SemanticAnalyzer",
|
|
22
|
+
"generate_nudge",
|
|
23
|
+
"prune_context",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Embedding adapters for clients the application already constructs.
|
|
2
|
+
|
|
3
|
+
The core package does not import OpenAI or Ollama. Pass an initialized client
|
|
4
|
+
and these wrappers call ``client.embeddings.create`` or the local Ollama
|
|
5
|
+
embedding API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"
|
|
16
|
+
DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseEmbeddingAdapter(Protocol):
|
|
20
|
+
"""Any embedding backend that maps one string to a dense vector."""
|
|
21
|
+
|
|
22
|
+
def embed(self, text: str) -> list[float]:
|
|
23
|
+
"""Return a dense vector for ``text``."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAIEmbeddingsResource(Protocol):
|
|
27
|
+
"""The ``client.embeddings`` resource on an ``openai.OpenAI`` client."""
|
|
28
|
+
|
|
29
|
+
def create(self, *, model: str, input: str, **kwargs: Any) -> Any:
|
|
30
|
+
"""Create an embedding for ``input``."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenAIClient(Protocol):
|
|
34
|
+
"""Initialized OpenAI client with an embeddings resource."""
|
|
35
|
+
|
|
36
|
+
embeddings: OpenAIEmbeddingsResource
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OllamaEmbedClient(Protocol):
|
|
40
|
+
"""Ollama client that exposes the current ``embed`` call."""
|
|
41
|
+
|
|
42
|
+
def embed(self, *, model: str, input: str, **kwargs: Any) -> Any:
|
|
43
|
+
"""Embed ``input`` with ``model``."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OllamaLegacyClient(Protocol):
|
|
47
|
+
"""Ollama client that still exposes the legacy ``embeddings`` call."""
|
|
48
|
+
|
|
49
|
+
def embeddings(self, *, model: str, prompt: str, **kwargs: Any) -> Any:
|
|
50
|
+
"""Embed ``prompt`` with ``model``."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpenAIEmbeddingAdapter:
|
|
54
|
+
"""Call ``client.embeddings.create`` and return the first vector."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
client: OpenAIClient,
|
|
59
|
+
*,
|
|
60
|
+
model: str = DEFAULT_OPENAI_EMBEDDING_MODEL,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._client = client
|
|
63
|
+
self.model = _require_model(model)
|
|
64
|
+
|
|
65
|
+
def embed(self, text: str) -> list[float]:
|
|
66
|
+
"""Embed ``text`` with the configured OpenAI model."""
|
|
67
|
+
|
|
68
|
+
response = self._client.embeddings.create(model=self.model, input=text)
|
|
69
|
+
return openai_embedding_vector(response)
|
|
70
|
+
|
|
71
|
+
def __call__(self, text: str) -> list[float]:
|
|
72
|
+
"""Allow the adapter to be used anywhere an ``embed_fn`` is accepted."""
|
|
73
|
+
|
|
74
|
+
return self.embed(text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class OllamaEmbeddingAdapter:
|
|
78
|
+
"""Call a local Ollama client and return the first vector.
|
|
79
|
+
|
|
80
|
+
Prefers ``client.embed(model=..., input=...)``. Falls back to the legacy
|
|
81
|
+
``client.embeddings(model=..., prompt=...)`` call when that is all the
|
|
82
|
+
client provides.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
client: OllamaEmbedClient | OllamaLegacyClient,
|
|
88
|
+
*,
|
|
89
|
+
model: str = DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._client = client
|
|
92
|
+
self.model = _require_model(model)
|
|
93
|
+
|
|
94
|
+
def embed(self, text: str) -> list[float]:
|
|
95
|
+
"""Embed ``text`` with the configured local model."""
|
|
96
|
+
|
|
97
|
+
embed = getattr(self._client, "embed", None)
|
|
98
|
+
if callable(embed):
|
|
99
|
+
return ollama_embedding_vector(embed(model=self.model, input=text))
|
|
100
|
+
embeddings = getattr(self._client, "embeddings", None)
|
|
101
|
+
if callable(embeddings):
|
|
102
|
+
return ollama_embedding_vector(embeddings(model=self.model, prompt=text))
|
|
103
|
+
raise TypeError("Ollama client must provide embed() or embeddings().")
|
|
104
|
+
|
|
105
|
+
def __call__(self, text: str) -> list[float]:
|
|
106
|
+
"""Allow the adapter to be used anywhere an ``embed_fn`` is accepted."""
|
|
107
|
+
|
|
108
|
+
return self.embed(text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def as_embed_fn(source: Any, *, model: str | None = None) -> Callable[[str], list[float]]:
|
|
112
|
+
"""Turn an adapter, embed callable, or OpenAI/Ollama client into an embed function.
|
|
113
|
+
|
|
114
|
+
``model`` is used only when ``source`` is a raw client. Adapters already
|
|
115
|
+
carry their model.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
if _is_text_embedding_adapter(source):
|
|
119
|
+
if model is not None:
|
|
120
|
+
raise ValueError("Set the model on the adapter, or pass the client with embedding_model.")
|
|
121
|
+
if callable(source):
|
|
122
|
+
return source
|
|
123
|
+
return source.embed
|
|
124
|
+
if _is_openai_client(source):
|
|
125
|
+
return OpenAIEmbeddingAdapter(source, model=model or DEFAULT_OPENAI_EMBEDDING_MODEL)
|
|
126
|
+
if _is_ollama_client(source):
|
|
127
|
+
return OllamaEmbeddingAdapter(source, model=model or DEFAULT_OLLAMA_EMBEDDING_MODEL)
|
|
128
|
+
if callable(source):
|
|
129
|
+
if model is not None:
|
|
130
|
+
raise ValueError("embedding_model applies to an OpenAI or Ollama client, not a plain embed function.")
|
|
131
|
+
return source
|
|
132
|
+
raise TypeError(
|
|
133
|
+
"embedding must be an embed callable, an embedding adapter, or an initialized OpenAI or Ollama client."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def openai_embedding_vector(response: Any) -> list[float]:
|
|
138
|
+
"""Read the first vector from an OpenAI embeddings response."""
|
|
139
|
+
|
|
140
|
+
data = response["data"] if isinstance(response, Mapping) else getattr(response, "data", None)
|
|
141
|
+
if not data:
|
|
142
|
+
raise ValueError("OpenAI embedding response did not include any vectors.")
|
|
143
|
+
first = data[0]
|
|
144
|
+
embedding = first["embedding"] if isinstance(first, Mapping) else getattr(first, "embedding", None)
|
|
145
|
+
return _as_floats(embedding)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ollama_embedding_vector(response: Any) -> list[float]:
|
|
149
|
+
"""Read the first vector from a current or legacy Ollama embeddings response."""
|
|
150
|
+
|
|
151
|
+
if isinstance(response, Mapping):
|
|
152
|
+
if "embeddings" in response:
|
|
153
|
+
return _first_vector(response["embeddings"], provider="Ollama")
|
|
154
|
+
if "embedding" in response:
|
|
155
|
+
return _as_floats(response["embedding"])
|
|
156
|
+
embeddings = getattr(response, "embeddings", None)
|
|
157
|
+
if embeddings:
|
|
158
|
+
return _first_vector(embeddings, provider="Ollama")
|
|
159
|
+
embedding = getattr(response, "embedding", None)
|
|
160
|
+
if embedding:
|
|
161
|
+
return _as_floats(embedding)
|
|
162
|
+
raise ValueError("Ollama embedding response did not include a vector.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _require_model(model: str) -> str:
|
|
166
|
+
cleaned = model.strip()
|
|
167
|
+
if not cleaned:
|
|
168
|
+
raise ValueError("model must be a non-empty string")
|
|
169
|
+
return cleaned
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _as_floats(values: Any) -> list[float]:
|
|
173
|
+
if values is None:
|
|
174
|
+
raise ValueError("Embedding response did not include a vector.")
|
|
175
|
+
try:
|
|
176
|
+
vector = [float(item) for item in values]
|
|
177
|
+
except TypeError as exc:
|
|
178
|
+
raise ValueError("Embedding response did not contain a numeric vector.") from exc
|
|
179
|
+
if not vector:
|
|
180
|
+
raise ValueError("Embedding response was empty.")
|
|
181
|
+
return vector
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _first_vector(vectors: Any, *, provider: str) -> list[float]:
|
|
185
|
+
try:
|
|
186
|
+
first = vectors[0]
|
|
187
|
+
except (IndexError, TypeError, KeyError) as exc:
|
|
188
|
+
raise ValueError(f"{provider} embedding response did not include any vectors.") from exc
|
|
189
|
+
return _as_floats(first)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_text_embedding_adapter(source: object) -> bool:
|
|
193
|
+
embed = getattr(source, "embed", None)
|
|
194
|
+
if not callable(embed):
|
|
195
|
+
return False
|
|
196
|
+
try:
|
|
197
|
+
signature = inspect.signature(embed)
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
return False
|
|
200
|
+
parameters = [parameter for name, parameter in signature.parameters.items() if name != "self"]
|
|
201
|
+
if not parameters:
|
|
202
|
+
return False
|
|
203
|
+
first = parameters[0]
|
|
204
|
+
return first.name == "text" and first.kind in (
|
|
205
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
206
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_openai_client(source: object) -> bool:
|
|
211
|
+
embeddings = getattr(source, "embeddings", None)
|
|
212
|
+
if embeddings is None or callable(embeddings):
|
|
213
|
+
return False
|
|
214
|
+
return callable(getattr(embeddings, "create", None))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _is_ollama_client(source: object) -> bool:
|
|
218
|
+
if _is_openai_client(source):
|
|
219
|
+
return False
|
|
220
|
+
if callable(getattr(source, "embed", None)):
|
|
221
|
+
return True
|
|
222
|
+
return callable(getattr(source, "embeddings", None))
|