computer-agent-py 0.1.1__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.1 → computer_agent_py-0.1.3}/PKG-INFO +1 -1
  2. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/pyproject.toml +1 -1
  3. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/_proxy/client.py +9 -1
  4. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/_proxy/query.py +33 -7
  5. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/event.py +8 -0
  6. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/agentos.py +20 -3
  7. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/otel.py +157 -0
  8. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/README.md +0 -0
  9. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/__init__.py +0 -0
  10. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/_proxy/__init__.py +0 -0
  11. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/policy/__init__.py +0 -0
  12. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/policy/authorizer.py +0 -0
  13. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/policy/cedar.py +0 -0
  14. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/policy/opa.py +0 -0
  15. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/policy/types.py +0 -0
  16. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/py.typed +0 -0
  17. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/__init__.py +0 -0
  18. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/config.py +0 -0
  19. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/__init__.py +0 -0
  20. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/guardrails.py +0 -0
  21. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/middleware/pii.py +0 -0
  22. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/pipeline.py +0 -0
  23. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/__init__.py +0 -0
  24. {computer_agent_py-0.1.1 → computer_agent_py-0.1.3}/src/computeragent/telemetry/sinks/message_archive.py +0 -0
  25. {computer_agent_py-0.1.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "computer-agent-py"
3
- version = "0.1.1"
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
@@ -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:",
@@ -84,6 +84,7 @@ logger = logging.getLogger("computeragent.telemetry")
84
84
  # constants for these keys move between alpha versions; literals are safer.
85
85
  GEN_AI_SYSTEM = "gen_ai.system"
86
86
  GEN_AI_AGENT_NAME = "gen_ai.agent.name"
87
+ GEN_AI_AGENT_DESCRIPTION = "gen_ai.agent.description"
87
88
  GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
88
89
  GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
89
90
  GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
@@ -145,6 +146,32 @@ class OtelSink:
145
146
  self._exporter = exporter or cfg.get("exporter") or _infer_exporter(self._endpoint)
146
147
  self._temporality: Temporality = _resolve_temporality(temporality or cfg.get("temporality"))
147
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)
158
+ )
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
174
+
148
175
  self._tracer_provider = tracer_provider or _build_tracer_provider(
149
176
  service_name=self._service_name,
150
177
  exporter=self._exporter,
@@ -210,6 +237,44 @@ class OtelSink:
210
237
 
211
238
  # ── handlers (one per event kind) --------------------------------------
212
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
+
213
278
  def _parent_ctx(self, session_id: str) -> Any:
214
279
  """Build an OTel Context pinned to the active invoke_agent span so
215
280
  child spans (chat / execute_tool / policy_decision) nest correctly.
@@ -230,10 +295,36 @@ class OtelSink:
230
295
  span.set_attribute(GEN_AI_SYSTEM, "claude-agent-sdk")
231
296
  if event.agent_name:
232
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)
233
300
  span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
234
301
  span.set_attribute(GEN_AI_OPERATION_NAME, "invoke_agent")
235
302
  if event.payload.get("model"):
236
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
+ )
237
328
  self._invoke_spans[event.session_id] = span
238
329
 
239
330
  def _on_assistant_message(self, event: TelemetryEvent) -> None:
@@ -248,6 +339,15 @@ class OtelSink:
248
339
  if event.payload.get("model"):
249
340
  chat.set_attribute(GEN_AI_REQUEST_MODEL, str(event.payload["model"]))
250
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
+ )
251
351
  chat.end()
252
352
 
253
353
  def _on_tool_use(self, event: TelemetryEvent) -> None:
@@ -262,6 +362,23 @@ class OtelSink:
262
362
  if call_id:
263
363
  span.set_attribute(GEN_AI_TOOL_CALL_ID, call_id)
264
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
+ )
265
382
  self._tool_spans.setdefault(event.session_id, {})[call_id] = span
266
383
 
267
384
  def _on_tool_result(self, event: TelemetryEvent) -> None:
@@ -271,6 +388,27 @@ class OtelSink:
271
388
  return
272
389
  if event.payload.get("is_error"):
273
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
+ )
274
412
  span.end()
275
413
 
276
414
  def _on_usage_snapshot(self, event: TelemetryEvent) -> None:
@@ -329,6 +467,18 @@ class OtelSink:
329
467
  v = usage.get(key)
330
468
  if isinstance(v, int):
331
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
+ )
332
482
  invoke.end()
333
483
  duration_s = float(event.payload.get("duration_ms", 0.0) or 0.0) / 1000.0
334
484
  if duration_s > 0:
@@ -418,6 +568,13 @@ def _signal_url(endpoint: str | None, signal: str) -> str | None:
418
568
  return f"{e}/v1/{signal}"
419
569
 
420
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
+
421
578
  def _parse_otlp_headers_env(raw: str | None) -> dict[str, str]:
422
579
  """Parse ``OTEL_EXPORTER_OTLP_HEADERS`` per the OTel env-spec format.
423
580