computer-agent-py 0.1.0__tar.gz → 0.1.3__tar.gz

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 (25) hide show
  1. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/PKG-INFO +3 -2
  2. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/README.md +2 -1
  3. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/pyproject.toml +1 -1
  4. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/_proxy/client.py +9 -1
  5. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/_proxy/query.py +33 -7
  6. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/config.py +8 -0
  7. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/event.py +8 -0
  8. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/agentos.py +20 -3
  9. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/otel.py +303 -11
  10. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/__init__.py +0 -0
  11. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/_proxy/__init__.py +0 -0
  12. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/policy/__init__.py +0 -0
  13. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/policy/authorizer.py +0 -0
  14. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/policy/cedar.py +0 -0
  15. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/policy/opa.py +0 -0
  16. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/policy/types.py +0 -0
  17. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/py.typed +0 -0
  18. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/__init__.py +0 -0
  19. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/__init__.py +0 -0
  20. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/guardrails.py +0 -0
  21. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/pii.py +0 -0
  22. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/pipeline.py +0 -0
  23. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/__init__.py +0 -0
  24. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/message_archive.py +0 -0
  25. {computer_agent_py-0.1.0 → computer_agent_py-0.1.3}/src/computeragent/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: computer-agent-py
3
- Version: 0.1.0
3
+ Version: 0.1.3
4
4
  Summary: Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks.
5
5
  Keywords: computeragent,claude-agent-sdk,claude,agent,telemetry,otel,opentelemetry,agentos,pii
6
6
  Author: Abhi Bhat
@@ -114,10 +114,11 @@ That's it — same shape as `claude-agent-sdk`. Telemetry is configured from env
114
114
 
115
115
  | Variable | Effect |
116
116
  |---|---|
117
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | Enables OTLP/HTTP export. Unset → console exporter (debug). |
117
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base URL of an OTLP/HTTP backend. The sink appends `/v1/traces` and `/v1/metrics` automatically — pass the base. Unset → console exporter (debug). |
118
118
  | `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` (e.g. `api-key=NRRX-...` for New Relic). |
119
119
  | `OTEL_SERVICE_NAME` | `service.name` attribute on every span. Default: `computeragent`. |
120
120
  | `COMPUTERAGENT_OTEL` | `disabled` to suppress OtelSink. |
121
+ | `COMPUTERAGENT_OTEL_TEMPORALITY` | `delta` (default) or `cumulative`. SaaS OTLP intakes (New Relic, Datadog direct, Honeycomb) require delta; cumulative is only correct when exporting to a collector that aggregates downstream. |
121
122
  | `AGENTOS_MONGO_URL` | When set + `[agentos]` installed, attaches AgentOS sinks (registry, logs, sessions, agent_messages, slack_threads). |
122
123
  | `AGENTOS_MONGO_DB` | Mongo database name. Default: `agentos`. |
123
124
  | `COMPUTERAGENT_CAPTURE_CONTENT` | `1` to include prompts/responses on OTel spans. Default: off. |
@@ -71,10 +71,11 @@ That's it — same shape as `claude-agent-sdk`. Telemetry is configured from env
71
71
 
72
72
  | Variable | Effect |
73
73
  |---|---|
74
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | Enables OTLP/HTTP export. Unset → console exporter (debug). |
74
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base URL of an OTLP/HTTP backend. The sink appends `/v1/traces` and `/v1/metrics` automatically — pass the base. Unset → console exporter (debug). |
75
75
  | `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` (e.g. `api-key=NRRX-...` for New Relic). |
76
76
  | `OTEL_SERVICE_NAME` | `service.name` attribute on every span. Default: `computeragent`. |
77
77
  | `COMPUTERAGENT_OTEL` | `disabled` to suppress OtelSink. |
78
+ | `COMPUTERAGENT_OTEL_TEMPORALITY` | `delta` (default) or `cumulative`. SaaS OTLP intakes (New Relic, Datadog direct, Honeycomb) require delta; cumulative is only correct when exporting to a collector that aggregates downstream. |
78
79
  | `AGENTOS_MONGO_URL` | When set + `[agentos]` installed, attaches AgentOS sinks (registry, logs, sessions, agent_messages, slack_threads). |
79
80
  | `AGENTOS_MONGO_DB` | Mongo database name. Default: `agentos`. |
80
81
  | `COMPUTERAGENT_CAPTURE_CONTENT` | `1` to include prompts/responses on OTel spans. Default: off. |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "computer-agent-py"
3
- version = "0.1.0"
3
+ version = "0.1.3"
4
4
  description = "Drop-in replacement for claude-agent-sdk that adds a proxied telemetry pipeline (PII redaction + guardrails) with OpenTelemetry and AgentOS sinks."
5
5
  authors = [
6
6
  { name = "Abhi Bhat", email = "abhishek.bhat@lyzr.ai" },
@@ -20,7 +20,7 @@ import claude_agent_sdk
20
20
  from claude_agent_sdk.types import AssistantMessage, ResultMessage, TextBlock
21
21
 
22
22
  from ..telemetry import TelemetryEvent, get_pipeline, new_session_id
23
- from .query import _derive_agent_name
23
+ from .query import _derive_agent_description, _derive_agent_name
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from collections.abc import AsyncIterable, AsyncIterator
@@ -50,6 +50,7 @@ class ClaudeSDKClient:
50
50
  # placeholder-vs-upstream rationale).
51
51
  self._upstream_session_id: str | None = None
52
52
  self._agent_name = _derive_agent_name(options)
53
+ self._agent_description = _derive_agent_description(options)
53
54
  self._started_at: float | None = None
54
55
  self._last_result: ResultMessage | None = None
55
56
  self._last_assistant_text: str = ""
@@ -75,6 +76,7 @@ class ClaudeSDKClient:
75
76
  TelemetryEvent.session_started(
76
77
  session_id=self._session_id,
77
78
  agent_name=self._agent_name,
79
+ agent_description=self._agent_description,
78
80
  options=self._options,
79
81
  prompt=prompt if prompt is not None else "<connect>",
80
82
  )
@@ -122,6 +124,8 @@ class ClaudeSDKClient:
122
124
  for ev in TelemetryEvent.from_message(
123
125
  msg, session_id=self._session_id, agent_name=self._agent_name
124
126
  ):
127
+ if self._agent_description is not None:
128
+ ev.agent_description = self._agent_description
125
129
  await pipeline.emit(ev)
126
130
  if isinstance(msg, AssistantMessage):
127
131
  for block in getattr(msg, "content", []) or []:
@@ -145,6 +149,8 @@ class ClaudeSDKClient:
145
149
  for ev in TelemetryEvent.from_message(
146
150
  msg, session_id=self._session_id, agent_name=self._agent_name
147
151
  ):
152
+ if self._agent_description is not None:
153
+ ev.agent_description = self._agent_description
148
154
  await pipeline.emit(ev)
149
155
  if isinstance(msg, AssistantMessage):
150
156
  for block in getattr(msg, "content", []) or []:
@@ -203,6 +209,7 @@ class ClaudeSDKClient:
203
209
  duration_ms=duration_ms,
204
210
  last_assistant_text=self._last_assistant_text,
205
211
  )
212
+ ev.agent_description = self._agent_description
206
213
  if self._upstream_session_id is not None:
207
214
  ev.payload["upstream_session_id"] = self._upstream_session_id
208
215
  await pipeline.emit(ev)
@@ -212,6 +219,7 @@ class ClaudeSDKClient:
212
219
  kind="session_ended",
213
220
  session_id=self._session_id,
214
221
  agent_name=self._agent_name,
222
+ agent_description=self._agent_description,
215
223
  payload={
216
224
  "is_error": False,
217
225
  "subtype": "no_result",
@@ -39,6 +39,7 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
39
39
  pipeline = get_pipeline()
40
40
  session_id = new_session_id()
41
41
  agent_name = _derive_agent_name(options)
42
+ agent_description = _derive_agent_description(options)
42
43
  started_at = time.monotonic()
43
44
  last_assistant_text = ""
44
45
 
@@ -47,6 +48,7 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
47
48
  TelemetryEvent.session_started(
48
49
  session_id=session_id,
49
50
  agent_name=agent_name,
51
+ agent_description=agent_description,
50
52
  options=options,
51
53
  prompt=prompt,
52
54
  )
@@ -78,6 +80,11 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
78
80
  for ev in TelemetryEvent.from_message(
79
81
  msg, session_id=session_id, agent_name=agent_name
80
82
  ):
83
+ # Carry the description forward on every event so sinks that
84
+ # group by description (or want to surface it on per-message
85
+ # telemetry) don't have to back-reference session_started.
86
+ if agent_description is not None:
87
+ ev.agent_description = agent_description
81
88
  await pipeline.emit(ev)
82
89
 
83
90
  if isinstance(msg, AssistantMessage):
@@ -100,6 +107,7 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
100
107
  duration_ms=duration_ms,
101
108
  last_assistant_text=last_assistant_text,
102
109
  )
110
+ ev.agent_description = agent_description
103
111
  if upstream_session_id is not None:
104
112
  ev.payload["upstream_session_id"] = upstream_session_id
105
113
  await pipeline.emit(ev)
@@ -110,6 +118,7 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
110
118
  kind="session_ended",
111
119
  session_id=session_id,
112
120
  agent_name=agent_name,
121
+ agent_description=agent_description,
113
122
  payload={
114
123
  "is_error": False,
115
124
  "subtype": "no_result",
@@ -124,14 +133,14 @@ async def query( # noqa: PLR0912 - mirror upstream's tolerant param surface
124
133
  except BaseException as exc:
125
134
  duration_ms = (time.monotonic() - started_at) * 1000.0
126
135
  try:
127
- await pipeline.emit(
128
- TelemetryEvent.session_ended_error(
129
- session_id=session_id,
130
- agent_name=agent_name,
131
- exc=exc,
132
- duration_ms=duration_ms,
133
- )
136
+ err_ev = TelemetryEvent.session_ended_error(
137
+ session_id=session_id,
138
+ agent_name=agent_name,
139
+ exc=exc,
140
+ duration_ms=duration_ms,
134
141
  )
142
+ err_ev.agent_description = agent_description
143
+ await pipeline.emit(err_ev)
135
144
  except Exception: # noqa: BLE001
136
145
  logger.debug("error-path telemetry emit failed", exc_info=True)
137
146
  raise
@@ -163,3 +172,20 @@ def _derive_agent_name(options: Any) -> str | None:
163
172
  if first:
164
173
  return first[:80]
165
174
  return None
175
+
176
+
177
+ def _derive_agent_description(options: Any) -> str | None:
178
+ """Pick an agent description from options.
179
+
180
+ Mirrors :func:`_derive_agent_name` — the description is purely opt-in.
181
+ Callers set it on options via ``options.agent_description = "..."`` and
182
+ the proxy threads it through to the telemetry pipeline. Unlike the
183
+ name, there's no implicit fallback to system_prompt: a description is
184
+ a human label, not something we want to synthesize from the prompt.
185
+ """
186
+ if options is None:
187
+ return None
188
+ desc = getattr(options, "agent_description", None)
189
+ if isinstance(desc, str) and desc:
190
+ return desc
191
+ return None
@@ -45,6 +45,7 @@ def configure(
45
45
  capture_content_mode: Literal["events", "attributes", "both"] = "events",
46
46
  max_attribute_length: int = 8192,
47
47
  fail_open: bool = False,
48
+ temporality: Literal["delta", "cumulative"] | None = None,
48
49
  ) -> Pipeline:
49
50
  """Programmatic configuration. Idempotent — replaces the active pipeline.
50
51
 
@@ -52,6 +53,11 @@ def configure(
52
53
  and :class:`~computeragent.telemetry.sinks.agentos.AgentRegistrySink` if their
53
54
  extras are installed and not already in ``sinks``. Pass ``sinks=[]`` to
54
55
  disable all auto-attachment.
56
+
57
+ ``temporality`` controls metric aggregation temporality. Defaults to
58
+ delta (set by OtelSink) which is what every major SaaS OTLP intake
59
+ requires — pass ``"cumulative"`` only when exporting to a collector that
60
+ aggregates downstream.
55
61
  """
56
62
  global _PIPELINE, _CONFIG
57
63
  _CONFIG = {
@@ -65,6 +71,7 @@ def configure(
65
71
  "capture_content": capture_content,
66
72
  "capture_content_mode": capture_content_mode,
67
73
  "max_attribute_length": max_attribute_length,
74
+ "temporality": temporality,
68
75
  }
69
76
 
70
77
  effective_sinks: list[TelemetrySink] = list(sinks) if sinks is not None else _auto_sinks()
@@ -106,6 +113,7 @@ def _build_default_pipeline() -> Pipeline:
106
113
  "endpoint": os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
107
114
  "headers": _parse_headers(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS")),
108
115
  "sample_rate": 1.0,
116
+ "temporality": os.environ.get("COMPUTERAGENT_OTEL_TEMPORALITY"),
109
117
  "capture_content": _env_bool("COMPUTERAGENT_CAPTURE_CONTENT", default=False),
110
118
  "capture_content_mode": os.environ.get("COMPUTERAGENT_CAPTURE_CONTENT_MODE", "events"),
111
119
  "max_attribute_length": int(os.environ.get("COMPUTERAGENT_MAX_ATTRIBUTE_LENGTH", "8192")),
@@ -46,6 +46,12 @@ class TelemetryEvent:
46
46
  session_id: str
47
47
  timestamp: datetime = field(default_factory=_utcnow)
48
48
  agent_name: str | None = None
49
+ # Human-readable description of the agent. Mirrors `agent_name`'s
50
+ # opt-in shape: derived from ``options.agent_description`` (set via
51
+ # setattr by the caller) and propagated through the same event path.
52
+ # ``None`` when the caller doesn't supply one — sinks fall back to an
53
+ # empty string in the registry doc.
54
+ agent_description: str | None = None
49
55
  payload: dict[str, Any] = field(default_factory=dict)
50
56
  raw: Any = None
51
57
 
@@ -60,6 +66,7 @@ class TelemetryEvent:
60
66
  *,
61
67
  session_id: str,
62
68
  agent_name: str | None,
69
+ agent_description: str | None = None,
63
70
  options: Any,
64
71
  prompt: Any,
65
72
  ) -> TelemetryEvent:
@@ -86,6 +93,7 @@ class TelemetryEvent:
86
93
  kind="session_started",
87
94
  session_id=session_id,
88
95
  agent_name=agent_name,
96
+ agent_description=agent_description,
89
97
  payload=payload,
90
98
  raw=options,
91
99
  )
@@ -151,6 +151,7 @@ class AgentRegistrySink:
151
151
 
152
152
  async def _upsert_registry(self, event: TelemetryEvent) -> None:
153
153
  name = event.agent_name or _agent_name_from_event(event)
154
+ description = event.agent_description or ""
154
155
  now = _utcnow()
155
156
  await self._registry.update_one(
156
157
  {"_id": name},
@@ -161,8 +162,12 @@ class AgentRegistrySink:
161
162
  },
162
163
  "$set": {
163
164
  "harness": "claude-agent-sdk",
164
- "source": _inline_source_for(name, event.payload),
165
+ # `source.manifest` carries the description too so any
166
+ # downstream consumer that reads the inline manifest
167
+ # gets the same value as the top-level `description`.
168
+ "source": _inline_source_for(name, event.payload, description),
165
169
  "model": event.payload.get("model"),
170
+ "description": description,
166
171
  "registeredBy": self._registered_by,
167
172
  "updatedAt": now,
168
173
  "lastSeen": now,
@@ -359,7 +364,11 @@ DEFAULT_AGENT_INSTRUCTIONS = (
359
364
  )
360
365
 
361
366
 
362
- def _inline_source_for(name: str, options_payload: dict[str, Any]) -> dict[str, Any]:
367
+ def _inline_source_for(
368
+ name: str,
369
+ options_payload: dict[str, Any],
370
+ description: str = "",
371
+ ) -> dict[str, Any]:
363
372
  """Build a complete inline ``IdentitySource`` the harness can clone into a
364
373
  sandbox workdir for live chat.
365
374
 
@@ -386,11 +395,16 @@ def _inline_source_for(name: str, options_payload: dict[str, Any]) -> dict[str,
386
395
  allowed_tools=allowed_tools,
387
396
  max_turns=max_turns,
388
397
  permission_mode=permission_mode,
398
+ description=description,
389
399
  )
390
400
 
401
+ manifest: dict[str, Any] = {"name": name, "version": "0.1.0"}
402
+ if description:
403
+ manifest["description"] = description
404
+
391
405
  return {
392
406
  "type": "inline",
393
- "manifest": {"name": name, "version": "0.1.0"},
407
+ "manifest": manifest,
394
408
  "model": model,
395
409
  "files": {
396
410
  "agent.yaml": agent_yaml,
@@ -406,6 +420,7 @@ def _build_agent_yaml(
406
420
  allowed_tools: list[str],
407
421
  max_turns: int | None,
408
422
  permission_mode: str,
423
+ description: str = "",
409
424
  ) -> str:
410
425
  """Render the agent.yaml the harness's inline loader expects.
411
426
 
@@ -419,6 +434,8 @@ def _build_agent_yaml(
419
434
  f"name: {safe_name}",
420
435
  "version: 0.1.0",
421
436
  ]
437
+ if description:
438
+ lines.append(f"description: {_yaml_str(description)}")
422
439
  if model:
423
440
  lines += [
424
441
  "model:",
@@ -22,7 +22,8 @@ from __future__ import annotations
22
22
 
23
23
  import contextlib
24
24
  import logging
25
- from typing import TYPE_CHECKING, Any
25
+ import os
26
+ from typing import TYPE_CHECKING, Any, Literal
26
27
 
27
28
  try:
28
29
  from opentelemetry import metrics, trace # noqa: F401 - probe imports only
@@ -33,7 +34,26 @@ try:
33
34
  OTLPSpanExporter as HttpSpanExporter,
34
35
  )
35
36
  from opentelemetry.sdk.metrics import MeterProvider
37
+ from opentelemetry.sdk.metrics._internal.instrument import (
38
+ Counter as _SDKCounter,
39
+ )
40
+ from opentelemetry.sdk.metrics._internal.instrument import (
41
+ Histogram as _SDKHistogram,
42
+ )
43
+ from opentelemetry.sdk.metrics._internal.instrument import (
44
+ ObservableCounter as _SDKObservableCounter,
45
+ )
46
+ from opentelemetry.sdk.metrics._internal.instrument import (
47
+ ObservableGauge as _SDKObservableGauge,
48
+ )
49
+ from opentelemetry.sdk.metrics._internal.instrument import (
50
+ ObservableUpDownCounter as _SDKObservableUpDownCounter,
51
+ )
52
+ from opentelemetry.sdk.metrics._internal.instrument import (
53
+ UpDownCounter as _SDKUpDownCounter,
54
+ )
36
55
  from opentelemetry.sdk.metrics.export import (
56
+ AggregationTemporality,
37
57
  ConsoleMetricExporter,
38
58
  PeriodicExportingMetricReader,
39
59
  )
@@ -49,6 +69,8 @@ except ImportError as _exc: # pragma: no cover - guarded by lazy export
49
69
  "OtelSink requires the [otel] extra. Install with: pip install 'computer-agent-py[otel]'"
50
70
  ) from _exc
51
71
 
72
+ Temporality = Literal["delta", "cumulative"]
73
+
52
74
  if TYPE_CHECKING:
53
75
  from opentelemetry.trace import Span
54
76
 
@@ -62,6 +84,7 @@ logger = logging.getLogger("computeragent.telemetry")
62
84
  # constants for these keys move between alpha versions; literals are safer.
63
85
  GEN_AI_SYSTEM = "gen_ai.system"
64
86
  GEN_AI_AGENT_NAME = "gen_ai.agent.name"
87
+ GEN_AI_AGENT_DESCRIPTION = "gen_ai.agent.description"
65
88
  GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
66
89
  GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
67
90
  GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
@@ -93,19 +116,61 @@ class OtelSink:
93
116
  exporter: str | None = None,
94
117
  endpoint: str | None = None,
95
118
  headers: dict[str, str] | None = None,
119
+ temporality: Temporality | None = None,
96
120
  tracer_provider: Any | None = None,
97
121
  meter_provider: Any | None = None,
98
122
  ) -> None:
99
- # Read effective config from the module-level resolver if no overrides.
123
+ # Resolve effective config. The user pattern is
124
+ # ``configure(sinks=[OtelSink(), ...])``: Python builds the sinks list
125
+ # (running this __init__) BEFORE configure() populates _CONFIG, so
126
+ # falling back through cfg alone misses env vars. Resolution order:
127
+ # explicit kwarg → configure()'d _CONFIG → env var → final default.
100
128
  from ..config import get_config
101
129
 
102
130
  cfg = get_config()
103
- self._service_name = service_name or cfg.get("service_name") or "computeragent"
104
- self._exporter = (
105
- exporter or cfg.get("exporter") or _infer_exporter(endpoint or cfg.get("endpoint"))
131
+ self._service_name = (
132
+ service_name
133
+ or cfg.get("service_name")
134
+ or os.environ.get("OTEL_SERVICE_NAME")
135
+ or "computeragent"
136
+ )
137
+ self._endpoint = (
138
+ endpoint or cfg.get("endpoint") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
139
+ )
140
+ if headers is not None:
141
+ self._headers: dict[str, str] = headers
142
+ elif cfg.get("headers"):
143
+ self._headers = cfg["headers"]
144
+ else:
145
+ self._headers = _parse_otlp_headers_env(os.environ.get("OTEL_EXPORTER_OTLP_HEADERS"))
146
+ self._exporter = exporter or cfg.get("exporter") or _infer_exporter(self._endpoint)
147
+ self._temporality: Temporality = _resolve_temporality(temporality or cfg.get("temporality"))
148
+
149
+ # Content capture — opt-in. When on, the sink writes prompt /
150
+ # completion / tool input / tool output to spans per the GenAI
151
+ # semconv. Middleware (PiiRedactor, GuardrailFilter) has already
152
+ # mutated the payload by this point, so what lands on spans is the
153
+ # redacted projection — never raw content from the agent stream.
154
+ self._capture_content = bool(
155
+ cfg.get("capture_content")
156
+ if "capture_content" in cfg
157
+ else _env_bool("COMPUTERAGENT_CAPTURE_CONTENT", False)
106
158
  )
107
- self._endpoint = endpoint or cfg.get("endpoint")
108
- self._headers = headers or cfg.get("headers") or {}
159
+ mode = cfg.get("capture_content_mode") or os.environ.get(
160
+ "COMPUTERAGENT_CAPTURE_CONTENT_MODE", "events"
161
+ )
162
+ if mode not in ("attributes", "events", "both"):
163
+ mode = "events"
164
+ self._capture_mode: str = mode
165
+ # Cap attribute size so a runaway prompt doesn't blow OTLP packet
166
+ # limits. Honors the same env that GuardrailFilter consumes.
167
+ try:
168
+ self._max_attr_length = int(
169
+ cfg.get("max_attribute_length")
170
+ or os.environ.get("COMPUTERAGENT_MAX_ATTRIBUTE_LENGTH", "8192")
171
+ )
172
+ except (TypeError, ValueError):
173
+ self._max_attr_length = 8192
109
174
 
110
175
  self._tracer_provider = tracer_provider or _build_tracer_provider(
111
176
  service_name=self._service_name,
@@ -118,6 +183,7 @@ class OtelSink:
118
183
  exporter=self._exporter,
119
184
  endpoint=self._endpoint,
120
185
  headers=self._headers,
186
+ temporality=self._temporality,
121
187
  )
122
188
  self._tracer = self._tracer_provider.get_tracer("computeragent")
123
189
  self._meter = self._meter_provider.get_meter("computeragent")
@@ -171,37 +237,148 @@ class OtelSink:
171
237
 
172
238
  # ── handlers (one per event kind) --------------------------------------
173
239
 
240
+ def _emit_content(
241
+ self,
242
+ span: Any,
243
+ role: str,
244
+ content: str,
245
+ attr_prefix: str,
246
+ event_name: str,
247
+ ) -> None:
248
+ """Write content to ``span`` per ``capture_content_mode``.
249
+
250
+ * ``attributes`` — ``<attr_prefix>.role`` + ``<attr_prefix>.content``
251
+ per the GenAI semconv 1.27 ``gen_ai.prompt.{i}.*`` / ``gen_ai.completion.{i}.*``
252
+ pattern. Easy to query in NRQL, lossy beyond ~32 KB.
253
+ * ``events`` — ``span.add_event(event_name, {role, content})``.
254
+ Preserves multi-message history without overwriting prior attrs.
255
+ * ``both`` — write both shapes. Useful while migrating backends.
256
+
257
+ Content is truncated to ``max_attribute_length`` chars to stay
258
+ under OTLP per-attribute limits. The middleware
259
+ (``PiiRedactor`` / ``GuardrailFilter``) has already mutated the
260
+ payload by this point, so the redacted projection is what lands.
261
+ """
262
+ if not self._capture_content or not content:
263
+ return
264
+ text = (
265
+ content
266
+ if len(content) <= self._max_attr_length
267
+ else (content[: self._max_attr_length - 14] + "...[truncated]")
268
+ )
269
+ if self._capture_mode in ("attributes", "both"):
270
+ span.set_attribute(f"{attr_prefix}.role", role)
271
+ span.set_attribute(f"{attr_prefix}.content", text)
272
+ if self._capture_mode in ("events", "both"):
273
+ try:
274
+ span.add_event(event_name, attributes={"role": role, "content": text})
275
+ except Exception: # noqa: BLE001 — never break a session for an event-emit
276
+ logger.debug("add_event(%s) failed", event_name, exc_info=True)
277
+
278
+ def _parent_ctx(self, session_id: str) -> Any:
279
+ """Build an OTel Context pinned to the active invoke_agent span so
280
+ child spans (chat / execute_tool / policy_decision) nest correctly.
281
+
282
+ ``invoke_agent`` is created via ``start_span`` (not
283
+ ``start_as_current_span``) because its lifetime spans many event
284
+ handler calls — we can't hold a ``with`` block across them. The
285
+ trade-off is that the span is never the "current" span by default, so
286
+ children would orphan into new traces unless we re-pin context here.
287
+ """
288
+ parent = self._invoke_spans.get(session_id)
289
+ if parent is None:
290
+ return None
291
+ return trace.set_span_in_context(parent)
292
+
174
293
  def _on_session_started(self, event: TelemetryEvent) -> None:
175
294
  span = self._tracer.start_span(name=f"invoke_agent {event.agent_name or 'agent'}")
176
295
  span.set_attribute(GEN_AI_SYSTEM, "claude-agent-sdk")
177
296
  if event.agent_name:
178
297
  span.set_attribute(GEN_AI_AGENT_NAME, event.agent_name)
298
+ if event.agent_description:
299
+ span.set_attribute(GEN_AI_AGENT_DESCRIPTION, event.agent_description)
179
300
  span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
180
301
  span.set_attribute(GEN_AI_OPERATION_NAME, "invoke_agent")
181
302
  if event.payload.get("model"):
182
303
  span.set_attribute(GEN_AI_REQUEST_MODEL, str(event.payload["model"]))
304
+ # Content capture — system prompt + initial user prompt, both on the
305
+ # invoke_agent root so a single span query surfaces what the agent
306
+ # was asked to do.
307
+ system_prompt = event.payload.get("system_prompt")
308
+ if isinstance(system_prompt, str) and system_prompt:
309
+ self._emit_content(
310
+ span,
311
+ "system",
312
+ system_prompt,
313
+ "gen_ai.prompt.0",
314
+ "gen_ai.system.message",
315
+ )
316
+ prompt = event.payload.get("prompt")
317
+ if isinstance(prompt, str) and prompt and prompt != "<streaming>":
318
+ # When the caller streams the prompt (AsyncIterable), the proxy
319
+ # writes the sentinel "<streaming>" instead of the body — skip
320
+ # capture in that case because there's nothing to record yet.
321
+ self._emit_content(
322
+ span,
323
+ "user",
324
+ prompt,
325
+ "gen_ai.prompt.1",
326
+ "gen_ai.user.message",
327
+ )
183
328
  self._invoke_spans[event.session_id] = span
184
329
 
185
330
  def _on_assistant_message(self, event: TelemetryEvent) -> None:
186
331
  # Open or reuse a chat span; spec models chat as one span per LLM call,
187
332
  # but the upstream stream gives us one AssistantMessage per LLM call,
188
333
  # so open/close per event.
189
- chat = self._tracer.start_span(name=f"chat {event.payload.get('model') or 'unknown'}")
334
+ chat = self._tracer.start_span(
335
+ name=f"chat {event.payload.get('model') or 'unknown'}",
336
+ context=self._parent_ctx(event.session_id),
337
+ )
190
338
  chat.set_attribute(GEN_AI_OPERATION_NAME, "chat")
191
339
  if event.payload.get("model"):
192
340
  chat.set_attribute(GEN_AI_REQUEST_MODEL, str(event.payload["model"]))
193
341
  chat.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
342
+ text = event.payload.get("text")
343
+ if isinstance(text, str) and text:
344
+ self._emit_content(
345
+ chat,
346
+ "assistant",
347
+ text,
348
+ "gen_ai.completion.0",
349
+ "gen_ai.assistant.message",
350
+ )
194
351
  chat.end()
195
352
 
196
353
  def _on_tool_use(self, event: TelemetryEvent) -> None:
197
354
  tool_name = str(event.payload.get("tool_name") or "tool")
198
- span = self._tracer.start_span(name=f"execute_tool {tool_name}")
355
+ span = self._tracer.start_span(
356
+ name=f"execute_tool {tool_name}",
357
+ context=self._parent_ctx(event.session_id),
358
+ )
199
359
  span.set_attribute(GEN_AI_OPERATION_NAME, "execute_tool")
200
360
  span.set_attribute(GEN_AI_TOOL_NAME, tool_name)
201
361
  call_id = str(event.payload.get("tool_use_id") or "")
202
362
  if call_id:
203
363
  span.set_attribute(GEN_AI_TOOL_CALL_ID, call_id)
204
364
  span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
365
+ # Tool arguments — JSON-encoded so multi-key inputs survive the
366
+ # attribute-string projection.
367
+ tool_input = event.payload.get("tool_input")
368
+ if isinstance(tool_input, dict) and tool_input:
369
+ import json
370
+
371
+ try:
372
+ serialised = json.dumps(tool_input, default=str)
373
+ except (TypeError, ValueError):
374
+ serialised = str(tool_input)
375
+ self._emit_content(
376
+ span,
377
+ "tool",
378
+ serialised,
379
+ "gen_ai.tool.call.arguments",
380
+ "gen_ai.tool.call.start",
381
+ )
205
382
  self._tool_spans.setdefault(event.session_id, {})[call_id] = span
206
383
 
207
384
  def _on_tool_result(self, event: TelemetryEvent) -> None:
@@ -211,6 +388,27 @@ class OtelSink:
211
388
  return
212
389
  if event.payload.get("is_error"):
213
390
  span.set_attribute("error.type", "tool_error")
391
+ # Tool output — agent-readable text projection. The block may be a
392
+ # str OR a list[dict] (Anthropic tool-result content blocks); we
393
+ # serialise dicts to JSON, leave strings alone.
394
+ content = event.payload.get("content")
395
+ if content:
396
+ if isinstance(content, str):
397
+ serialised = content
398
+ else:
399
+ import json
400
+
401
+ try:
402
+ serialised = json.dumps(content, default=str)
403
+ except (TypeError, ValueError):
404
+ serialised = str(content)
405
+ self._emit_content(
406
+ span,
407
+ "tool",
408
+ serialised,
409
+ "gen_ai.tool.call.result",
410
+ "gen_ai.tool.call.end",
411
+ )
214
412
  span.end()
215
413
 
216
414
  def _on_usage_snapshot(self, event: TelemetryEvent) -> None:
@@ -269,6 +467,18 @@ class OtelSink:
269
467
  v = usage.get(key)
270
468
  if isinstance(v, int):
271
469
  invoke.set_attribute(attr, v)
470
+ # Final answer — surface the ResultMessage.result (or last assistant
471
+ # text fallback) on the invoke_agent root so a single span query
472
+ # returns the agent's response.
473
+ result_text = event.payload.get("result")
474
+ if isinstance(result_text, str) and result_text:
475
+ self._emit_content(
476
+ invoke,
477
+ "assistant",
478
+ result_text,
479
+ "gen_ai.completion.final",
480
+ "gen_ai.assistant.message",
481
+ )
272
482
  invoke.end()
273
483
  duration_s = float(event.payload.get("duration_ms", 0.0) or 0.0) / 1000.0
274
484
  if duration_s > 0:
@@ -341,6 +551,78 @@ def _infer_exporter(endpoint: str | None) -> str:
341
551
  return "otlp-http" if endpoint else "console"
342
552
 
343
553
 
554
+ def _signal_url(endpoint: str | None, signal: str) -> str | None:
555
+ """Append ``/v1/<signal>`` if ``endpoint`` looks like a base URL.
556
+
557
+ OTLP HTTP exporters treat a non-None ``endpoint`` constructor arg as the
558
+ full URL — no automatic path appending — so passing the documented base
559
+ URL (e.g. New Relic's ``https://otlp.nr-data.net``) produces 404s. When
560
+ the caller has already supplied a signal-specific URL, return it
561
+ unchanged so we never double-append.
562
+ """
563
+ if endpoint is None:
564
+ return None
565
+ e = endpoint.rstrip("/")
566
+ if any(e.endswith(suffix) for suffix in ("/v1/traces", "/v1/metrics", "/v1/logs")):
567
+ return endpoint
568
+ return f"{e}/v1/{signal}"
569
+
570
+
571
+ def _env_bool(name: str, default: bool) -> bool:
572
+ raw = os.environ.get(name)
573
+ if raw is None:
574
+ return default
575
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
576
+
577
+
578
+ def _parse_otlp_headers_env(raw: str | None) -> dict[str, str]:
579
+ """Parse ``OTEL_EXPORTER_OTLP_HEADERS`` per the OTel env-spec format.
580
+
581
+ Comma-separated ``key=value`` pairs; whitespace around either side is
582
+ stripped. Lines without an ``=`` are dropped silently.
583
+ """
584
+ if not raw:
585
+ return {}
586
+ out: dict[str, str] = {}
587
+ for pair in raw.split(","):
588
+ if "=" not in pair:
589
+ continue
590
+ k, _, v = pair.partition("=")
591
+ k = k.strip()
592
+ v = v.strip()
593
+ if k:
594
+ out[k] = v
595
+ return out
596
+
597
+
598
+ _VALID_TEMPORALITIES: frozenset[str] = frozenset(("delta", "cumulative"))
599
+
600
+
601
+ def _resolve_temporality(value: Temporality | str | None) -> Temporality:
602
+ """Pick a temporality, falling back through env then to the default.
603
+
604
+ Default is ``"delta"`` because every major SaaS OTLP intake (New Relic,
605
+ Datadog direct, Honeycomb) requires delta for histograms — cumulative
606
+ silently ingests but renders as zero. Users running their own collector
607
+ can opt into cumulative.
608
+ """
609
+ candidate = value or os.environ.get("COMPUTERAGENT_OTEL_TEMPORALITY") or "delta"
610
+ if candidate not in _VALID_TEMPORALITIES:
611
+ return "delta"
612
+ return candidate # type: ignore[return-value]
613
+
614
+
615
+ def _delta_temporality_map() -> dict[Any, AggregationTemporality]:
616
+ return {
617
+ _SDKCounter: AggregationTemporality.DELTA,
618
+ _SDKUpDownCounter: AggregationTemporality.DELTA,
619
+ _SDKHistogram: AggregationTemporality.DELTA,
620
+ _SDKObservableCounter: AggregationTemporality.DELTA,
621
+ _SDKObservableUpDownCounter: AggregationTemporality.DELTA,
622
+ _SDKObservableGauge: AggregationTemporality.DELTA,
623
+ }
624
+
625
+
344
626
  def _build_tracer_provider(
345
627
  *,
346
628
  service_name: str,
@@ -351,7 +633,10 @@ def _build_tracer_provider(
351
633
  resource = Resource.create({"service.name": service_name})
352
634
  provider = TracerProvider(resource=resource)
353
635
  if exporter == "otlp-http":
354
- span_exporter = HttpSpanExporter(endpoint=endpoint, headers=headers or None)
636
+ span_exporter = HttpSpanExporter(
637
+ endpoint=_signal_url(endpoint, "traces"),
638
+ headers=headers or None,
639
+ )
355
640
  provider.add_span_processor(BatchSpanProcessor(span_exporter))
356
641
  else:
357
642
  provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
@@ -364,11 +649,18 @@ def _build_meter_provider(
364
649
  exporter: str,
365
650
  endpoint: str | None,
366
651
  headers: dict[str, str],
652
+ temporality: Temporality = "delta",
367
653
  ) -> MeterProvider:
368
654
  resource = Resource.create({"service.name": service_name})
369
655
  metric_exporter: Any
370
656
  if exporter == "otlp-http":
371
- metric_exporter = HttpMetricExporter(endpoint=endpoint, headers=headers or None)
657
+ kwargs: dict[str, Any] = {
658
+ "endpoint": _signal_url(endpoint, "metrics"),
659
+ "headers": headers or None,
660
+ }
661
+ if temporality == "delta":
662
+ kwargs["preferred_temporality"] = _delta_temporality_map()
663
+ metric_exporter = HttpMetricExporter(**kwargs)
372
664
  else:
373
665
  metric_exporter = ConsoleMetricExporter()
374
666
  reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000)