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