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