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.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. 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