Core concepts
agentstage has two separate plug-in points, and this documentation set is organized around that split:
- Sources are where traces come from: Langfuse and Braintrust today. A source's job ends at producing a
ReconstructedSystem. This is also the only place a source's own raw shape (Langfuse's nested trace object, Braintrust's flat span stream) is ever visible; both normalize into the exact same canonical shape before anything else in this library sees them (seeagentstage/sources/base.py). - Adapters are which framework you plug that reconstruction into: LangGraph, CrewAI, and Agno.
This page covers what's common to every source once you already have an Environment: what reconstruct() returns, and how replay matching works. It runs against the local Langfuse-shaped fixture described on the Langfuse source page (there was no live Langfuse project available while writing these docs), but nothing on this page is Langfuse-specific; a Braintrust-sourced ReconstructedSystem behaves identically from here on, as the Braintrust source page shows directly.
from examples.fixtures import build_environment
env = build_environment()
Reconstructing a system
env.reconstruct(agent_name) builds a ReconstructedSystem from one group's traces. Starting with the simplest shape, a single flat role with no orchestration structure:
system = env.reconstruct("invoice-agent")
print(system.label)
print([r.role for r in system.roles])
role = system.role("agent")
print(role.name, role.system_prompt_source)
print(role.system_prompt)
print(role.tools)
print(list(system.tools.keys()))
print(system.orchestration.source, system.orchestration.pattern)
print(system.provenance.trace_count, system.provenance.source_agent_name)
invoice-agent
['agent']
agent observed
You are an invoicing support assistant. Look up invoices and answer billing questions accurately using the tools provided. Never guess at an invoice's status.
['lookup_invoice', 'send_receipt']
['lookup_invoice', 'send_receipt']
observed single_agent
5 invoice-agent
The system prompt is tagged observed because the fixture's GENERATION observations actually carried a system field. system.tools holds every tool seen anywhere in the batch; role.tools holds the subset attributed to that particular role (here, all of them, since there is only one role).
Now a system with more than one role, where system prompt capture is genuinely mixed:
team = env.reconstruct("support-team")
for r in team.roles:
print(r.role, "system_prompt_source=", r.system_prompt_source, "tools=", r.tools)
print("entry_role:", team.orchestration.entry_role)
print("terminal_roles:", team.orchestration.terminal_roles)
print("pattern:", team.orchestration.pattern)
for edge in team.orchestration.edges:
print(" edge:", edge.from_role, "->", edge.to_role, "trigger=", edge.trigger, "n_observed=", edge.n_observed)
researcher system_prompt_source= observed tools= ['search_docs']
supervisor system_prompt_source= observed tools= []
writer system_prompt_source= unavailable tools= ['draft_reply']
entry_role: supervisor
terminal_roles: ['researcher', 'writer']
pattern: supervisor_delegates
edge: supervisor -> researcher trigger= handoff n_observed= 3
edge: supervisor -> writer trigger= handoff n_observed= 3
edge: supervisor -> researcher trigger= nested_span n_observed= 3
edge: supervisor -> writer trigger= nested_span n_observed= 3
writer is genuinely unavailable: the fixture's writer step never had a GENERATION with a system field under it, the same way a real system's traces might simply not have captured it. Nothing fills that gap with a guess. Note also that a single handoff can show up as more than one edge: supervisor -> researcher appears once with trigger="handoff" (an explicit transfer_to_researcher observation name) and once with trigger="nested_span" (the researcher's own span was independently tagged with a different role than its parent). Both are real, independent signals for the same transition, so both are kept rather than collapsed.
ReconstructionProvenance.other_groups_found reports what else was in the same cached pull, so a developer reconstructing one system knows other systems exist in the same project:
print(team.provenance.other_groups_found)
[OtherGroupFound(agent_name='invoice-agent', trace_count=5), OtherGroupFound(agent_name='support-pipeline', trace_count=3), OtherGroupFound(agent_name='multi-hub', trace_count=2)]
Replay matching
Every tool on a reconstructed system carries a ToolBehaviorProfile built from its observed historical calls. replay_tool_call scores a new call's arguments against every historical call for that tool by similarity, and either replays the closest match or reports the call as unavailable. No LLM call and no external API call happens anywhere in this path.
An exact match:
from agentstage.replay.matcher import ReplayConfig, replay_tool_call
lookup_invoice = system.tools["lookup_invoice"]
result = replay_tool_call("lookup_invoice", {"invoice_id": "inv_1003"}, lookup_invoice.behavior)
print(result)
MatchResult(tool_name='lookup_invoice', arguments={'invoice_id': 'inv_1003'}, source='replay', threshold=0.5, response={'status': 'paid', 'amount': 89.99}, similarity=1.0, closest_candidate=None, closest_similarity=None)
A call with nothing close in the observed history:
result = replay_tool_call("lookup_invoice", {"invoice_id": "not_a_real_invoice_at_all"}, lookup_invoice.behavior)
print(result)
MatchResult(tool_name='lookup_invoice', arguments={'invoice_id': 'not_a_real_invoice_at_all'}, source='unavailable', threshold=0.5, response=None, similarity=None, closest_candidate=ObservedToolCall(arguments={'invoice_id': 'inv_1001'}, response={'status': 'paid', 'amount': 129.0}, observed_at='2026-01-01T09:00:02Z'), closest_similarity=0.24242424242424243)
response is None. agentstage never invents a plausible-looking reply here; it reports exactly what it found instead, the closest candidate and its similarity score, so the gap is informative rather than a bare failure.
Configuring the similarity threshold
The default threshold is 0.5. It can be lowered globally:
lenient = ReplayConfig(default_threshold=0.2)
result = replay_tool_call("lookup_invoice", {"invoice_id": "not_a_real_invoice_at_all"}, lookup_invoice.behavior, config=lenient)
print(result)
MatchResult(tool_name='lookup_invoice', arguments={'invoice_id': 'not_a_real_invoice_at_all'}, source='replay', threshold=0.2, response={'status': 'paid', 'amount': 129.0}, similarity=0.24242424242424243, closest_candidate=None, closest_similarity=None)
The same 0.24-similarity call that was unavailable at threshold 0.5 now replays, because it clears 0.2. Or per tool, leaving every other tool at the default:
per_tool = ReplayConfig(default_threshold=0.9, per_tool_thresholds={"lookup_invoice": 0.2})
result = replay_tool_call("lookup_invoice", {"invoice_id": "not_a_real_invoice_at_all"}, lookup_invoice.behavior, config=per_tool)
print(result)
MatchResult(tool_name='lookup_invoice', arguments={'invoice_id': 'not_a_real_invoice_at_all'}, source='replay', threshold=0.2, response={'status': 'paid', 'amount': 129.0}, similarity=0.24242424242424243, closest_candidate=None, closest_similarity=None)
on_unavailable: return or raise
The default, "return", hands back a MatchResult with source="unavailable" for the caller to check:
default_config = ReplayConfig()
result = replay_tool_call("lookup_invoice", {"invoice_id": "not_a_real_invoice_at_all"}, lookup_invoice.behavior, config=default_config)
print(result.source)
unavailable
"raise" is for callers that would rather fail loudly, for example a test harness that treats "no replay coverage for this call" as a hard failure:
from agentstage.replay.matcher import ToolCallUnavailableError
strict_config = ReplayConfig(on_unavailable="raise")
try:
replay_tool_call("lookup_invoice", {"invoice_id": "not_a_real_invoice_at_all"}, lookup_invoice.behavior, config=strict_config)
except ToolCallUnavailableError as e:
print(f"{type(e).__name__}: {e}")
ToolCallUnavailableError: tool call to 'lookup_invoice' has no replayable match — closest candidate scored 0.24 (threshold 0.50)
Where to go next
Sources:
Adapters:
Reference: