adjacency-agents 0.1.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.
- adjacency_agents/__init__.py +28 -0
- adjacency_agents/adapters/__init__.py +7 -0
- adjacency_agents/adapters/anthropic.py +216 -0
- adjacency_agents/adapters/ollama.py +220 -0
- adjacency_agents/adapters/openai.py +199 -0
- adjacency_agents/decorators.py +115 -0
- adjacency_agents/engine.py +615 -0
- adjacency_agents/errors.py +53 -0
- adjacency_agents/llm.py +89 -0
- adjacency_agents/models.py +97 -0
- adjacency_agents/py.typed +0 -0
- adjacency_agents/registry.py +58 -0
- adjacency_agents/router.py +23 -0
- adjacency_agents/schema.py +169 -0
- adjacency_agents/tracing.py +126 -0
- adjacency_agents-0.1.0.dist-info/METADATA +466 -0
- adjacency_agents-0.1.0.dist-info/RECORD +20 -0
- adjacency_agents-0.1.0.dist-info/WHEEL +5 -0
- adjacency_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
- adjacency_agents-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""adjacency-agents public facade (§8.1)."""
|
|
2
|
+
|
|
3
|
+
from adjacency_agents.decorators import tool_node
|
|
4
|
+
from adjacency_agents.engine import DeterministicEngine
|
|
5
|
+
from adjacency_agents.models import (
|
|
6
|
+
EnrichedPointer,
|
|
7
|
+
FinalAnswer,
|
|
8
|
+
Message,
|
|
9
|
+
Observation,
|
|
10
|
+
ToolCall,
|
|
11
|
+
ToolPolicy,
|
|
12
|
+
UserContext,
|
|
13
|
+
)
|
|
14
|
+
from adjacency_agents.tracing import ExecutionTrace, TraceEvent
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DeterministicEngine",
|
|
18
|
+
"EnrichedPointer",
|
|
19
|
+
"ExecutionTrace",
|
|
20
|
+
"FinalAnswer",
|
|
21
|
+
"Message",
|
|
22
|
+
"Observation",
|
|
23
|
+
"TraceEvent",
|
|
24
|
+
"ToolCall",
|
|
25
|
+
"ToolPolicy",
|
|
26
|
+
"UserContext",
|
|
27
|
+
"tool_node",
|
|
28
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Provider adapters.
|
|
2
|
+
|
|
3
|
+
Adapters live in their own subpackage so that the core library has no
|
|
4
|
+
hard dependency on any specific LLM SDK. Each adapter module imports
|
|
5
|
+
its provider SDK lazily when needed, and accepts the SDK client object
|
|
6
|
+
directly so that tests can substitute lightweight fakes.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter — DDD §13.4, §18.3, Phase 6.
|
|
2
|
+
|
|
3
|
+
Translates between the engine's provider-agnostic protocol and the
|
|
4
|
+
Anthropic Messages API.
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
|
|
8
|
+
* No hard dependency on ``anthropic``. The user passes an instantiated
|
|
9
|
+
client (sync or async); the adapter only relies on the duck-typed
|
|
10
|
+
shape ``client.messages.create(**kwargs)``.
|
|
11
|
+
* Anthropic's tool format is ``{name, description, input_schema}`` with
|
|
12
|
+
no function envelope, and ``input_schema`` IS the JSON schema (no
|
|
13
|
+
nesting under ``parameters``). The adapter strips our internal
|
|
14
|
+
``title``/``description`` keys before forwarding the schema body.
|
|
15
|
+
* ``role="system"`` messages are pulled out of the conversation and
|
|
16
|
+
promoted to the top-level ``system`` kwarg; multiple system messages
|
|
17
|
+
are concatenated with two newlines.
|
|
18
|
+
* ``role="tool"`` messages (produced by synthesis) are rewritten as
|
|
19
|
+
``user`` messages prefixed with ``[tool: <name>]`` so the API does
|
|
20
|
+
not require a paired assistant ``tool_use`` block — which our
|
|
21
|
+
synthesis flow never produces.
|
|
22
|
+
* ``allow_tool_calls=False`` omits ``tools`` entirely. If the model
|
|
23
|
+
still returns a ``tool_use`` block, ``SynthesisError`` is raised.
|
|
24
|
+
* The Anthropic response is a list of content blocks. ``text`` blocks
|
|
25
|
+
are concatenated; a ``tool_use`` block returns ``ToolCall`` with the
|
|
26
|
+
already-decoded ``input`` dict.
|
|
27
|
+
* ``max_tokens`` is required by the API. Defaults to 1024 unless the
|
|
28
|
+
caller overrides it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from adjacency_agents.errors import InvalidToolCallError, SynthesisError
|
|
37
|
+
from adjacency_agents.models import FinalAnswer, Message, ToolCall
|
|
38
|
+
|
|
39
|
+
__all__ = ["AnthropicClient", "AsyncAnthropicClient"]
|
|
40
|
+
|
|
41
|
+
_DEFAULT_MAX_TOKENS = 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
45
|
+
"""Project internal schemas onto Anthropic's ``{name, description,
|
|
46
|
+
input_schema}`` shape."""
|
|
47
|
+
out: list[dict[str, Any]] = []
|
|
48
|
+
for schema in tools:
|
|
49
|
+
input_schema: dict[str, Any] = {
|
|
50
|
+
k: v for k, v in schema.items() if k not in ("title", "description")
|
|
51
|
+
}
|
|
52
|
+
if "type" not in input_schema:
|
|
53
|
+
input_schema["type"] = "object"
|
|
54
|
+
entry: dict[str, Any] = {
|
|
55
|
+
"name": schema.get("title", ""),
|
|
56
|
+
"input_schema": input_schema,
|
|
57
|
+
}
|
|
58
|
+
description = schema.get("description")
|
|
59
|
+
if description:
|
|
60
|
+
entry["description"] = description
|
|
61
|
+
out.append(entry)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _split_messages(
|
|
66
|
+
messages: Sequence[Message],
|
|
67
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
68
|
+
"""Pull ``role=system`` messages into a single system string and
|
|
69
|
+
convert the remaining messages to Anthropic's wire format."""
|
|
70
|
+
system_parts: list[str] = []
|
|
71
|
+
chat: list[dict[str, Any]] = []
|
|
72
|
+
for msg in messages:
|
|
73
|
+
if msg.role == "system":
|
|
74
|
+
system_parts.append(msg.content)
|
|
75
|
+
continue
|
|
76
|
+
if msg.role == "tool":
|
|
77
|
+
label = msg.name or "tool"
|
|
78
|
+
chat.append(
|
|
79
|
+
{
|
|
80
|
+
"role": "user",
|
|
81
|
+
"content": f"[tool: {label}] {msg.content}",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
chat.append({"role": msg.role, "content": msg.content})
|
|
86
|
+
system = "\n\n".join(system_parts) if system_parts else None
|
|
87
|
+
return system, chat
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_kwargs(
|
|
91
|
+
*,
|
|
92
|
+
model: str,
|
|
93
|
+
max_tokens: int,
|
|
94
|
+
messages: Sequence[Message],
|
|
95
|
+
tools: list[dict[str, Any]],
|
|
96
|
+
allow_tool_calls: bool,
|
|
97
|
+
extra: dict[str, Any] | None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
system, chat = _split_messages(messages)
|
|
100
|
+
payload: dict[str, Any] = {
|
|
101
|
+
"model": model,
|
|
102
|
+
"max_tokens": max_tokens,
|
|
103
|
+
"messages": chat,
|
|
104
|
+
}
|
|
105
|
+
if system is not None:
|
|
106
|
+
payload["system"] = system
|
|
107
|
+
if allow_tool_calls and tools:
|
|
108
|
+
payload["tools"] = _convert_tools(tools)
|
|
109
|
+
payload["tool_choice"] = {"type": "auto"}
|
|
110
|
+
if extra:
|
|
111
|
+
payload.update(extra)
|
|
112
|
+
return payload
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_response(
|
|
116
|
+
response: Any, *, allow_tool_calls: bool
|
|
117
|
+
) -> ToolCall | FinalAnswer | str:
|
|
118
|
+
blocks = list(getattr(response, "content", []) or [])
|
|
119
|
+
text_chunks: list[str] = []
|
|
120
|
+
for block in blocks:
|
|
121
|
+
btype = getattr(block, "type", None)
|
|
122
|
+
if btype == "tool_use":
|
|
123
|
+
if not allow_tool_calls:
|
|
124
|
+
raise SynthesisError(
|
|
125
|
+
"Anthropic returned a tool_use block during synthesis (§14.7.3)"
|
|
126
|
+
)
|
|
127
|
+
name = getattr(block, "name", None)
|
|
128
|
+
args = getattr(block, "input", None)
|
|
129
|
+
if not isinstance(name, str) or not name:
|
|
130
|
+
raise InvalidToolCallError("Anthropic tool_use block missing name")
|
|
131
|
+
if args is None:
|
|
132
|
+
args = {}
|
|
133
|
+
if not isinstance(args, dict):
|
|
134
|
+
raise InvalidToolCallError(
|
|
135
|
+
f"Anthropic tool_use input must be a JSON object, "
|
|
136
|
+
f"got {type(args).__name__}"
|
|
137
|
+
)
|
|
138
|
+
return ToolCall(name=name, kwargs=dict(args))
|
|
139
|
+
if btype == "text":
|
|
140
|
+
text_chunks.append(getattr(block, "text", "") or "")
|
|
141
|
+
|
|
142
|
+
if not text_chunks:
|
|
143
|
+
raise InvalidToolCallError(
|
|
144
|
+
"Anthropic response had no text and no tool_use blocks"
|
|
145
|
+
)
|
|
146
|
+
return FinalAnswer(content="".join(text_chunks))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class AnthropicClient:
|
|
150
|
+
"""Synchronous adapter for ``anthropic.Anthropic``-shaped clients."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
client: Any,
|
|
156
|
+
model: str,
|
|
157
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
158
|
+
extra_create_kwargs: dict[str, Any] | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._client = client
|
|
161
|
+
self._model = model
|
|
162
|
+
self._max_tokens = max_tokens
|
|
163
|
+
self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
|
|
164
|
+
|
|
165
|
+
def complete(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
messages: Sequence[Message],
|
|
169
|
+
tools: list[dict[str, Any]],
|
|
170
|
+
allow_tool_calls: bool = True,
|
|
171
|
+
) -> ToolCall | FinalAnswer | str:
|
|
172
|
+
payload = _build_kwargs(
|
|
173
|
+
model=self._model,
|
|
174
|
+
max_tokens=self._max_tokens,
|
|
175
|
+
messages=messages,
|
|
176
|
+
tools=tools,
|
|
177
|
+
allow_tool_calls=allow_tool_calls,
|
|
178
|
+
extra=self._extra,
|
|
179
|
+
)
|
|
180
|
+
response = self._client.messages.create(**payload)
|
|
181
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class AsyncAnthropicClient:
|
|
185
|
+
"""Asynchronous adapter for ``anthropic.AsyncAnthropic``-shaped clients."""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
client: Any,
|
|
191
|
+
model: str,
|
|
192
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
193
|
+
extra_create_kwargs: dict[str, Any] | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
self._client = client
|
|
196
|
+
self._model = model
|
|
197
|
+
self._max_tokens = max_tokens
|
|
198
|
+
self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
|
|
199
|
+
|
|
200
|
+
async def acomplete(
|
|
201
|
+
self,
|
|
202
|
+
*,
|
|
203
|
+
messages: Sequence[Message],
|
|
204
|
+
tools: list[dict[str, Any]],
|
|
205
|
+
allow_tool_calls: bool = True,
|
|
206
|
+
) -> ToolCall | FinalAnswer | str:
|
|
207
|
+
payload = _build_kwargs(
|
|
208
|
+
model=self._model,
|
|
209
|
+
max_tokens=self._max_tokens,
|
|
210
|
+
messages=messages,
|
|
211
|
+
tools=tools,
|
|
212
|
+
allow_tool_calls=allow_tool_calls,
|
|
213
|
+
extra=self._extra,
|
|
214
|
+
)
|
|
215
|
+
response = await self._client.messages.create(**payload)
|
|
216
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Ollama adapter — DDD §13.4, §18.3, Phase 6.
|
|
2
|
+
|
|
3
|
+
Targets the official ``ollama`` Python SDK and any HTTP server that
|
|
4
|
+
mimics its ``client.chat(model=..., messages=..., tools=...)`` shape.
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
|
|
8
|
+
* No hard dependency on ``ollama``. The user passes any object exposing
|
|
9
|
+
``client.chat(**kwargs)`` (sync) or an awaitable equivalent (async).
|
|
10
|
+
* Ollama uses the same tool envelope as OpenAI:
|
|
11
|
+
``{"type": "function", "function": {"name", "description",
|
|
12
|
+
"parameters"}}``. Conversion mirrors the OpenAI adapter.
|
|
13
|
+
* The conversation roles supported are ``system``, ``user``,
|
|
14
|
+
``assistant`` and ``tool``. Ollama keeps ``system`` inside the
|
|
15
|
+
messages list (unlike Anthropic). ``role="tool"`` messages produced
|
|
16
|
+
by synthesis are rewritten as ``user`` messages with a
|
|
17
|
+
``[tool: <name>]`` prefix because most Ollama models will not accept
|
|
18
|
+
a bare tool role without a paired assistant ``tool_calls`` block.
|
|
19
|
+
* ``allow_tool_calls=False`` (synthesis) omits ``tools`` entirely. A
|
|
20
|
+
``tool_calls`` payload returned anyway raises ``SynthesisError``.
|
|
21
|
+
* Tool arguments returned by the SDK may be either a Python dict
|
|
22
|
+
(current versions) or a JSON string (older clients / some local
|
|
23
|
+
servers). Both are accepted; JSON strings are ``json.loads``ed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from adjacency_agents.errors import InvalidToolCallError, SynthesisError
|
|
33
|
+
from adjacency_agents.models import FinalAnswer, Message, ToolCall
|
|
34
|
+
|
|
35
|
+
__all__ = ["OllamaClient", "AsyncOllamaClient"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
39
|
+
"""Wrap internal schemas in the OpenAI-compatible function envelope
|
|
40
|
+
that Ollama accepts."""
|
|
41
|
+
out: list[dict[str, Any]] = []
|
|
42
|
+
for schema in tools:
|
|
43
|
+
params: dict[str, Any] = {
|
|
44
|
+
k: v for k, v in schema.items() if k not in ("title", "description")
|
|
45
|
+
}
|
|
46
|
+
if "type" not in params:
|
|
47
|
+
params["type"] = "object"
|
|
48
|
+
fn_def: dict[str, Any] = {
|
|
49
|
+
"name": schema.get("title", ""),
|
|
50
|
+
"parameters": params,
|
|
51
|
+
}
|
|
52
|
+
description = schema.get("description")
|
|
53
|
+
if description:
|
|
54
|
+
fn_def["description"] = description
|
|
55
|
+
out.append({"type": "function", "function": fn_def})
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
60
|
+
"""Map our Message dataclasses to Ollama's wire format."""
|
|
61
|
+
out: list[dict[str, Any]] = []
|
|
62
|
+
for msg in messages:
|
|
63
|
+
if msg.role == "tool":
|
|
64
|
+
label = msg.name or "tool"
|
|
65
|
+
out.append(
|
|
66
|
+
{
|
|
67
|
+
"role": "user",
|
|
68
|
+
"content": f"[tool: {label}] {msg.content}",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
72
|
+
out.append({"role": msg.role, "content": msg.content})
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_kwargs(
|
|
77
|
+
*,
|
|
78
|
+
model: str,
|
|
79
|
+
messages: Sequence[Message],
|
|
80
|
+
tools: list[dict[str, Any]],
|
|
81
|
+
allow_tool_calls: bool,
|
|
82
|
+
extra: dict[str, Any] | None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
payload: dict[str, Any] = {
|
|
85
|
+
"model": model,
|
|
86
|
+
"messages": _convert_messages(messages),
|
|
87
|
+
}
|
|
88
|
+
if allow_tool_calls and tools:
|
|
89
|
+
payload["tools"] = _convert_tools(tools)
|
|
90
|
+
if extra:
|
|
91
|
+
payload.update(extra)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decode_arguments(raw: Any, tool_name: str) -> dict[str, Any]:
|
|
96
|
+
"""Ollama's ``arguments`` field is a dict in modern clients but may
|
|
97
|
+
be a JSON string from older servers. Accept both."""
|
|
98
|
+
if isinstance(raw, dict):
|
|
99
|
+
return raw
|
|
100
|
+
if isinstance(raw, str):
|
|
101
|
+
try:
|
|
102
|
+
decoded = json.loads(raw)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise InvalidToolCallError(
|
|
105
|
+
f"Ollama returned non-JSON tool arguments for {tool_name!r}: {raw!r}"
|
|
106
|
+
) from exc
|
|
107
|
+
if not isinstance(decoded, dict):
|
|
108
|
+
raise InvalidToolCallError(
|
|
109
|
+
f"Ollama tool arguments must be a JSON object, "
|
|
110
|
+
f"got {type(decoded).__name__}"
|
|
111
|
+
)
|
|
112
|
+
return decoded
|
|
113
|
+
if raw is None:
|
|
114
|
+
return {}
|
|
115
|
+
raise InvalidToolCallError(
|
|
116
|
+
f"Ollama tool arguments must be dict or JSON string, got {type(raw).__name__}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _get(obj: Any, key: str, default: Any = None) -> Any:
|
|
121
|
+
"""Ollama responses come back as either typed objects or plain
|
|
122
|
+
dicts depending on the client version."""
|
|
123
|
+
if isinstance(obj, dict):
|
|
124
|
+
return obj.get(key, default)
|
|
125
|
+
return getattr(obj, key, default)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_response(
|
|
129
|
+
response: Any, *, allow_tool_calls: bool
|
|
130
|
+
) -> ToolCall | FinalAnswer | str:
|
|
131
|
+
message = _get(response, "message")
|
|
132
|
+
if message is None:
|
|
133
|
+
raise InvalidToolCallError("Ollama response had no message")
|
|
134
|
+
tool_calls = _get(message, "tool_calls") or []
|
|
135
|
+
if tool_calls:
|
|
136
|
+
if not allow_tool_calls:
|
|
137
|
+
raise SynthesisError(
|
|
138
|
+
"Ollama returned tool_calls during synthesis (§14.7.3)"
|
|
139
|
+
)
|
|
140
|
+
first = tool_calls[0]
|
|
141
|
+
function = _get(first, "function")
|
|
142
|
+
if function is None:
|
|
143
|
+
raise InvalidToolCallError("Ollama tool_call missing function field")
|
|
144
|
+
name = _get(function, "name")
|
|
145
|
+
if not isinstance(name, str) or not name:
|
|
146
|
+
raise InvalidToolCallError("Ollama tool_call missing function name")
|
|
147
|
+
raw_args = _get(function, "arguments")
|
|
148
|
+
kwargs = _decode_arguments(raw_args, name)
|
|
149
|
+
return ToolCall(name=name, kwargs=kwargs)
|
|
150
|
+
|
|
151
|
+
content = _get(message, "content")
|
|
152
|
+
if not isinstance(content, str) or content == "":
|
|
153
|
+
raise InvalidToolCallError(
|
|
154
|
+
"Ollama response had neither tool_calls nor non-empty content"
|
|
155
|
+
)
|
|
156
|
+
return FinalAnswer(content=content)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OllamaClient:
|
|
160
|
+
"""Synchronous adapter for ``ollama.Client``-shaped clients."""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
client: Any,
|
|
166
|
+
model: str,
|
|
167
|
+
extra_chat_kwargs: dict[str, Any] | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._client = client
|
|
170
|
+
self._model = model
|
|
171
|
+
self._extra = dict(extra_chat_kwargs) if extra_chat_kwargs else None
|
|
172
|
+
|
|
173
|
+
def complete(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
messages: Sequence[Message],
|
|
177
|
+
tools: list[dict[str, Any]],
|
|
178
|
+
allow_tool_calls: bool = True,
|
|
179
|
+
) -> ToolCall | FinalAnswer | str:
|
|
180
|
+
payload = _build_kwargs(
|
|
181
|
+
model=self._model,
|
|
182
|
+
messages=messages,
|
|
183
|
+
tools=tools,
|
|
184
|
+
allow_tool_calls=allow_tool_calls,
|
|
185
|
+
extra=self._extra,
|
|
186
|
+
)
|
|
187
|
+
response = self._client.chat(**payload)
|
|
188
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AsyncOllamaClient:
|
|
192
|
+
"""Asynchronous adapter for ``ollama.AsyncClient``-shaped clients."""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
*,
|
|
197
|
+
client: Any,
|
|
198
|
+
model: str,
|
|
199
|
+
extra_chat_kwargs: dict[str, Any] | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
self._client = client
|
|
202
|
+
self._model = model
|
|
203
|
+
self._extra = dict(extra_chat_kwargs) if extra_chat_kwargs else None
|
|
204
|
+
|
|
205
|
+
async def acomplete(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
messages: Sequence[Message],
|
|
209
|
+
tools: list[dict[str, Any]],
|
|
210
|
+
allow_tool_calls: bool = True,
|
|
211
|
+
) -> ToolCall | FinalAnswer | str:
|
|
212
|
+
payload = _build_kwargs(
|
|
213
|
+
model=self._model,
|
|
214
|
+
messages=messages,
|
|
215
|
+
tools=tools,
|
|
216
|
+
allow_tool_calls=allow_tool_calls,
|
|
217
|
+
extra=self._extra,
|
|
218
|
+
)
|
|
219
|
+
response = await self._client.chat(**payload)
|
|
220
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""OpenAI Chat Completions adapter — DDD §13.4, §18.3, Phase 6.
|
|
2
|
+
|
|
3
|
+
Translates between the engine's provider-agnostic protocol
|
|
4
|
+
(`LLMClient`/`AsyncLLMClient`) and the OpenAI Chat Completions API.
|
|
5
|
+
|
|
6
|
+
Design notes:
|
|
7
|
+
|
|
8
|
+
* No hard dependency on ``openai``. The user passes an instantiated
|
|
9
|
+
client (sync or async) and the adapter only relies on the duck-typed
|
|
10
|
+
shape ``client.chat.completions.create(**kwargs)``.
|
|
11
|
+
* The internal JSON schema produced by ``build_json_schema`` is wrapped
|
|
12
|
+
in OpenAI's ``{"type": "function", "function": {...}}`` envelope.
|
|
13
|
+
* When ``allow_tool_calls=False`` the adapter sends ``tool_choice="none"``
|
|
14
|
+
and **omits** the ``tools`` argument entirely. If the model still
|
|
15
|
+
returns a tool call, ``SynthesisError`` is raised.
|
|
16
|
+
* Messages with ``role="tool"`` (produced by the engine during
|
|
17
|
+
synthesis) cannot be sent as-is — OpenAI requires a paired assistant
|
|
18
|
+
``tool_calls`` block, which never exists in our flow. They are
|
|
19
|
+
rewritten into ``system`` messages prefixed with ``[tool: <name>]``.
|
|
20
|
+
* Tool arguments returned by the model arrive as JSON strings; we
|
|
21
|
+
``json.loads`` them. Any parse error raises ``InvalidToolCallError``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from adjacency_agents.errors import InvalidToolCallError, SynthesisError
|
|
31
|
+
from adjacency_agents.models import FinalAnswer, Message, ToolCall
|
|
32
|
+
|
|
33
|
+
__all__ = ["OpenAIClient", "AsyncOpenAIClient"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
37
|
+
"""Wrap our internal schemas in OpenAI's function-tool envelope."""
|
|
38
|
+
converted: list[dict[str, Any]] = []
|
|
39
|
+
for schema in tools:
|
|
40
|
+
params: dict[str, Any] = {
|
|
41
|
+
k: v for k, v in schema.items() if k not in ("title", "description")
|
|
42
|
+
}
|
|
43
|
+
# OpenAI expects parameters to be a JSON schema object.
|
|
44
|
+
if "type" not in params:
|
|
45
|
+
params["type"] = "object"
|
|
46
|
+
fn_def: dict[str, Any] = {
|
|
47
|
+
"name": schema.get("title", ""),
|
|
48
|
+
"parameters": params,
|
|
49
|
+
}
|
|
50
|
+
description = schema.get("description")
|
|
51
|
+
if description:
|
|
52
|
+
fn_def["description"] = description
|
|
53
|
+
converted.append({"type": "function", "function": fn_def})
|
|
54
|
+
return converted
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _convert_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
58
|
+
"""Map our Message dataclasses to OpenAI's wire format.
|
|
59
|
+
|
|
60
|
+
``role="tool"`` messages from synthesis are repackaged as ``system``
|
|
61
|
+
messages so OpenAI does not reject them (it would otherwise require
|
|
62
|
+
a matching assistant ``tool_calls`` block we never produced).
|
|
63
|
+
"""
|
|
64
|
+
out: list[dict[str, Any]] = []
|
|
65
|
+
for msg in messages:
|
|
66
|
+
if msg.role == "tool":
|
|
67
|
+
label = msg.name or "tool"
|
|
68
|
+
out.append(
|
|
69
|
+
{
|
|
70
|
+
"role": "system",
|
|
71
|
+
"content": f"[tool: {label}] {msg.content}",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
out.append({"role": msg.role, "content": msg.content})
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_kwargs(
|
|
80
|
+
*,
|
|
81
|
+
model: str,
|
|
82
|
+
messages: Sequence[Message],
|
|
83
|
+
tools: list[dict[str, Any]],
|
|
84
|
+
allow_tool_calls: bool,
|
|
85
|
+
extra: dict[str, Any] | None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
payload: dict[str, Any] = {
|
|
88
|
+
"model": model,
|
|
89
|
+
"messages": _convert_messages(messages),
|
|
90
|
+
}
|
|
91
|
+
if allow_tool_calls and tools:
|
|
92
|
+
payload["tools"] = _convert_tools(tools)
|
|
93
|
+
payload["tool_choice"] = "auto"
|
|
94
|
+
else:
|
|
95
|
+
# Synthesis: forbid tool calls outright.
|
|
96
|
+
payload["tool_choice"] = "none"
|
|
97
|
+
if extra:
|
|
98
|
+
payload.update(extra)
|
|
99
|
+
return payload
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_response(
|
|
103
|
+
response: Any, *, allow_tool_calls: bool
|
|
104
|
+
) -> ToolCall | FinalAnswer | str:
|
|
105
|
+
choices = getattr(response, "choices", None)
|
|
106
|
+
if not choices:
|
|
107
|
+
raise InvalidToolCallError("OpenAI response had no choices")
|
|
108
|
+
message = choices[0].message
|
|
109
|
+
tool_calls = getattr(message, "tool_calls", None) or []
|
|
110
|
+
|
|
111
|
+
if tool_calls:
|
|
112
|
+
if not allow_tool_calls:
|
|
113
|
+
raise SynthesisError(
|
|
114
|
+
"OpenAI returned a tool_call during synthesis (§14.7.3)"
|
|
115
|
+
)
|
|
116
|
+
first = tool_calls[0]
|
|
117
|
+
name = first.function.name
|
|
118
|
+
raw_args = first.function.arguments or "{}"
|
|
119
|
+
try:
|
|
120
|
+
kwargs = json.loads(raw_args)
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise InvalidToolCallError(
|
|
123
|
+
f"OpenAI returned non-JSON tool arguments for {name!r}: {raw_args!r}"
|
|
124
|
+
) from exc
|
|
125
|
+
if not isinstance(kwargs, dict):
|
|
126
|
+
raise InvalidToolCallError(
|
|
127
|
+
f"OpenAI tool arguments must decode to a JSON object, "
|
|
128
|
+
f"got {type(kwargs).__name__}"
|
|
129
|
+
)
|
|
130
|
+
return ToolCall(name=name, kwargs=kwargs)
|
|
131
|
+
|
|
132
|
+
content = getattr(message, "content", None)
|
|
133
|
+
if content is None:
|
|
134
|
+
raise InvalidToolCallError("OpenAI response had neither content nor tool_calls")
|
|
135
|
+
return FinalAnswer(content=content)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class OpenAIClient:
|
|
139
|
+
"""Synchronous adapter for ``openai.OpenAI``-shaped clients."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
client: Any,
|
|
145
|
+
model: str,
|
|
146
|
+
extra_create_kwargs: dict[str, Any] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._client = client
|
|
149
|
+
self._model = model
|
|
150
|
+
self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
|
|
151
|
+
|
|
152
|
+
def complete(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
messages: Sequence[Message],
|
|
156
|
+
tools: list[dict[str, Any]],
|
|
157
|
+
allow_tool_calls: bool = True,
|
|
158
|
+
) -> ToolCall | FinalAnswer | str:
|
|
159
|
+
payload = _build_kwargs(
|
|
160
|
+
model=self._model,
|
|
161
|
+
messages=messages,
|
|
162
|
+
tools=tools,
|
|
163
|
+
allow_tool_calls=allow_tool_calls,
|
|
164
|
+
extra=self._extra,
|
|
165
|
+
)
|
|
166
|
+
response = self._client.chat.completions.create(**payload)
|
|
167
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AsyncOpenAIClient:
|
|
171
|
+
"""Asynchronous adapter for ``openai.AsyncOpenAI``-shaped clients."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
client: Any,
|
|
177
|
+
model: str,
|
|
178
|
+
extra_create_kwargs: dict[str, Any] | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._client = client
|
|
181
|
+
self._model = model
|
|
182
|
+
self._extra = dict(extra_create_kwargs) if extra_create_kwargs else None
|
|
183
|
+
|
|
184
|
+
async def acomplete(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
messages: Sequence[Message],
|
|
188
|
+
tools: list[dict[str, Any]],
|
|
189
|
+
allow_tool_calls: bool = True,
|
|
190
|
+
) -> ToolCall | FinalAnswer | str:
|
|
191
|
+
payload = _build_kwargs(
|
|
192
|
+
model=self._model,
|
|
193
|
+
messages=messages,
|
|
194
|
+
tools=tools,
|
|
195
|
+
allow_tool_calls=allow_tool_calls,
|
|
196
|
+
extra=self._extra,
|
|
197
|
+
)
|
|
198
|
+
response = await self._client.chat.completions.create(**payload)
|
|
199
|
+
return _parse_response(response, allow_tool_calls=allow_tool_calls)
|