Skip to content

Commit 4f342ed

Browse files
feat(sdk): add StepRecorder for incremental trace/session Step trees (#287)
## Summary PR #271 added `Step.children` and a recursive Galileo record factory, but the only way to populate `children` today is to build the whole `Step` tree by hand before calling `evaluate_controls(children=...)`. The ace-demo (`banking_multilevel_streamlit_app.py` / `banking_multilevel_cases.py`) shows the cost: every leaf function hand-builds a side-channel `record` dict next to its real return value, parents manually assemble `spans`/`traces` lists, a bespoke recursive converter (`build_agent_control_step`) renames keys into `Step`, and the caller then unpacks that `Step` back into `evaluate_controls(...)` kwargs, which immediately repacks it into a new `Step`. This PR lets callers build the tree directly, incrementally, as children execute - no intermediate dict, no converter, no unpack/repack. - Extract `evaluate_step(step, ...)` out of `evaluate_controls` (`sdks/python/src/agent_control/evaluation.py`): the shared Step -> evaluation tail (server check, target resolution, client, `check_evaluation_with_local`). `evaluate_controls` now builds its `Step` and delegates to it. Also widen `children` to accept `Sequence[Step | Mapping[str, Any]]` (dicts are coerced via pydantic). - Add `agent_control.record_step()` / `StepRecorder` (new `step_recorder.py`): a mutable builder since `Step` is frozen. - `.child(type, name, **kwargs)` nests a child recorder, attached on `__exit__`. - `.add(step)` attaches an already-built `Step` or dict. - `.call(func, ...)` / `await .acall(...)` run the function and record it as a child using the same capture logic `@control()` uses (`_create_evaluation_payload`), returning the real result - no `StepExecution`-style wrapper needed. A failing call is still recorded (with the error in `context`) before re-raising. - `.build()` recursively constructs the frozen `Step`, with `children=[]` for a trace/session with no recorded children (matching the "allow empty trace and session children" fix). - `.evaluate(stage=..., agent_name=...)` builds and evaluates via `evaluate_step()`, defaulting `agent_name` from `init()`. - Export `evaluate_step`, `record_step`, `StepRecorder` from `agent_control`. - README: add a "Building trace/session steps" before/after section. ## Test plan - [x] New `tests/test_step_recorder.py` (11 tests): nested trace/session trees, `.call`/`.acall` payload parity with the decorator, async `.acall`, exception recording + re-raise, empty-trace/session `children=[]`, non-trace/session types stay `children=None`, `.add()` with `Step`/dict, `.evaluate()` forwarding + agent_name default resolution + missing-agent error. - [x] Extended `tests/test_evaluation.py`: `evaluate_controls` now routes through `evaluate_step` (mocked), dict children coerce to `Step`. - [x] New `tests/test_step_recorder_galileo_integration.py` (skips if galileo extras aren't installed): builds a trace and a session via `StepRecorder` and feeds them into `agent_control_evaluator_galileo`'s `record_from_step`, asserting the same shapes the demo's `validate_record` checks (3-span trace of llm/tool/retriever; 2-trace session with >=2 spans each). - [x] `ruff check` / `mypy` clean on all changed/new files. - [x] Full SDK suite: 710 passed, 13 skipped (no local server), 1 pre-existing failure unrelated to this change (`test_integration_health.py::test_client_context_manager`, requires a live server; fails identically on `main`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 59b2ec5 commit 4f342ed

9 files changed

Lines changed: 874 additions & 31 deletions

File tree

‎evaluators/contrib/galileo/Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ sync:
1616
uv sync
1717

1818
test:
19-
uv run --with pytest --with pytest-asyncio --with pytest-cov --package $(PACKAGE) pytest tests --cov=src --cov-report=xml:../../../coverage-evaluators-galileo.xml -q
19+
uv run --extra dev --with pytest --with pytest-asyncio --with pytest-cov --package $(PACKAGE) pytest tests --cov=src --cov-report=xml:../../../coverage-evaluators-galileo.xml -q
2020

2121
lint:
2222
uv run --with ruff --package $(PACKAGE) ruff check --config ../../../pyproject.toml src/

‎evaluators/contrib/galileo/pyproject.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ dependencies = [
1616

1717
[project.optional-dependencies]
1818
dev = [
19+
"agent-control-sdk>=8.11.0",
20+
"agent-control-telemetry>=8.6.0",
21+
"agent-control-engine>=8.6.0",
1922
"pytest>=8.0.0",
2023
"pytest-asyncio>=0.23.0",
2124
"pytest-cov>=4.0.0",
@@ -37,3 +40,6 @@ packages = ["src/agent_control_evaluator_galileo"]
3740
[tool.uv.sources]
3841
agent-control-evaluators = { path = "../../builtin", editable = true }
3942
agent-control-models = { path = "../../../models", editable = true }
43+
agent-control-sdk = { path = "../../../sdks/python", editable = true }
44+
agent-control-telemetry = { path = "../../../telemetry", editable = true }
45+
agent-control-engine = { path = "../../../engine", editable = true }
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Cross-package check: StepRecorder trees feed the Galileo record factory."""
2+
3+
from __future__ import annotations
4+
5+
from agent_control import record_step
6+
from agent_control_evaluator_galileo.records.factory import record_from_step as _record_from_step
7+
8+
9+
def test_recorder_built_trace_matches_demo_shape():
10+
"""A trace with one llm/tool/retriever child each builds a 3-span Trace."""
11+
12+
def policy_search(query: str) -> dict:
13+
return {"docs": ["policy-1"]}
14+
15+
def account_lookup(account_id: str) -> dict:
16+
return {"balance": 500}
17+
18+
def banking_llm(prompt: str) -> str:
19+
return f"response: {prompt}"
20+
21+
with record_step("trace", "banking_trace", input={"request": "r1"}) as trace:
22+
trace.call(
23+
policy_search, query="refund policy", step_type="retriever", step_name="policy_search"
24+
)
25+
trace.call(
26+
account_lookup, account_id="acct-1", step_type="tool", step_name="account_lookup"
27+
)
28+
trace.call(banking_llm, "draft a reply", step_type="llm", step_name="banking_llm")
29+
trace.output = {"status": "done"}
30+
31+
record = _record_from_step(trace.build())
32+
33+
assert type(record).__name__ == "Trace"
34+
assert len(record.spans) == 3
35+
assert {type(span).__name__ for span in record.spans} == {
36+
"LlmSpan",
37+
"ToolSpan",
38+
"RetrieverSpan",
39+
}
40+
41+
42+
def test_recorder_built_session_matches_demo_shape():
43+
"""A session with 2 traces, each with >=2 spans, builds the matching Session."""
44+
with record_step("session", "banking_session") as session:
45+
for i in range(2):
46+
with session.child("trace", f"turn_{i}", input={"turn": i}) as trace:
47+
with trace.child("llm", "respond", input="hi") as llm:
48+
llm.output = "hello"
49+
with trace.child("tool", "lookup", input={}) as tool:
50+
tool.output = {}
51+
trace.output = {"turn": i, "status": "ok"}
52+
53+
record = _record_from_step(session.build())
54+
55+
assert type(record).__name__ == "Session"
56+
assert len(record.traces) == 2
57+
for sub_trace in record.traces:
58+
assert len(sub_trace.spans) >= 2

‎sdks/python/README.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,60 @@ async with agent_control.AgentControlClient() as client:
8484
The existing `evaluate_controls` helper remains available for callers that
8585
prefer its field-based convenience arguments.
8686

87+
## Building a step with children
88+
89+
A control's scope can target any step type, including one that aggregates
90+
several already-executed child steps - `Step.children` just has to be
91+
populated by the caller. Building that tree by hand means hand-rolling a
92+
side-channel record for every leaf call and converting it into `Step`
93+
objects afterward:
94+
95+
```python
96+
# Before: a hand-built dict record next to the real return value
97+
def execute_policy_search(query):
98+
docs = search_policy_documents(query)
99+
record = {"type": "lookup", "query": query, "docs": docs}
100+
return StepExecution(value=docs, record=record)
101+
102+
children = []
103+
result = execute_policy_search(query)
104+
children.append(result.record)
105+
...
106+
workflow_step = build_agent_control_step({"type": "workflow", "children": children, ...})
107+
```
108+
109+
`agent_control.record_step()` builds the same tree incrementally, in `Step`
110+
vocabulary, with no intermediate dict and no converter:
111+
112+
```python
113+
with agent_control.record_step("workflow", "banking_workflow", input={"request": req}) as workflow:
114+
with workflow.child("lookup", "policy_lookup", input=query) as span:
115+
span.output = search_policy_documents(query)
116+
117+
account = workflow.call(lookup_account, account_id="acct-1001", step_type="tool")
118+
plan = workflow.call(run_banking_model, req, step_type="llm", tools=TOOL_DEFINITIONS)
119+
workflow.output = {"status": "planned", "message": plan["content"]}
120+
121+
result = await workflow.evaluate(stage="post")
122+
```
123+
124+
`workflow.call(...)`/`await workflow.acall(...)` run the function and record
125+
it as a child using the same capture logic as `@control()` (input from bound
126+
arguments, output from the return value), then return the real result -
127+
removing the need for a separate `StepExecution`-style wrapper. A failed
128+
call is still recorded (with the error in `context`) before the exception is
129+
re-raised. `workflow.child(...)` nests another recorder the same way, so a
130+
parent step can nest children of any depth, e.g. `session.child("trace",
131+
...)` for callers that model multi-turn sessions of traces.
132+
133+
`workflow.build()` produces the frozen `Step`, with `children=[]` instead of
134+
omitted (`None`) for a step type passed via `container_types` even when no
135+
children were recorded - e.g. `record_step("workflow", ..., container_types=
136+
{"workflow"})` for a workflow that legitimately ran with zero children.
137+
`workflow.evaluate(...)` builds the step and evaluates it in one call via
138+
`evaluate_step()` - the same function `evaluate_controls()` uses internally
139+
once its `Step` is built.
140+
87141
## Sharing an OpenTelemetry provider with Google ADK
88142

89143
When Google ADK and Agent Control should export through the same OpenTelemetry

‎sdks/python/src/agent_control/__init__.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ async def handle_input(user_message: str) -> str:
9292
)
9393
from .client import AgentControlClient
9494
from .control_decorators import ControlSteerError, ControlViolationError, control
95-
from .evaluation import check_evaluation_with_local, evaluate_controls
95+
from .evaluation import check_evaluation_with_local, evaluate_controls, evaluate_step
9696
from .observability import (
9797
LogConfig,
9898
add_event,
@@ -116,6 +116,7 @@ async def handle_input(user_message: str) -> str:
116116
)
117117
from .otel_sink import control_event_to_otel_span
118118
from .runtime_auth import validate_http_field_name
119+
from .step_recorder import StepRecorder, record_step
119120
from .tracing import (
120121
get_current_span_id,
121122
get_current_trace_id,
@@ -1619,6 +1620,10 @@ async def main():
16191620
# Local evaluation
16201621
"check_evaluation_with_local",
16211622
"evaluate_controls",
1623+
"evaluate_step",
1624+
# Step recorder (incremental trace/session Step tree builder)
1625+
"record_step",
1626+
"StepRecorder",
16221627
# Tracing
16231628
"get_trace_and_span_ids",
16241629
"get_current_trace_id",

‎sdks/python/src/agent_control/evaluation.py‎

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Evaluation check operations for Agent Control SDK."""
22

3-
from collections.abc import Awaitable, Callable
3+
from collections.abc import Awaitable, Callable, Mapping, Sequence
44
from dataclasses import dataclass
55
from inspect import iscoroutinefunction
66
from typing import Any, Literal, cast
@@ -515,6 +515,58 @@ def _with_parse_errors(result: EvaluationResult) -> EvaluationResult:
515515
return _with_parse_errors(EvaluationResult(is_safe=True, confidence=1.0))
516516

517517

518+
async def evaluate_step(
519+
step: Step,
520+
*,
521+
agent_name: str,
522+
stage: Literal["pre", "post"] = "pre",
523+
target_type: str | None = None,
524+
target_id: str | None = None,
525+
trace_id: str | None = None,
526+
span_id: str | None = None,
527+
) -> EvaluationResult:
528+
"""Evaluate controls for an already-built ``Step``.
529+
530+
This is the shared tail of :func:`evaluate_controls`: resolve the
531+
session target, open a client, and run local/server evaluation. Use
532+
it directly when the ``Step`` - including any ``children`` - was
533+
already assembled, e.g. via :class:`~agent_control.step_recorder.StepRecorder`.
534+
535+
When ``target_type`` and ``target_id`` are both supplied, the request
536+
is target-bearing: the server merges target bindings into the
537+
effective control set. If they are omitted, the SDK falls back to the
538+
target context fixed at ``init()`` time when present. A per-call
539+
override that disagrees with the session target is rejected because
540+
the cached controls were fetched for the session target and would
541+
otherwise drive stale local-first evaluation.
542+
"""
543+
if state.server_url is None:
544+
raise RuntimeError("Server URL not configured. Call agent_control.init() first.")
545+
546+
target_type, target_id = _resolve_session_target(target_type, target_id)
547+
resolved_controls = state.server_controls or []
548+
549+
async with AgentControlClient(
550+
base_url=state.server_url,
551+
api_key=state.api_key,
552+
api_key_header=state.api_key_header,
553+
runtime_token_header=state.runtime_token_header,
554+
runtime_token_cache=state.runtime_token_cache,
555+
) as client:
556+
return await check_evaluation_with_local(
557+
client=client,
558+
agent_name=agent_name,
559+
step=step,
560+
stage=stage,
561+
controls=resolved_controls,
562+
target_type=target_type,
563+
target_id=target_id,
564+
trace_id=trace_id,
565+
span_id=span_id,
566+
event_agent_name=agent_name,
567+
)
568+
569+
518570
async def evaluate_controls(
519571
step_name: str,
520572
*,
@@ -523,7 +575,7 @@ async def evaluate_controls(
523575
context: dict[str, Any] | None = None,
524576
tools: list[dict[str, JSONValue]] | None = None,
525577
ground_truth: JSONValue | None = None,
526-
children: list[Step] | None = None,
578+
children: Sequence[Step | Mapping[str, Any]] | None = None,
527579
step_type: str = "llm",
528580
stage: Literal["pre", "post"] = "pre",
529581
agent_name: str,
@@ -544,11 +596,6 @@ async def evaluate_controls(
544596
"""
545597
step_type = ensure_step_type(step_type)
546598

547-
if state.server_url is None:
548-
raise RuntimeError("Server URL not configured. Call agent_control.init() first.")
549-
550-
target_type, target_id = _resolve_session_target(target_type, target_id)
551-
552599
default_value = {} if step_type == "tool" else ""
553600
step_dict: dict[str, Any] = {
554601
"type": step_type,
@@ -566,24 +613,13 @@ async def evaluate_controls(
566613
step_dict["children"] = children
567614

568615
step_obj = Step(**step_dict) # type: ignore[arg-type]
569-
resolved_controls = state.server_controls or []
570616

571-
async with AgentControlClient(
572-
base_url=state.server_url,
573-
api_key=state.api_key,
574-
api_key_header=state.api_key_header,
575-
runtime_token_header=state.runtime_token_header,
576-
runtime_token_cache=state.runtime_token_cache,
577-
) as client:
578-
return await check_evaluation_with_local(
579-
client=client,
580-
agent_name=agent_name,
581-
step=step_obj,
582-
stage=stage,
583-
controls=resolved_controls,
584-
target_type=target_type,
585-
target_id=target_id,
586-
trace_id=trace_id,
587-
span_id=span_id,
588-
event_agent_name=agent_name,
589-
)
617+
return await evaluate_step(
618+
step_obj,
619+
agent_name=agent_name,
620+
stage=stage,
621+
target_type=target_type,
622+
target_id=target_id,
623+
trace_id=trace_id,
624+
span_id=span_id,
625+
)

0 commit comments

Comments
 (0)