agent-observatory-cli 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.
@@ -0,0 +1,234 @@
1
+ """LangChain and LangGraph callback adapter for Observatory traces."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ try:
8
+ from langchain_core.callbacks import BaseCallbackHandler
9
+ except ImportError as exc: # pragma: no cover - depends on optional integration extra
10
+ raise ImportError(
11
+ "LangChainCallbackHandler requires the optional dependency: "
12
+ "pip install 'agent-observatory-cli[langchain]'"
13
+ ) from exc
14
+
15
+
16
+ class LangChainCallbackHandler(BaseCallbackHandler):
17
+ """Capture chain, tool, retriever, and model callbacks inside one active run.
18
+
19
+ Attach an instance to a :class:`observatory.client.Run` before invoking a
20
+ LangChain runnable or LangGraph app. Prompt, tool input, and output content
21
+ are omitted unless ``capture_content=True``.
22
+ """
23
+
24
+ def __init__(self, *, capture_content=False, max_content_chars=1000):
25
+ super().__init__()
26
+ self.capture_content = bool(capture_content)
27
+ self.max_content_chars = max(1, int(max_content_chars))
28
+ self.run = None
29
+ self._spans = {}
30
+
31
+ def attach(self, run):
32
+ if self.run is not None:
33
+ raise RuntimeError("callback handler is already attached to a run")
34
+ if run._closed:
35
+ raise RuntimeError("cannot attach to a closed Observatory run")
36
+ self.run = run
37
+ return self
38
+
39
+ def _content(self, value):
40
+ return str(value)[:self.max_content_chars]
41
+
42
+ @staticmethod
43
+ def _run_key(run_id):
44
+ return str(run_id) if run_id is not None else None
45
+
46
+ @staticmethod
47
+ def _name(serialized, kwargs, fallback):
48
+ return str((serialized or {}).get("name") or kwargs.get("name") or fallback)
49
+
50
+ @staticmethod
51
+ def _model(serialized, kwargs):
52
+ values = [kwargs.get("invocation_params"), (serialized or {}).get("kwargs"), kwargs]
53
+ for mapping in values:
54
+ if not isinstance(mapping, Mapping):
55
+ continue
56
+ for key in ("model", "model_name", "model_id", "deployment_name"):
57
+ value = mapping.get(key)
58
+ if value:
59
+ return str(value)
60
+ return None
61
+
62
+ def _start(self, kind, name, run_id, parent_run_id=None, attributes=None):
63
+ if self.run is None:
64
+ return None
65
+ parent = self._spans.get(self._run_key(parent_run_id))
66
+ parent_span_id = parent.span_id if parent else self.run.root.span_id
67
+ attrs = {"langchain.run_type": kind}
68
+ attrs.update(attributes or {})
69
+ span = self.run.span(name, attributes=attrs, parent_span_id=parent_span_id)
70
+ key = self._run_key(run_id)
71
+ if key is not None:
72
+ self._spans[key] = span
73
+ return span
74
+
75
+ def _finish(self, run_id, error=None, attributes=None):
76
+ span = self._spans.get(self._run_key(run_id))
77
+ if span is None:
78
+ return
79
+ for key, value in (attributes or {}).items():
80
+ span.set_attribute(key, value)
81
+ span.finish(error)
82
+
83
+ def _capture_output(self, span, output):
84
+ if self.capture_content and span is not None:
85
+ span.set_attribute("langchain.output", self._content(output))
86
+
87
+ def on_chain_start(self, serialized, inputs, *, run_id, parent_run_id=None,
88
+ tags=None, metadata=None, **kwargs):
89
+ attrs = {}
90
+ if tags:
91
+ attrs["langchain.tags"] = [str(tag) for tag in tags]
92
+ if self.capture_content:
93
+ attrs["langchain.input"] = self._content(inputs)
94
+ self._start("chain", self._name(serialized, kwargs, "chain"),
95
+ run_id, parent_run_id, attrs)
96
+
97
+ def on_chain_end(self, outputs, *, run_id, **kwargs):
98
+ span = self._spans.get(self._run_key(run_id))
99
+ self._capture_output(span, outputs)
100
+ self._finish(run_id)
101
+
102
+ def on_chain_error(self, error, *, run_id, **kwargs):
103
+ self._finish(run_id, error)
104
+
105
+ def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None,
106
+ tags=None, metadata=None, **kwargs):
107
+ name = self._name(serialized, kwargs, "tool")
108
+ attrs = {"tool.name": name}
109
+ if self.capture_content:
110
+ attrs["tool.input"] = self._content(input_str)
111
+ self._start("tool", f"tool.{name}", run_id, parent_run_id, attrs)
112
+
113
+ def on_tool_end(self, output, *, run_id, **kwargs):
114
+ span = self._spans.get(self._run_key(run_id))
115
+ self._capture_output(span, output)
116
+ self._finish(run_id)
117
+
118
+ def on_tool_error(self, error, *, run_id, **kwargs):
119
+ self._finish(run_id, error)
120
+
121
+ def on_retriever_start(self, serialized, query, *, run_id, parent_run_id=None,
122
+ tags=None, metadata=None, **kwargs):
123
+ attrs = {}
124
+ if self.capture_content:
125
+ attrs["retrieval.query"] = self._content(query)
126
+ self._start("retriever", self._name(serialized, kwargs, "retriever.search"),
127
+ run_id, parent_run_id, attrs)
128
+
129
+ def on_retriever_end(self, documents, *, run_id, **kwargs):
130
+ span = self._spans.get(self._run_key(run_id))
131
+ if span is not None:
132
+ span.set_attribute("retrieval.document_count", len(documents))
133
+ self._finish(run_id)
134
+
135
+ def on_retriever_error(self, error, *, run_id, **kwargs):
136
+ self._finish(run_id, error)
137
+
138
+ def on_llm_start(self, serialized, prompts, *, run_id, parent_run_id=None,
139
+ tags=None, metadata=None, **kwargs):
140
+ attrs = {"gen_ai.operation.name": "text_completion"}
141
+ model = self._model(serialized, kwargs)
142
+ if model:
143
+ attrs["gen_ai.request.model"] = model
144
+ if self.capture_content:
145
+ attrs["langchain.input"] = self._content(prompts)
146
+ self._start("llm", "llm.call", run_id, parent_run_id, attrs)
147
+
148
+ def on_chat_model_start(self, serialized, messages, *, run_id, parent_run_id=None,
149
+ tags=None, metadata=None, **kwargs):
150
+ attrs = {"gen_ai.operation.name": "chat"}
151
+ model = self._model(serialized, kwargs)
152
+ if model:
153
+ attrs["gen_ai.request.model"] = model
154
+ if self.capture_content:
155
+ attrs["langchain.input"] = self._content(messages)
156
+ self._start("llm", "chat.call", run_id, parent_run_id, attrs)
157
+
158
+ @staticmethod
159
+ def _token_usage(response):
160
+ generations = getattr(response, "generations", None) or []
161
+ totals = {"input_tokens": 0, "output_tokens": 0}
162
+ found = False
163
+ for group in generations:
164
+ for generation in group[:1]:
165
+ message = getattr(generation, "message", None)
166
+ usage = getattr(message, "usage_metadata", None)
167
+ if isinstance(usage, Mapping):
168
+ for key in totals:
169
+ value = usage.get(key)
170
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
171
+ totals[key] += int(value)
172
+ found = True
173
+ if found:
174
+ return totals
175
+ output = getattr(response, "llm_output", None) or {}
176
+ if not isinstance(output, Mapping):
177
+ return {}
178
+ usage = output.get("token_usage") or output.get("usage") or {}
179
+ if not isinstance(usage, Mapping):
180
+ return {}
181
+ return {"input_tokens": usage.get("input_tokens", usage.get("prompt_tokens")),
182
+ "output_tokens": usage.get("output_tokens", usage.get("completion_tokens"))}
183
+
184
+ def on_llm_end(self, response, *, run_id, **kwargs):
185
+ span = self._spans.get(self._run_key(run_id))
186
+ if span is None:
187
+ return
188
+ usage = self._token_usage(response)
189
+ for source, target in (("input_tokens", "gen_ai.usage.input_tokens"),
190
+ ("output_tokens", "gen_ai.usage.output_tokens")):
191
+ value = usage.get(source)
192
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
193
+ span.set_attribute(target, int(value))
194
+ if self.capture_content:
195
+ self._capture_output(span, response)
196
+ self._finish(run_id)
197
+
198
+ def on_llm_error(self, error, *, run_id, **kwargs):
199
+ self._finish(run_id, error)
200
+
201
+ def on_agent_action(self, action, *, run_id, **kwargs):
202
+ span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
203
+ if span is not None:
204
+ attrs = {"tool.name": str(getattr(action, "tool", "agent.action"))}
205
+ if self.capture_content:
206
+ attrs["tool.input"] = self._content(getattr(action, "tool_input", ""))
207
+ span.add_event("agent.action", attrs)
208
+
209
+ def on_agent_finish(self, finish, *, run_id, **kwargs):
210
+ span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
211
+ if span is not None:
212
+ span.add_event("agent.finish")
213
+
214
+ def on_custom_event(self, name, data, *, run_id=None, **kwargs):
215
+ span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
216
+ event_name = str(name)
217
+ if span is not None:
218
+ attrs = {"event.name": event_name}
219
+ if self.capture_content:
220
+ attrs["event.data"] = self._content(data)
221
+ span.add_event("langchain.custom", attrs)
222
+ if self.run is None or event_name.lower() not in {
223
+ "progress", "agent.progress", "langgraph.progress"}:
224
+ return
225
+ progress = data if isinstance(data, Mapping) else {}
226
+ stage = progress.get("stage") or progress.get("agent.progress.stage") or "agent"
227
+ message = progress.get("message") or ""
228
+ percent = progress.get("percent", progress.get("agent.progress.percent"))
229
+ self.run.progress(str(stage), str(message), percent)
230
+
231
+ def on_retry(self, retry_state, *, run_id, **kwargs):
232
+ span = self._spans.get(self._run_key(run_id))
233
+ if span is not None:
234
+ span.add_event("langchain.retry")
@@ -0,0 +1,177 @@
1
+ """OpenAI Agents SDK tracing exporter for Observatory."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import OrderedDict
7
+ from collections.abc import Mapping
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ try:
12
+ from agents.tracing.processor_interface import TracingExporter
13
+ except ImportError as exc: # pragma: no cover - optional integration dependency
14
+ raise ImportError(
15
+ "OpenAIAgentsExporter requires the optional dependency: "
16
+ "pip install 'agent-observatory-cli[openai-agents]'"
17
+ ) from exc
18
+
19
+
20
+ _SAFE_TEXT_KEYS = {"type", "name", "model", "model_name", "model_id", "provider",
21
+ "agent_name", "tool_name", "handoff_name", "operation"}
22
+ _TOKEN_KEYS = {"input_tokens", "output_tokens", "prompt_tokens", "completion_tokens",
23
+ "total_tokens", "cached_input_tokens", "reasoning_tokens"}
24
+
25
+
26
+ def _stable_id(value: Any, length: int) -> str:
27
+ """Convert SDK IDs (which may be prefixed) to valid, stable hex IDs."""
28
+ return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:length]
29
+
30
+
31
+ def _timestamp_ns(value: str | None) -> int:
32
+ if not value:
33
+ return 0
34
+ try:
35
+ return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1_000_000_000)
36
+ except (TypeError, ValueError, OverflowError):
37
+ return 0
38
+
39
+
40
+ def _safe_attributes(data: Any, span_kind: str, exported=None) -> dict[str, Any]:
41
+ """Pick identifying fields and token counts; omit inputs, outputs, and prompts."""
42
+ if exported is None:
43
+ exported = data.export() if callable(getattr(data, "export", None)) else {}
44
+ if not isinstance(exported, Mapping):
45
+ return {}
46
+ result: dict[str, Any] = {}
47
+
48
+ def visit(value: Any, prefix: str = "") -> None:
49
+ if not isinstance(value, Mapping):
50
+ return
51
+ for raw_key, item in value.items():
52
+ key = str(raw_key)
53
+ normalized = key.lower()
54
+ if isinstance(item, Mapping):
55
+ visit(item, f"{prefix}{key}.")
56
+ elif normalized in _SAFE_TEXT_KEYS and isinstance(item, (str, int, float, bool)):
57
+ if normalized in {"model", "model_name", "model_id"}:
58
+ result.setdefault("gen_ai.request.model", item)
59
+ elif normalized == "provider":
60
+ result["gen_ai.provider.name"] = item
61
+ elif normalized == "agent_name" or (normalized == "name" and span_kind == "agent"):
62
+ result["gen_ai.agent.name"] = item
63
+ elif normalized == "name" and span_kind == "function":
64
+ result["tool.name"] = item
65
+ elif normalized == "tool_name":
66
+ result["tool.name"] = item
67
+ else:
68
+ result[f"openai_agents.{prefix}{key}"] = item
69
+ elif (span_kind == "generation" and normalized in _TOKEN_KEYS and
70
+ isinstance(item, (int, float)) and not isinstance(item, bool) and item >= 0):
71
+ token_name = {"prompt_tokens": "input_tokens", "completion_tokens": "output_tokens"}.get(normalized, normalized)
72
+ result[f"gen_ai.usage.{token_name}"] = int(item)
73
+
74
+ visit(exported)
75
+ return result
76
+
77
+
78
+ def _bounded_content(value: Any, max_chars: int) -> str:
79
+ if isinstance(value, str):
80
+ content = value
81
+ else:
82
+ content = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
83
+ if len(content) > max_chars:
84
+ return content[:max_chars] + "… [truncated]"
85
+ return content
86
+
87
+
88
+ class OpenAIAgentsExporter(TracingExporter):
89
+ """Export OpenAI Agents SDK spans to an Observatory :class:`Client`.
90
+
91
+ Pass this exporter to the SDK's ``BatchTraceProcessor`` and register that
92
+ processor with ``agents.tracing.add_trace_processor``. Span input/output
93
+ payloads and arbitrary trace metadata are intentionally omitted.
94
+ """
95
+
96
+ def __init__(self, client, *, capture_content=False, max_content_chars=4000):
97
+ self.client = client
98
+ self._trace_context: OrderedDict[str, tuple[str, str | None]] = OrderedDict()
99
+ self.capture_content = bool(capture_content)
100
+ self.max_content_chars = max(1, min(100_000, int(max_content_chars)))
101
+
102
+ def export(self, items) -> None:
103
+ spans = []
104
+ for item in items:
105
+ if hasattr(item, "span_data"):
106
+ spans.append(self._span_payload(item))
107
+ elif hasattr(item, "trace_id") and hasattr(item, "name"):
108
+ trace_id = str(item.trace_id)
109
+ group_id = getattr(item, "group_id", None)
110
+ self._trace_context[trace_id] = (str(item.name or "agent.run"),
111
+ str(group_id) if group_id else None)
112
+ self._trace_context.move_to_end(trace_id)
113
+ while len(self._trace_context) > 4096:
114
+ self._trace_context.popitem(last=False)
115
+ if spans:
116
+ post_spans = getattr(self.client, "_post_spans", None)
117
+ if callable(post_spans):
118
+ post_spans(spans)
119
+ else:
120
+ self.client._post("/api/spans", {"service": self.client.service, "spans": spans})
121
+
122
+ def _span_payload(self, span):
123
+ raw_trace_id = str(span.trace_id)
124
+ raw_span_id = str(span.span_id)
125
+ raw_parent_id = span.parent_id
126
+ data = span.span_data
127
+ kind = getattr(data, "type", None) or type(data).__name__.removesuffix("SpanData").lower()
128
+ exported = data.export() if callable(getattr(data, "export", None)) else {}
129
+ attrs = _safe_attributes(data, str(kind), exported)
130
+ if self.capture_content and isinstance(exported, Mapping):
131
+ for field in ("input", "output"):
132
+ if exported.get(field) is not None:
133
+ attrs[f"openai_agents.{field}"] = _bounded_content(
134
+ exported[field], self.max_content_chars)
135
+ attrs["openai_agents.trace_id"] = raw_trace_id
136
+ attrs["openai_agents.span_id"] = raw_span_id
137
+ workflow, group_id = self._trace_context.get(raw_trace_id, ("agent.run", None))
138
+ attrs["openai_agents.workflow"] = workflow
139
+ if not raw_parent_id:
140
+ attrs["agent.task"] = workflow
141
+ if group_id:
142
+ attrs["session.id"] = group_id
143
+ error = span.error
144
+ status = "ERROR" if error else "OK"
145
+ if error:
146
+ attrs["exception.type"] = "OpenAIAgentsError"
147
+ payload = {
148
+ "trace_id": _stable_id(raw_trace_id, 32),
149
+ "span_id": _stable_id(raw_span_id, 16),
150
+ "parent_span_id": _stable_id(raw_parent_id, 16) if raw_parent_id else None,
151
+ "name": f"openai_agents.{kind}",
152
+ "start_unix_nano": _timestamp_ns(span.started_at),
153
+ "end_unix_nano": _timestamp_ns(span.ended_at),
154
+ "status": status,
155
+ "attributes": attrs,
156
+ "events": [],
157
+ "resource_attributes": self.client.resource_attributes,
158
+ }
159
+ if payload["start_unix_nano"] <= 0:
160
+ payload["start_unix_nano"] = payload["end_unix_nano"]
161
+ payload["end_unix_nano"] = max(payload["start_unix_nano"], payload["end_unix_nano"])
162
+ return payload
163
+
164
+
165
+ def create_processor(client, *, capture_content=False, max_content_chars=4000,
166
+ **processor_options):
167
+ """Create the SDK's background batching processor for this Observatory client."""
168
+ try:
169
+ from agents.tracing.processors import BatchTraceProcessor
170
+ except ImportError as exc: # pragma: no cover - optional integration dependency
171
+ raise ImportError(
172
+ "create_processor requires the optional dependency: "
173
+ "pip install 'agent-observatory-cli[openai-agents]'"
174
+ ) from exc
175
+ exporter = OpenAIAgentsExporter(client, capture_content=capture_content,
176
+ max_content_chars=max_content_chars)
177
+ return BatchTraceProcessor(exporter, **processor_options)
@@ -0,0 +1,207 @@
1
+ """Bounded, redacted activity extracted from native agent session records."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+ from .findings import _safe_text, _SENSITIVE_FIELD, _TOKEN_FIELD
10
+
11
+ CONTENT_LIMIT = 8000
12
+
13
+
14
+ def safe(value, limit=CONTENT_LIMIT):
15
+ if isinstance(value, dict):
16
+ value = {str(key): "[REDACTED]" if _SENSITIVE_FIELD.search(str(key)) or _TOKEN_FIELD.search(str(key))
17
+ else "[opaque payload omitted]" if str(key) in {"encrypted_content", "signature", "raw_content"} or str(key) == "data" and isinstance(child, str) and len(child) > 1000
18
+ else safe(child, 2000) for key, child in list(value.items())[:60]}
19
+ value = json.dumps(value, ensure_ascii=False, indent=2)
20
+ elif isinstance(value, list):
21
+ value = json.dumps([safe(child, 2000) for child in value[:60]], ensure_ascii=False, indent=2)
22
+ text = _safe_text(value)
23
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
24
+ text = "".join(char for char in text if char in "\n\t" or ord(char) >= 32 and ord(char) != 127)
25
+ return text[:limit] + ("\n[truncated]" if len(text) > limit else "")
26
+
27
+
28
+ def plaintext(content):
29
+ if isinstance(content, str):
30
+ return safe(content)
31
+ if not isinstance(content, list):
32
+ return ""
33
+ return safe("\n".join(str(block.get("text") or "") for block in content
34
+ if isinstance(block, dict) and block.get("type") in {"text", "input_text", "output_text"}))
35
+
36
+
37
+ def emit(session, row, stamp, domain, action, subject, detail="", result="observed", extra=None):
38
+ if not stamp:
39
+ return None
40
+ from .local_agents import identity
41
+ attrs = {"session.id": session.session_id, "observatory.project": session.project,
42
+ "observatory.data_source": "local-session-log", "agent.activity.domain": domain,
43
+ "agent.activity.action": action, "agent.activity.subject": safe(subject, 500),
44
+ "agent.activity.result": result, "agent.activity.actor": session.runtime,
45
+ "observatory.record_id": identity(session.source_id, row.get("ordinal") or row.get("uuid") or stamp,
46
+ domain, action, str(subject)[:500]), **(extra or {})}
47
+ payload = row.get("payload") or {}
48
+ item = payload.get("item") or {} if isinstance(payload, dict) else {}
49
+ span_id = None
50
+ if isinstance(item, dict) and item.get("type") in {"CommandExecution", "FileChange", "McpToolCall", "Extension"}:
51
+ span_id = identity(session.source_id, ("tool", item.get("id") or row.get("ordinal") or stamp), size=16)
52
+ return {"trace_id": identity(session.source_id, session.turn), "span_id": span_id,
53
+ "time_unix_nano": stamp, "service": session.service,
54
+ "severity_number": 17 if result in {"error", "failed", "denied"} else 9,
55
+ "severity_text": "ERROR" if result in {"error", "failed", "denied"} else "INFO",
56
+ "event_name": "agent.activity", "body": safe(detail) if session.capture_details else "",
57
+ "attributes": attrs, "resource_attributes": {"observatory.source_id": session.source_id,
58
+ "observatory.capture": "local-session-log"}, "scope_name": "observatory.local_agents"}
59
+
60
+
61
+ def duration(item):
62
+ value = item.get("duration")
63
+ if isinstance(value, dict):
64
+ try:
65
+ return float(value.get("secs", 0)) * 1000 + float(value.get("nanos", 0))/1e6
66
+ except (ValueError, TypeError):
67
+ pass
68
+ return None
69
+
70
+
71
+ def codex_activity(session, row, stamp):
72
+ payload = row.get("payload") or {}
73
+ if not isinstance(payload, dict):
74
+ return []
75
+ kind = row.get("type")
76
+ event = payload.get("type")
77
+ out = []
78
+ if kind == "token_usage_record":
79
+ usage = payload.get("usage") or {}
80
+ out.append(emit(session, row, stamp, "model", "usage", session.model or "Model not reported", usage,
81
+ "reported", {"gen_ai.request.model": str(session.model or "unknown")}))
82
+ elif kind == "response_item" and event in {"function_call", "custom_tool_call"}:
83
+ name = str(payload.get("name") or "Tool")
84
+ args = payload.get("arguments") or payload.get("input") or ""
85
+ try:
86
+ parsed = json.loads(args) if isinstance(args, str) else args
87
+ except (ValueError, TypeError):
88
+ parsed = {}
89
+ session.tool_inputs[str(payload.get("call_id") or payload.get("id"))] = (name, safe(args))
90
+ subject = name
91
+ domain = "tool"
92
+ if isinstance(parsed, dict):
93
+ command = parsed.get("cmd") or parsed.get("command")
94
+ if command:
95
+ domain, subject = "shell", str(command)
96
+ elif parsed.get("url"):
97
+ domain, subject = "browser", str(parsed["url"])
98
+ elif parsed.get("path"):
99
+ domain, subject = "file", str(parsed["path"])
100
+ out.append(emit(session, row, stamp, domain, "call", subject if session.capture_details else name,
101
+ args, "started", {"tool.name": name[:128]}))
102
+ elif kind == "response_item" and event in {"function_call_output", "custom_tool_call_output"}:
103
+ key = str(payload.get("call_id") or payload.get("id"))
104
+ name, _ = session.tool_inputs.get(key, ("Tool", ""))
105
+ out.append(emit(session, row, stamp, "tool", "result", name, payload.get("output") or "", "observed"))
106
+ elif kind == "event_msg" and event in {"task_started", "task_complete", "task_aborted"}:
107
+ action = "started" if event == "task_started" else "completed" if event == "task_complete" else "interrupted"
108
+ out.append(emit(session, row, stamp, "agent", action, session.project or session.runtime,
109
+ {key: payload[key] for key in ("duration_ms", "time_to_first_token_ms") if key in payload}, action))
110
+ elif kind == "turn_context":
111
+ # Only selected settings, never the system instructions or environment.
112
+ settings = {key: payload[key] for key in ("model", "effort", "approval_policy", "sandbox_policy") if key in payload}
113
+ out.append(emit(session, row, stamp, "context", "settings", session.model or "Agent settings", settings))
114
+ elif kind == "event_msg" and event == "item_completed":
115
+ item = payload.get("item") or {}
116
+ if not isinstance(item, dict):
117
+ return []
118
+ item_type = item.get("type")
119
+ extra = {"observatory.item_type": item_type}
120
+ elapsed = duration(item)
121
+ if elapsed is not None:
122
+ extra["duration_ms"] = elapsed
123
+ failed = str(item.get("status", "")).lower() in {"failed", "error", "declined", "denied"}
124
+ result = "error" if failed else "success"
125
+ if item_type == "CommandExecution":
126
+ command = item.get("command") or []
127
+ command = shlex.join(map(str, command)) if isinstance(command, list) else str(command)
128
+ code = item.get("exit_code")
129
+ if isinstance(code, int):
130
+ extra["process.exit.code"] = code
131
+ if code:
132
+ result = "error"
133
+ out.append(emit(session, row, stamp, "shell", "execute", command if session.capture_details else "Shell command",
134
+ {"command": command, "cwd": item.get("cwd"), "exit_code": code, "output": item.get("aggregated_output", "")}, result, extra))
135
+ elif item_type == "FileChange":
136
+ changes = item.get("changes") or {}
137
+ if isinstance(changes, dict):
138
+ for path, change in list(changes.items())[:60]:
139
+ operation = change.get("type") or change.get("kind") or "change" if isinstance(change, dict) else "change"
140
+ if isinstance(operation, dict):
141
+ operation = next(iter(operation), "change")
142
+ operation = str(operation)
143
+ out.append(emit(session, row, stamp, "file", operation[:40], path if session.capture_details else Path(path).name,
144
+ change, result, extra))
145
+ elif item_type == "Extension":
146
+ action = item.get("action") or {}
147
+ name = str(action.get("type") or item.get("kind") or "activity") if isinstance(action, dict) else str(item.get("kind") or "activity")
148
+ subject = action.get("url") or item.get("query") or name if isinstance(action, dict) else name
149
+ out.append(emit(session, row, stamp, "browser", name, subject if session.capture_details else name,
150
+ {"action": action, "query": item.get("query"), "results": item.get("results")}, result, extra))
151
+ elif item_type == "McpToolCall":
152
+ name = str(item.get("tool") or "MCP tool")
153
+ extra["mcp.server.name"] = str(item.get("server") or "")[:128]
154
+ extra["mcp.tool.name"] = name
155
+ extra["mcp.method.name"] = "tools/call"
156
+ details = {"arguments": item.get("arguments"), "result": item.get("result")}
157
+ if isinstance(item.get("result"), dict) and item["result"].get("isError"):
158
+ result = "error"
159
+ out.append(emit(session, row, stamp, "mcp", name, str(item.get("server") or "MCP"), details, result, extra))
160
+ elif item_type in {"UserMessage", "AgentMessage"}:
161
+ text = "" if session.internal else plaintext(item.get("content"))
162
+ role = "user" if item_type == "UserMessage" else "assistant"
163
+ if role == "user" and text and session.capture_details:
164
+ session.tasks[session.turn] = text[:4000]
165
+ out.append(emit(session, row, stamp, "message", role,
166
+ "Approval review" if session.internal else text.splitlines()[0][:300] if text and session.capture_details else role + " message", text,
167
+ "observed", {"message.role": role, "message.phase": item.get("phase") or ""}))
168
+ elif item_type in {"Reasoning", "ContextCompaction"}:
169
+ out.append(emit(session, row, stamp, "agent", "processing" if item_type == "Reasoning" else "compaction",
170
+ "Model processing" if item_type == "Reasoning" else "Context compacted"))
171
+ return [entry for entry in out if entry]
172
+
173
+
174
+ def claude_activity(session, row, stamp):
175
+ message = row.get("message") or {}
176
+ if not isinstance(message, dict):
177
+ return []
178
+ content = message.get("content") or []
179
+ blocks = content if isinstance(content, list) else []
180
+ out = []
181
+ if row.get("type") == "assistant" and isinstance(message.get("usage"), dict):
182
+ out.append(emit(session, row, stamp, "model", "usage", session.model or "Model not reported", message["usage"], "reported"))
183
+ text = plaintext(content)
184
+ if text and row.get("type") in {"assistant", "user"}:
185
+ role = row["type"]
186
+ if role == "user" and session.capture_details:
187
+ session.tasks[session.turn] = text[:4000]
188
+ out.append(emit(session, row, stamp, "message", role, text.splitlines()[0][:300] if session.capture_details else role+" message", text))
189
+ for block in blocks:
190
+ if not isinstance(block, dict):
191
+ continue
192
+ if block.get("type") == "tool_use":
193
+ name, args = str(block.get("name") or "Tool"), block.get("input") or {}
194
+ domain = "shell" if name.lower() in {"bash", "shell", "terminal"} else "file" if name.lower() in {"read", "write", "edit", "glob", "grep", "multiedit"} else "browser" if name.lower() in {"webfetch", "websearch"} else "tool"
195
+ subject = args.get("command") or args.get("file_path") or args.get("path") or args.get("url") or args.get("query") or name if isinstance(args, dict) else name
196
+ session.tool_inputs[str(block.get("id"))] = (name, safe(subject, 500))
197
+ out.append(emit(session, row, stamp, domain, name, subject if session.capture_details else name, args, "started", {"tool.name": name}))
198
+ elif block.get("type") == "tool_result":
199
+ name, subject = session.tool_inputs.get(str(block.get("tool_use_id")), ("Tool", "Tool result"))
200
+ out.append(emit(session, row, stamp, "tool", name+" result", subject if session.capture_details else name,
201
+ block.get("content") or "", "error" if block.get("is_error") else "success"))
202
+ elif block.get("type") == "thinking":
203
+ out.append(emit(session, row, stamp, "agent", "processing", "Model processing"))
204
+ if row.get("type") == "system":
205
+ out.append(emit(session, row, stamp, "context", str(row.get("subtype") or "event"), "Session event",
206
+ {key:row[key] for key in ("durationMs", "messageCount", "isMeta") if key in row}))
207
+ return [entry for entry in out if entry]