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/__init__.py
ADDED
|
@@ -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
|
+
]
|
loopshield/adapters.py
ADDED
|
@@ -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))
|