Skip to content

Commit 935ff2b

Browse files
committed
feat: expose tool_call_id on RunContextWrapper for lifecycle hooks
Add a `tool_call_id: str | None` field (init=False, default None) to `RunContextWrapper` so that callers of `on_tool_start` / `on_tool_end` hooks can read the tool call ID directly from the context parameter without needing an `isinstance(context, ToolContext)` guard. `ToolContext` already declares `tool_call_id` as a required init field (str, not str | None), so subclass behaviour is unchanged. The base field is `init=False` to avoid clashing with `ToolContext.from_agent_context` which copies base-class init fields by name. Both `_fork_with_tool_input` and `_fork_without_tool_input` are updated to propagate the value when forking. Closes #1849
1 parent 4f3c8a5 commit 935ff2b

File tree

2 files changed

+59
-0
lines changed

2 files changed

+59
-0
lines changed

src/agents/run_context.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ class RunContextWrapper(Generic[TContext]):
6161
tool_input: Any | None = None
6262
"""Structured input for the current agent tool run, when available."""
6363

64+
tool_call_id: str | None = field(default=None, init=False)
65+
"""The tool call ID for the current tool invocation, when available.
66+
67+
Populated on :class:`ToolContext` instances that are passed to
68+
:meth:`RunHooksBase.on_tool_start <agents.lifecycle.RunHooksBase.on_tool_start>` and
69+
:meth:`RunHooksBase.on_tool_end <agents.lifecycle.RunHooksBase.on_tool_end>` for
70+
function-tool invocations. ``None`` for non-function-tool hook invocations and for plain
71+
:class:`RunContextWrapper` instances used outside of a tool call.
72+
"""
73+
6474
@staticmethod
6575
def _to_str_or_none(value: Any) -> str | None:
6676
if isinstance(value, str):
@@ -461,6 +471,7 @@ def _fork_with_tool_input(self, tool_input: Any) -> RunContextWrapper[TContext]:
461471
fork._approvals = self._approvals
462472
fork.turn_input = self.turn_input
463473
fork.tool_input = tool_input
474+
fork.tool_call_id = self.tool_call_id
464475
return fork
465476

466477
def _fork_without_tool_input(self) -> RunContextWrapper[TContext]:
@@ -469,6 +480,7 @@ def _fork_without_tool_input(self) -> RunContextWrapper[TContext]:
469480
fork.usage = self.usage
470481
fork._approvals = self._approvals
471482
fork.turn_input = self.turn_input
483+
fork.tool_call_id = self.tool_call_id
472484
return fork
473485

474486

tests/test_run_hooks.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .fake_model import FakeModel
1717
from .test_responses import (
1818
get_function_tool,
19+
get_function_tool_call,
1920
get_text_message,
2021
)
2122

@@ -318,3 +319,49 @@ async def test_run_hooks_receives_turn_input_streamed():
318319
turn_input = hooks.captured_turn_inputs[0]
319320
assert len(turn_input) == 1
320321
assert turn_input[0]["content"] == "streamed input"
322+
323+
324+
class RunHooksToolCallId(RunHooks):
325+
"""Capture tool_call_id from on_tool_start and on_tool_end via RunContextWrapper directly."""
326+
327+
def __init__(self):
328+
self.start_ids: list[str | None] = []
329+
self.end_ids: list[str | None] = []
330+
331+
async def on_tool_start(
332+
self, context: RunContextWrapper[TContext], agent: Agent[TContext], tool: Tool
333+
) -> None:
334+
# tool_call_id is now accessible directly on RunContextWrapper, no isinstance check needed.
335+
self.start_ids.append(context.tool_call_id)
336+
337+
async def on_tool_end(
338+
self,
339+
context: RunContextWrapper[TContext],
340+
agent: Agent[TContext],
341+
tool: Tool,
342+
result: str,
343+
) -> None:
344+
self.end_ids.append(context.tool_call_id)
345+
346+
347+
@pytest.mark.asyncio
348+
async def test_tool_call_id_exposed_on_run_context_wrapper():
349+
"""tool_call_id on RunContextWrapper is set during on_tool_start/on_tool_end."""
350+
hooks = RunHooksToolCallId()
351+
model = FakeModel()
352+
agent = Agent(name="A", model=model, tools=[get_function_tool("my_tool", "ok")])
353+
354+
model.set_next_output([get_function_tool_call("my_tool", "{}", call_id="call_abc123")])
355+
model.add_multiple_turn_outputs([[get_text_message("done")]])
356+
357+
await Runner.run(agent, input="go", hooks=hooks)
358+
359+
assert hooks.start_ids == ["call_abc123"], f"Expected [call_abc123], got {hooks.start_ids}"
360+
assert hooks.end_ids == ["call_abc123"], f"Expected [call_abc123], got {hooks.end_ids}"
361+
362+
363+
@pytest.mark.asyncio
364+
async def test_tool_call_id_is_none_outside_tool_context():
365+
"""tool_call_id on a plain RunContextWrapper (outside a tool call) is None."""
366+
ctx = RunContextWrapper(context=None)
367+
assert ctx.tool_call_id is None

0 commit comments

Comments
 (0)