adjacency-agents 0.1.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.
- adjacency_agents-0.1.0/LICENSE +21 -0
- adjacency_agents-0.1.0/PKG-INFO +466 -0
- adjacency_agents-0.1.0/README.md +426 -0
- adjacency_agents-0.1.0/pyproject.toml +118 -0
- adjacency_agents-0.1.0/setup.cfg +4 -0
- adjacency_agents-0.1.0/src/adjacency_agents/__init__.py +28 -0
- adjacency_agents-0.1.0/src/adjacency_agents/adapters/__init__.py +7 -0
- adjacency_agents-0.1.0/src/adjacency_agents/adapters/anthropic.py +216 -0
- adjacency_agents-0.1.0/src/adjacency_agents/adapters/ollama.py +220 -0
- adjacency_agents-0.1.0/src/adjacency_agents/adapters/openai.py +199 -0
- adjacency_agents-0.1.0/src/adjacency_agents/decorators.py +115 -0
- adjacency_agents-0.1.0/src/adjacency_agents/engine.py +615 -0
- adjacency_agents-0.1.0/src/adjacency_agents/errors.py +53 -0
- adjacency_agents-0.1.0/src/adjacency_agents/llm.py +89 -0
- adjacency_agents-0.1.0/src/adjacency_agents/models.py +97 -0
- adjacency_agents-0.1.0/src/adjacency_agents/py.typed +0 -0
- adjacency_agents-0.1.0/src/adjacency_agents/registry.py +58 -0
- adjacency_agents-0.1.0/src/adjacency_agents/router.py +23 -0
- adjacency_agents-0.1.0/src/adjacency_agents/schema.py +169 -0
- adjacency_agents-0.1.0/src/adjacency_agents/tracing.py +126 -0
- adjacency_agents-0.1.0/src/adjacency_agents.egg-info/PKG-INFO +466 -0
- adjacency_agents-0.1.0/src/adjacency_agents.egg-info/SOURCES.txt +40 -0
- adjacency_agents-0.1.0/src/adjacency_agents.egg-info/dependency_links.txt +1 -0
- adjacency_agents-0.1.0/src/adjacency_agents.egg-info/requires.txt +16 -0
- adjacency_agents-0.1.0/src/adjacency_agents.egg-info/top_level.txt +1 -0
- adjacency_agents-0.1.0/tests/test_anthropic_adapter.py +299 -0
- adjacency_agents-0.1.0/tests/test_async_engine.py +74 -0
- adjacency_agents-0.1.0/tests/test_context_injection.py +68 -0
- adjacency_agents-0.1.0/tests/test_decorators.py +150 -0
- adjacency_agents-0.1.0/tests/test_engine.py +357 -0
- adjacency_agents-0.1.0/tests/test_errors.py +60 -0
- adjacency_agents-0.1.0/tests/test_fake_llm.py +62 -0
- adjacency_agents-0.1.0/tests/test_models.py +150 -0
- adjacency_agents-0.1.0/tests/test_ollama_adapter.py +272 -0
- adjacency_agents-0.1.0/tests/test_openai_adapter.py +281 -0
- adjacency_agents-0.1.0/tests/test_pointer_transitions.py +113 -0
- adjacency_agents-0.1.0/tests/test_registry.py +83 -0
- adjacency_agents-0.1.0/tests/test_router.py +88 -0
- adjacency_agents-0.1.0/tests/test_schema.py +280 -0
- adjacency_agents-0.1.0/tests/test_synthesis.py +52 -0
- adjacency_agents-0.1.0/tests/test_tool_execution_errors.py +156 -0
- adjacency_agents-0.1.0/tests/test_tracing.py +212 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AdjacencyAgents contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adjacency-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic tool orchestration for LLM/SLM flows with policy-gated parsing.
|
|
5
|
+
Author: AdjacencyAgents
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SDWLincoln/adjacency-agents
|
|
8
|
+
Project-URL: Documentation, https://github.com/SDWLincoln/adjacency-agents#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/SDWLincoln/adjacency-agents
|
|
10
|
+
Project-URL: Issues, https://github.com/SDWLincoln/adjacency-agents/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/SDWLincoln/adjacency-agents/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: llm,slm,agents,tool-calling,openai,anthropic,ollama,pydantic,policy
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
33
|
+
Provides-Extra: openai
|
|
34
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
35
|
+
Provides-Extra: anthropic
|
|
36
|
+
Requires-Dist: anthropic>=0.40; extra == "anthropic"
|
|
37
|
+
Provides-Extra: ollama
|
|
38
|
+
Requires-Dist: ollama>=0.3; extra == "ollama"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# adjacency-agents
|
|
42
|
+
|
|
43
|
+
> Backend defines the scenario. The engine builds the allowlist. The LLM
|
|
44
|
+
> chooses inside a safe space. Python executes and validates.
|
|
45
|
+
|
|
46
|
+
`adjacency-agents` is a microlibrary for deterministic tool
|
|
47
|
+
orchestration in flows that include LLMs/SLMs. Instead of asking the
|
|
48
|
+
model to pick the *right* tool among many semantically-similar ones, the
|
|
49
|
+
engine removes incompatible tools from the parser **before** calling the
|
|
50
|
+
model.
|
|
51
|
+
|
|
52
|
+
**Status:** MVP — Phases 1–5 of the DDD spec are implemented and
|
|
53
|
+
covered by tests. Provider-specific adapters (Phase 6) are not in
|
|
54
|
+
scope yet.
|
|
55
|
+
|
|
56
|
+
## Why
|
|
57
|
+
|
|
58
|
+
Small LLMs (and even big ones) regularly call the wrong tool when two
|
|
59
|
+
tools are semantically close — e.g. a "reissue boleto for guests" tool
|
|
60
|
+
and a "reissue boleto for registered users" tool. That is not a
|
|
61
|
+
permission bug. It is a **contextual parsing** bug: the model is
|
|
62
|
+
filling arguments for a tool that should not even exist in the current
|
|
63
|
+
scenario.
|
|
64
|
+
|
|
65
|
+
`adjacency-agents` does not try to make the LLM smarter via prompting.
|
|
66
|
+
It reduces the model's choice space before the call.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install -e .
|
|
72
|
+
# or, once published:
|
|
73
|
+
# pip install adjacency-agents
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Python 3.10+. Depends on `pydantic>=2.7,<3`.
|
|
77
|
+
|
|
78
|
+
## Quickstart
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from adjacency_agents import DeterministicEngine, UserContext, tool_node
|
|
82
|
+
from adjacency_agents.llm import FakeLLMClient
|
|
83
|
+
from adjacency_agents import ToolCall
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@tool_node(requires=["public"])
|
|
87
|
+
def listar_servicos() -> str:
|
|
88
|
+
"""Lista serviços disponíveis."""
|
|
89
|
+
return "Temos atendimento comercial, financeiro e suporte."
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
fake = FakeLLMClient(script=[ToolCall(name="listar_servicos")])
|
|
93
|
+
engine = DeterministicEngine(llm=fake, tools=[listar_servicos])
|
|
94
|
+
ctx = UserContext(session_id="s1", capabilities={"public"})
|
|
95
|
+
print(engine.invoke(prompt="quais serviços?", context=ctx).content)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Real LLM providers
|
|
99
|
+
|
|
100
|
+
Adapters live in `adjacency_agents.adapters.*` and accept any
|
|
101
|
+
duck-typed client — the SDKs are optional dependencies.
|
|
102
|
+
|
|
103
|
+
### OpenAI
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install -e ".[openai]"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from openai import OpenAI
|
|
111
|
+
|
|
112
|
+
from adjacency_agents import DeterministicEngine, UserContext, tool_node
|
|
113
|
+
from adjacency_agents.adapters.openai import OpenAIClient
|
|
114
|
+
|
|
115
|
+
adapter = OpenAIClient(client=OpenAI(), model="gpt-4o-mini")
|
|
116
|
+
engine = DeterministicEngine(llm=adapter, tools=[listar_servicos])
|
|
117
|
+
answer = engine.invoke(
|
|
118
|
+
prompt="quais serviços?",
|
|
119
|
+
context=UserContext(session_id="s1", capabilities={"public"}),
|
|
120
|
+
)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`AsyncOpenAIClient` is the async counterpart for `engine.ainvoke(...)`.
|
|
124
|
+
|
|
125
|
+
### Anthropic
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e ".[anthropic]"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from anthropic import Anthropic
|
|
133
|
+
|
|
134
|
+
from adjacency_agents import DeterministicEngine, UserContext, tool_node
|
|
135
|
+
from adjacency_agents.adapters.anthropic import AnthropicClient
|
|
136
|
+
|
|
137
|
+
adapter = AnthropicClient(
|
|
138
|
+
client=Anthropic(), model="claude-haiku-4-5", max_tokens=512
|
|
139
|
+
)
|
|
140
|
+
engine = DeterministicEngine(llm=adapter, tools=[listar_servicos])
|
|
141
|
+
answer = engine.invoke(
|
|
142
|
+
prompt="quais serviços?",
|
|
143
|
+
context=UserContext(session_id="s1", capabilities={"public"}),
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`AsyncAnthropicClient` is the async counterpart.
|
|
148
|
+
|
|
149
|
+
### Ollama (local models)
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install -e ".[ollama]"
|
|
153
|
+
# and: ollama pull llama3.1
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from ollama import Client
|
|
158
|
+
|
|
159
|
+
from adjacency_agents import DeterministicEngine, UserContext, tool_node
|
|
160
|
+
from adjacency_agents.adapters.ollama import OllamaClient
|
|
161
|
+
|
|
162
|
+
adapter = OllamaClient(client=Client(host="http://localhost:11434"), model="llama3.1")
|
|
163
|
+
engine = DeterministicEngine(llm=adapter, tools=[listar_servicos])
|
|
164
|
+
answer = engine.invoke(
|
|
165
|
+
prompt="quais serviços?",
|
|
166
|
+
context=UserContext(session_id="s1", capabilities={"public"}),
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`AsyncOllamaClient` wraps `ollama.AsyncClient`. The adapter targets
|
|
171
|
+
models with native tool calling (Llama 3.1+, Qwen 2.5, Mistral Small,
|
|
172
|
+
etc.). SLMs that lack tool calling will still work for plain text
|
|
173
|
+
answers but cannot drive policy-gated tool selection.
|
|
174
|
+
|
|
175
|
+
All three adapters translate the engine's provider-agnostic JSON schema
|
|
176
|
+
into the provider's tool format, parse tool calls back into the
|
|
177
|
+
internal `ToolCall`, and disable tool calling during synthesis (so the
|
|
178
|
+
final answer is always plain text).
|
|
179
|
+
|
|
180
|
+
## Capabilities
|
|
181
|
+
|
|
182
|
+
Capabilities are short string labels derived from trusted facts in your
|
|
183
|
+
application (session, DB, API). The library does **not** interpret them
|
|
184
|
+
semantically — it only matches them against tool policies.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
ctx = UserContext(
|
|
188
|
+
session_id="whatsapp_123",
|
|
189
|
+
capabilities={"public", "registered", "active_account"},
|
|
190
|
+
metadata={"registration_id": "abc-123"},
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## ToolPolicy
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from adjacency_agents import ToolPolicy, tool_node
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@tool_node(
|
|
201
|
+
policy=ToolPolicy(
|
|
202
|
+
all_of={"registered", "active_account"},
|
|
203
|
+
none_of={"blocked", "fraud_suspected"},
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
def consultar_area_restrita() -> str:
|
|
207
|
+
"""Disponível apenas para conta ativa e não bloqueada."""
|
|
208
|
+
return "Área restrita liberada."
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A tool with no `requires`/`policy` is denied by default. Empty policies
|
|
212
|
+
do not grant access (§4.1 of the spec).
|
|
213
|
+
|
|
214
|
+
## EnrichedPointer — deterministic transitions
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from adjacency_agents import EnrichedPointer, tool_node
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@tool_node(
|
|
221
|
+
requires=["registered"],
|
|
222
|
+
structural_neighbors=["consultar_detalhe"],
|
|
223
|
+
)
|
|
224
|
+
def buscar_recente() -> EnrichedPointer | str:
|
|
225
|
+
return EnrichedPointer(
|
|
226
|
+
next_tool="consultar_detalhe",
|
|
227
|
+
kwargs={"item_id": "ITEM-007"},
|
|
228
|
+
reason="item encontrado",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@tool_node(requires=["registered"], llm_visible=False)
|
|
233
|
+
def consultar_detalhe(item_id: str) -> str:
|
|
234
|
+
return f"Item {item_id}: enviado em 2026-05-20."
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The second tool is `llm_visible=False`. The LLM never sees its schema;
|
|
238
|
+
it can only be reached via a validated pointer from a declared neighbor.
|
|
239
|
+
|
|
240
|
+
## Observation + synthesis
|
|
241
|
+
|
|
242
|
+
A tool that returns an `Observation` (or any `dict`/`list`/`BaseModel`
|
|
243
|
+
under `response_mode="auto"`) triggers a single synthesis call with
|
|
244
|
+
**tools disabled**. The LLM cannot start a new routing decision during
|
|
245
|
+
synthesis.
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from adjacency_agents import Observation, tool_node
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@tool_node(requires=["public"])
|
|
252
|
+
def saldo() -> Observation:
|
|
253
|
+
return Observation(data={"saldo": 123.45, "moeda": "BRL"})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Argument descriptions and constraints
|
|
257
|
+
|
|
258
|
+
Use `typing.Annotated[T, Field(...)]` to attach descriptions and
|
|
259
|
+
validation rules to individual tool arguments. They flow into the JSON
|
|
260
|
+
schema sent to the LLM **and** are enforced by Pydantic on every call.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from typing import Annotated
|
|
264
|
+
from pydantic import Field
|
|
265
|
+
from adjacency_agents import tool_node
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@tool_node(requires=["public"])
|
|
269
|
+
def buscar(
|
|
270
|
+
query: Annotated[str, Field(description="termo de busca")],
|
|
271
|
+
limit: Annotated[int, Field(description="máx. resultados", ge=1, le=100)] = 10,
|
|
272
|
+
) -> str:
|
|
273
|
+
...
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Multi-turn `messages`
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from adjacency_agents import Message
|
|
280
|
+
|
|
281
|
+
engine.invoke(
|
|
282
|
+
messages=[
|
|
283
|
+
Message(role="user", content="Quero atendimento"),
|
|
284
|
+
Message(role="assistant", content="Você já é cadastrado?"),
|
|
285
|
+
Message(role="user", content="Sim"),
|
|
286
|
+
],
|
|
287
|
+
context=ctx,
|
|
288
|
+
)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`UserContext` carries trusted facts. `messages` carries conversation.
|
|
292
|
+
The engine never mixes the two.
|
|
293
|
+
|
|
294
|
+
## `ainvoke` and async tools
|
|
295
|
+
|
|
296
|
+
`ainvoke` is the recommended production path. It supports `async def`
|
|
297
|
+
tools natively and runs `def` tools in a worker thread by default so
|
|
298
|
+
they cannot block the event loop.
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
import asyncio
|
|
302
|
+
|
|
303
|
+
from adjacency_agents import (
|
|
304
|
+
DeterministicEngine,
|
|
305
|
+
ToolCall,
|
|
306
|
+
UserContext,
|
|
307
|
+
tool_node,
|
|
308
|
+
)
|
|
309
|
+
from adjacency_agents.llm import FakeLLMClient
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@tool_node(requires=["public"])
|
|
313
|
+
async def fetch_status() -> str:
|
|
314
|
+
"""Pretend this awaits an HTTP call."""
|
|
315
|
+
await asyncio.sleep(0)
|
|
316
|
+
return "online"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def main() -> None:
|
|
320
|
+
fake = FakeLLMClient(script=[ToolCall(name="fetch_status")])
|
|
321
|
+
engine = DeterministicEngine(llm=fake, tools=[fetch_status])
|
|
322
|
+
ctx = UserContext(session_id="s", capabilities={"public"})
|
|
323
|
+
|
|
324
|
+
answer = await engine.ainvoke(prompt="qual o status?", context=ctx)
|
|
325
|
+
print(answer.content) # → "online"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
asyncio.run(main())
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`invoke()` is a convenience wrapper for synchronous scripts. Calling it
|
|
332
|
+
from inside an active event loop raises `AsyncRequiredError` — use
|
|
333
|
+
`await engine.ainvoke(...)` there.
|
|
334
|
+
|
|
335
|
+
## Context injection
|
|
336
|
+
|
|
337
|
+
Confiable values from the application (`registration_id`, `tenant_id`,
|
|
338
|
+
`session_id`, ...) must not be filled by the LLM. Declare them with
|
|
339
|
+
`inject={...}` and the engine resolves them at execution time.
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
@tool_node(
|
|
343
|
+
requires=["registered"],
|
|
344
|
+
inject={"registration_id": "metadata.registration_id"},
|
|
345
|
+
)
|
|
346
|
+
def consultar_dados(registration_id: str) -> dict:
|
|
347
|
+
return {"id": registration_id}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The injected parameter is excluded from the schema sent to the LLM.
|
|
351
|
+
Any attempt to supply it from the LLM or an `EnrichedPointer` is
|
|
352
|
+
rejected before execution.
|
|
353
|
+
|
|
354
|
+
## Tool runtime errors
|
|
355
|
+
|
|
356
|
+
By default, an exception raised inside a tool body is wrapped in
|
|
357
|
+
`ToolExecutionError` (preserving the original as `__cause__`) and
|
|
358
|
+
propagated. Configure `tool_error_mode` to convert it into a safe
|
|
359
|
+
final answer or sanitized synthesis instead.
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
from adjacency_agents import (
|
|
363
|
+
DeterministicEngine,
|
|
364
|
+
ToolCall,
|
|
365
|
+
UserContext,
|
|
366
|
+
tool_node,
|
|
367
|
+
)
|
|
368
|
+
from adjacency_agents.errors import ToolExecutionError
|
|
369
|
+
from adjacency_agents.llm import FakeLLMClient
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@tool_node(requires=["public"])
|
|
373
|
+
def consultar_saldo() -> str:
|
|
374
|
+
raise TimeoutError("upstream took too long")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
fake = FakeLLMClient(script=[ToolCall(name="consultar_saldo")])
|
|
378
|
+
ctx = UserContext(session_id="s", capabilities={"public"})
|
|
379
|
+
|
|
380
|
+
# 1. Default: ToolExecutionError bubbles up — the application decides
|
|
381
|
+
# how to render it.
|
|
382
|
+
engine = DeterministicEngine(llm=fake, tools=[consultar_saldo])
|
|
383
|
+
try:
|
|
384
|
+
engine.invoke(prompt="qual meu saldo?", context=ctx)
|
|
385
|
+
except ToolExecutionError as exc:
|
|
386
|
+
print("falhou:", exc.__cause__)
|
|
387
|
+
|
|
388
|
+
# 2. tool_error_mode="final": the engine returns a safe canned answer
|
|
389
|
+
# without calling the LLM again.
|
|
390
|
+
fake = FakeLLMClient(script=[ToolCall(name="consultar_saldo")])
|
|
391
|
+
engine = DeterministicEngine(
|
|
392
|
+
llm=fake,
|
|
393
|
+
tools=[consultar_saldo],
|
|
394
|
+
tool_error_mode="final",
|
|
395
|
+
default_tool_error_message="Não foi possível concluir agora.",
|
|
396
|
+
)
|
|
397
|
+
print(engine.invoke(prompt="qual meu saldo?", context=ctx).content)
|
|
398
|
+
# → "Não foi possível concluir agora."
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`tool_error_mode="synthesize"` sends only a sanitized `Observation` to
|
|
402
|
+
the LLM — tool names, hop counts, pointers and tracebacks never leak.
|
|
403
|
+
|
|
404
|
+
## Execution trace
|
|
405
|
+
|
|
406
|
+
Every engine invocation stores a sanitized `ExecutionTrace` in
|
|
407
|
+
`engine.last_trace`. It is intended for audit, debugging and tests.
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from adjacency_agents import ExecutionTrace
|
|
411
|
+
|
|
412
|
+
answer = engine.invoke(prompt="...", context=ctx)
|
|
413
|
+
trace: ExecutionTrace | None = engine.last_trace
|
|
414
|
+
|
|
415
|
+
if trace is not None:
|
|
416
|
+
print(trace.names())
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Trace events include routing, validation, tool execution, pointer
|
|
420
|
+
transitions, synthesis, policy denials, context injection failures and
|
|
421
|
+
`max_steps` aborts. By default the trace records structural metadata only:
|
|
422
|
+
tool names, event names, counts and type names. It does not record raw
|
|
423
|
+
prompts, capabilities, `UserContext.metadata`, kwargs, tool payloads or
|
|
424
|
+
tracebacks.
|
|
425
|
+
|
|
426
|
+
## Security guarantees (the short list)
|
|
427
|
+
|
|
428
|
+
- **Default deny** — empty policy never grants access.
|
|
429
|
+
- **Allowlist per turn** — the schema sent to the LLM is built from the
|
|
430
|
+
current `UserContext`, never from the full catalog.
|
|
431
|
+
- **Triple validation** — before schema, before tool execution, before
|
|
432
|
+
every transition.
|
|
433
|
+
- **The LLM never decides authorization** — it only picks from a
|
|
434
|
+
pre-filtered, contextual allowlist.
|
|
435
|
+
- **No global registry** — every `DeterministicEngine` owns its own
|
|
436
|
+
`ToolRegistry`, so tests and multi-tenant deployments are isolated.
|
|
437
|
+
|
|
438
|
+
## Project layout
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
src/adjacency_agents/
|
|
442
|
+
├── __init__.py # public facade
|
|
443
|
+
├── decorators.py # @tool_node
|
|
444
|
+
├── engine.py # DeterministicEngine
|
|
445
|
+
├── errors.py
|
|
446
|
+
├── llm.py # protocols + FakeLLMClient
|
|
447
|
+
├── models.py
|
|
448
|
+
├── registry.py
|
|
449
|
+
├── router.py
|
|
450
|
+
├── schema.py # Pydantic v2 schema + validation
|
|
451
|
+
└── tracing.py # ExecutionTrace + sanitization
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Tests
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
.venv/bin/pytest
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The MVP test suite covers all invariants listed in §23 of the DDD
|
|
461
|
+
spec.
|
|
462
|
+
|
|
463
|
+
## Documentation
|
|
464
|
+
|
|
465
|
+
The full Documentation-Driven Development specification lives in
|
|
466
|
+
[`adjacency_agents_documentation_driven_development_v0_4_final.md`](./adjacency_agents_documentation_driven_development_v0_4_final.md).
|