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
aid_runtime/steps.py ADDED
@@ -0,0 +1,284 @@
1
+ """Leaf pipeline steps: ``generate``, ``retrieve``, ``tool`` and ``emit``.
2
+
3
+ :mod:`~aid_runtime.pipeline` runs steps and :mod:`~aid_runtime.control` composes
4
+ them; this module is the third part of the family and builds the leaves a pipeline
5
+ actually reaches the outside world with. Each factory returns an ordinary
6
+ :data:`~aid_runtime.pipeline.Step`, so a leaf composes with a composite by the same
7
+ rule and replays unchanged on the streaming route.
8
+
9
+ Why these exist at all: a generated application inlines its steps as hand-written
10
+ async functions, which leaves a caller who is *not* the generator -- a test, a
11
+ conformance runner, a hand-built pipeline -- with no way to drive one step kind
12
+ without re-writing the primitive's call and its binding rule. That duplication is
13
+ where the two disagree. A step here is the same primitive the generator calls:
14
+
15
+ * ``retrieve`` calls :meth:`~aid_runtime.retrieval.Retriever.retrieve` and binds
16
+ the :class:`~aid_runtime.retrieval.RetrievalResult` it returns.
17
+ * ``tool`` calls :meth:`~aid_runtime.tools.ToolExecutor.execute` and binds what the
18
+ tool returned, refusing an error outcome and parsing the content.
19
+ * ``generate`` renders a prompt, calls
20
+ :meth:`~aid_runtime.gateway.ModelGateway.generate`, and runs the structured path
21
+ when the caller names an output type.
22
+ * ``emit`` projects the context into the event fields a caller names.
23
+
24
+ Every factory takes its inputs **already resolved**: the query string, the argument
25
+ mapping, the prompt values. Interpolating a spec template against the context is the
26
+ *emitted step body's* job, and a second implementation of it here would be a second
27
+ thing to keep in step with the generator's. What is not duplicated is the part that
28
+ is the same for every caller: the primitive call, the error contract, the parse, and
29
+ the name the result is bound under.
30
+
31
+ ``emit`` is the one leaf whose whole behaviour is reading the context, so
32
+ :func:`resolve_source` lives here rather than in a caller: a field's source is a
33
+ binding name, optionally followed by one attribute, which is exactly the reference
34
+ path the generator derives for the same field.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Mapping, Sequence
40
+ from json import loads
41
+ from typing import Any, Protocol
42
+
43
+ from pydantic import BaseModel
44
+
45
+ from aid_runtime.control import CompositeStep
46
+ from aid_runtime.errors import PipelineError
47
+ from aid_runtime.gateway import ModelGateway
48
+ from aid_runtime.pipeline import PipelineContext
49
+ from aid_runtime.prompts import PromptRegistry
50
+ from aid_runtime.retrieval import RetrievalResult
51
+ from aid_runtime.structured import run_structured
52
+ from aid_runtime.tools import ToolExecutor
53
+ from aid_runtime.types import CompletionRequest, Message, ToolCall
54
+
55
+ __all__ = [
56
+ "EMITTED_KEY",
57
+ "RetrieverSeam",
58
+ "emit_step",
59
+ "generate_step",
60
+ "resolve_source",
61
+ "retrieve_step",
62
+ "tool_step",
63
+ ]
64
+
65
+ #: The context key an ``emit`` step binds its projected payload under.
66
+ #:
67
+ #: Deliberately *not* in :data:`~aid_runtime.control.RESERVED_KEYS`. That set is
68
+ #: mirrored in the generator and pinned against this module by
69
+ #: ``packages/generators/py-fastapi/test/control.test.ts``, so a seventh entry would
70
+ #: have to be emitted as well -- and the generated applications do not compose
71
+ #: their response through this step yet. A generated route keeps building its event
72
+ #: object itself; adding the key here would reserve a spec name for a step the
73
+ #: emitted apps never call. ``emit_step`` takes the key as an argument so the
74
+ #: generator can adopt it under its own name when it does.
75
+ EMITTED_KEY = "emitted"
76
+
77
+
78
+ def _named(step: CompositeStep, name: str | None) -> CompositeStep:
79
+ """Name a step's callable, so a trace and ``Pipeline.of`` identify it.
80
+
81
+ Separate from :func:`aid_runtime.control._named` because this module builds
82
+ leaves rather than composites, and the two families never share a callable.
83
+ """
84
+ if name is not None:
85
+ step.__name__ = name
86
+ return step
87
+
88
+
89
+ def resolve_source(context: PipelineContext, source: str) -> Any:
90
+ """One ``emit`` field's value: a binding, then at most one attribute below it.
91
+
92
+ The one-segment form is the request event's or a scalar binding's whole value;
93
+ the two-segment form is a struct's field. Deeper paths are not accepted, because
94
+ the spec layer's reference paths stop at one field and this resolver exists to
95
+ read exactly what the generator reads.
96
+
97
+ A missing binding is :class:`~aid_runtime.errors.PipelineError` naming the key --
98
+ the same refusal ``PipelineContext.require`` makes -- and a missing attribute is
99
+ one naming both the source and the field, because "the field is not there" and
100
+ "the binding is not there" are different mistakes and both mean the response
101
+ would be wrong.
102
+
103
+ A field is read off a struct: a model's attribute, or a mapping's key. Both are
104
+ "a struct" here -- a generated application carries typed event models, while a
105
+ context seeded from JSON carries plain mappings -- and the two runtimes must read
106
+ the same struct the same way, so a mapping is not the fallback but a first-class
107
+ shape.
108
+ """
109
+ root, _, attribute = source.partition(".")
110
+ value = context.require(root)
111
+ if not attribute:
112
+ return value
113
+ if "." in attribute:
114
+ raise PipelineError(
115
+ f"emit source {source!r} names more than one attribute below {root!r}; "
116
+ f"a field is read from a binding or one struct below it"
117
+ )
118
+ missing = PipelineError(
119
+ f"emit source {source!r} names the field {attribute!r}, which the "
120
+ f"{root!r} binding does not carry"
121
+ )
122
+ if isinstance(value, Mapping):
123
+ if attribute not in value:
124
+ raise missing
125
+ return value[attribute]
126
+ try:
127
+ return getattr(value, attribute)
128
+ except AttributeError:
129
+ raise missing from None
130
+
131
+
132
+ def emit_step(
133
+ fields: Mapping[str, str],
134
+ *,
135
+ key: str = EMITTED_KEY,
136
+ name: str | None = None,
137
+ ) -> CompositeStep:
138
+ """Build the event payload from the context, field by field.
139
+
140
+ ``fields`` maps each event field to its source, in the order the caller declared
141
+ them: an ordered mapping keeps the payload's own order stable, so a projection
142
+ that reorders its fields is observable rather than invisible. Each source is a
143
+ binding name or a ``binding.attribute`` path, which is the shape the generator
144
+ derives for the same field via ``responsePlan``.
145
+
146
+ The step **binds** its payload and does not stop the pipeline. Stopping would
147
+ lose it: Python assigns inside the ``try`` and returns the *pre-step* context on
148
+ :class:`~aid_runtime.pipeline.StopPipeline`, so a stopping step cannot bind
149
+ anything. A generated route does not stop at its emit either -- it runs every
150
+ step and only then builds the response object -- so a later step still runs
151
+ after this one, which is the behaviour a pipeline that composed it would see.
152
+ """
153
+
154
+ ordered = tuple((field, source) for field, source in fields.items())
155
+
156
+ async def _emit(context: PipelineContext) -> PipelineContext:
157
+ payload = {field: resolve_source(context, source) for field, source in ordered}
158
+ return context.with_values(**{key: payload})
159
+
160
+ return _named(_emit, name)
161
+
162
+
163
+ class RetrieverSeam(Protocol):
164
+ """The one method a ``retrieve`` step needs.
165
+
166
+ :class:`~aid_runtime.retrieval.Retriever` satisfies it, which is what a generated
167
+ application passes. Stated as a seam rather than as the concrete class so a caller
168
+ that does not own a retriever -- a test, a conformance runner -- can drive the step
169
+ without building an embedding model and a vector store it will never search. The
170
+ step duplicates no part of the search: it decides the query, the ceiling and the
171
+ name, and binding the result is all it adds.
172
+ """
173
+
174
+ async def retrieve(self, query: str, *, top_k: int | None = None) -> RetrievalResult: ...
175
+
176
+
177
+ def retrieve_step(
178
+ retriever: RetrieverSeam,
179
+ *,
180
+ binding: str,
181
+ query: str,
182
+ top_k: int | None = None,
183
+ name: str | None = None,
184
+ ) -> CompositeStep:
185
+ """Search ``retriever`` and bind the cited passages under ``binding``.
186
+
187
+ ``top_k`` is passed through rather than resolved here, so ``None`` keeps meaning
188
+ "the binding's own ceiling" exactly as :meth:`Retriever.retrieve` defines it.
189
+ """
190
+
191
+ async def _retrieve(context: PipelineContext) -> PipelineContext:
192
+ found: RetrievalResult = await retriever.retrieve(query, top_k=top_k)
193
+ return context.with_values(**{binding: found})
194
+
195
+ return _named(_retrieve, name)
196
+
197
+
198
+ def tool_step(
199
+ tools: ToolExecutor,
200
+ *,
201
+ binding: str,
202
+ tool: str,
203
+ arguments: Mapping[str, Any],
204
+ output: type[BaseModel] | None = None,
205
+ name: str | None = None,
206
+ ) -> CompositeStep:
207
+ """Call ``tool`` and bind what it returned, refusing an error outcome.
208
+
209
+ The call id is the binding name, which makes a trace of a tool step name the value
210
+ it produced. An error outcome raises rather than binding a message: a tool the
211
+ model called and that failed is not a value the next step can meaningfully read,
212
+ and the generator's own step raises for the same reason.
213
+
214
+ ``output`` is the struct to parse the content into when the spec declared a named
215
+ output; without one the content is bound as parsed JSON, so a tool declaring
216
+ ``string`` binds the string it returned rather than a re-encoded copy.
217
+ """
218
+
219
+ async def _tool(context: PipelineContext) -> PipelineContext:
220
+ outcome = await tools.execute(ToolCall(id=binding, name=tool, arguments=dict(arguments)))
221
+ if outcome.is_error:
222
+ raise PipelineError(f"tool {tool} failed: {outcome.content}")
223
+ value = (
224
+ loads(outcome.content)
225
+ if output is None
226
+ else output.model_validate_json(outcome.content)
227
+ )
228
+ return context.with_values(**{binding: value})
229
+
230
+ return _named(_tool, name)
231
+
232
+
233
+ def generate_step(
234
+ gateway: ModelGateway,
235
+ prompts: PromptRegistry,
236
+ *,
237
+ binding: str,
238
+ prompt: str,
239
+ model: str,
240
+ values: Mapping[str, str] | None = None,
241
+ version: str | None = None,
242
+ structured: type[BaseModel] | None = None,
243
+ temperature: float | None = None,
244
+ max_output_tokens: int | None = None,
245
+ fallbacks: Sequence[str] = (),
246
+ name: str | None = None,
247
+ ) -> CompositeStep:
248
+ """Render ``prompt``, ask ``model`` for one completion, and bind the answer.
249
+
250
+ The call is built once and handed to the structured path as its ``call``, so a
251
+ repair round re-sends an already-rendered prompt instead of rendering again --
252
+ the same closure the generated step emits, and the reason a retry cannot silently
253
+ re-read a binding that changed underneath it.
254
+
255
+ ``values`` are the prompt's own values and are passed to the registry unrendered:
256
+ :meth:`PromptRegistry.render` is the one place a template becomes text. With
257
+ ``structured`` the answer is parsed into that type, and a parse failure raises
258
+ after the repair rounds rather than binding a half-valid object.
259
+ """
260
+
261
+ async def _generate(context: PipelineContext) -> PipelineContext:
262
+ rendered = prompts.render(prompt, version=version, values=values)
263
+
264
+ async def _call(text: str) -> str:
265
+ request = (
266
+ CompletionRequest.prompt(text)
267
+ if temperature is None and max_output_tokens is None
268
+ else CompletionRequest(
269
+ messages=(Message.user(text),),
270
+ temperature=temperature,
271
+ max_output_tokens=max_output_tokens,
272
+ )
273
+ )
274
+ response = await gateway.generate(model, request, fallbacks=fallbacks)
275
+ return response.text
276
+
277
+ value: Any = (
278
+ await _call(rendered.text)
279
+ if structured is None
280
+ else await run_structured(_call, structured, rendered.text)
281
+ )
282
+ return context.with_values(**{binding: value})
283
+
284
+ return _named(_generate, name)
@@ -0,0 +1,150 @@
1
+ """Structured-output enforcement with a parse-repair loop.
2
+
3
+ This is the only place Pydantic AI is used, and it is used for what it is good at:
4
+ getting a typed object out of an untrusted model response. Pipeline control flow
5
+ is plain async; Pydantic AI is not an orchestration dependency here.
6
+
7
+ Two entry points:
8
+
9
+ * :func:`parse_structured` coerces already-received text into a typed model.
10
+ * :func:`run_structured` drives a call-repair-retry loop, feeding the validation
11
+ failure back to the model. This is the difference between "the model must be
12
+ right the first time" and "the model must eventually be right" -- the second is
13
+ the only achievable contract.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import Any, TypeVar
20
+
21
+ from pydantic import BaseModel, ValidationError
22
+
23
+ from aid_runtime.errors import StructuredOutputError
24
+
25
+ __all__ = [
26
+ "ModelT",
27
+ "build_agent",
28
+ "format_problems",
29
+ "parse_structured",
30
+ "repair_prompt",
31
+ "run_structured",
32
+ ]
33
+
34
+ ModelT = TypeVar("ModelT", bound=BaseModel)
35
+
36
+ #: The correction turn appended after a failed parse.
37
+ REPAIR_INSTRUCTION = (
38
+ "Your previous response could not be parsed. Reply with corrected JSON only, "
39
+ "matching the required schema exactly. Errors: {problems}"
40
+ )
41
+
42
+
43
+ def format_problems(exc: ValidationError) -> list[str]:
44
+ """Flatten a Pydantic error into ``location: message`` strings."""
45
+ problems: list[str] = []
46
+ for error in exc.errors():
47
+ location = ".".join(str(part) for part in error["loc"]) or "<root>"
48
+ problems.append(f"{location}: {error['msg']}")
49
+ return problems
50
+
51
+
52
+ def parse_structured(text: str, output_type: type[ModelT]) -> ModelT:
53
+ """Validate ``text`` as ``output_type``.
54
+
55
+ Raises :class:`~aid_runtime.errors.StructuredOutputError` with every problem,
56
+ not just the first: feeding one error back at a time turns a two-round repair
57
+ into a five-round one.
58
+ """
59
+ stripped = _strip_code_fence(text).strip()
60
+ if not stripped:
61
+ raise StructuredOutputError(
62
+ f"model returned empty output for {output_type.__name__}", attempts=1
63
+ )
64
+ try:
65
+ return output_type.model_validate_json(stripped)
66
+ except ValidationError as exc:
67
+ problems = format_problems(exc)
68
+ raise StructuredOutputError(
69
+ f"output did not validate as {output_type.__name__}", attempts=1, problems=problems
70
+ ) from exc
71
+
72
+
73
+ async def run_structured(
74
+ call: Callable[[str], Awaitable[str]],
75
+ output_type: type[ModelT],
76
+ prompt: str,
77
+ *,
78
+ retries: int = 2,
79
+ ) -> ModelT:
80
+ """Call, parse, and on failure re-ask with the validation errors attached.
81
+
82
+ ``retries`` counts *repair* attempts beyond the first call, so
83
+ ``retries=2`` means at most three calls. Raising after the final attempt is
84
+ correct: a generated endpoint that returns a half-valid object is worse than
85
+ one that fails loudly.
86
+ """
87
+ if retries < 0:
88
+ raise ValueError("retries must not be negative")
89
+
90
+ current = prompt
91
+ problems: list[str] = []
92
+ for attempt in range(1, retries + 2):
93
+ text = await call(current)
94
+ try:
95
+ return parse_structured(text, output_type)
96
+ except StructuredOutputError as exc:
97
+ problems = list(exc.problems)
98
+ if attempt <= retries:
99
+ current = f"{prompt}\n\n{repair_prompt(exc)}"
100
+
101
+ raise StructuredOutputError(
102
+ f"{output_type.__name__} not produced from {retries + 1} attempt(s)",
103
+ attempts=retries + 1,
104
+ problems=problems,
105
+ )
106
+
107
+
108
+ def repair_prompt(error: StructuredOutputError) -> str:
109
+ """The corrective turn for a failed parse."""
110
+ problems = "; ".join(error.problems) if error.problems else str(error)
111
+ return REPAIR_INSTRUCTION.format(problems=problems)
112
+
113
+
114
+ def build_agent(
115
+ model: Any,
116
+ output_type: type[ModelT],
117
+ *,
118
+ system_prompt: str | None = None,
119
+ retries: int = 2,
120
+ ) -> Any:
121
+ """Build a Pydantic AI agent bound to ``output_type``.
122
+
123
+ Kept to a one-liner over the library's own constructor so the typed-output
124
+ behaviour -- including its internal retry/repair -- is available to generated
125
+ endpoints without this runtime growing its own orchestration layer.
126
+ """
127
+ from pydantic_ai import Agent
128
+
129
+ kwargs: dict[str, Any] = {}
130
+ if system_prompt is not None:
131
+ kwargs["system_prompt"] = system_prompt
132
+ return Agent(model, output_type=output_type, retries=retries, **kwargs)
133
+
134
+
135
+ def _strip_code_fence(text: str) -> str:
136
+ """Remove a ```json fence if the model wrapped its answer in one.
137
+
138
+ Models do this constantly when asked for JSON. Treating a valid payload as
139
+ invalid because of presentation is a self-inflicted repair loop.
140
+ """
141
+ stripped = text.strip()
142
+ if not stripped.startswith("```"):
143
+ return stripped
144
+ lines = stripped.splitlines()
145
+ if len(lines) < 2:
146
+ return stripped
147
+ lines = lines[1:]
148
+ if lines and lines[-1].strip().startswith("```"):
149
+ lines = lines[:-1]
150
+ return "\n".join(lines)