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/tools.py ADDED
@@ -0,0 +1,271 @@
1
+ """Tool definitions and the executor that runs them.
2
+
3
+ A tool handler is business logic -- in a generated app it lives under
4
+ ``business/`` and is never overwritten by regeneration. The executor only owns
5
+ argument validation, dispatch, and turning a handler crash into a tool error the
6
+ model can read.
7
+
8
+ Validation covers the JSON Schema subset the model is actually given (object
9
+ ``required`` and ``properties`` with scalar/array types). It is deliberately not a
10
+ general JSON Schema implementation: a fuller validator belongs to the schema
11
+ library, not to a runtime that must stay small.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ import json
18
+ from collections.abc import Callable, Iterable, Mapping, Sequence
19
+ from typing import Any
20
+
21
+ from aid_runtime.concurrency import ConcurrencyLimiter
22
+ from aid_runtime.errors import ToolArgumentError, ToolExecutionError, ToolNotFoundError
23
+ from aid_runtime.types import ToolCall, ToolDefinition, ToolResult
24
+
25
+ __all__ = ["Tool", "ToolExecutor"]
26
+
27
+ #: A tool handler: sync or async, taking keyword arguments, returning anything JSON-ish.
28
+ Handler = Callable[..., Any]
29
+
30
+ _SCALAR_TYPES: dict[str, tuple[type, ...]] = {
31
+ "string": (str,),
32
+ "integer": (int,),
33
+ "number": (int, float),
34
+ "boolean": (bool,),
35
+ "object": (dict,),
36
+ "array": (list, tuple),
37
+ "null": (type(None),),
38
+ }
39
+
40
+
41
+ class Tool:
42
+ """A definition plus the callable that implements it."""
43
+
44
+ __slots__ = ("definition", "handler")
45
+
46
+ def __init__(self, definition: ToolDefinition, handler: Handler) -> None:
47
+ self.definition = definition
48
+ self.handler = handler
49
+
50
+ @property
51
+ def name(self) -> str:
52
+ return self.definition.name
53
+
54
+ @classmethod
55
+ def of(
56
+ cls,
57
+ name: str,
58
+ handler: Handler,
59
+ *,
60
+ description: str = "",
61
+ parameters: Mapping[str, Any] | None = None,
62
+ ) -> Tool:
63
+ """Declare a tool, inferring the parameter schema from the handler signature.
64
+
65
+ Inference covers names and required-ness only. Types must be declared
66
+ explicitly: guessing a type from a default is how a schema quietly
67
+ disagrees with the implementation.
68
+
69
+ An explicit ``parameters`` schema is checked against the handler at
70
+ registration, so a generated tool wired to the wrong function fails at
71
+ import time rather than the first time a model calls it.
72
+ """
73
+ declared = parameters if parameters is not None else _infer_parameters(handler)
74
+ _check_declaration(name, handler, declared)
75
+ return cls(
76
+ ToolDefinition(
77
+ name=name,
78
+ description=description or (inspect.getdoc(handler) or ""),
79
+ parameters=dict(declared),
80
+ ),
81
+ handler,
82
+ )
83
+
84
+
85
+ class ToolExecutor:
86
+ """Validates and dispatches tool calls.
87
+
88
+ An executor may share a :class:`~aid_runtime.concurrency.ConcurrencyLimiter`
89
+ with the gateway; when one is supplied, every tool call acquires a slot so tool
90
+ calls and model calls draw from the same budget of concurrent work.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ tools: Iterable[Tool] = (),
96
+ *,
97
+ strict: bool = False,
98
+ limiter: ConcurrencyLimiter | None = None,
99
+ ) -> None:
100
+ self._tools: dict[str, Tool] = {}
101
+ self._strict = strict
102
+ self._limiter = limiter
103
+ for tool in tools:
104
+ self.register(tool)
105
+
106
+ @property
107
+ def names(self) -> tuple[str, ...]:
108
+ return tuple(sorted(self._tools))
109
+
110
+ def register(self, tool: Tool) -> None:
111
+ self._tools[tool.name] = tool
112
+
113
+ def definition(self, name: str) -> ToolDefinition:
114
+ return self._get(name).definition
115
+
116
+ def definitions(self) -> tuple[ToolDefinition, ...]:
117
+ return tuple(self._tools[name].definition for name in self.names)
118
+
119
+ async def execute(self, call: ToolCall) -> ToolResult:
120
+ """Run a tool call, never raising for a bad argument or a handler crash.
121
+
122
+ Both are *tool* errors: the model composed the call, so the model is the
123
+ party that can fix it, and it needs the message rather than a stack trace
124
+ to do so. A call naming an unregistered tool is a programming error and
125
+ does raise -- no prompt repair makes a missing tool exist.
126
+
127
+ A ``strict`` executor is for development, where a handler traceback is
128
+ what you actually want: it raises
129
+ :class:`~aid_runtime.errors.ToolExecutionError` instead of returning an
130
+ error the model would silently try to talk its way around.
131
+
132
+ When a limiter is shared, the call holds one slot for its whole duration,
133
+ released on success, handler error, or cancellation.
134
+ """
135
+ if self._limiter is None:
136
+ return await self._execute(call)
137
+ async with self._limiter.run():
138
+ return await self._execute(call)
139
+
140
+ async def _execute(self, call: ToolCall) -> ToolResult:
141
+ tool = self._get(call.name)
142
+ problems = validate_arguments(tool.definition.parameters, call.arguments)
143
+ if problems:
144
+ return ToolResult.from_error(
145
+ call, f"invalid arguments: {'; '.join(problems)} -- {json.dumps(call.arguments)}"
146
+ )
147
+ try:
148
+ value = await _invoke(tool.handler, call.arguments)
149
+ except Exception as exc:
150
+ if self._strict:
151
+ raise ToolExecutionError(
152
+ f"tool {call.name!r} raised {type(exc).__name__}: {exc}"
153
+ ) from exc
154
+ return ToolResult.from_error(call, f"{type(exc).__name__}: {exc}")
155
+ return ToolResult.from_value(call, value)
156
+
157
+ async def execute_all(self, calls: Iterable[ToolCall]) -> tuple[ToolResult, ...]:
158
+ """Run calls in order, each independent of the others' outcome."""
159
+ return tuple([await self.execute(call) for call in calls])
160
+
161
+ def _get(self, name: str) -> Tool:
162
+ try:
163
+ return self._tools[name]
164
+ except KeyError:
165
+ raise ToolNotFoundError(name, self.names) from None
166
+
167
+
168
+ def validate_arguments(schema: Mapping[str, Any], arguments: Mapping[str, Any]) -> list[str]:
169
+ """Return every problem with ``arguments`` against ``schema``, in one pass."""
170
+ problems: list[str] = []
171
+
172
+ properties = schema.get("properties")
173
+ if not isinstance(properties, Mapping):
174
+ return problems
175
+
176
+ for name in schema.get("required") or []:
177
+ if name not in arguments:
178
+ problems.append(f"missing required argument {name!r}")
179
+
180
+ if schema.get("additionalProperties") is False:
181
+ for name in arguments:
182
+ if name not in properties:
183
+ problems.append(f"unexpected argument {name!r}")
184
+
185
+ for name, value in arguments.items():
186
+ spec = properties.get(name)
187
+ if not isinstance(spec, Mapping):
188
+ continue
189
+ problems.extend(_check_value(name, value, spec))
190
+
191
+ return problems
192
+
193
+
194
+ def _check_value(name: str, value: Any, spec: Mapping[str, Any]) -> list[str]:
195
+ declared = spec.get("type")
196
+ if declared is None or value is None:
197
+ return []
198
+
199
+ allowed = [declared] if isinstance(declared, str) else list(declared)
200
+ if any(_matches(value, kind) for kind in allowed):
201
+ return []
202
+
203
+ return [f"argument {name!r} must be {_join(allowed)}, got {type(value).__name__}"]
204
+
205
+
206
+ def _matches(value: Any, kind: str) -> bool:
207
+ expected = _SCALAR_TYPES.get(kind)
208
+ if expected is None:
209
+ # An unknown type name cannot be checked, so it cannot be reported as a
210
+ # mismatch either; rejecting here would fail every call.
211
+ return True
212
+ if kind in {"integer", "number"} and isinstance(value, bool):
213
+ # bool is a subclass of int in Python; JSON Schema does not consider
214
+ # `true` a valid number.
215
+ return False
216
+ return isinstance(value, expected)
217
+
218
+
219
+ def _join(kinds: list[str]) -> str:
220
+ return " or ".join(kinds)
221
+
222
+
223
+ def _check_declaration(name: str, handler: Handler, parameters: Mapping[str, Any]) -> None:
224
+ """Reject a schema that requires an argument the handler cannot receive."""
225
+ if _accepts_keywords(handler):
226
+ return
227
+ accepted = set(inspect.signature(handler).parameters)
228
+ required = parameters.get("required")
229
+ if not isinstance(required, Sequence) or isinstance(required, str):
230
+ return
231
+ undeclared = sorted(str(item) for item in required if str(item) not in accepted)
232
+ if undeclared:
233
+ raise ToolArgumentError(
234
+ name,
235
+ [f"required argument(s) {', '.join(undeclared)} not accepted by the handler"],
236
+ )
237
+
238
+
239
+ def _infer_parameters(handler: Handler) -> dict[str, Any]:
240
+ signature = inspect.signature(handler)
241
+ names = [
242
+ name
243
+ for name, parameter in signature.parameters.items()
244
+ if parameter.kind in {parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY}
245
+ ]
246
+ return {"type": "object", "properties": {name: {} for name in names}, "required": names}
247
+
248
+
249
+ def _accepts_keywords(handler: Handler) -> bool:
250
+ try:
251
+ signature = inspect.signature(handler)
252
+ except (TypeError, ValueError):
253
+ return False
254
+ return any(
255
+ parameter.kind is parameter.VAR_KEYWORD for parameter in signature.parameters.values()
256
+ )
257
+
258
+
259
+ async def _invoke(handler: Handler, arguments: Mapping[str, Any]) -> Any:
260
+ """Call a sync or async handler with ``arguments`` as keyword arguments."""
261
+ kwargs = dict(arguments) if _accepts_keywords(handler) else _bind(handler, arguments)
262
+ result = handler(**kwargs)
263
+ if inspect.isawaitable(result):
264
+ return await result
265
+ return result
266
+
267
+
268
+ def _bind(handler: Handler, arguments: Mapping[str, Any]) -> dict[str, Any]:
269
+ """Drop arguments the handler does not name, so an over-supplying model is tolerated."""
270
+ accepted = set(inspect.signature(handler).parameters)
271
+ return {key: value for key, value in arguments.items() if key in accepted}
aid_runtime/tracing.py ADDED
@@ -0,0 +1,241 @@
1
+ """OpenTelemetry tracing using the GenAI semantic conventions.
2
+
3
+ Attribute names come from the OpenTelemetry GenAI conventions (``gen_ai.*``) so
4
+ spans land in any OTLP backend already understood as model telemetry. Attributes
5
+ the conventions do not cover -- which pinned alias produced the call, and what it
6
+ cost -- are namespaced under ``aid.*`` rather than smuggled into a ``gen_ai.*``
7
+ key with the wrong meaning.
8
+
9
+ Only the OpenTelemetry *API* is a runtime dependency. With no tracer provider
10
+ installed every call here is a no-op, which is the correct behaviour for a library
11
+ that must not decide a host application's telemetry setup for it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ from collections.abc import Iterator
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from opentelemetry import trace
21
+ from opentelemetry.trace import Span, SpanKind, Status, StatusCode
22
+
23
+ from aid_runtime.types import TokenUsage
24
+
25
+ if TYPE_CHECKING:
26
+ from aid_runtime.agents.guardrails import GuardrailRecord
27
+
28
+ __all__ = [
29
+ "ATTRIBUTES",
30
+ "GEN_AI_OPERATION_CHAT",
31
+ "agent_span",
32
+ "aid_attributes",
33
+ "get_tracer",
34
+ "llm_span",
35
+ "record_attributes",
36
+ "record_cost",
37
+ "record_exception",
38
+ "record_guardrail",
39
+ "record_usage",
40
+ ]
41
+
42
+ GEN_AI_OPERATION_CHAT = "chat"
43
+
44
+ #: The GenAI semantic-convention attribute keys this runtime writes.
45
+ ATTRIBUTES: dict[str, str] = {
46
+ "system": "gen_ai.system",
47
+ "operation": "gen_ai.operation.name",
48
+ "request_model": "gen_ai.request.model",
49
+ "request_temperature": "gen_ai.request.temperature",
50
+ "request_max_tokens": "gen_ai.request.max_tokens",
51
+ "response_model": "gen_ai.response.model",
52
+ "response_finish_reasons": "gen_ai.response.finish_reasons",
53
+ "input_tokens": "gen_ai.usage.input_tokens",
54
+ "output_tokens": "gen_ai.usage.output_tokens",
55
+ # Framework-specific, deliberately outside the gen_ai namespace.
56
+ "alias": "aid.model.alias",
57
+ "cost_usd": "aid.cost.usd",
58
+ "attempts": "aid.gateway.attempts",
59
+ "tool_names": "aid.tool.names",
60
+ # Agent loop. ``agent_status`` is the run's terminal state, recorded on the
61
+ # agent span so a paused run is distinguishable from a completed one without
62
+ # reading the loop's return value.
63
+ "agent_id": "aid.agent.id",
64
+ "agent_engine": "aid.agent.engine",
65
+ "agent_steps": "aid.agent.steps",
66
+ "agent_status": "aid.agent.status",
67
+ "agent_transfer_to": "aid.agent.transfer.to",
68
+ "agent_delegate_to": "aid.agent.delegate.to",
69
+ "agent_budget_degraded": "aid.agent.budget.degraded",
70
+ "agent_budget_from": "aid.agent.budget.from",
71
+ "agent_budget_to": "aid.agent.budget.to",
72
+ # Human-in-the-loop: who was asked, on what channel, at which step, and what
73
+ # they decided. A pause is an ordinary outcome, not an error.
74
+ "hitl_channel": "aid.hitl.channel",
75
+ "hitl_when": "aid.hitl.when",
76
+ "hitl_decision": "aid.hitl.decision",
77
+ "hitl_step": "aid.hitl.step",
78
+ "hitl_phase": "aid.hitl.phase",
79
+ "memory_scope": "aid.memory.scope",
80
+ "memory_entries": "aid.memory.entries",
81
+ "memory_tokens": "aid.memory.tokens",
82
+ # Guardrails. An event per hit rather than one attribute, for the same reason
83
+ # HITL uses events: several guardrails can fire at one stage, and a single
84
+ # attribute would keep only the last one while still looking complete.
85
+ "guardrail_id": "aid.guardrail.id",
86
+ "guardrail_stage": "aid.guardrail.stage",
87
+ "guardrail_action": "aid.guardrail.action",
88
+ "guardrail_policies": "aid.guardrail.policies",
89
+ }
90
+
91
+ _TRACER_NAME = "aid_runtime"
92
+
93
+
94
+ def get_tracer(name: str = _TRACER_NAME) -> trace.Tracer:
95
+ return trace.get_tracer(name)
96
+
97
+
98
+ @contextlib.contextmanager
99
+ def llm_span(
100
+ *,
101
+ model: str,
102
+ provider: str,
103
+ operation: str = GEN_AI_OPERATION_CHAT,
104
+ alias: str | None = None,
105
+ temperature: float | None = None,
106
+ max_tokens: int | None = None,
107
+ tracer: trace.Tracer | None = None,
108
+ ) -> Iterator[Span]:
109
+ """Open a CLIENT span for one model call and close it with an error status."""
110
+ active = tracer or get_tracer()
111
+ attributes: dict[str, Any] = {
112
+ ATTRIBUTES["system"]: provider,
113
+ ATTRIBUTES["operation"]: operation,
114
+ ATTRIBUTES["request_model"]: model,
115
+ }
116
+ if temperature is not None:
117
+ attributes[ATTRIBUTES["request_temperature"]] = temperature
118
+ if max_tokens is not None:
119
+ attributes[ATTRIBUTES["request_max_tokens"]] = max_tokens
120
+ if alias is not None:
121
+ attributes[ATTRIBUTES["alias"]] = alias
122
+
123
+ with active.start_as_current_span(
124
+ f"{operation} {model}",
125
+ kind=SpanKind.CLIENT,
126
+ attributes=attributes,
127
+ # The exception is recorded once, explicitly, from the ``except`` below.
128
+ # Left at their defaults these would record it a second time and overwrite
129
+ # the status description with the exception's repr.
130
+ record_exception=False,
131
+ set_status_on_exception=False,
132
+ ) as span:
133
+ try:
134
+ yield span
135
+ except BaseException as exc:
136
+ record_exception(span, exc)
137
+ raise
138
+
139
+
140
+ def record_usage(span: Span, usage: TokenUsage) -> None:
141
+ span.set_attribute(ATTRIBUTES["input_tokens"], usage.input_tokens)
142
+ span.set_attribute(ATTRIBUTES["output_tokens"], usage.output_tokens)
143
+
144
+
145
+ def record_attributes(span: Span, **values: Any) -> None:
146
+ """Attach ``aid.*`` attributes by short name, skipping ``None`` values.
147
+
148
+ A recorded ``None`` would look like a decided empty value, so an attribute
149
+ that does not apply is omitted rather than nulled.
150
+ """
151
+ for key, value in aid_attributes(**values).items():
152
+ if value is not None:
153
+ span.set_attribute(key, value)
154
+
155
+
156
+ @contextlib.contextmanager
157
+ def agent_span(
158
+ *,
159
+ agent_id: str,
160
+ engine: str,
161
+ tracer: trace.Tracer | None = None,
162
+ ) -> Iterator[Span]:
163
+ """Open an INTERNAL span for one agent's turn.
164
+
165
+ INTERNAL, not CLIENT: an agent is not a remote service, and the model calls it
166
+ makes open their own CLIENT child spans.
167
+ """
168
+ active = tracer or get_tracer()
169
+ with active.start_as_current_span(
170
+ f"invoke_agent {agent_id}",
171
+ kind=SpanKind.INTERNAL,
172
+ attributes=aid_attributes(agent_id=agent_id, agent_engine=engine),
173
+ record_exception=False,
174
+ set_status_on_exception=False,
175
+ ) as span:
176
+ try:
177
+ yield span
178
+ except BaseException as exc:
179
+ record_exception(span, exc)
180
+ raise
181
+
182
+
183
+ def record_hitl(
184
+ span: Span,
185
+ *,
186
+ phase: str,
187
+ channel: str = "",
188
+ decision: str | None = None,
189
+ when: str | None = None,
190
+ step: int | None = None,
191
+ ) -> None:
192
+ """Record one pause or resume of a human-in-the-loop gate.
193
+
194
+ An event, not only attributes: a run can pause and resume more than once, and a
195
+ span attribute would silently keep the last decision while the span still looked
196
+ like it paused exactly once. ``phase`` is ``pause`` or ``resume``.
197
+ """
198
+ attributes: dict[str, Any] = {
199
+ ATTRIBUTES["hitl_phase"]: phase,
200
+ ATTRIBUTES["hitl_channel"]: channel,
201
+ }
202
+ if when is not None:
203
+ attributes[ATTRIBUTES["hitl_when"]] = when
204
+ if decision is not None:
205
+ attributes[ATTRIBUTES["hitl_decision"]] = decision
206
+ if step is not None:
207
+ attributes[ATTRIBUTES["hitl_step"]] = step
208
+ span.add_event("aid.hitl", attributes)
209
+
210
+
211
+ def record_guardrail(span: Span, record: GuardrailRecord) -> None:
212
+ """Record one guardrail hit as an ``aid.guardrail`` span event."""
213
+ span.add_event(
214
+ "aid.guardrail",
215
+ {
216
+ ATTRIBUTES["guardrail_id"]: record.guardrail,
217
+ ATTRIBUTES["guardrail_stage"]: record.stage,
218
+ ATTRIBUTES["guardrail_action"]: record.action,
219
+ ATTRIBUTES["guardrail_policies"]: ",".join(record.policies),
220
+ },
221
+ )
222
+
223
+
224
+ def record_cost(span: Span, cost_usd: float, *, alias: str | None = None) -> None:
225
+ span.set_attribute(ATTRIBUTES["cost_usd"], cost_usd)
226
+ if alias is not None:
227
+ span.set_attribute(ATTRIBUTES["alias"], alias)
228
+
229
+
230
+ def record_exception(span: Span, exc: BaseException) -> None:
231
+ span.record_exception(exc)
232
+ span.set_status(Status(StatusCode.ERROR, str(exc)))
233
+
234
+
235
+ def aid_attributes(**values: Any) -> dict[str, Any]:
236
+ """Map short names from :data:`ATTRIBUTES` to their namespaced keys.
237
+
238
+ Unmapped names pass through, so a caller can attach a bespoke ``aid.*``
239
+ attribute without editing this module.
240
+ """
241
+ return {ATTRIBUTES.get(key, key): value for key, value in values.items()}