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.
- agentpile/__init__.py +88 -0
- agentpile/_capture.py +561 -0
- agentpile/_graph.py +139 -0
- agentpile/_io.py +54 -0
- agentpile/_send.py +83 -0
- agentpile/_show.py +700 -0
- agentpile/_tables.py +199 -0
- agentpile/cache.py +77 -0
- agentpile/corpora/__init__.py +1 -0
- agentpile/corpora/_base.py +252 -0
- agentpile/corpora/adp.py +316 -0
- agentpile/corpora/agent_reward_bench.py +498 -0
- agentpile/corpora/agentdojo.py +120 -0
- agentpile/corpora/catchbench_pre.py +109 -0
- agentpile/corpora/manifests/adp.json +667 -0
- agentpile/corpora/manifests/agent_reward_bench.json +95 -0
- agentpile/corpora/manifests/agentdojo.json +114 -0
- agentpile/corpora/manifests/catchbench_pre.json +66 -0
- agentpile/corpora/manifests/mast.json +109 -0
- agentpile/corpora/manifests/swe_agent_nebius.json +58 -0
- agentpile/corpora/manifests/swe_gym_openhands.json +72 -0
- agentpile/corpora/manifests/swe_rebench_openhands.json +69 -0
- agentpile/corpora/manifests/swe_smith.json +75 -0
- agentpile/corpora/manifests/tau2_bench.json +69 -0
- agentpile/corpora/manifests/tau_bench.json +73 -0
- agentpile/corpora/manifests/trail.json +82 -0
- agentpile/corpora/manifests/who_and_when.json +75 -0
- agentpile/corpora/mast.py +94 -0
- agentpile/corpora/swe_agent_nebius.py +134 -0
- agentpile/corpora/swe_gym_openhands.py +194 -0
- agentpile/corpora/swe_rebench_openhands.py +178 -0
- agentpile/corpora/swe_smith.py +198 -0
- agentpile/corpora/tau2_bench.py +164 -0
- agentpile/corpora/tau_bench.py +140 -0
- agentpile/corpora/trail.py +891 -0
- agentpile/corpora/who_and_when.py +147 -0
- agentpile/deps.py +654 -0
- agentpile/errors.py +17 -0
- agentpile/formats/__init__.py +1 -0
- agentpile/formats/atif.py +43 -0
- agentpile/formats/config.py +37 -0
- agentpile/formats/jsonfile.py +14 -0
- agentpile/formats/messages.py +431 -0
- agentpile/formats/otlp.py +2211 -0
- agentpile/formats/tools.py +334 -0
- agentpile/integrations/__init__.py +51 -0
- agentpile/integrations/_common.py +88 -0
- agentpile/integrations/_otel.py +177 -0
- agentpile/integrations/openai_agents.py +412 -0
- agentpile/integrations/pydantic_ai.py +280 -0
- agentpile/loading.py +74 -0
- agentpile/record.py +354 -0
- agentpile/registry.py +120 -0
- agentpile/schemas/agentpile-v0.schema.json +200 -0
- agentpile/validate.py +249 -0
- agentpile-0.1.0.dist-info/METADATA +283 -0
- agentpile-0.1.0.dist-info/RECORD +60 -0
- agentpile-0.1.0.dist-info/WHEEL +5 -0
- agentpile-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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)
|