Skip to content

TraceBuilder & TraceTree

Use TraceBuilder when you already know what happened and want to record it without a provider adapter. It creates the trace that assertions check; TraceTree lets you inspect nested agent runs and sum their recorded metrics.

from attest import TraceBuilder
builder = TraceBuilder(agent_id="my-agent")
ParameterTypeDefaultDescription
agent_idstr | NoneNoneIdentifier for the agent producing this trace

A unique trace_id is auto-generated as trc_{uuid_hex[:12]}.

Use builder.set_trace_id(trace_id: str) to override the generated ID. Builder setters and step-addition methods return the builder for chaining; build() returns the trace.

# Keyword arguments
builder.set_input(user_message="hello", context="greeting")
# From a dict
builder.set_input_dict({"user_message": "hello", "context": "greeting"})

Four step types correspond to the operations an agent performs:

Record a language model invocation.

builder.add_llm_call(
name="gpt-4.1",
args={"messages": [{"role": "user", "content": "hello"}]},
result={"content": "Hi there!", "model": "gpt-4.1"},
metadata={"temperature": 0.7},
started_at_ms=1708000000000,
ended_at_ms=1708000001500,
)

Record a tool/function invocation.

builder.add_tool_call(
name="search",
args={"query": "weather in tokyo"},
result={"temperature": 22, "condition": "sunny"},
)

Record a RAG retrieval operation.

builder.add_retrieval(
name="vector-search",
args={"query": "refund policy", "top_k": 5},
result={"documents": ["Policy doc 1", "Policy doc 2"]},
)

Add a raw Step object directly.

from attest import Step
builder.add_step(Step(
type="tool_call",
name="calculator",
args={"expression": "2+2"},
result={"value": 4},
))

add_llm_call, add_tool_call, and add_retrieval share these parameters. add_step(step: Step) instead accepts a complete step, including nested trace or trace-v2 evidence.

ParameterTypeDefaultDescription
namestrrequiredStep identifier (model name, tool name, etc.)
argsdict[str, Any] | NoneNoneInput arguments
resultdict[str, Any] | NoneNoneOutput result
metadatadict[str, Any] | NoneNoneArbitrary metadata
started_at_msint | NoneNoneStart timestamp (epoch ms)
ended_at_msint | NoneNoneEnd timestamp (epoch ms)
agent_idstr | NoneNoneAgent that produced this step
agent_rolestr | NoneNoneRole of the agent (e.g., “planner”, “executor”)
# Keyword arguments
builder.set_output(message="The weather in Tokyo is 22C and sunny.")
# From a dict
builder.set_output_dict({"message": "The weather in Tokyo is 22C and sunny."})
builder.set_metadata(
total_tokens=1500,
cost_usd=0.003,
latency_ms=1200,
model="gpt-4.1",
timestamp="2025-02-22T10:30:00Z",
)
ParameterTypeDefaultDescription
total_tokensint | NoneNoneTotal token count
cost_usdfloat | NoneNoneTotal cost in USD
latency_msint | NoneNoneWall-clock latency
modelstr | NoneNonePrimary model used
timestampstr | NoneNoneISO 8601 timestamp

set_metadata() merges supplied non-None values with existing metadata. Input/output setters replace their dictionaries. No builder method automatically measures token usage, cost or latency.

Link a trace to a parent for multi-agent scenarios:

builder.set_parent_trace_id("trc_abc123def456")
trace = builder.build()

Returns a Trace dataclass with schema_version=1. The builder is not consumed. Each build() copies the step list, but the steps, input/output dictionaries and metadata are shared references, not deep independent snapshots. Do not mutate them after recording a trace.

from attest import TraceBuilder, expect, AgentResult
builder = TraceBuilder(agent_id="weather-agent")
builder.set_input(query="weather in tokyo")
builder.add_llm_call(
name="gpt-4.1",
args={"messages": [{"role": "user", "content": "weather in tokyo"}]},
result={"content": "Let me check the weather."},
)
builder.add_tool_call(
name="get_weather",
args={"city": "tokyo"},
result={"temp_c": 22, "condition": "sunny"},
)
builder.add_llm_call(
name="gpt-4.1",
args={"messages": [{"role": "user", "content": "summarize weather data"}]},
result={"content": "Tokyo is 22C and sunny."},
)
builder.set_output(message="Tokyo is 22C and sunny.")
builder.set_metadata(total_tokens=350, cost_usd=0.001, latency_ms=800)
trace = builder.build()
result = AgentResult(trace=trace)
# Evaluate with the pytest fixture: attest.evaluate(chain)
def test_weather_trace(attest):
attest.evaluate(expect(result).output_contains("Tokyo").cost_under(0.01))

The Trace object produced by build():

FieldTypeDescription
trace_idstrUnique identifier
outputdict[str, Any]Agent output (required)
schema_versionintTrace schema version (builder emits 1; captured traces can be 2); not the JSON-RPC protocol version
agent_idstr | NoneAgent identifier
inputdict[str, Any] | NoneAgent input
stepslist[Step]Ordered execution steps
metadataTraceMetadata | NoneAggregate metrics
parent_trace_idstr | NoneParent trace link
conversation_idstr | NoneTrace-v2 conversation identity
turnslist[Turn]Trace-v2 turns and their step indices
handoffslist[Handoff]Trace-v2 agent handoffs and their step indices
FieldTypeDescription
typestrOne of: llm_call, tool_call, retrieval, agent_call
namestrStep identifier
argsdict[str, Any] | NoneInput arguments
resultdict[str, Any] | NoneOutput result
sub_traceTrace | NoneNested trace for agent_call steps
metadatadict[str, Any] | NoneArbitrary metadata
started_at_msint | NoneStart timestamp
ended_at_msint | NoneEnd timestamp
agent_idstr | NoneAgent identifier
agent_rolestr | NoneAgent role
span_id, parent_span_idstr | NoneTrace-v2 span identity and parent
tool_call_idstr | NoneTrace-v2 provider tool-call identity
retry_ofint | NoneTrace-v2 index of the retried step
errorStepError | NoneTrace-v2 error type and optional message
exchangeExchange | NoneTrace-v2 request/response evidence

The extra trace-v2 types live in attest._proto.types: StepError(type, message=None), Exchange(request, response), Turn(index, step_indices=[], span_id=None, input=None, output=None) and Handoff(from_agent, to_agent, step_index). Construct a schema-2 Trace directly or preserve one from capture when using this evidence; TraceBuilder still emits schema 1.

Trace, Step and TraceMetadata provide to_dict(). Trace.from_dict() reconstructs a trace and its nested steps; Step has no standalone from_dict() method. Import Trace from attest for the example below.

# Serialize
trace_dict = trace.to_dict()
# Deserialize
restored = Trace.from_dict(trace_dict)

TraceTree wraps a root Trace and provides multi-agent analysis over the tree formed by agent_call steps with nested sub_trace objects.

from attest import TraceTree
tree = TraceTree(root=trace)

Or from an AgentResult:

tree = result.trace_tree()

List all agent_id values in the tree (depth-first).

tree.agents # ["orchestrator", "researcher", "writer"]

Maximum nesting depth. Root with no children = 0.

tree.depth # 2

List of (parent_agent_id, child_agent_id) pairs.

tree.delegations
# [("orchestrator", "researcher"), ("orchestrator", "writer")]

Sum of total_tokens across all traces in the tree.

tree.aggregate_tokens # 4500

Sum of cost_usd across all traces.

tree.aggregate_cost # 0.012

Sum of latency_ms across all traces.

tree.aggregate_latency # 3200

Find a sub-trace by agent_id. Returns None if not found.

researcher_trace = tree.find_agent("researcher")
if researcher_trace:
print(researcher_trace.output)

Return all traces in depth-first order.

all_traces = tree.flatten()
# [root_trace, researcher_trace, writer_trace]

Return all tool_call steps across the entire tree.

tools = tree.all_tool_calls()
for step in tools:
print(f"{step.agent_id}: {step.name}")
from attest import TraceBuilder, TraceTree, delegate, agent
@agent("orchestrator")
def orchestrator(builder, task):
builder.add_llm_call(
name="gpt-4.1",
args={"messages": [{"role": "user", "content": task}]},
result={"content": "I'll delegate this to specialists."},
)
with delegate("researcher") as child:
child.add_tool_call(
name="search",
args={"query": task},
result={"documents": ["doc1", "doc2"]},
)
child.set_output(message="Found 2 relevant documents.", documents=["doc1", "doc2"])
with delegate("writer") as child:
child.set_input(documents=["doc1", "doc2"])
child.add_llm_call(
name="gpt-4.1",
args={"messages": [{"role": "user", "content": "summarize docs"}]},
result={"content": "Summary of findings."},
)
child.set_output(message="Summary of findings.")
return {"message": "Task complete: Summary of findings."}
result = orchestrator(task="research quantum computing")
tree = result.trace_tree()
print(tree.agents) # ["orchestrator", "researcher", "writer"]
print(tree.depth) # 1
print(tree.delegations) # [("orchestrator", "researcher"), ("orchestrator", "writer")]

The expect() DSL includes trace tree assertions (layer 3) that operate on the tree structure:

chain = (
expect(result)
.agent_called("researcher")
.agent_called("writer")
.delegation_depth(2)
.agent_output_contains("researcher", "documents")
.cross_agent_data_flow("researcher", "writer", "documents")
.follows_transitions([("orchestrator", "researcher"), ("orchestrator", "writer")])
.aggregate_cost_under(0.05)
.aggregate_tokens_under(10000)
)
attest.evaluate(chain) # inside a pytest test using the `attest` fixture

See the Expect DSL reference for the full list of trace tree assertions.