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
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)
|