agentpile 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.
Files changed (60) hide show
  1. agentpile/__init__.py +88 -0
  2. agentpile/_capture.py +561 -0
  3. agentpile/_graph.py +139 -0
  4. agentpile/_io.py +54 -0
  5. agentpile/_send.py +83 -0
  6. agentpile/_show.py +700 -0
  7. agentpile/_tables.py +199 -0
  8. agentpile/cache.py +77 -0
  9. agentpile/corpora/__init__.py +1 -0
  10. agentpile/corpora/_base.py +252 -0
  11. agentpile/corpora/adp.py +316 -0
  12. agentpile/corpora/agent_reward_bench.py +498 -0
  13. agentpile/corpora/agentdojo.py +120 -0
  14. agentpile/corpora/catchbench_pre.py +109 -0
  15. agentpile/corpora/manifests/adp.json +667 -0
  16. agentpile/corpora/manifests/agent_reward_bench.json +95 -0
  17. agentpile/corpora/manifests/agentdojo.json +114 -0
  18. agentpile/corpora/manifests/catchbench_pre.json +66 -0
  19. agentpile/corpora/manifests/mast.json +109 -0
  20. agentpile/corpora/manifests/swe_agent_nebius.json +58 -0
  21. agentpile/corpora/manifests/swe_gym_openhands.json +72 -0
  22. agentpile/corpora/manifests/swe_rebench_openhands.json +69 -0
  23. agentpile/corpora/manifests/swe_smith.json +75 -0
  24. agentpile/corpora/manifests/tau2_bench.json +69 -0
  25. agentpile/corpora/manifests/tau_bench.json +73 -0
  26. agentpile/corpora/manifests/trail.json +82 -0
  27. agentpile/corpora/manifests/who_and_when.json +75 -0
  28. agentpile/corpora/mast.py +94 -0
  29. agentpile/corpora/swe_agent_nebius.py +134 -0
  30. agentpile/corpora/swe_gym_openhands.py +194 -0
  31. agentpile/corpora/swe_rebench_openhands.py +178 -0
  32. agentpile/corpora/swe_smith.py +198 -0
  33. agentpile/corpora/tau2_bench.py +164 -0
  34. agentpile/corpora/tau_bench.py +140 -0
  35. agentpile/corpora/trail.py +891 -0
  36. agentpile/corpora/who_and_when.py +147 -0
  37. agentpile/deps.py +654 -0
  38. agentpile/errors.py +17 -0
  39. agentpile/formats/__init__.py +1 -0
  40. agentpile/formats/atif.py +43 -0
  41. agentpile/formats/config.py +37 -0
  42. agentpile/formats/jsonfile.py +14 -0
  43. agentpile/formats/messages.py +431 -0
  44. agentpile/formats/otlp.py +2211 -0
  45. agentpile/formats/tools.py +334 -0
  46. agentpile/integrations/__init__.py +51 -0
  47. agentpile/integrations/_common.py +88 -0
  48. agentpile/integrations/_otel.py +177 -0
  49. agentpile/integrations/openai_agents.py +412 -0
  50. agentpile/integrations/pydantic_ai.py +280 -0
  51. agentpile/loading.py +74 -0
  52. agentpile/record.py +354 -0
  53. agentpile/registry.py +120 -0
  54. agentpile/schemas/agentpile-v0.schema.json +200 -0
  55. agentpile/validate.py +249 -0
  56. agentpile-0.1.0.dist-info/METADATA +283 -0
  57. agentpile-0.1.0.dist-info/RECORD +60 -0
  58. agentpile-0.1.0.dist-info/WHEEL +5 -0
  59. agentpile-0.1.0.dist-info/licenses/LICENSE +201 -0
  60. agentpile-0.1.0.dist-info/top_level.txt +1 -0
agentpile/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """agentpile: load public agent-trajectory datasets and agent traces into one run record.
2
+
3
+ import agentpile as ap
4
+
5
+ for run in ap.load("who_and_when"): # a dataset at its pinned revision
6
+ run.labels, run.agents, run.steps
7
+ run = next(ap.load("trace.json")) # a file; the format is detected, never guessed
8
+ run.to_json("run.json"); run.to_otlp()
9
+
10
+ A trajectory record is an ATIF v1.7 trajectory with agentpile fields under
11
+ ``extra["agentpile"]``; a config record is ``agentpile-config/0`` JSON.
12
+ ``ap.datasets.names()`` lists the datasets, ``ap.readers.names()`` the file
13
+ formats. Downloads go to ``ap.cache_root()`` (``~/.cache/agentpile`` unless
14
+ ``AGENTPILE_CACHE`` is set); ``ap.cache_info()`` and ``ap.clear_cache()``
15
+ list and remove them.
16
+ """
17
+
18
+ from ._capture import CaptureError, capture, config_from
19
+ from ._graph import to_networkx
20
+ from ._send import send_otlp
21
+ from ._show import show
22
+ from ._tables import table, to_pandas
23
+ from .cache import cache_info, cache_root, clear_cache
24
+ from .deps import derive_deps
25
+ from .errors import AgentpileError, AmbiguousSource, UnrecognizedSource, UnsupportedVersion
26
+ from .loading import load
27
+ from .record import ATIF_VERSION, CONFIG_SCHEMA, EXT, MISSING, Event, Run
28
+ from .registry import datasets, readers, register, writers
29
+ from .validate import validate
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ # Built-in plugins go in the same registries as installed ones, as lazy
34
+ # module strings, so importing agentpile imports none of them. They are added
35
+ # before entry points are read, so a plugin cannot shadow them by name.
36
+ readers._builtin("atif", "agentpile.formats.atif")
37
+ readers._builtin("agentpile-config", "agentpile.formats.config")
38
+ readers._builtin("openai-messages", "agentpile.formats.messages")
39
+ readers._builtin("otlp-json", "agentpile.formats.otlp")
40
+ readers._builtin("mcp-tools-list", "agentpile.formats.tools:mcp")
41
+ readers._builtin("openai-tools", "agentpile.formats.tools:openai")
42
+ readers._builtin("anthropic-tools", "agentpile.formats.tools:anthropic")
43
+ writers._builtin("json", "agentpile.formats.jsonfile")
44
+ writers._builtin("otlp-json", "agentpile.formats.otlp")
45
+ datasets._builtin("who_and_when", "agentpile.corpora.who_and_when:dataset")
46
+ datasets._builtin("mast", "agentpile.corpora.mast:dataset")
47
+ datasets._builtin("catchbench_pre", "agentpile.corpora.catchbench_pre:dataset")
48
+ datasets._builtin("tau_bench", "agentpile.corpora.tau_bench:dataset")
49
+ datasets._builtin("tau2_bench", "agentpile.corpora.tau2_bench:dataset")
50
+ datasets._builtin("swe_gym_openhands", "agentpile.corpora.swe_gym_openhands:dataset")
51
+ datasets._builtin("swe_rebench_openhands", "agentpile.corpora.swe_rebench_openhands:dataset")
52
+ datasets._builtin("agentdojo", "agentpile.corpora.agentdojo:dataset")
53
+ datasets._builtin("trail", "agentpile.corpora.trail:dataset")
54
+ datasets._builtin("agent_reward_bench", "agentpile.corpora.agent_reward_bench:dataset")
55
+ datasets._builtin("swe_agent_nebius", "agentpile.corpora.swe_agent_nebius:dataset")
56
+ datasets._builtin("swe_smith", "agentpile.corpora.swe_smith:dataset")
57
+ datasets._builtin("adp", "agentpile.corpora.adp:dataset")
58
+
59
+ __all__ = [
60
+ "ATIF_VERSION",
61
+ "AgentpileError",
62
+ "AmbiguousSource",
63
+ "CaptureError",
64
+ "CONFIG_SCHEMA",
65
+ "EXT",
66
+ "Event",
67
+ "MISSING",
68
+ "Run",
69
+ "UnrecognizedSource",
70
+ "UnsupportedVersion",
71
+ "cache_info",
72
+ "cache_root",
73
+ "capture",
74
+ "clear_cache",
75
+ "config_from",
76
+ "datasets",
77
+ "derive_deps",
78
+ "load",
79
+ "readers",
80
+ "register",
81
+ "send_otlp",
82
+ "show",
83
+ "table",
84
+ "to_networkx",
85
+ "to_pandas",
86
+ "validate",
87
+ "writers",
88
+ ]
agentpile/_capture.py ADDED
@@ -0,0 +1,561 @@
1
+ """Capture a live agent run as OTLP JSON and as a run record.
2
+
3
+ import agentpile as ap
4
+
5
+ with ap.capture(agent=my_agent) as cap:
6
+ result = my_agent.run_sync("...") # or an Agents SDK Runner call
7
+ cap.otlp # the finished spans as an OTLP JSON document (a dict)
8
+ cap.run # the run record, with the agent's configuration attached
9
+ ap.config_from(my_agent) # the agent's configuration as an agentpile-config/0 record
10
+
11
+ Supported agents: Pydantic AI (``pydantic_ai.Agent``) and the OpenAI Agents SDK
12
+ (``agents.Agent``); install ``agentpile[pydantic-ai]`` or ``agentpile[openai-agents]``.
13
+ Nothing here sets the global OpenTelemetry tracer provider. A capture adds
14
+ what it needs on entry, and on exit (also by exception) removes it and puts
15
+ back what it replaced; a host's own instrumentation, processors and exporters
16
+ keep working throughout, and a provider the caller owns is never shut down.
17
+
18
+ A capture records the runs in which one of its agents takes part and that
19
+ start while it is open, including runs started from other threads. A run of
20
+ other agents that happens in the same window is not recorded, and the
21
+ configuration is attached only to the agents that appear in a recorded run.
22
+
23
+ This module imports only the standard library. The OTLP encoder here is the
24
+ standard ``ExportTraceServiceRequest`` JSON encoding: ``resourceSpans``,
25
+ ``scopeSpans``, hexadecimal ids, nanosecond times as strings, typed attribute
26
+ values.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import base64
32
+ import copy
33
+ import math
34
+ import warnings
35
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Union
36
+
37
+ from .errors import AgentpileError
38
+ from .loading import load
39
+ from .record import CONFIG, EXT, TRAJECTORY, Run
40
+ from .registry import readers
41
+
42
+ READER = "otlp-json"
43
+ """Name of the reader that turns an OTLP JSON document into a run (written separately from this module)."""
44
+
45
+
46
+ class CaptureError(AgentpileError):
47
+ """A capture could not start, or its result cannot be built."""
48
+
49
+
50
+ # -- OTLP JSON encoder (standard library only) ---------------------------------
51
+
52
+ _SPAN_KIND = {"INTERNAL": 1, "SERVER": 2, "CLIENT": 3, "PRODUCER": 4, "CONSUMER": 5}
53
+ _STATUS_CODE = {"UNSET": 0, "OK": 1, "ERROR": 2}
54
+ _INT64 = 1 << 63
55
+
56
+
57
+ def _any_value(value: Any) -> Dict[str, Any]:
58
+ """One OTLP ``AnyValue`` in its JSON form."""
59
+ if isinstance(value, bool):
60
+ return {"boolValue": value}
61
+ if isinstance(value, int):
62
+ if -_INT64 <= value < _INT64:
63
+ return {"intValue": str(value)}
64
+ return {"stringValue": str(value)} # beyond int64: keep the digits, not a wrong number
65
+ if isinstance(value, float):
66
+ if math.isnan(value):
67
+ return {"doubleValue": "NaN"}
68
+ if math.isinf(value):
69
+ return {"doubleValue": "Infinity" if value > 0 else "-Infinity"}
70
+ return {"doubleValue": value}
71
+ if isinstance(value, str):
72
+ return {"stringValue": value}
73
+ if isinstance(value, (bytes, bytearray, memoryview)):
74
+ return {"bytesValue": base64.b64encode(bytes(value)).decode("ascii")}
75
+ if isinstance(value, dict):
76
+ return {"kvlistValue": {"values": [{"key": str(k), "value": _any_value(v)} for k, v in value.items()]}}
77
+ if isinstance(value, (list, tuple)):
78
+ return {"arrayValue": {"values": [_any_value(v) for v in value]}}
79
+ if value is None:
80
+ return {}
81
+ return {"stringValue": str(value)}
82
+
83
+
84
+ def _attributes(attrs: Any) -> List[Dict[str, Any]]:
85
+ if not attrs:
86
+ return []
87
+ return [{"key": str(k), "value": _any_value(v)} for k, v in attrs.items()]
88
+
89
+
90
+ def _enum_name(value: Any) -> str:
91
+ return getattr(value, "name", None) or str(value).rsplit(".", 1)[-1]
92
+
93
+
94
+ def _hex_id(value: int, width: int) -> str:
95
+ return format(int(value), "0%dx" % width)
96
+
97
+
98
+ def _trace_state(state: Any) -> Optional[str]:
99
+ if not state:
100
+ return None
101
+ to_header = getattr(state, "to_header", None)
102
+ text = to_header() if callable(to_header) else str(state)
103
+ return text or None
104
+
105
+
106
+ def _span_document(span: Any) -> Dict[str, Any]:
107
+ ctx = span.get_span_context() if hasattr(span, "get_span_context") else span.context
108
+ doc: Dict[str, Any] = {
109
+ "traceId": _hex_id(ctx.trace_id, 32),
110
+ "spanId": _hex_id(ctx.span_id, 16),
111
+ }
112
+ state = _trace_state(getattr(ctx, "trace_state", None))
113
+ if state:
114
+ doc["traceState"] = state
115
+ if span.parent is not None:
116
+ doc["parentSpanId"] = _hex_id(span.parent.span_id, 16)
117
+ doc["name"] = span.name
118
+ doc["kind"] = _SPAN_KIND.get(_enum_name(span.kind), 0)
119
+ doc["startTimeUnixNano"] = str(span.start_time if span.start_time is not None else 0)
120
+ doc["endTimeUnixNano"] = str(span.end_time if span.end_time is not None else 0)
121
+ doc["attributes"] = _attributes(span.attributes)
122
+ dropped = getattr(span, "dropped_attributes", 0)
123
+ if dropped:
124
+ doc["droppedAttributesCount"] = int(dropped)
125
+ events = []
126
+ for event in span.events or ():
127
+ e: Dict[str, Any] = {"timeUnixNano": str(event.timestamp), "name": event.name,
128
+ "attributes": _attributes(event.attributes)}
129
+ if getattr(event, "dropped_attributes", 0):
130
+ e["droppedAttributesCount"] = int(event.dropped_attributes)
131
+ events.append(e)
132
+ doc["events"] = events
133
+ if getattr(span, "dropped_events", 0):
134
+ doc["droppedEventsCount"] = int(span.dropped_events)
135
+ links = []
136
+ for link in span.links or ():
137
+ lc = link.context
138
+ entry: Dict[str, Any] = {"traceId": _hex_id(lc.trace_id, 32), "spanId": _hex_id(lc.span_id, 16),
139
+ "attributes": _attributes(link.attributes)}
140
+ lstate = _trace_state(getattr(lc, "trace_state", None))
141
+ if lstate:
142
+ entry["traceState"] = lstate
143
+ links.append(entry)
144
+ doc["links"] = links
145
+ if getattr(span, "dropped_links", 0):
146
+ doc["droppedLinksCount"] = int(span.dropped_links)
147
+ status = span.status
148
+ code = _STATUS_CODE.get(_enum_name(status.status_code), 0) if status is not None else 0
149
+ sdoc: Dict[str, Any] = {"code": code}
150
+ if status is not None and status.description:
151
+ sdoc["message"] = status.description
152
+ doc["status"] = sdoc
153
+ return doc
154
+
155
+
156
+ def _resource_key(resource: Any) -> Any:
157
+ attrs = getattr(resource, "attributes", None) or {}
158
+ return (tuple(sorted((str(k), repr(v)) for k, v in attrs.items())), getattr(resource, "schema_url", "") or "")
159
+
160
+
161
+ def _scope_key(scope: Any) -> Any:
162
+ if scope is None:
163
+ return ("", "", "")
164
+ return (getattr(scope, "name", "") or "", getattr(scope, "version", "") or "", getattr(scope, "schema_url", "") or "")
165
+
166
+
167
+ def encode_otlp(spans: Iterable[Any]) -> Dict[str, Any]:
168
+ """Encode finished spans (OpenTelemetry SDK ``ReadableSpan`` objects) as an OTLP JSON ``ExportTraceServiceRequest``.
169
+
170
+ Spans are grouped by resource and instrumentation scope, and ordered by
171
+ start time (ties keep the order they finished in), so parents precede
172
+ their children in the document.
173
+ """
174
+ ordered = sorted(enumerate(spans), key=lambda pair: (pair[1].start_time or 0, pair[0]))
175
+ groups: Dict[Any, Dict[str, Any]] = {}
176
+ for _, span in ordered:
177
+ rkey = _resource_key(span.resource)
178
+ group = groups.get(rkey)
179
+ if group is None:
180
+ resource = span.resource
181
+ rdoc: Dict[str, Any] = {"attributes": _attributes(getattr(resource, "attributes", None))}
182
+ group = {"resource": rdoc, "scopes": {}}
183
+ schema_url = getattr(resource, "schema_url", "") or ""
184
+ if schema_url:
185
+ group["schemaUrl"] = schema_url
186
+ groups[rkey] = group
187
+ skey = _scope_key(getattr(span, "instrumentation_scope", None))
188
+ scope_doc = group["scopes"].get(skey)
189
+ if scope_doc is None:
190
+ scope = getattr(span, "instrumentation_scope", None)
191
+ sdoc: Dict[str, Any] = {"scope": {"name": skey[0]}, "spans": []}
192
+ if skey[1]:
193
+ sdoc["scope"]["version"] = skey[1]
194
+ scope_attrs = getattr(scope, "attributes", None)
195
+ if scope_attrs:
196
+ sdoc["scope"]["attributes"] = _attributes(scope_attrs)
197
+ if skey[2]:
198
+ sdoc["schemaUrl"] = skey[2]
199
+ group["scopes"][skey] = scope_doc = sdoc
200
+ scope_doc["spans"].append(_span_document(span))
201
+ resource_spans = []
202
+ for group in groups.values():
203
+ entry: Dict[str, Any] = {"resource": group["resource"], "scopeSpans": list(group["scopes"].values())}
204
+ if "schemaUrl" in group:
205
+ entry["schemaUrl"] = group["schemaUrl"]
206
+ resource_spans.append(entry)
207
+ return {"resourceSpans": resource_spans}
208
+
209
+
210
+ def count_spans(document: Dict[str, Any]) -> int:
211
+ """Number of spans in an OTLP JSON document."""
212
+ return sum(len(ss.get("spans") or []) for rs in document.get("resourceSpans") or []
213
+ for ss in rs.get("scopeSpans") or [])
214
+
215
+
216
+ # -- attaching the configuration to a built run --------------------------------
217
+
218
+ def _fill(target: Dict[str, Any], source: Dict[str, Any], keys: Sequence[str]) -> None:
219
+ for key in keys:
220
+ if target.get(key) in (None, "") and source.get(key) not in (None, ""):
221
+ target[key] = copy.deepcopy(source[key])
222
+
223
+
224
+ def _merge_unique(target: List[Any], extra: Iterable[Any]) -> None:
225
+ for item in extra:
226
+ if item not in target:
227
+ target.append(item)
228
+
229
+
230
+ def _attach_to(record: Dict[str, Any], config: Dict[str, Any], top: bool) -> Set[str]:
231
+ """Attach the configuration to the agents of one trajectory record; the ids of the configured agents that matched.
232
+
233
+ The top-level record always gets its ``agents`` and ``tools`` containers.
234
+ A subagent trajectory is left untouched unless it has an agent roster.
235
+ """
236
+ if top:
237
+ ext = record.setdefault("extra", {}).setdefault(EXT, {})
238
+ else:
239
+ ext = (record.get("extra") or {}).get(EXT)
240
+ if not isinstance(ext, dict) or not ext.get("agents"):
241
+ return set()
242
+ agents = ext.setdefault("agents", [])
243
+ tools = ext.setdefault("tools", {})
244
+
245
+ configured = {cfg["id"] for cfg in config.get("agents") or []}
246
+ agent_ids: Dict[str, str] = {}
247
+ for cfg in config.get("agents") or []:
248
+ match = next((a for a in agents if a.get("id") == cfg["id"]), None)
249
+ if match is None and cfg.get("name"):
250
+ named = [a for a in agents if a.get("name") == cfg["name"]]
251
+ match = named[0] if len(named) == 1 else None
252
+ if match is None:
253
+ continue
254
+ _fill(match, cfg, ("name", "instructions", "model"))
255
+ agent_ids[cfg["id"]] = match["id"]
256
+
257
+ tool_ids: Dict[str, str] = {}
258
+ for cid, cfg in (config.get("tools") or {}).items():
259
+ owner_id = cfg.get("agent_or_server")
260
+ if owner_id in configured and owner_id not in agent_ids:
261
+ continue # the tool of an agent that is not in this record
262
+ owner = agent_ids.get(owner_id, owner_id)
263
+ candidates = [(tid, t) for tid, t in tools.items() if t.get("name") == cfg.get("name")]
264
+ narrowed = [(tid, t) for tid, t in candidates if t.get("agent_or_server") == owner]
265
+ if not narrowed:
266
+ narrowed = [(tid, t) for tid, t in candidates if t.get("agent_or_server") in (None, "")]
267
+ if len(narrowed) == 1:
268
+ tid, entry = narrowed[0]
269
+ _fill(entry, cfg, ("agent_or_server", "type", "definition", "definition_format"))
270
+ if entry.get("agent_or_server") in (None, ""):
271
+ entry["agent_or_server"] = owner
272
+ else:
273
+ tid = cid
274
+ n = 2
275
+ while tid in tools:
276
+ tid = f"{cid}#{n}"
277
+ n += 1
278
+ entry = copy.deepcopy(cfg)
279
+ entry["agent_or_server"] = owner
280
+ entry["atif_index"] = None
281
+ tools[tid] = entry
282
+ tool_ids[cid] = tid
283
+
284
+ for cfg in config.get("agents") or []:
285
+ if cfg["id"] not in agent_ids:
286
+ continue
287
+ match = next(a for a in agents if a.get("id") == agent_ids[cfg["id"]])
288
+ _merge_unique(match.setdefault("tools", []), [tool_ids.get(t, t) for t in cfg.get("tools") or []])
289
+ _merge_unique(match.setdefault("handoffs", []), [agent_ids.get(h, h) for h in cfg.get("handoffs") or []])
290
+ return set(agent_ids)
291
+
292
+
293
+ def attach_config(record: Dict[str, Any], config: Dict[str, Any]) -> None:
294
+ """Merge a config record's agents and tools into a trajectory record, in place.
295
+
296
+ The configuration belongs to the agents that appear in the record, so it
297
+ is attached to those and to nothing else. What the trace itself recorded
298
+ wins: a value the reader already set stays, and the configuration fills
299
+ only what is empty (instructions, definition, handoffs). Agents match by
300
+ id, then by unique name, in the record's own roster and in the roster of
301
+ each of its subagent trajectories. A configured agent that matches no agent
302
+ anywhere in the record, because it took no part in this run or because its
303
+ name is ambiguous, is not added, and neither are its tools; its id goes into
304
+ ``dataset.capture.config_agents_not_attached``. Tools match by name within
305
+ the matching agent; a tool of a matched agent that matches nothing, or more
306
+ than one entry, is added under the configuration's own id and never merged
307
+ by guess. A handoff keeps the configured target even when that target is
308
+ not an agent of the record.
309
+ """
310
+ matched = _attach_to(record, config, True)
311
+ pending = list(record.get("subagent_trajectories") or [])
312
+ while pending:
313
+ sub = pending.pop(0)
314
+ if isinstance(sub, dict):
315
+ matched |= _attach_to(sub, config, False)
316
+ pending.extend(sub.get("subagent_trajectories") or [])
317
+
318
+ ext = record["extra"][EXT]
319
+ note = ext.setdefault("dataset", {}).setdefault("capture", {})
320
+ cds = config.get("dataset") or {}
321
+ note["framework"] = cds.get("framework")
322
+ note["framework_version"] = cds.get("framework_version")
323
+ note["config_lost_fields"] = list((config.get("source") or {}).get("lost_fields") or [])
324
+ not_attached = [cfg["id"] for cfg in config.get("agents") or [] if cfg["id"] not in matched]
325
+ if not_attached:
326
+ note["config_agents_not_attached"] = not_attached
327
+
328
+
329
+ def build_runs(otlp: Dict[str, Any], config: Optional[Dict[str, Any]] = None,
330
+ allow_untested_version: bool = False) -> List[Run]:
331
+ """Read an OTLP JSON document with the ``otlp-json`` reader, then attach ``config`` to every trajectory.
332
+
333
+ Raises ``CaptureError`` if the reader is not registered or the document holds no span.
334
+ ``allow_untested_version`` is passed to the reader.
335
+ """
336
+ if READER not in readers:
337
+ raise CaptureError(
338
+ f"the {READER!r} reader is not registered in this agentpile build, so a captured run cannot be turned "
339
+ "into a run record; the OTLP document itself is available as capture.otlp")
340
+ if not count_spans(otlp):
341
+ raise CaptureError(
342
+ "no span was captured: the agent did not run inside the capture, or the framework's tracing is switched "
343
+ "off for that run (for example RunConfig(tracing_disabled=True) in the Agents SDK)")
344
+ runs = list(load(otlp, reader=READER, allow_untested_version=allow_untested_version))
345
+ if config is not None:
346
+ for run in runs:
347
+ if run.kind == TRAJECTORY:
348
+ attach_config(run.record, config)
349
+ return runs
350
+
351
+
352
+ # -- configuration -------------------------------------------------------------
353
+
354
+ def _agent_list(agent: Any) -> List[Any]:
355
+ if agent is None:
356
+ raise TypeError("an agent is required")
357
+ if isinstance(agent, (list, tuple)):
358
+ if not agent:
359
+ raise TypeError("an empty list of agents")
360
+ return list(agent)
361
+ return [agent]
362
+
363
+
364
+ def config_from(agent: Any) -> Run:
365
+ """The configuration of an agent as a config record (``agentpile-config/0``).
366
+
367
+ It lists the agent's instructions and model, each tool with its full
368
+ definition (name, description, input schema), and, for the OpenAI Agents
369
+ SDK, handoffs and the agents they reach. A list of agents gives one
370
+ record. What cannot be read without running the agent (dynamic
371
+ instructions, the tools of an MCP server) is named in
372
+ ``source.lost_fields``, never filled in.
373
+ """
374
+ from . import integrations
375
+
376
+ agents = _agent_list(agent)
377
+ by_framework: Dict[str, List[Any]] = {}
378
+ for item in agents:
379
+ by_framework.setdefault(integrations.framework_of(item), []).append(item)
380
+ records = []
381
+ for framework, members in by_framework.items():
382
+ records.append(integrations.load(framework).config_record(members))
383
+ record = records[0] if len(records) == 1 else _merge_configs(records)
384
+ return Run(record, CONFIG)
385
+
386
+
387
+ def _merge_configs(records: List[Dict[str, Any]]) -> Dict[str, Any]:
388
+ """One record from the configs of agents of different frameworks (ids stay unique across them)."""
389
+ first = copy.deepcopy(records[0])
390
+ first["source"]["format"] = "+".join(r["source"]["format"] for r in records)
391
+ first["dataset"] = {"frameworks": [r["dataset"] for r in records]}
392
+ for other in records[1:]:
393
+ taken = {a["id"] for a in first["agents"]}
394
+ rename = {}
395
+ for a in other["agents"]:
396
+ if a["id"] in taken:
397
+ rename[a["id"]] = f"{a['id']}@{other['source']['format']}"
398
+ for a in copy.deepcopy(other["agents"]):
399
+ a["id"] = rename.get(a["id"], a["id"])
400
+ a["handoffs"] = [rename.get(h, h) for h in a.get("handoffs") or []]
401
+ first["agents"].append(a)
402
+ for tid, tool in copy.deepcopy(other["tools"]).items():
403
+ tool["agent_or_server"] = rename.get(tool.get("agent_or_server"), tool.get("agent_or_server"))
404
+ first["tools"][tid if tid not in first["tools"] else f"{tid}@{other['source']['format']}"] = tool
405
+ first["source"]["lost_fields"] = list(first["source"]["lost_fields"]) + list(other["source"]["lost_fields"])
406
+ return first
407
+
408
+
409
+ # -- the capture ---------------------------------------------------------------
410
+
411
+ class Capture:
412
+ """The context manager returned by ``capture()``.
413
+
414
+ After the block: ``otlp`` (the OTLP JSON document), ``runs`` and ``run``
415
+ (the run records built from it, with the agent configuration attached),
416
+ ``config`` (the configuration record) and ``span_count``. Inside the block
417
+ the same attributes describe the spans that have finished so far.
418
+ """
419
+
420
+ def __init__(self, agents: Sequence[Any], allow_untested_version: bool = False) -> None:
421
+ self._agents = list(agents)
422
+ self._allow_untested = bool(allow_untested_version)
423
+ self._session: Any = None
424
+ self._bindings: List[Any] = []
425
+ self._state = "new" # new -> open -> closed
426
+ self._otlp: Optional[Dict[str, Any]] = None
427
+ self._config: Optional[Run] = None
428
+ self._config_error: Optional[str] = None
429
+ self._runs: Optional[List[Run]] = None
430
+ self._frameworks: Dict[str, List[Any]] = {}
431
+
432
+ # -- lifecycle ---------------------------------------------------------
433
+
434
+ def __enter__(self) -> "Capture":
435
+ from . import integrations
436
+
437
+ if self._state != "new":
438
+ raise CaptureError("a capture can be entered once; make a new one with capture(agent)")
439
+ for agent in self._agents:
440
+ self._frameworks.setdefault(integrations.framework_of(agent), []).append(agent)
441
+ modules = {name: integrations.load(name) for name in self._frameworks} # fail before touching any agent
442
+ try:
443
+ from .integrations import _otel
444
+ except ImportError as exc:
445
+ raise CaptureError(
446
+ f"capture needs the OpenTelemetry SDK ({exc}); install it with: pip install 'agentpile[otel]'") from exc
447
+ self._session = _otel.Session()
448
+ try:
449
+ for name, members in self._frameworks.items():
450
+ for agent in members:
451
+ binding = modules[name].Binding(agent, self._session)
452
+ binding.enter()
453
+ self._bindings.append(binding)
454
+ except BaseException:
455
+ self._release()
456
+ raise
457
+ self._state = "open"
458
+ return self
459
+
460
+ def _release(self) -> None:
461
+ """Undo every binding (newest first), then close the session. A failed undo is reported, not raised."""
462
+ for binding in reversed(self._bindings):
463
+ try:
464
+ binding.exit()
465
+ except Exception as exc:
466
+ warnings.warn(f"agentpile.capture: could not restore {type(binding).__name__}: {exc!r}",
467
+ RuntimeWarning, stacklevel=3)
468
+ self._bindings = []
469
+ if self._session is not None:
470
+ self._session.close()
471
+
472
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
473
+ if self._state != "open":
474
+ return None
475
+ self._release()
476
+ self._state = "closed"
477
+ self._config = self._read_config()
478
+ self._otlp = self._encode()
479
+ if self._session.unfinished:
480
+ warnings.warn(f"agentpile.capture: {self._session.unfinished} span(s) had not ended when the capture "
481
+ "closed and are not in the document", RuntimeWarning, stacklevel=2)
482
+ return None
483
+
484
+ async def __aenter__(self) -> "Capture":
485
+ return self.__enter__()
486
+
487
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
488
+ return self.__exit__(exc_type, exc, tb)
489
+
490
+ # -- results -----------------------------------------------------------
491
+
492
+ def _require_started(self) -> None:
493
+ if self._state == "new":
494
+ raise CaptureError("the capture has not been entered: use it as `with capture(agent) as cap:`")
495
+
496
+ def _encode(self) -> Dict[str, Any]:
497
+ return encode_otlp(self._session.spans())
498
+
499
+ @property
500
+ def otlp(self) -> Dict[str, Any]:
501
+ """The captured spans as an OTLP JSON document (``resourceSpans``, standard JSON encoding)."""
502
+ self._require_started()
503
+ return self._otlp if self._otlp is not None else self._encode()
504
+
505
+ @property
506
+ def span_count(self) -> int:
507
+ self._require_started()
508
+ return count_spans(self.otlp)
509
+
510
+ def _read_config(self) -> Optional[Run]:
511
+ try:
512
+ return config_from(self._agents)
513
+ except Exception as exc: # a configuration that cannot be read must not lose the captured run
514
+ self._config_error = repr(exc)
515
+ warnings.warn(f"agentpile.capture: the agent configuration could not be read ({exc!r}); "
516
+ "the run record is built without it", RuntimeWarning, stacklevel=3)
517
+ return None
518
+
519
+ @property
520
+ def config(self) -> Optional[Run]:
521
+ """The configuration record of the captured agents (``None`` if it could not be read)."""
522
+ self._require_started()
523
+ if self._state == "closed":
524
+ return self._config
525
+ return self._read_config()
526
+
527
+ @property
528
+ def runs(self) -> List[Run]:
529
+ """The run records built from the document, one per run the reader finds; needs the ``otlp-json`` reader."""
530
+ self._require_started()
531
+ if self._runs is not None:
532
+ return list(self._runs)
533
+ config = self.config
534
+ runs = build_runs(self.otlp, config.record if config is not None else None, self._allow_untested)
535
+ if self._state == "closed":
536
+ self._runs = runs
537
+ return list(runs)
538
+
539
+ @property
540
+ def run(self) -> Run:
541
+ """The captured run record, with the agent configuration attached to its agents and tools."""
542
+ runs = self.runs
543
+ if len(runs) != 1:
544
+ raise CaptureError(f"the capture holds {len(runs)} runs; read them from capture.runs")
545
+ return runs[0]
546
+
547
+ def __repr__(self) -> str:
548
+ spans = self._session.exporter.get_finished_spans() if self._session is not None else ()
549
+ return f"<Capture {self._state} agents={len(self._agents)} spans={len(spans)}>"
550
+
551
+
552
+ def capture(agent: Union[Any, Sequence[Any]], *, allow_untested_version: bool = False) -> Capture:
553
+ """Capture the runs in which ``agent`` (one agent, or a list of agents) takes part, from entry to exit of the ``with`` block.
554
+
555
+ Runs of other agents in the same window are not captured. Use ``async with``
556
+ for an async run. See the module documentation for the lifecycle guarantees.
557
+ ``allow_untested_version=True`` builds the run records even when the framework's
558
+ instrumentation version is not one this release was tested with; they are then
559
+ marked ``source.dialect_tested`` false, as ``ap.load`` marks them.
560
+ """
561
+ return Capture(_agent_list(agent), allow_untested_version)