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/cache.py ADDED
@@ -0,0 +1,185 @@
1
+ """A completion cache with exact (prompt-keyed) and opt-in semantic reuse.
2
+
3
+ The exact key is ``(model, prompt transcript hash, tool identity hash, temperature,
4
+ max output tokens, stop sequences)``. Semantic reuse is strictly opt-in (an explicit
5
+ threshold and TTL; there is no default) and is gated by the *same* structural key: a
6
+ stored entry may only answer a new request whose model, tool identity, temperature,
7
+ output cap and stop sequences all match, and then only when the prompt text is
8
+ similar enough and young enough.
9
+
10
+ The invalidation rule, stated: an entry is reusable if and only if (a) the model,
11
+ tool-identity hash, temperature, output cap and stop sequences are identical, (b)
12
+ the Jaccard similarity of the two prompt transcripts is at least ``threshold``, and
13
+ (c) the entry is younger than ``ttl_seconds``. Text similarity is the *match* test,
14
+ never the storage key: the key stays a structural hash so a prompt change can never
15
+ resurrect a response that was computed with different tools, a different model, a
16
+ different cap, or different stop sequences.
17
+
18
+ The similarity measure is lexical (token Jaccard), not an embedding cosine. A true
19
+ embedding similarity would need an embedder at cache-construction time; wiring that
20
+ in is deferred rather than implied.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import time
28
+ from dataclasses import dataclass, field
29
+
30
+ from aid_runtime.types import CompletionRequest, CompletionResponse, ModelSpec
31
+
32
+ __all__ = ["CompletionCache", "cache_key_for"]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CacheKey:
37
+ """The structural key: everything in a request that can change its output.
38
+
39
+ Model, transcript hash, full tool identity, temperature, output cap, and stop
40
+ sequences. ``tools_hash`` covers each tool's name, description, and parameters
41
+ in request order, since a provider preserves that order. The cap and stop
42
+ sequences are stored verbatim rather than omitted: a cached body must never
43
+ answer a request that asked for a different cap or stop set.
44
+ """
45
+
46
+ model: str
47
+ prompt_hash: str
48
+ tools_hash: str
49
+ temperature: str
50
+ max_output_tokens: str
51
+ stop: str
52
+
53
+ def same_structure(self, other: CacheKey) -> bool:
54
+ """Whether two keys may share a completion, ignoring prompt similarity."""
55
+ return (
56
+ self.model == other.model
57
+ and self.tools_hash == other.tools_hash
58
+ and self.temperature == other.temperature
59
+ and self.max_output_tokens == other.max_output_tokens
60
+ and self.stop == other.stop
61
+ )
62
+
63
+
64
+ def cache_key_for(model: str, request: CompletionRequest) -> CacheKey:
65
+ """Derive the structural key for a request.
66
+
67
+ "Prompt hash" hashes the full transcript (roles, content, and tool context),
68
+ not concatenated text, so a system/user/tool distinction is part of the key.
69
+ The output cap and stop sequences are part of the key too, so a cached body
70
+ cannot answer a request that asked for a different cap or stop set.
71
+ """
72
+ return CacheKey(
73
+ model=model,
74
+ prompt_hash=_transcript_hash(request),
75
+ tools_hash=_tools_hash(request),
76
+ temperature=repr(request.temperature),
77
+ max_output_tokens=repr(request.max_output_tokens),
78
+ stop=repr(request.stop),
79
+ )
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class CacheEntry:
84
+ response: CompletionResponse
85
+ prompt: str
86
+ stored_at: float = field(default_factory=time.monotonic)
87
+
88
+
89
+ class CompletionCache:
90
+ """An in-process, async-safe cache of provider completions.
91
+
92
+ Async-safe by construction: lookups and inserts are synchronous and complete
93
+ before the first ``await`` in the caller, and a caller never observes a
94
+ half-populated entry because an entry is written whole, once, after the call
95
+ completes. In-flight identical requests are *not* coalesced: each miss calls
96
+ the provider. That is the explicit choice here -- coalescing would add a
97
+ per-key condition variable and change failure semantics -- and a test pins the
98
+ duplicate-work behaviour.
99
+ """
100
+
101
+ def __init__(self) -> None:
102
+ self._entries: dict[CacheKey, CacheEntry] = {}
103
+
104
+ def get(self, key: CacheKey) -> CompletionResponse | None:
105
+ entry = self._entries.get(key)
106
+ return None if entry is None else entry.response
107
+
108
+ def semantic_get(
109
+ self,
110
+ spec: ModelSpec,
111
+ request: CompletionRequest,
112
+ *,
113
+ threshold: float,
114
+ ttl_seconds: int,
115
+ ) -> CompletionResponse | None:
116
+ """The opt-in semantic lookup: same structure, similar prompt, young enough."""
117
+ key = cache_key_for(spec.name, request)
118
+ prompt = _transcript_text(request)
119
+ now = time.monotonic()
120
+ for existing_key, entry in self._entries.items():
121
+ if not existing_key.same_structure(key):
122
+ continue
123
+ if now - entry.stored_at > ttl_seconds:
124
+ continue
125
+ if _jaccard(prompt, entry.prompt) < threshold:
126
+ continue
127
+ return entry.response
128
+ return None
129
+
130
+ def put(self, key: CacheKey, response: CompletionResponse, request: CompletionRequest) -> None:
131
+ self._entries[key] = CacheEntry(response=response, prompt=_transcript_text(request))
132
+
133
+ def clear(self) -> None:
134
+ self._entries.clear()
135
+
136
+ def __len__(self) -> int:
137
+ return len(self._entries)
138
+
139
+
140
+ def _transcript_text(request: CompletionRequest) -> str:
141
+ return "\n".join(message.content for message in request.messages)
142
+
143
+
144
+ def _transcript_hash(request: CompletionRequest) -> str:
145
+ messages = [
146
+ {
147
+ "role": message.role,
148
+ "content": message.content,
149
+ "tool_call_id": message.tool_call_id,
150
+ "name": message.name,
151
+ "tool_calls": [call.model_dump(mode="json") for call in message.tool_calls],
152
+ }
153
+ for message in request.messages
154
+ ]
155
+ blob = json.dumps(messages, sort_keys=True, separators=(",", ":"))
156
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
157
+
158
+
159
+ def _tools_hash(request: CompletionRequest) -> str:
160
+ blob = json.dumps(
161
+ [
162
+ {
163
+ "name": tool.name,
164
+ "description": tool.description,
165
+ "parameters": tool.parameters,
166
+ }
167
+ for tool in request.tools
168
+ ],
169
+ sort_keys=True,
170
+ separators=(",", ":"),
171
+ )
172
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
173
+
174
+
175
+ def _jaccard(left: str, right: str) -> float:
176
+ """Token Jaccard similarity in [0, 1]; two empty transcripts are identical."""
177
+ a = set(_tokens(left))
178
+ b = set(_tokens(right))
179
+ if not a and not b:
180
+ return 1.0
181
+ return len(a & b) / len(a | b)
182
+
183
+
184
+ def _tokens(text: str) -> list[str]:
185
+ return [word for word in text.lower().replace("?", " ").split() if word]
@@ -0,0 +1,70 @@
1
+ """A single shared concurrency limiter for a generated application.
2
+
3
+ ``deployments.concurrency`` produces exactly one :class:`ConcurrencyLimiter`. The
4
+ model gateway, the tool executor, and the agent engine's delegation all acquire it,
5
+ so there is one limiter -- not one per component -- and a single cap bounds every
6
+ unit of work the app performs concurrently.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from contextvars import ContextVar
15
+
16
+ from aid_runtime.errors import ConfigurationError
17
+
18
+ __all__ = ["ConcurrencyLimiter", "holds_slot"]
19
+
20
+ #: Whether the current task is already inside :meth:`ConcurrencyLimiter.run`.
21
+ #:
22
+ #: A fan-out worker holds a slot for its whole body, and that body's model and tool
23
+ #: calls acquire the same limiter. Acquiring twice would deadlock at a cap of one,
24
+ #: so an acquisition made while a slot is already held is a no-op: the work is
25
+ #: already counted against the cap above it. The flag is a ``ContextVar`` because
26
+ #: each worker runs in its own task, and a task's copy must not leak to a sibling.
27
+ _HELD: ContextVar[bool] = ContextVar("aid_runtime_holds_slot", default=False)
28
+
29
+
30
+ def holds_slot() -> bool:
31
+ """Whether this task already holds a slot of the limiter it would acquire."""
32
+ return _HELD.get()
33
+
34
+
35
+ class ConcurrencyLimiter:
36
+ """An ``asyncio.Semaphore`` with a name and an explicit, validated cap.
37
+
38
+ Sharing the same instance between the gateway and the executor is what makes
39
+ "one limiter, not two" true: both acquire the same counter, so tool calls and
40
+ model calls draw from the same budget of concurrent slots.
41
+ """
42
+
43
+ def __init__(self, limit: int) -> None:
44
+ if limit < 1:
45
+ raise ConfigurationError(f"concurrency must be at least 1, got {limit}")
46
+ self.limit = limit
47
+ self._semaphore = asyncio.Semaphore(limit)
48
+
49
+ @asynccontextmanager
50
+ async def run(self) -> AsyncIterator[None]:
51
+ """Hold one slot for the duration of a single unit of work.
52
+
53
+ Cancellation and errors release the slot: ``asyncio.Semaphore`` is an async
54
+ context manager whose ``__aexit__`` releases on both the normal and
55
+ exceptional paths, so a cancelled or failed call cannot leak capacity.
56
+
57
+ Nested acquisition inside an already-held slot yields at once rather than
58
+ waiting: the enclosing unit of work is already counted, and waiting would
59
+ deadlock at a cap of one, where the fan-out worker holding the only slot is
60
+ the caller of the gateway that wants it.
61
+ """
62
+ if _HELD.get():
63
+ yield
64
+ return
65
+ async with self._semaphore:
66
+ token = _HELD.set(True)
67
+ try:
68
+ yield
69
+ finally:
70
+ _HELD.reset(token)
aid_runtime/control.py ADDED
@@ -0,0 +1,329 @@
1
+ """Composite pipeline steps: ``branch``, ``parallel``, ``map`` and ``reduce``.
2
+
3
+ :class:`~aid_runtime.pipeline.Pipeline` is a sequential reducer, and it stays one.
4
+ Each factory here returns an ordinary :data:`~aid_runtime.pipeline.Step`, so a
5
+ composite composes with the flat steps by the same rule and replays unchanged on
6
+ the streaming route, which inlines the pipeline instead of driving it step by step.
7
+
8
+ Six context keys are reserved for the family, and no step may bind a name equal to
9
+ one of them: ``principal``, ``limiter``, ``item``, ``index``, ``reduced`` and
10
+ ``depth``. The first two are seeded by the application; the rest belong to the
11
+ composites -- ``item``, ``index`` and ``depth`` are written and removed around a
12
+ fan-out, while ``reduced`` is the binding a ``reduce`` body must produce for the
13
+ composite to collect -- so an inner step can read ``item`` without a matching
14
+ binding leaking to a later sibling.
15
+
16
+ Two bounds are enforced here and both fail the run rather than degrade it:
17
+
18
+ * nesting, at :data:`MAX_STEP_DEPTH`. The spec layer rejects a deeper pipeline
19
+ before generation, so reaching this bound at run time means a hand-built
20
+ pipeline, and the reason is named in the error.
21
+ * concurrency, through the application's one :class:`ConcurrencyLimiter`. A
22
+ fan-out with no limiter in the context raises rather than running unbounded,
23
+ because the cap is what makes a fan-out safe and its absence is a wiring bug.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
30
+ from typing import Any, TypeVar
31
+
32
+ from aid_runtime.agents.policy import Principal, evaluate_policy, policy_source
33
+ from aid_runtime.concurrency import ConcurrencyLimiter, holds_slot
34
+ from aid_runtime.errors import PipelineError
35
+ from aid_runtime.pipeline import STEPS_KEY, Pipeline, PipelineContext
36
+
37
+ __all__ = [
38
+ "DEPTH_KEY",
39
+ "INDEX_KEY",
40
+ "ITEM_KEY",
41
+ "LIMITER_KEY",
42
+ "MAX_STEP_DEPTH",
43
+ "PRINCIPAL_KEY",
44
+ "REDUCED_KEY",
45
+ "RESERVED_KEYS",
46
+ "branch",
47
+ "map_step",
48
+ "parallel",
49
+ "reduce_step",
50
+ ]
51
+
52
+ #: Deepest composite nesting a pipeline may declare.
53
+ #:
54
+ #: The spec layer owns this number and the generator *emits* it into the generated
55
+ #: package, so a generated app passes its own build's value; this default is what a
56
+ #: hand-written pipeline gets. ``test_max_step_depth_matches_the_emitted_constant``
57
+ #: holds the two together, so a drift fails a test instead of diverging silently.
58
+ MAX_STEP_DEPTH = 4
59
+
60
+ #: Who the caller is, for ``branch``. Seeded by the route.
61
+ PRINCIPAL_KEY = "principal"
62
+ #: The application's single limiter, from ``deployments.concurrency``.
63
+ LIMITER_KEY = "limiter"
64
+ #: The current item and its position, written by ``map``/``reduce`` for one iteration.
65
+ ITEM_KEY = "item"
66
+ INDEX_KEY = "index"
67
+ #: How many composites deep the current step is.
68
+ DEPTH_KEY = "depth"
69
+ #: The binding ``reduce`` collects from each iteration. Reserved so a reduce body's
70
+ #: own binding cannot collide with the name the composite reads.
71
+ REDUCED_KEY = "reduced"
72
+
73
+ #: Names a composite owns. A spec binding equal to one of them is refused upstream.
74
+ RESERVED_KEYS = frozenset({PRINCIPAL_KEY, LIMITER_KEY, ITEM_KEY, INDEX_KEY, DEPTH_KEY, REDUCED_KEY})
75
+
76
+ #: A composite: ``async`` by construction, so ``await step(context)`` is well-typed.
77
+ #: ``Step`` also allows a sync return, which is what makes a generated ``await`` of one
78
+ #: untypeable; the factories below return this narrower alias instead.
79
+ CompositeStep = Callable[[PipelineContext], Awaitable[PipelineContext]]
80
+
81
+ _StepT = TypeVar("_StepT", bound=CompositeStep)
82
+
83
+ #: Values a fan-out child produced that its parent did not already hold.
84
+ _NewValues = dict[str, Any]
85
+
86
+
87
+ def _named(step: _StepT, name: str | None) -> _StepT:
88
+ """Name a composite's callable, so a trace and ``Pipeline.of`` identify it.
89
+
90
+ ``Pipeline`` names a step by the callable's ``__name__`` and refuses two steps
91
+ with the same name, so a pipeline holding two ``branch`` composites must give
92
+ them distinct names. The generator passes one; the default is the kind.
93
+ """
94
+ if name is not None:
95
+ step.__name__ = name
96
+ return step
97
+
98
+
99
+ def _depth(context: PipelineContext) -> int:
100
+ value = context.get(DEPTH_KEY, 0)
101
+ return 0 if value is None else int(value)
102
+
103
+
104
+ def _enter(context: PipelineContext, max_depth: int, where: str) -> PipelineContext:
105
+ """The context a nested step list runs in, or a named refusal past the bound."""
106
+ depth = _depth(context)
107
+ if depth >= max_depth:
108
+ raise PipelineError(
109
+ f"composite step {where} is nested deeper than {max_depth} levels; "
110
+ f"nesting is bounded so a self-similar spec cannot recurse without end"
111
+ )
112
+ return context.with_values(**{DEPTH_KEY: depth + 1})
113
+
114
+
115
+ def _limiter(context: PipelineContext, where: str) -> ConcurrencyLimiter:
116
+ limiter = context.get(LIMITER_KEY)
117
+ if not isinstance(limiter, ConcurrencyLimiter):
118
+ raise PipelineError(
119
+ f"step {where} fans out but the pipeline context has no concurrency "
120
+ f"limiter; declare `concurrency` under a deployment in the spec so the "
121
+ f"generator seeds one"
122
+ )
123
+ return limiter
124
+
125
+
126
+ def _escape(context: PipelineContext) -> PipelineContext:
127
+ """Drop the iteration keys, so nothing a composite added reaches a later sibling."""
128
+ return context.without(DEPTH_KEY, ITEM_KEY, INDEX_KEY, REDUCED_KEY)
129
+
130
+
131
+ async def _run_body(body: Pipeline, context: PipelineContext) -> tuple[PipelineContext, _NewValues]:
132
+ """Run one iteration and return its context with only the bindings it added.
133
+
134
+ Only the added names come back, so a composite hands its siblings the bindings
135
+ the spec declared and nothing else. The inner run records its own ``steps``; it
136
+ is excluded here because the outer ``Pipeline.run`` writes that key itself when
137
+ the composite returns, and an inner list would otherwise be read as a binding.
138
+ """
139
+ result = await body.run(context)
140
+ added = {
141
+ key: value
142
+ for key, value in result.values.items()
143
+ if key not in context.values and key not in RESERVED_KEYS and key != STEPS_KEY
144
+ }
145
+ return result, added
146
+
147
+
148
+ async def _fan_out(
149
+ workers: Sequence[Awaitable[tuple[PipelineContext, _NewValues]]],
150
+ ) -> list[tuple[PipelineContext, _NewValues]]:
151
+ """Run every worker, in the order given, whatever order they finish in.
152
+
153
+ ``asyncio.gather`` returns results positionally, so the caller's ordering rule is
154
+ input order even when the last worker finishes first.
155
+
156
+ A worker inside a slot this task already holds runs its siblings one at a time
157
+ instead of acquiring a second slot: the limiter is shared with the gateway and
158
+ the tool executor, so a worker that acquired again while its own body waited for
159
+ the gateway would deadlock at a cap of one. Serialising keeps the cap and
160
+ cannot deadlock, which is the trade this family makes at nesting depth two.
161
+ """
162
+ if holds_slot():
163
+ return [await worker for worker in workers]
164
+ gathered = await asyncio.gather(*workers, return_exceptions=True)
165
+ # Every worker is awaited even after one fails, so no task is left running and the
166
+ # error that surfaces is the first in input order rather than the first to finish.
167
+ for entry in gathered:
168
+ if isinstance(entry, BaseException):
169
+ raise entry
170
+ return [entry for entry in gathered if not isinstance(entry, BaseException)]
171
+
172
+
173
+ def branch(
174
+ *,
175
+ when: Mapping[str, Any],
176
+ then: Pipeline,
177
+ otherwise: Pipeline | None = None,
178
+ max_depth: int = MAX_STEP_DEPTH,
179
+ name: str | None = None,
180
+ ) -> CompositeStep:
181
+ """Run ``then`` when the caller's principal satisfies ``when``, else ``otherwise``.
182
+
183
+ ``when`` is a ``PolicyExpr`` AST, evaluated against the principal and nothing
184
+ else: the grammar has no operand for a context value, so a branch cannot read
185
+ what an earlier step produced. It is a ``Mapping`` rather than a parsed object
186
+ so the generated code passes the AST the IR already carries, and
187
+ :func:`evaluate_policy` stays the only evaluator.
188
+
189
+ No principal in the context is an error, not a false. Defaulting would decide an
190
+ authorization question for a caller nobody identified.
191
+ """
192
+
193
+ async def _branch(context: PipelineContext) -> PipelineContext:
194
+ principal = context.get(PRINCIPAL_KEY)
195
+ if not isinstance(principal, Principal):
196
+ raise PipelineError(
197
+ "branch needs a principal in the pipeline context to evaluate "
198
+ f"{policy_source(when)!r}; none was seeded"
199
+ )
200
+ nested = _enter(context, max_depth, f"branch ({policy_source(when)})")
201
+ chosen = then if evaluate_policy(when, principal) else otherwise
202
+ if chosen is None:
203
+ # No alternative declared: the branch binds nothing at all.
204
+ return _escape(context)
205
+ _, added = await _run_body(chosen, nested)
206
+ return _escape(context.with_values(**added))
207
+
208
+ return _named(_branch, name)
209
+
210
+
211
+ def parallel(
212
+ *,
213
+ branches: Sequence[Pipeline],
214
+ max_depth: int = MAX_STEP_DEPTH,
215
+ name: str | None = None,
216
+ ) -> CompositeStep:
217
+ """Run every arm concurrently under the shared cap, then merge what they bound.
218
+
219
+ A failing arm fails the whole run: the results are inspected in input order and
220
+ the first failure is raised, so which error surfaces does not depend on which
221
+ arm happened to fail first. Two arms binding the same new name is an error
222
+ rather than a silent last-writer-wins, because the winning value would depend on
223
+ completion order and the ordering rule exists to remove that.
224
+ """
225
+
226
+ async def _parallel(context: PipelineContext) -> PipelineContext:
227
+ if not branches:
228
+ return _escape(context)
229
+ limiter = _limiter(context, "parallel")
230
+ nested = _enter(context, max_depth, "parallel")
231
+
232
+ async def run_arm(arm: Pipeline) -> tuple[PipelineContext, _NewValues]:
233
+ async with limiter.run():
234
+ return await _run_body(arm, nested)
235
+
236
+ outcomes = await _fan_out([run_arm(arm) for arm in branches])
237
+ merged: dict[str, Any] = {}
238
+ for _, added in outcomes:
239
+ for key, value in added.items():
240
+ if key in merged:
241
+ raise PipelineError(
242
+ f"parallel arms both bind {key!r}; a name may be bound by one arm"
243
+ )
244
+ merged[key] = value
245
+ return _escape(context.with_values(**merged))
246
+
247
+ return _named(_parallel, name)
248
+
249
+
250
+ def map_step(
251
+ *,
252
+ over: str,
253
+ as_: str,
254
+ step: Pipeline,
255
+ max_depth: int = MAX_STEP_DEPTH,
256
+ name: str | None = None,
257
+ ) -> CompositeStep:
258
+ """Run ``step`` once per item of ``over``, binding the results in input order.
259
+
260
+ ``as_`` holds one mapping of the names the body bound for that item, so an item
261
+ that bound nothing contributes ``{}`` rather than vanishing: the list keeps the
262
+ length of the input. An empty or absent-over value binds an empty list.
263
+ """
264
+
265
+ async def _map(context: PipelineContext) -> PipelineContext:
266
+ items = _items(context, over, "map")
267
+ limiter = _limiter(context, "map")
268
+ nested = _enter(context, max_depth, f"map over {over!r}")
269
+
270
+ async def run_item(index: int, item: Any) -> tuple[PipelineContext, _NewValues]:
271
+ iteration = nested.with_values(**{ITEM_KEY: item, INDEX_KEY: index})
272
+ async with limiter.run():
273
+ return await _run_body(step, iteration)
274
+
275
+ outcomes = await _fan_out(
276
+ [run_item(index, item) for index, item in enumerate(items)],
277
+ )
278
+ results = [added for _, added in outcomes]
279
+ return _escape(context.with_values(**{as_: results}))
280
+
281
+ return _named(_map, name)
282
+
283
+
284
+ def reduce_step(
285
+ *,
286
+ over: str,
287
+ as_: str,
288
+ step: Pipeline,
289
+ max_depth: int = MAX_STEP_DEPTH,
290
+ name: str | None = None,
291
+ ) -> CompositeStep:
292
+ """Run ``step`` once per item of ``over``, binding its :data:`REDUCED_KEY` results.
293
+
294
+ ``step`` is a one-step pipeline that renders the reduce prompt for one item and
295
+ binds :data:`REDUCED_KEY`; the composite collects that binding into ``as_`` in
296
+ input order. Combining the results is a following ``generate`` step, so this
297
+ layer never invents a merge strategy.
298
+ """
299
+
300
+ async def _reduce(context: PipelineContext) -> PipelineContext:
301
+ items = _items(context, over, "reduce")
302
+ limiter = _limiter(context, "reduce")
303
+ nested = _enter(context, max_depth, f"reduce over {over!r}")
304
+
305
+ async def run_item(index: int, item: Any) -> tuple[PipelineContext, _NewValues]:
306
+ iteration = nested.with_values(**{ITEM_KEY: item, INDEX_KEY: index})
307
+ async with limiter.run():
308
+ result = await step.run(iteration)
309
+ if REDUCED_KEY not in result.values:
310
+ raise PipelineError(
311
+ f"reduce over {over!r} produced no {REDUCED_KEY!r} binding for item {index}"
312
+ )
313
+ return result, {REDUCED_KEY: result.require(REDUCED_KEY)}
314
+
315
+ outcomes = await _fan_out(
316
+ [run_item(index, item) for index, item in enumerate(items)],
317
+ )
318
+ results = [added[REDUCED_KEY] for _, added in outcomes]
319
+ return _escape(context.with_values(**{as_: results}))
320
+
321
+ return _named(_reduce, name)
322
+
323
+
324
+ def _items(context: PipelineContext, over: str, where: str) -> list[Any]:
325
+ """The sequence a ``map``/``reduce`` iterates, refusing a missing or odd value."""
326
+ value = context.require(over)
327
+ if isinstance(value, str) or not isinstance(value, Sequence):
328
+ raise PipelineError(f"{where} over {over!r} needs a list, got {type(value).__name__}")
329
+ return list(value)