aid-runtime-python 0.1.39__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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Expose :class:`~aid_runtime.gateway.ModelGateway` as a LangChain chat model.
|
|
2
|
+
|
|
3
|
+
This module is the load-bearing part of the Deep Agents backend. A harness that
|
|
4
|
+
imports ``langchain-anthropic`` and calls the provider itself would keep working
|
|
5
|
+
while silently dropping every guarantee the gateway exists to provide: the
|
|
6
|
+
fallback chain, the budget preflight, the cost ledger and the per-attempt LLM
|
|
7
|
+
span. The agent would look fine and the budget would stop applying. That is the
|
|
8
|
+
exact defect class this project was built to prevent, so the harness is never
|
|
9
|
+
handed a provider -- it is handed *this adapter*, and every model call inside the
|
|
10
|
+
graph goes back out through our gateway.
|
|
11
|
+
|
|
12
|
+
What the adapter is responsible for:
|
|
13
|
+
|
|
14
|
+
* **Translation, both directions.** LangChain messages to our :class:`Message`,
|
|
15
|
+
and our :class:`CompletionResponse` back to an ``AIMessage`` with tool calls,
|
|
16
|
+
``usage_metadata`` and the alias/cost the gateway actually charged. The alias
|
|
17
|
+
matters: a fallback means the answer did not come from the pinned model, and
|
|
18
|
+
the response says so rather than letting the harness believe otherwise.
|
|
19
|
+
* **Tool filtering.** ``bind_tools`` is the single funnel every tool reaches the
|
|
20
|
+
model through, which makes it the one place to enforce what the agent is
|
|
21
|
+
actually allowed to call. Deep Agents bundles ``ls``/``read_file``/``write_file``/
|
|
22
|
+
``edit_file``/``delete``/``glob``/``grep``/``execute`` and binds all of them; the
|
|
23
|
+
allow-list drops everything the agent did not declare, so the filesystem and
|
|
24
|
+
shell tools are unreachable rather than merely undocumented.
|
|
25
|
+
* **Budget continuity.** The agent's ceiling is passed to every call, so a breach
|
|
26
|
+
surfaces as the gateway's own :class:`~aid_runtime.errors.BudgetExceededError`
|
|
27
|
+
from inside the harness. There is no second accounting path here to disagree
|
|
28
|
+
with it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import AsyncIterator, Sequence
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from langchain_core.callbacks import (
|
|
37
|
+
AsyncCallbackManagerForLLMRun,
|
|
38
|
+
CallbackManagerForLLMRun,
|
|
39
|
+
)
|
|
40
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
41
|
+
from langchain_core.messages import (
|
|
42
|
+
AIMessage,
|
|
43
|
+
BaseMessage,
|
|
44
|
+
HumanMessage,
|
|
45
|
+
SystemMessage,
|
|
46
|
+
ToolMessage,
|
|
47
|
+
)
|
|
48
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
49
|
+
from pydantic import Field
|
|
50
|
+
|
|
51
|
+
from aid_runtime.cost import Budget
|
|
52
|
+
from aid_runtime.errors import DeepAgentsEngineError, DeepAgentsMaxStepsError
|
|
53
|
+
from aid_runtime.gateway import ModelGateway
|
|
54
|
+
from aid_runtime.types import CompletionRequest, Message, ToolCall, ToolDefinition
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"GatewayChatModel",
|
|
58
|
+
"to_aid_messages",
|
|
59
|
+
"to_langchain_messages",
|
|
60
|
+
"to_tool_definitions",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GatewayChatModel(BaseChatModel):
|
|
65
|
+
"""A LangChain chat model whose every generation is a gateway call.
|
|
66
|
+
|
|
67
|
+
Construct it with the alias the IR pinned, the gateway that owns fallback and
|
|
68
|
+
accounting, and the ceiling this agent is allowed to spend. ``allowed_tools``
|
|
69
|
+
is the tool allow-list: ``None`` admits everything the harness binds, which is
|
|
70
|
+
only appropriate for an agent that declares no tools.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
gateway: ModelGateway
|
|
74
|
+
alias: str
|
|
75
|
+
fallbacks: tuple[str, ...] = ()
|
|
76
|
+
budget: Budget | None = None
|
|
77
|
+
allowed_tools: frozenset[str] | None = None
|
|
78
|
+
#: The agent's ``maxSteps``, counted in model calls. Enforced here because this
|
|
79
|
+
#: adapter is the only place that sees every model call -- and because the
|
|
80
|
+
#: harness's own limiter completes the run with a synthetic answer instead of
|
|
81
|
+
#: failing. ``None`` disables the counter (used by the offline fixtures).
|
|
82
|
+
max_calls: int | None = None
|
|
83
|
+
#: What ``bind_tools`` last admitted. Read by tests, never by the harness.
|
|
84
|
+
bound_tools: tuple[ToolDefinition, ...] = ()
|
|
85
|
+
#: Every generation this model produced, in order. The proof that the harness
|
|
86
|
+
#: went through us rather than around us.
|
|
87
|
+
calls: list[Any] = Field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def _llm_type(self) -> str:
|
|
91
|
+
return "aid-gateway"
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def _identifying_params(self) -> dict[str, Any]:
|
|
95
|
+
return {"alias": self.alias, "fallbacks": list(self.fallbacks)}
|
|
96
|
+
|
|
97
|
+
def bind_tools(
|
|
98
|
+
self,
|
|
99
|
+
tools: Sequence[Any],
|
|
100
|
+
*,
|
|
101
|
+
tool_choice: str | None = None,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
) -> GatewayChatModel:
|
|
104
|
+
"""Admit only the tools this agent declared.
|
|
105
|
+
|
|
106
|
+
Every tool the harness offers -- bundled ones included -- arrives here, so
|
|
107
|
+
an allow-list applied at this point is the difference between "we do not
|
|
108
|
+
document a shell" and "there is no shell the model can reach". The filtered
|
|
109
|
+
set is kept on a copy rather than on ``self`` so two agents sharing one
|
|
110
|
+
adapter cannot widen each other's grant.
|
|
111
|
+
"""
|
|
112
|
+
admitted = to_tool_definitions(tools, allowed=self.allowed_tools)
|
|
113
|
+
return self.model_copy(update={"bound_tools": admitted})
|
|
114
|
+
|
|
115
|
+
def _generate(
|
|
116
|
+
self,
|
|
117
|
+
messages: list[BaseMessage],
|
|
118
|
+
stop: list[str] | None = None,
|
|
119
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
120
|
+
**kwargs: Any,
|
|
121
|
+
) -> ChatResult:
|
|
122
|
+
raise DeepAgentsEngineError(
|
|
123
|
+
"the deepagents engine generates asynchronously only; the gateway's providers "
|
|
124
|
+
"are async, so a synchronous invoke cannot route through it without a second "
|
|
125
|
+
"accounting path. Use ainvoke."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def _agenerate(
|
|
129
|
+
self,
|
|
130
|
+
messages: list[BaseMessage],
|
|
131
|
+
stop: list[str] | None = None,
|
|
132
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
133
|
+
**kwargs: Any,
|
|
134
|
+
) -> ChatResult:
|
|
135
|
+
"""One harness model call, routed through the gateway.
|
|
136
|
+
|
|
137
|
+
``fallbacks`` and ``budget`` travel with the call, so the harness gets the
|
|
138
|
+
same fallback behaviour and the same hard ceiling as the native loop. A
|
|
139
|
+
breach is raised, not returned: the harness must not be able to continue
|
|
140
|
+
past a budget the ledger already refused.
|
|
141
|
+
"""
|
|
142
|
+
request = CompletionRequest(
|
|
143
|
+
messages=to_aid_messages(messages),
|
|
144
|
+
tools=self.bound_tools,
|
|
145
|
+
)
|
|
146
|
+
if self.max_calls is not None and len(self.calls) >= self.max_calls:
|
|
147
|
+
# Refused before the call, not after: an agent that has spent its step
|
|
148
|
+
# budget must not spend one more to discover that it has.
|
|
149
|
+
raise DeepAgentsMaxStepsError(self.alias, self.max_calls)
|
|
150
|
+
generation = await self.gateway.generate(
|
|
151
|
+
self.alias, request, fallbacks=self.fallbacks, budget=self.budget
|
|
152
|
+
)
|
|
153
|
+
self.calls.append(generation)
|
|
154
|
+
return ChatResult(generations=[ChatGeneration(message=self._to_ai_message(generation))])
|
|
155
|
+
|
|
156
|
+
def _to_ai_message(self, generation: Any) -> AIMessage:
|
|
157
|
+
"""Carry the gateway's answer across, including what it cost and where from."""
|
|
158
|
+
response = generation.response
|
|
159
|
+
usage = generation.usage
|
|
160
|
+
return AIMessage(
|
|
161
|
+
content=response.text,
|
|
162
|
+
tool_calls=[
|
|
163
|
+
{
|
|
164
|
+
"name": call.name,
|
|
165
|
+
"args": dict(call.arguments),
|
|
166
|
+
"id": call.id,
|
|
167
|
+
"type": "tool_call",
|
|
168
|
+
}
|
|
169
|
+
for call in response.tool_calls
|
|
170
|
+
],
|
|
171
|
+
usage_metadata={
|
|
172
|
+
"input_tokens": usage.input_tokens,
|
|
173
|
+
"output_tokens": usage.output_tokens,
|
|
174
|
+
"total_tokens": usage.total_tokens,
|
|
175
|
+
},
|
|
176
|
+
response_metadata={
|
|
177
|
+
"model": response.model,
|
|
178
|
+
"provider": response.provider,
|
|
179
|
+
"finish_reason": response.finish_reason,
|
|
180
|
+
# Not decoration: after a fallback these differ from the alias the
|
|
181
|
+
# IR pinned, and that has to stay visible to the caller.
|
|
182
|
+
"alias": generation.alias,
|
|
183
|
+
"cost_usd": generation.cost_usd,
|
|
184
|
+
"fell_back": generation.fell_back,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _astream(
|
|
189
|
+
self,
|
|
190
|
+
messages: list[BaseMessage],
|
|
191
|
+
stop: list[str] | None = None,
|
|
192
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
193
|
+
**kwargs: Any,
|
|
194
|
+
) -> AsyncIterator[Any]:
|
|
195
|
+
raise DeepAgentsEngineError(
|
|
196
|
+
"streaming is not exposed through the deepagents engine: "
|
|
197
|
+
"ModelGateway.stream serves Server-Sent Event bytes for the HTTP layer, "
|
|
198
|
+
"but the engine consumes request/response via agenerate/ainvoke and never "
|
|
199
|
+
"an SSE byte stream. A structured LangChain chunk stream would be a second "
|
|
200
|
+
"streaming contract and a second accounting path, so it is refused here "
|
|
201
|
+
"rather than buffered into a non-stream."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def to_aid_messages(messages: Sequence[BaseMessage]) -> tuple[Message, ...]:
|
|
206
|
+
"""Translate the harness transcript into ours.
|
|
207
|
+
|
|
208
|
+
Tool calls and their ids are preserved verbatim: the harness pairs a result
|
|
209
|
+
with its call by id, and renaming or renumbering them here would break the
|
|
210
|
+
pairing in a way that only shows up as a tool result attached to the wrong
|
|
211
|
+
call.
|
|
212
|
+
"""
|
|
213
|
+
converted: list[Message] = []
|
|
214
|
+
for message in messages:
|
|
215
|
+
if isinstance(message, SystemMessage):
|
|
216
|
+
converted.append(Message.system(_text(message)))
|
|
217
|
+
elif isinstance(message, HumanMessage):
|
|
218
|
+
converted.append(Message.user(_text(message)))
|
|
219
|
+
elif isinstance(message, AIMessage):
|
|
220
|
+
calls = tuple(
|
|
221
|
+
ToolCall(
|
|
222
|
+
id=str(call.get("id") or f"call_{index}"),
|
|
223
|
+
name=str(call.get("name") or ""),
|
|
224
|
+
arguments=dict(call.get("args") or {}),
|
|
225
|
+
)
|
|
226
|
+
for index, call in enumerate(message.tool_calls)
|
|
227
|
+
)
|
|
228
|
+
if calls:
|
|
229
|
+
converted.append(Message.assistant_tool_calls(_text(message), calls))
|
|
230
|
+
else:
|
|
231
|
+
converted.append(Message.assistant(_text(message)))
|
|
232
|
+
elif isinstance(message, ToolMessage):
|
|
233
|
+
converted.append(
|
|
234
|
+
Message.tool(
|
|
235
|
+
str(message.tool_call_id),
|
|
236
|
+
_text(message),
|
|
237
|
+
name=str(message.name) if message.name else None,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
# Deep Agents may hand us one of its own message subclasses. Dropping it
|
|
242
|
+
# would lose a turn from the transcript, so it is carried as plain text.
|
|
243
|
+
converted.append(Message.user(_text(message)))
|
|
244
|
+
return tuple(converted)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def to_tool_definitions(
|
|
248
|
+
tools: Sequence[Any], *, allowed: frozenset[str] | None = None
|
|
249
|
+
) -> tuple[ToolDefinition, ...]:
|
|
250
|
+
"""Normalise the harness's tool specs and drop everything outside the grant.
|
|
251
|
+
|
|
252
|
+
The harness offers OpenAI-shaped dicts, sometimes a bare ``BaseTool``. Both are
|
|
253
|
+
accepted so the allow-list cannot be bypassed by a shape the filter did not
|
|
254
|
+
recognise: anything unparseable has no name, and a tool with no name is
|
|
255
|
+
refused rather than passed through.
|
|
256
|
+
"""
|
|
257
|
+
definitions: list[ToolDefinition] = []
|
|
258
|
+
for tool in tools:
|
|
259
|
+
definition = _tool_definition(tool)
|
|
260
|
+
if definition is None:
|
|
261
|
+
continue
|
|
262
|
+
if allowed is not None and definition.name not in allowed:
|
|
263
|
+
continue
|
|
264
|
+
definitions.append(definition)
|
|
265
|
+
return tuple(definitions)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _tool_definition(tool: Any) -> ToolDefinition | None:
|
|
269
|
+
if isinstance(tool, dict):
|
|
270
|
+
body = tool.get("function") if tool.get("type") == "function" else tool
|
|
271
|
+
if not isinstance(body, dict):
|
|
272
|
+
return None
|
|
273
|
+
name = body.get("name")
|
|
274
|
+
parameters = body.get("parameters")
|
|
275
|
+
description = body.get("description")
|
|
276
|
+
else:
|
|
277
|
+
name = getattr(tool, "name", None)
|
|
278
|
+
description = getattr(tool, "description", "")
|
|
279
|
+
schema = getattr(tool, "args_schema", None)
|
|
280
|
+
parameters = schema if isinstance(schema, dict) else None
|
|
281
|
+
if parameters is None and schema is not None and hasattr(schema, "model_json_schema"):
|
|
282
|
+
# A LangChain tool normally carries a pydantic model here. Reading it as
|
|
283
|
+
# a dict keeps the model's parameter schema intact instead of handing it
|
|
284
|
+
# an empty one, which would only fail later as a tool call with no args.
|
|
285
|
+
try:
|
|
286
|
+
parameters = schema.model_json_schema()
|
|
287
|
+
except (TypeError, ValueError):
|
|
288
|
+
parameters = None
|
|
289
|
+
if not isinstance(name, str) or not name:
|
|
290
|
+
return None
|
|
291
|
+
return ToolDefinition(
|
|
292
|
+
name=name,
|
|
293
|
+
description=str(description or ""),
|
|
294
|
+
parameters=dict(parameters) if isinstance(parameters, dict) else {},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _text(message: BaseMessage) -> str:
|
|
299
|
+
"""Flatten message content to text, keeping block content that carries prose."""
|
|
300
|
+
content = message.content
|
|
301
|
+
if isinstance(content, str):
|
|
302
|
+
return content
|
|
303
|
+
parts: list[str] = []
|
|
304
|
+
for block in content:
|
|
305
|
+
if isinstance(block, str):
|
|
306
|
+
parts.append(block)
|
|
307
|
+
elif isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
308
|
+
parts.append(block["text"])
|
|
309
|
+
return "".join(parts)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def to_langchain_messages(messages: Sequence[Message]) -> list[BaseMessage]:
|
|
313
|
+
"""Translate our transcript back out, for seeding the harness with prior turns.
|
|
314
|
+
|
|
315
|
+
This exists so a resumed run hands the harness the same transcript the native
|
|
316
|
+
loop would have, rather than a summary of it. Tool calls are re-emitted as
|
|
317
|
+
``AIMessage.tool_calls`` so the harness sees them as calls it can pair, which
|
|
318
|
+
is what keeps a resumed run's transcript comparable to an uninterrupted one.
|
|
319
|
+
"""
|
|
320
|
+
converted: list[BaseMessage] = []
|
|
321
|
+
for message in messages:
|
|
322
|
+
if message.role == "system":
|
|
323
|
+
converted.append(SystemMessage(content=message.content))
|
|
324
|
+
elif message.role == "user":
|
|
325
|
+
converted.append(HumanMessage(content=message.content))
|
|
326
|
+
elif message.role == "tool":
|
|
327
|
+
converted.append(
|
|
328
|
+
ToolMessage(
|
|
329
|
+
content=message.content,
|
|
330
|
+
tool_call_id=message.tool_call_id or "",
|
|
331
|
+
name=message.name or "",
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
elif message.tool_calls:
|
|
335
|
+
converted.append(
|
|
336
|
+
AIMessage(
|
|
337
|
+
content=message.content,
|
|
338
|
+
tool_calls=[
|
|
339
|
+
{
|
|
340
|
+
"name": call.name,
|
|
341
|
+
"args": dict(call.arguments),
|
|
342
|
+
"id": call.id,
|
|
343
|
+
"type": "tool_call",
|
|
344
|
+
}
|
|
345
|
+
for call in message.tool_calls
|
|
346
|
+
],
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
converted.append(AIMessage(content=message.content))
|
|
351
|
+
return converted
|