computer-agent-py 0.1.0__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.
- computer_agent_py-0.1.0.dist-info/METADATA +307 -0
- computer_agent_py-0.1.0.dist-info/RECORD +25 -0
- computer_agent_py-0.1.0.dist-info/WHEEL +4 -0
- computeragent/__init__.py +90 -0
- computeragent/_proxy/__init__.py +8 -0
- computeragent/_proxy/client.py +225 -0
- computeragent/_proxy/query.py +165 -0
- computeragent/policy/__init__.py +59 -0
- computeragent/policy/authorizer.py +161 -0
- computeragent/policy/cedar.py +182 -0
- computeragent/policy/opa.py +124 -0
- computeragent/policy/types.py +121 -0
- computeragent/py.typed +0 -0
- computeragent/telemetry/__init__.py +24 -0
- computeragent/telemetry/config.py +176 -0
- computeragent/telemetry/event.py +355 -0
- computeragent/telemetry/middleware/__init__.py +8 -0
- computeragent/telemetry/middleware/guardrails.py +127 -0
- computeragent/telemetry/middleware/pii.py +158 -0
- computeragent/telemetry/pipeline.py +160 -0
- computeragent/telemetry/sinks/__init__.py +28 -0
- computeragent/telemetry/sinks/agentos.py +442 -0
- computeragent/telemetry/sinks/message_archive.py +136 -0
- computeragent/telemetry/sinks/otel.py +375 -0
- computeragent/types.py +13 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Canonical policy types — engine-agnostic dataclasses + the ``PolicyEngine``
|
|
2
|
+
Protocol that OPA and Cedar implementations satisfy.
|
|
3
|
+
|
|
4
|
+
The same :class:`PolicyInput` shape is sent to both engines so users can switch
|
|
5
|
+
between OPA and Cedar without changing the worker code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Literal, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PolicyPrincipal:
|
|
17
|
+
"""Who is requesting the action — user / session / agent identifier."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
groups: list[str] = field(default_factory=list)
|
|
21
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
def to_json(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"id": self.id,
|
|
26
|
+
"groups": list(self.groups),
|
|
27
|
+
"attributes": dict(self.attributes),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PolicyAction:
|
|
33
|
+
"""What is being attempted. v0.2 covers tool_use; ``name`` is extensible."""
|
|
34
|
+
|
|
35
|
+
name: Literal["tool_use"]
|
|
36
|
+
tool_name: str
|
|
37
|
+
tool_input: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"tool_name": self.tool_name,
|
|
43
|
+
"tool_input": dict(self.tool_input),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PolicyResource:
|
|
49
|
+
"""What the action is being attempted *on* — typically the running agent."""
|
|
50
|
+
|
|
51
|
+
type: str = "agent"
|
|
52
|
+
agent_name: str = ""
|
|
53
|
+
model: str = ""
|
|
54
|
+
session_id: str = ""
|
|
55
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
def to_json(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"type": self.type,
|
|
60
|
+
"agent_name": self.agent_name,
|
|
61
|
+
"model": self.model,
|
|
62
|
+
"session_id": self.session_id,
|
|
63
|
+
"attributes": dict(self.attributes),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class PolicyInput:
|
|
69
|
+
"""One authorization request. Identical shape across OPA and Cedar engines."""
|
|
70
|
+
|
|
71
|
+
principal: PolicyPrincipal
|
|
72
|
+
action: PolicyAction
|
|
73
|
+
resource: PolicyResource
|
|
74
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
def to_json(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"principal": self.principal.to_json(),
|
|
79
|
+
"action": self.action.to_json(),
|
|
80
|
+
"resource": self.resource.to_json(),
|
|
81
|
+
"context": dict(self.context),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PolicyDecision(Enum):
|
|
86
|
+
ALLOW = "allow"
|
|
87
|
+
DENY = "deny"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class PolicyResult:
|
|
92
|
+
"""Engine output — decision plus human-readable reason + optional obligations.
|
|
93
|
+
|
|
94
|
+
Obligations are advisory in v0.2 (not enforced); kept on the result so
|
|
95
|
+
sinks / audit logs can surface them.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
decision: PolicyDecision
|
|
99
|
+
reason: str = ""
|
|
100
|
+
obligations: list[dict[str, Any]] = field(default_factory=list)
|
|
101
|
+
engine: str = "" # populated by the concrete engine ("opa" | "cedar")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PolicyEngine(Protocol):
|
|
105
|
+
"""Pluggable backend for tool-use authorization.
|
|
106
|
+
|
|
107
|
+
Implementations: :class:`~computeragent.policy.opa.OpaPolicyEngine` (remote
|
|
108
|
+
HTTP) and :class:`~computeragent.policy.cedar.CedarPolicyEngine` (in-process
|
|
109
|
+
via ``cedarpy``).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
name: str
|
|
113
|
+
"""Short identifier used in telemetry events (``"opa"`` / ``"cedar"``)."""
|
|
114
|
+
|
|
115
|
+
async def evaluate(self, input: PolicyInput) -> PolicyResult: ...
|
|
116
|
+
|
|
117
|
+
async def aclose(self) -> None: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
FailMode = Literal["deny", "allow"]
|
|
121
|
+
"""What to return when the engine itself raises (network error, parse error)."""
|
computeragent/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Telemetry surface — pipeline, middleware, sinks, configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import configure, get_config, get_pipeline, shutdown
|
|
6
|
+
from .event import EventKind, TelemetryEvent, new_session_id
|
|
7
|
+
from .middleware.guardrails import GuardrailFilter
|
|
8
|
+
from .middleware.pii import PiiRedactor
|
|
9
|
+
from .pipeline import Pipeline, TelemetryMiddleware, TelemetrySink
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"EventKind",
|
|
13
|
+
"GuardrailFilter",
|
|
14
|
+
"PiiRedactor",
|
|
15
|
+
"Pipeline",
|
|
16
|
+
"TelemetryEvent",
|
|
17
|
+
"TelemetryMiddleware",
|
|
18
|
+
"TelemetrySink",
|
|
19
|
+
"configure",
|
|
20
|
+
"get_config",
|
|
21
|
+
"get_pipeline",
|
|
22
|
+
"new_session_id",
|
|
23
|
+
"shutdown",
|
|
24
|
+
]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Module-level pipeline singleton + ``configure()`` API.
|
|
2
|
+
|
|
3
|
+
Users either call :func:`configure` explicitly, or rely on the env-driven
|
|
4
|
+
defaults that :func:`get_pipeline` builds on first use. Both paths are safe
|
|
5
|
+
to interleave (later ``configure()`` replaces the lazy default).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from .pipeline import Pipeline, TelemetryMiddleware, TelemetrySink
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# Globals
|
|
21
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
_PIPELINE: Pipeline | None = None
|
|
24
|
+
_CONFIG: dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_pipeline() -> Pipeline:
|
|
28
|
+
"""Return the active pipeline, lazy-building from env if not configured."""
|
|
29
|
+
global _PIPELINE
|
|
30
|
+
if _PIPELINE is None:
|
|
31
|
+
_PIPELINE = _build_default_pipeline()
|
|
32
|
+
return _PIPELINE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def configure(
|
|
36
|
+
*,
|
|
37
|
+
middleware: list[TelemetryMiddleware] | None = None,
|
|
38
|
+
sinks: list[TelemetrySink] | None = None,
|
|
39
|
+
service_name: str | None = None,
|
|
40
|
+
exporter: Literal["console", "otlp-http"] | None = None,
|
|
41
|
+
endpoint: str | None = None,
|
|
42
|
+
headers: dict[str, str] | None = None,
|
|
43
|
+
sample_rate: float = 1.0,
|
|
44
|
+
capture_content: bool = False,
|
|
45
|
+
capture_content_mode: Literal["events", "attributes", "both"] = "events",
|
|
46
|
+
max_attribute_length: int = 8192,
|
|
47
|
+
fail_open: bool = False,
|
|
48
|
+
) -> Pipeline:
|
|
49
|
+
"""Programmatic configuration. Idempotent — replaces the active pipeline.
|
|
50
|
+
|
|
51
|
+
Auto-attaches the bundled :class:`~computeragent.telemetry.sinks.otel.OtelSink`
|
|
52
|
+
and :class:`~computeragent.telemetry.sinks.agentos.AgentRegistrySink` if their
|
|
53
|
+
extras are installed and not already in ``sinks``. Pass ``sinks=[]`` to
|
|
54
|
+
disable all auto-attachment.
|
|
55
|
+
"""
|
|
56
|
+
global _PIPELINE, _CONFIG
|
|
57
|
+
_CONFIG = {
|
|
58
|
+
"service_name": service_name or os.environ.get("OTEL_SERVICE_NAME", "computeragent"),
|
|
59
|
+
"exporter": exporter,
|
|
60
|
+
"endpoint": endpoint or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
61
|
+
"headers": headers
|
|
62
|
+
if headers is not None
|
|
63
|
+
else _parse_headers(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")),
|
|
64
|
+
"sample_rate": sample_rate,
|
|
65
|
+
"capture_content": capture_content,
|
|
66
|
+
"capture_content_mode": capture_content_mode,
|
|
67
|
+
"max_attribute_length": max_attribute_length,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
effective_sinks: list[TelemetrySink] = list(sinks) if sinks is not None else _auto_sinks()
|
|
71
|
+
_PIPELINE = Pipeline(
|
|
72
|
+
middleware=list(middleware or []),
|
|
73
|
+
sinks=effective_sinks,
|
|
74
|
+
fail_open=fail_open,
|
|
75
|
+
)
|
|
76
|
+
return _PIPELINE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def shutdown(timeout: float = 5.0) -> None:
|
|
80
|
+
"""Flush and close the active pipeline. Safe to call multiple times."""
|
|
81
|
+
global _PIPELINE
|
|
82
|
+
if _PIPELINE is None:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
await _PIPELINE.aclose()
|
|
86
|
+
finally:
|
|
87
|
+
_PIPELINE = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
91
|
+
# Internals
|
|
92
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_config() -> dict[str, Any]:
|
|
96
|
+
"""Expose the resolved config to sinks (read-only contract)."""
|
|
97
|
+
return dict(_CONFIG)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_default_pipeline() -> Pipeline:
|
|
101
|
+
"""Construct a pipeline from env vars only. No middleware by default."""
|
|
102
|
+
global _CONFIG
|
|
103
|
+
_CONFIG = {
|
|
104
|
+
"service_name": os.environ.get("OTEL_SERVICE_NAME", "computeragent"),
|
|
105
|
+
"exporter": None,
|
|
106
|
+
"endpoint": os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
107
|
+
"headers": _parse_headers(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")),
|
|
108
|
+
"sample_rate": 1.0,
|
|
109
|
+
"capture_content": _env_bool("COMPUTERAGENT_CAPTURE_CONTENT", default=False),
|
|
110
|
+
"capture_content_mode": os.environ.get("COMPUTERAGENT_CAPTURE_CONTENT_MODE", "events"),
|
|
111
|
+
"max_attribute_length": int(os.environ.get("COMPUTERAGENT_MAX_ATTRIBUTE_LENGTH", "8192")),
|
|
112
|
+
}
|
|
113
|
+
return Pipeline(middleware=[], sinks=_auto_sinks(), fail_open=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _auto_sinks() -> list[TelemetrySink]:
|
|
117
|
+
"""Build the sink list implied by environment variables.
|
|
118
|
+
|
|
119
|
+
OTel sink: on when ``COMPUTERAGENT_OTEL!=disabled`` (default on) AND the
|
|
120
|
+
``opentelemetry-*`` packages are importable. Falls back to console
|
|
121
|
+
exporter when no OTLP endpoint is configured.
|
|
122
|
+
|
|
123
|
+
AgentOS sink: on when ``AGENTOS_MONGO_URL`` is set AND ``motor`` is
|
|
124
|
+
importable.
|
|
125
|
+
"""
|
|
126
|
+
sinks: list[TelemetrySink] = []
|
|
127
|
+
|
|
128
|
+
if os.environ.get("COMPUTERAGENT_OTEL", "").lower() != "disabled":
|
|
129
|
+
try:
|
|
130
|
+
from .sinks.otel import OtelSink # noqa: WPS433
|
|
131
|
+
|
|
132
|
+
sinks.append(OtelSink())
|
|
133
|
+
except ImportError:
|
|
134
|
+
logger.debug("OtelSink not attached (computer-agent-py[otel] not installed)")
|
|
135
|
+
|
|
136
|
+
mongo_url = os.environ.get("AGENTOS_MONGO_URL")
|
|
137
|
+
if mongo_url:
|
|
138
|
+
try:
|
|
139
|
+
from .sinks.agentos import AgentRegistrySink # noqa: WPS433
|
|
140
|
+
|
|
141
|
+
sinks.append(AgentRegistrySink(mongo_url=mongo_url))
|
|
142
|
+
except ImportError:
|
|
143
|
+
logger.debug(
|
|
144
|
+
"AgentRegistrySink not attached (computer-agent-py[agentos] not installed)"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return sinks
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_headers(raw: str | None) -> dict[str, str]:
|
|
151
|
+
if not raw:
|
|
152
|
+
return {}
|
|
153
|
+
out: dict[str, str] = {}
|
|
154
|
+
for pair in raw.split(","):
|
|
155
|
+
if "=" not in pair:
|
|
156
|
+
continue
|
|
157
|
+
k, _, v = pair.partition("=")
|
|
158
|
+
k = k.strip()
|
|
159
|
+
v = v.strip()
|
|
160
|
+
if k:
|
|
161
|
+
out[k] = v
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _env_bool(name: str, *, default: bool) -> bool:
|
|
166
|
+
raw = os.environ.get(name)
|
|
167
|
+
if raw is None:
|
|
168
|
+
return default
|
|
169
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _reset_for_tests() -> None:
|
|
173
|
+
"""Test hook — drop the cached pipeline so the next call rebuilds it."""
|
|
174
|
+
global _PIPELINE, _CONFIG
|
|
175
|
+
_PIPELINE = None
|
|
176
|
+
_CONFIG = {}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""``TelemetryEvent`` — the unit that flows through the telemetry pipeline.
|
|
2
|
+
|
|
3
|
+
One event is emitted for each meaningful thing that happens during a query:
|
|
4
|
+
session start, each message yielded, each tool call, the final usage snapshot,
|
|
5
|
+
and session end. Middleware can mutate ``payload`` (the user-visible projection)
|
|
6
|
+
or drop the event entirely; sinks consume the event and write it somewhere.
|
|
7
|
+
|
|
8
|
+
The ``raw`` reference points back to the original upstream object so sinks
|
|
9
|
+
that need the verbatim shape (e.g. a debug audit log) can grab it. Sinks that
|
|
10
|
+
ship content to external systems should read from ``payload`` so middleware
|
|
11
|
+
redaction is honoured.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
23
|
+
|
|
24
|
+
EventKind = Literal[
|
|
25
|
+
"session_started",
|
|
26
|
+
"user_message",
|
|
27
|
+
"assistant_message",
|
|
28
|
+
"system_message",
|
|
29
|
+
"tool_use",
|
|
30
|
+
"tool_result",
|
|
31
|
+
"usage_snapshot",
|
|
32
|
+
"session_ended",
|
|
33
|
+
"policy_decision",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _utcnow() -> datetime:
|
|
38
|
+
return datetime.now(timezone.utc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TelemetryEvent:
|
|
43
|
+
"""Single unit of telemetry. Mutable: middleware modifies ``payload`` in place."""
|
|
44
|
+
|
|
45
|
+
kind: EventKind
|
|
46
|
+
session_id: str
|
|
47
|
+
timestamp: datetime = field(default_factory=_utcnow)
|
|
48
|
+
agent_name: str | None = None
|
|
49
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
raw: Any = None
|
|
51
|
+
|
|
52
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
53
|
+
# Factories — keep all the upstream-shape-to-event-shape mapping in one
|
|
54
|
+
# place so message_translate-style spaghetti doesn't end up scattered.
|
|
55
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def session_started(
|
|
59
|
+
cls,
|
|
60
|
+
*,
|
|
61
|
+
session_id: str,
|
|
62
|
+
agent_name: str | None,
|
|
63
|
+
options: Any,
|
|
64
|
+
prompt: Any,
|
|
65
|
+
) -> TelemetryEvent:
|
|
66
|
+
# Capture only metadata that a sink might want — never the full options
|
|
67
|
+
# object, which can hold callables (hooks, can_use_tool) that don't
|
|
68
|
+
# serialize.
|
|
69
|
+
# `system_prompt` may be a string OR a preset/file object — only persist
|
|
70
|
+
# the string form, since the inline-source manifest we build downstream
|
|
71
|
+
# treats it as plain CLAUDE.md content.
|
|
72
|
+
sp = getattr(options, "system_prompt", None)
|
|
73
|
+
system_prompt = sp if isinstance(sp, str) else None
|
|
74
|
+
payload: dict[str, Any] = {
|
|
75
|
+
"model": getattr(options, "model", None),
|
|
76
|
+
"system_prompt": system_prompt,
|
|
77
|
+
"permission_mode": getattr(options, "permission_mode", None),
|
|
78
|
+
"allowed_tools": list(getattr(options, "allowed_tools", []) or []),
|
|
79
|
+
"disallowed_tools": list(getattr(options, "disallowed_tools", []) or []),
|
|
80
|
+
"max_turns": getattr(options, "max_turns", None),
|
|
81
|
+
"cwd": str(getattr(options, "cwd", "") or ""),
|
|
82
|
+
"add_dirs": [str(p) for p in (getattr(options, "add_dirs", []) or [])],
|
|
83
|
+
"prompt": prompt if isinstance(prompt, str) else "<streaming>",
|
|
84
|
+
}
|
|
85
|
+
return cls(
|
|
86
|
+
kind="session_started",
|
|
87
|
+
session_id=session_id,
|
|
88
|
+
agent_name=agent_name,
|
|
89
|
+
payload=payload,
|
|
90
|
+
raw=options,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def session_ended_ok(
|
|
95
|
+
cls,
|
|
96
|
+
*,
|
|
97
|
+
session_id: str,
|
|
98
|
+
agent_name: str | None,
|
|
99
|
+
result: Any,
|
|
100
|
+
duration_ms: float,
|
|
101
|
+
last_assistant_text: str,
|
|
102
|
+
) -> TelemetryEvent:
|
|
103
|
+
payload: dict[str, Any] = {
|
|
104
|
+
"is_error": bool(getattr(result, "is_error", False)),
|
|
105
|
+
"subtype": getattr(result, "subtype", "success"),
|
|
106
|
+
"result": getattr(result, "result", "") or last_assistant_text,
|
|
107
|
+
"num_turns": int(getattr(result, "num_turns", 0) or 0),
|
|
108
|
+
"total_cost_usd": float(getattr(result, "total_cost_usd", 0.0) or 0.0),
|
|
109
|
+
"duration_ms": duration_ms,
|
|
110
|
+
"duration_api_ms": float(getattr(result, "duration_api_ms", 0.0) or 0.0),
|
|
111
|
+
"usage": _extract_usage(result),
|
|
112
|
+
}
|
|
113
|
+
return cls(
|
|
114
|
+
kind="session_ended",
|
|
115
|
+
session_id=session_id,
|
|
116
|
+
agent_name=agent_name,
|
|
117
|
+
payload=payload,
|
|
118
|
+
raw=result,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def session_ended_error(
|
|
123
|
+
cls,
|
|
124
|
+
*,
|
|
125
|
+
session_id: str,
|
|
126
|
+
agent_name: str | None,
|
|
127
|
+
exc: BaseException,
|
|
128
|
+
duration_ms: float,
|
|
129
|
+
) -> TelemetryEvent:
|
|
130
|
+
return cls(
|
|
131
|
+
kind="session_ended",
|
|
132
|
+
session_id=session_id,
|
|
133
|
+
agent_name=agent_name,
|
|
134
|
+
payload={
|
|
135
|
+
"is_error": True,
|
|
136
|
+
"subtype": "error",
|
|
137
|
+
"error_type": type(exc).__name__,
|
|
138
|
+
"error_message": str(exc),
|
|
139
|
+
"duration_ms": duration_ms,
|
|
140
|
+
"result": "",
|
|
141
|
+
"num_turns": 0,
|
|
142
|
+
"total_cost_usd": 0.0,
|
|
143
|
+
"usage": {},
|
|
144
|
+
},
|
|
145
|
+
raw=exc,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def policy_decision(
|
|
150
|
+
cls,
|
|
151
|
+
*,
|
|
152
|
+
input: Any,
|
|
153
|
+
result: Any,
|
|
154
|
+
error: BaseException | None,
|
|
155
|
+
latency_ms: float,
|
|
156
|
+
engine_name: str,
|
|
157
|
+
) -> TelemetryEvent:
|
|
158
|
+
"""Emitted by ``PolicyToolAuthorizer`` after each authorization check.
|
|
159
|
+
|
|
160
|
+
``input`` is a :class:`computeragent.policy.PolicyInput` and ``result``
|
|
161
|
+
a :class:`PolicyResult` (or ``None`` if the engine raised). Sinks pick
|
|
162
|
+
this up via the standard pipeline; ``OtelSink`` annotates the active
|
|
163
|
+
``execute_tool`` span with the decision and reason.
|
|
164
|
+
"""
|
|
165
|
+
principal = getattr(input, "principal", None)
|
|
166
|
+
action = getattr(input, "action", None)
|
|
167
|
+
resource = getattr(input, "resource", None)
|
|
168
|
+
decision_value = getattr(getattr(result, "decision", None), "value", None)
|
|
169
|
+
payload: dict[str, Any] = {
|
|
170
|
+
"engine": engine_name,
|
|
171
|
+
"decision": decision_value or ("error" if error else "unknown"),
|
|
172
|
+
"reason": getattr(result, "reason", "") if result is not None else "",
|
|
173
|
+
"latency_ms": latency_ms,
|
|
174
|
+
"principal_id": getattr(principal, "id", ""),
|
|
175
|
+
"principal_groups": list(getattr(principal, "groups", []) or []),
|
|
176
|
+
"tool_name": getattr(action, "tool_name", ""),
|
|
177
|
+
"agent_name": getattr(resource, "agent_name", ""),
|
|
178
|
+
"session_id": getattr(resource, "session_id", "")
|
|
179
|
+
or getattr(input, "context", {}).get("session_id", ""),
|
|
180
|
+
}
|
|
181
|
+
if error is not None:
|
|
182
|
+
payload["error_type"] = type(error).__name__
|
|
183
|
+
payload["error_message"] = str(error)
|
|
184
|
+
return cls(
|
|
185
|
+
kind="policy_decision",
|
|
186
|
+
session_id=payload["session_id"] or "unknown",
|
|
187
|
+
agent_name=payload["agent_name"] or None,
|
|
188
|
+
payload=payload,
|
|
189
|
+
raw=result if result is not None else error,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_message(
|
|
194
|
+
cls, msg: Any, *, session_id: str, agent_name: str | None
|
|
195
|
+
) -> list[TelemetryEvent]:
|
|
196
|
+
"""Map one upstream message to zero or more telemetry events.
|
|
197
|
+
|
|
198
|
+
The kinds emitted depend on the upstream message type and any content
|
|
199
|
+
blocks it carries. The translation here is deliberately tolerant: if
|
|
200
|
+
upstream adds a new field we don't recognise, we just don't emit
|
|
201
|
+
anything for it rather than raising.
|
|
202
|
+
"""
|
|
203
|
+
# Lazy import — only need the types at runtime when translation runs.
|
|
204
|
+
from claude_agent_sdk.types import (
|
|
205
|
+
AssistantMessage,
|
|
206
|
+
ResultMessage,
|
|
207
|
+
SystemMessage,
|
|
208
|
+
ToolResultBlock,
|
|
209
|
+
ToolUseBlock,
|
|
210
|
+
UserMessage,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
events: list[TelemetryEvent] = []
|
|
214
|
+
|
|
215
|
+
if isinstance(msg, AssistantMessage):
|
|
216
|
+
text_parts: list[str] = []
|
|
217
|
+
for block in getattr(msg, "content", []) or []:
|
|
218
|
+
if isinstance(block, ToolUseBlock):
|
|
219
|
+
events.append(
|
|
220
|
+
cls(
|
|
221
|
+
kind="tool_use",
|
|
222
|
+
session_id=session_id,
|
|
223
|
+
agent_name=agent_name,
|
|
224
|
+
payload={
|
|
225
|
+
"tool_use_id": getattr(block, "id", ""),
|
|
226
|
+
"tool_name": getattr(block, "name", ""),
|
|
227
|
+
"tool_input": dict(getattr(block, "input", {}) or {}),
|
|
228
|
+
},
|
|
229
|
+
raw=block,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
text = getattr(block, "text", None)
|
|
234
|
+
if isinstance(text, str):
|
|
235
|
+
text_parts.append(text)
|
|
236
|
+
events.append(
|
|
237
|
+
cls(
|
|
238
|
+
kind="assistant_message",
|
|
239
|
+
session_id=session_id,
|
|
240
|
+
agent_name=agent_name,
|
|
241
|
+
payload={
|
|
242
|
+
"model": getattr(msg, "model", None),
|
|
243
|
+
"text": "".join(text_parts),
|
|
244
|
+
"block_count": len(getattr(msg, "content", []) or []),
|
|
245
|
+
},
|
|
246
|
+
raw=msg,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
elif isinstance(msg, UserMessage):
|
|
250
|
+
tool_results: list[dict[str, Any]] = []
|
|
251
|
+
text_parts = []
|
|
252
|
+
for block in getattr(msg, "content", []) or []:
|
|
253
|
+
if isinstance(block, ToolResultBlock):
|
|
254
|
+
tool_results.append(
|
|
255
|
+
{
|
|
256
|
+
"tool_use_id": getattr(block, "tool_use_id", ""),
|
|
257
|
+
"is_error": bool(getattr(block, "is_error", False)),
|
|
258
|
+
"content": getattr(block, "content", ""),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
text = getattr(block, "text", None)
|
|
263
|
+
if isinstance(text, str):
|
|
264
|
+
text_parts.append(text)
|
|
265
|
+
if tool_results:
|
|
266
|
+
for tr in tool_results:
|
|
267
|
+
events.append(
|
|
268
|
+
cls(
|
|
269
|
+
kind="tool_result",
|
|
270
|
+
session_id=session_id,
|
|
271
|
+
agent_name=agent_name,
|
|
272
|
+
payload=tr,
|
|
273
|
+
raw=msg,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
if text_parts:
|
|
277
|
+
events.append(
|
|
278
|
+
cls(
|
|
279
|
+
kind="user_message",
|
|
280
|
+
session_id=session_id,
|
|
281
|
+
agent_name=agent_name,
|
|
282
|
+
payload={"text": "".join(text_parts)},
|
|
283
|
+
raw=msg,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
elif isinstance(msg, SystemMessage):
|
|
287
|
+
events.append(
|
|
288
|
+
cls(
|
|
289
|
+
kind="system_message",
|
|
290
|
+
session_id=session_id,
|
|
291
|
+
agent_name=agent_name,
|
|
292
|
+
payload={
|
|
293
|
+
"subtype": getattr(msg, "subtype", ""),
|
|
294
|
+
"data": dict(getattr(msg, "data", {}) or {}),
|
|
295
|
+
},
|
|
296
|
+
raw=msg,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
elif isinstance(msg, ResultMessage):
|
|
300
|
+
# ResultMessage also drives a `usage_snapshot` so token + cost
|
|
301
|
+
# telemetry doesn't only appear at session_ended (sinks that
|
|
302
|
+
# record metrics per session want the histogram measurement now).
|
|
303
|
+
events.append(
|
|
304
|
+
cls(
|
|
305
|
+
kind="usage_snapshot",
|
|
306
|
+
session_id=session_id,
|
|
307
|
+
agent_name=agent_name,
|
|
308
|
+
payload={
|
|
309
|
+
"model": _model_from_options_or_result(msg),
|
|
310
|
+
"total_cost_usd": float(getattr(msg, "total_cost_usd", 0.0) or 0.0),
|
|
311
|
+
"usage": _extract_usage(msg),
|
|
312
|
+
},
|
|
313
|
+
raw=msg,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
# Anything else (StreamEvent, RateLimitEvent, TaskStartedMessage, …):
|
|
317
|
+
# skip silently. These can be added later if a sink needs them.
|
|
318
|
+
return events
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _extract_usage(result_like: Any) -> dict[str, int | float | None]:
|
|
322
|
+
"""Pull tokens + cache fields out of a ResultMessage's nested usage shape."""
|
|
323
|
+
usage = getattr(result_like, "usage", None)
|
|
324
|
+
if usage is None:
|
|
325
|
+
return {}
|
|
326
|
+
# Pydantic-style models, dataclasses, or dicts — handle all three.
|
|
327
|
+
if hasattr(usage, "model_dump"):
|
|
328
|
+
try:
|
|
329
|
+
return dict(usage.model_dump())
|
|
330
|
+
except Exception: # noqa: BLE001 - defensive
|
|
331
|
+
logger.debug("usage.model_dump() failed", exc_info=True)
|
|
332
|
+
if hasattr(usage, "__dataclass_fields__"):
|
|
333
|
+
return {k: getattr(usage, k, None) for k in usage.__dataclass_fields__}
|
|
334
|
+
if isinstance(usage, dict):
|
|
335
|
+
return dict(usage)
|
|
336
|
+
# Last resort — pick up the conventional Anthropic fields if they exist.
|
|
337
|
+
return {
|
|
338
|
+
k: getattr(usage, k, None)
|
|
339
|
+
for k in (
|
|
340
|
+
"input_tokens",
|
|
341
|
+
"output_tokens",
|
|
342
|
+
"cache_read_input_tokens",
|
|
343
|
+
"cache_creation_input_tokens",
|
|
344
|
+
)
|
|
345
|
+
if hasattr(usage, k)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _model_from_options_or_result(result: Any) -> str | None:
|
|
350
|
+
return getattr(result, "model", None)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def new_session_id() -> str:
|
|
354
|
+
"""Generate a placeholder session id used before the upstream returns one."""
|
|
355
|
+
return f"ca_{uuid.uuid4().hex}"
|