Skip to content

Commit 93dfa4d

Browse files
feat(sdk): add step_context to StepRecorder.call()/acall()
call()/acall() run func(*args, **kwargs) and forward every unrecognized keyword straight to func, so a plain `context=` parameter on call() would collide with (and silently steal) a same-named kwarg on func - exactly the case for functions like run_banking_model(..., context=...). step_context is merged into the recorded child's Step.context instead, without touching what gets forwarded to func. On failure, the error key added to context takes precedence over the same key in step_context. Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent b032c25 commit 93dfa4d

2 files changed

Lines changed: 94 additions & 6 deletions

File tree

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,17 @@ def _record_call_result(
132132
step_name: str | None,
133133
step_type: str | None,
134134
tools: ToolsConfiguration,
135+
step_context: dict[str, Any] | None,
135136
error: BaseException | None,
136137
) -> None:
137138
payload = _create_evaluation_payload(
138139
func, args, kwargs, output, step_name, step_type, tools
139140
)
141+
context = {**(payload.get("context") or {}), **(step_context or {})}
140142
if error is not None:
141-
payload["context"] = {**(payload.get("context") or {}), "error": repr(error)}
143+
context["error"] = repr(error)
144+
if context:
145+
payload["context"] = context
142146
self._children.append(Step.model_validate(payload))
143147

144148
def call(
@@ -148,15 +152,27 @@ def call(
148152
step_type: str | None = None,
149153
step_name: str | None = None,
150154
tools: ToolsConfiguration = None,
155+
step_context: dict[str, Any] | None = None,
151156
**kwargs: Any,
152157
) -> T:
153-
"""Run ``func``, record it via ``@control()``'s capture logic, return its output."""
158+
"""Run ``func``, record it via ``@control()``'s capture logic, return its output.
159+
160+
``step_context`` is merged into the recorded child's ``context`` (an
161+
``error`` key added on failure takes precedence over the same key in
162+
``step_context``). It is named ``step_context`` rather than ``context``
163+
so it can't collide with a ``context`` keyword argument on ``func``
164+
itself, which ``**kwargs`` would otherwise forward unchanged.
165+
"""
154166
try:
155167
output = func(*args, **kwargs)
156168
except Exception as exc:
157-
self._record_call_result(func, args, kwargs, None, step_name, step_type, tools, exc)
169+
self._record_call_result(
170+
func, args, kwargs, None, step_name, step_type, tools, step_context, exc
171+
)
158172
raise
159-
self._record_call_result(func, args, kwargs, output, step_name, step_type, tools, None)
173+
self._record_call_result(
174+
func, args, kwargs, output, step_name, step_type, tools, step_context, None
175+
)
160176
return output
161177

162178
async def acall(
@@ -166,15 +182,20 @@ async def acall(
166182
step_type: str | None = None,
167183
step_name: str | None = None,
168184
tools: ToolsConfiguration = None,
185+
step_context: dict[str, Any] | None = None,
169186
**kwargs: Any,
170187
) -> T:
171188
"""Async counterpart to :meth:`call`."""
172189
try:
173190
output = await func(*args, **kwargs)
174191
except Exception as exc:
175-
self._record_call_result(func, args, kwargs, None, step_name, step_type, tools, exc)
192+
self._record_call_result(
193+
func, args, kwargs, None, step_name, step_type, tools, step_context, exc
194+
)
176195
raise
177-
self._record_call_result(func, args, kwargs, output, step_name, step_type, tools, None)
196+
self._record_call_result(
197+
func, args, kwargs, output, step_name, step_type, tools, step_context, None
198+
)
178199
return output
179200

180201
def build(self) -> Step:

‎sdks/python/tests/test_step_recorder.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for StepRecorder, the incremental Step tree builder."""
22

3+
from typing import Any
34
from unittest.mock import AsyncMock, patch
45

56
import pytest
@@ -89,6 +90,72 @@ async def run_banking_model(prompt: str) -> str:
8990
assert child.output == "answer: hello"
9091

9192

93+
def test_call_step_context_is_recorded():
94+
"""step_context is merged into the recorded child's context."""
95+
96+
def lookup_account(account_id: str) -> dict:
97+
return {"balance": 100}
98+
99+
with record_step("trace", "t") as trace:
100+
trace.call(
101+
lookup_account,
102+
account_id="acct-1",
103+
step_type="tool",
104+
step_name="lookup_account",
105+
step_context={"executed_function": "lookup_account"},
106+
)
107+
108+
[child] = trace.build().children
109+
assert child.context == {"executed_function": "lookup_account"}
110+
111+
112+
def test_call_step_context_does_not_collide_with_func_context_kwarg():
113+
"""step_context is merged into Step.context, not forwarded to func - a plain
114+
`context` kwarg on call() would instead be swallowed by **kwargs and sent
115+
to func, silently dropping the caller's intended Step metadata."""
116+
117+
received: dict[str, Any] = {}
118+
119+
def run_banking_model(prompt: str, *, context: dict[str, Any] | None = None) -> str:
120+
received["context"] = context
121+
return f"answer: {prompt}"
122+
123+
with record_step("trace", "t") as trace:
124+
result = trace.call(
125+
run_banking_model,
126+
"hello",
127+
context={"account": "acct-1"},
128+
step_type="llm",
129+
step_name="respond",
130+
step_context={"executed_function": "run_banking_model"},
131+
)
132+
133+
assert result == "answer: hello"
134+
assert received["context"] == {"account": "acct-1"}
135+
[child] = trace.build().children
136+
assert child.context == {"executed_function": "run_banking_model"}
137+
138+
139+
def test_call_step_context_and_error_merge_on_failure():
140+
"""On failure, the error key is added to step_context's dict and wins on collision."""
141+
142+
def flaky(x: int) -> int:
143+
raise ValueError("boom")
144+
145+
with record_step("trace", "t") as trace:
146+
with pytest.raises(ValueError, match="boom"):
147+
trace.call(
148+
flaky,
149+
1,
150+
step_type="tool",
151+
step_name="flaky",
152+
step_context={"attempt": 1, "error": "overwritten"},
153+
)
154+
155+
[child] = trace.build().children
156+
assert child.context == {"attempt": 1, "error": "ValueError('boom')"}
157+
158+
92159
def test_call_records_exception_and_reraises():
93160
"""A failing call is still recorded as a child (with the error in context), then re-raised."""
94161

0 commit comments

Comments
 (0)