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.
Files changed (42) hide show
  1. adjacency_agents-0.1.0/LICENSE +21 -0
  2. adjacency_agents-0.1.0/PKG-INFO +466 -0
  3. adjacency_agents-0.1.0/README.md +426 -0
  4. adjacency_agents-0.1.0/pyproject.toml +118 -0
  5. adjacency_agents-0.1.0/setup.cfg +4 -0
  6. adjacency_agents-0.1.0/src/adjacency_agents/__init__.py +28 -0
  7. adjacency_agents-0.1.0/src/adjacency_agents/adapters/__init__.py +7 -0
  8. adjacency_agents-0.1.0/src/adjacency_agents/adapters/anthropic.py +216 -0
  9. adjacency_agents-0.1.0/src/adjacency_agents/adapters/ollama.py +220 -0
  10. adjacency_agents-0.1.0/src/adjacency_agents/adapters/openai.py +199 -0
  11. adjacency_agents-0.1.0/src/adjacency_agents/decorators.py +115 -0
  12. adjacency_agents-0.1.0/src/adjacency_agents/engine.py +615 -0
  13. adjacency_agents-0.1.0/src/adjacency_agents/errors.py +53 -0
  14. adjacency_agents-0.1.0/src/adjacency_agents/llm.py +89 -0
  15. adjacency_agents-0.1.0/src/adjacency_agents/models.py +97 -0
  16. adjacency_agents-0.1.0/src/adjacency_agents/py.typed +0 -0
  17. adjacency_agents-0.1.0/src/adjacency_agents/registry.py +58 -0
  18. adjacency_agents-0.1.0/src/adjacency_agents/router.py +23 -0
  19. adjacency_agents-0.1.0/src/adjacency_agents/schema.py +169 -0
  20. adjacency_agents-0.1.0/src/adjacency_agents/tracing.py +126 -0
  21. adjacency_agents-0.1.0/src/adjacency_agents.egg-info/PKG-INFO +466 -0
  22. adjacency_agents-0.1.0/src/adjacency_agents.egg-info/SOURCES.txt +40 -0
  23. adjacency_agents-0.1.0/src/adjacency_agents.egg-info/dependency_links.txt +1 -0
  24. adjacency_agents-0.1.0/src/adjacency_agents.egg-info/requires.txt +16 -0
  25. adjacency_agents-0.1.0/src/adjacency_agents.egg-info/top_level.txt +1 -0
  26. adjacency_agents-0.1.0/tests/test_anthropic_adapter.py +299 -0
  27. adjacency_agents-0.1.0/tests/test_async_engine.py +74 -0
  28. adjacency_agents-0.1.0/tests/test_context_injection.py +68 -0
  29. adjacency_agents-0.1.0/tests/test_decorators.py +150 -0
  30. adjacency_agents-0.1.0/tests/test_engine.py +357 -0
  31. adjacency_agents-0.1.0/tests/test_errors.py +60 -0
  32. adjacency_agents-0.1.0/tests/test_fake_llm.py +62 -0
  33. adjacency_agents-0.1.0/tests/test_models.py +150 -0
  34. adjacency_agents-0.1.0/tests/test_ollama_adapter.py +272 -0
  35. adjacency_agents-0.1.0/tests/test_openai_adapter.py +281 -0
  36. adjacency_agents-0.1.0/tests/test_pointer_transitions.py +113 -0
  37. adjacency_agents-0.1.0/tests/test_registry.py +83 -0
  38. adjacency_agents-0.1.0/tests/test_router.py +88 -0
  39. adjacency_agents-0.1.0/tests/test_schema.py +280 -0
  40. adjacency_agents-0.1.0/tests/test_synthesis.py +52 -0
  41. adjacency_agents-0.1.0/tests/test_tool_execution_errors.py +156 -0
  42. 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).