From aaaad6c44f954753bff2e707a22caa7bfb74e348 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:57:43 +0100 Subject: [PATCH 1/5] feat: expose deterministic tool action references --- src/agents/tool_context.py | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index dc9e167b60..71d1b88f17 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass, field, fields from typing import TYPE_CHECKING, Any, cast @@ -35,9 +37,49 @@ def _assert_must_pass_tool_arguments() -> str: raise ValueError("tool_arguments must be passed to ToolContext") +_ACTION_REF_DOMAIN = b"openai-agents-python:tool-action-ref:v1\0" _MISSING = object() +def _canonical_tool_request(tool_arguments: str) -> bytes: + """Return deterministic bytes for hashing a tool request without changing execution behavior.""" + try: + parsed_arguments = json.loads(tool_arguments) + canonical_arguments = json.dumps( + parsed_arguments, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + except (json.JSONDecodeError, TypeError, ValueError): + return b"raw\0" + tool_arguments.encode("utf-8") + return b"json\0" + canonical_arguments.encode("utf-8") + + +def _compute_tool_action_ref( + agent_name: str | None, + qualified_tool_name: str, + tool_arguments: str, +) -> str | None: + if agent_name is None: + return None + + request_digest = hashlib.sha256(_canonical_tool_request(tool_arguments)).hexdigest() + commitment = json.dumps( + { + "agent_id": agent_name, + "request_digest": request_digest, + "tool_name": qualified_tool_name, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + digest = hashlib.sha256(_ACTION_REF_DOMAIN + commitment).hexdigest() + return f"act_v1_{digest}" + + @dataclass(eq=False) class ToolContext(RunContextWrapper[TContext]): """The context of a tool call.""" @@ -63,6 +105,8 @@ class ToolContext(RunContextWrapper[TContext]): run_config: RunConfig | None = None """The active run config for this tool call, when available.""" + _action_ref: str | None = field(default=None, init=False, repr=False) + def __init__( self, context: TContext, @@ -114,6 +158,11 @@ def __init__( self.run_config = _coerce_run_config(run_config) else: self.run_config = None + self._action_ref = _compute_tool_action_ref( + agent.name if agent is not None else None, + self.qualified_tool_name, + self.tool_arguments, + ) # Internal adapter hook used to attach SDK-only custom data to the emitted output item. self._custom_data: dict[str, Any] | None = None @@ -122,6 +171,11 @@ def qualified_tool_name(self) -> str: """Return the tool name qualified by namespace when available.""" return tool_trace_name(self.tool_name, self.tool_namespace) or self.tool_name + @property + def action_ref(self) -> str | None: + """Return the deterministic commitment for this tool action, when agent metadata exists.""" + return self._action_ref + def _find_nested_approval_target( self, approval_item: ToolApprovalItem, From 847a5be4c12cd5906b461f0ad0dbbc38f5560205 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:57:58 +0100 Subject: [PATCH 2/5] test: cover deterministic tool action references --- tests/test_tool_context_action_ref.py | 126 ++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/test_tool_context_action_ref.py diff --git a/tests/test_tool_context_action_ref.py b/tests/test_tool_context_action_ref.py new file mode 100644 index 0000000000..fa613a7d37 --- /dev/null +++ b/tests/test_tool_context_action_ref.py @@ -0,0 +1,126 @@ +import json +from typing import Any + +import pytest + +from agents import Agent, RunHooks, Runner, function_tool +from agents.run_context import RunContextWrapper +from agents.testing import ScriptedModel +from agents.tool import Tool +from agents.tool_context import ToolContext + +from .test_responses import get_function_tool_call, get_text_message + + +def _tool_context( + *, + agent_name: str = "Verifier", + tool_name: str = "lookup", + tool_arguments: str = '{"account": 7, "active": true}', + tool_namespace: str | None = None, +) -> ToolContext[dict[str, object]]: + return ToolContext( + context={}, + tool_name=tool_name, + tool_call_id="call-local", + tool_arguments=tool_arguments, + tool_namespace=tool_namespace, + agent=Agent(name=agent_name), + ) + + +def test_action_ref_is_stable_for_equivalent_json_arguments() -> None: + first = _tool_context(tool_arguments='{"account":7,"active":true}') + second = _tool_context(tool_arguments='{ "active": true, "account": 7 }') + + assert first.action_ref is not None + assert first.action_ref.startswith("act_v1_") + assert first.action_ref == second.action_ref + + +def test_action_ref_commits_agent_tool_and_request() -> None: + baseline = _tool_context().action_ref + + assert baseline is not None + assert _tool_context(agent_name="Other verifier").action_ref != baseline + assert _tool_context(tool_name="update").action_ref != baseline + assert _tool_context(tool_arguments='{"account": 8, "active": true}').action_ref != baseline + assert _tool_context(tool_namespace="billing").action_ref != baseline + + +def test_action_ref_does_not_depend_on_opaque_tool_call_id() -> None: + agent = Agent(name="Verifier") + first: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="lookup", + tool_call_id="call-1", + tool_arguments='{"account": 7}', + agent=agent, + ) + second: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="lookup", + tool_call_id="call-2", + tool_arguments='{"account": 7}', + agent=agent, + ) + + assert first.action_ref == second.action_ref + + +def test_action_ref_is_none_without_agent_metadata() -> None: + context: ToolContext[dict[str, object]] = ToolContext( + context={}, + tool_name="lookup", + tool_call_id="call-1", + tool_arguments="{}", + ) + + assert context.action_ref is None + + +class CaptureActionRefHooks(RunHooks[Any]): + def __init__(self) -> None: + self.action_refs: list[str | None] = [] + + async def on_tool_start( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + ) -> None: + assert isinstance(context, ToolContext) + self.action_refs.append(context.action_ref) + + async def on_tool_end( + self, + context: RunContextWrapper[Any], + agent: Agent[Any], + tool: Tool, + result: object, + ) -> None: + assert isinstance(context, ToolContext) + self.action_refs.append(context.action_ref) + + +@pytest.mark.asyncio +async def test_action_ref_is_unchanged_across_tool_hooks() -> None: + @function_tool + def echo(value: str) -> str: + return value + + hooks = CaptureActionRefHooks() + model = ScriptedModel() + model.extend( + [ + [get_function_tool_call("echo", json.dumps({"value": "hello"}))], + [get_text_message("done")], + ] + ) + agent = Agent(name="Hook verifier", model=model, tools=[echo]) + + await Runner.run(agent, input="call the echo tool", hooks=hooks) + + assert len(hooks.action_refs) == 2 + assert hooks.action_refs[0] is not None + assert hooks.action_refs[0] == hooks.action_refs[1] From cda0c18097247e5df58c58a3ecb4867c63f83508 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:34:28 +0100 Subject: [PATCH 3/5] fix: preserve canonical tool identity in action refs --- src/agents/tool_context.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index 71d1b88f17..1cf349281f 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -7,7 +7,14 @@ from openai.types.responses import ResponseFunctionToolCall -from ._tool_identity import HostedMCPApprovalKey, get_tool_call_namespace, tool_trace_name +from ._tool_identity import ( + FunctionToolLookupKey, + HostedMCPApprovalKey, + get_function_tool_lookup_key, + get_tool_call_namespace, + serialize_function_tool_lookup_key, + tool_trace_name, +) from ._tool_invocation import tool_invocation_identity, tool_invocation_identity_and_scope from .agent_tool_state import ( get_agent_tool_state_scope, @@ -47,22 +54,22 @@ def _canonical_tool_request(tool_arguments: str) -> bytes: parsed_arguments = json.loads(tool_arguments) canonical_arguments = json.dumps( parsed_arguments, - ensure_ascii=False, + ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False, ) except (json.JSONDecodeError, TypeError, ValueError): - return b"raw\0" + tool_arguments.encode("utf-8") - return b"json\0" + canonical_arguments.encode("utf-8") + return b"raw\0" + tool_arguments.encode("utf-8", errors="surrogatepass") + return b"json\0" + canonical_arguments.encode("ascii") def _compute_tool_action_ref( agent_name: str | None, - qualified_tool_name: str, + tool_lookup_key: FunctionToolLookupKey | None, tool_arguments: str, ) -> str | None: - if agent_name is None: + if agent_name is None or tool_lookup_key is None: return None request_digest = hashlib.sha256(_canonical_tool_request(tool_arguments)).hexdigest() @@ -70,12 +77,12 @@ def _compute_tool_action_ref( { "agent_id": agent_name, "request_digest": request_digest, - "tool_name": qualified_tool_name, + "tool_identity": serialize_function_tool_lookup_key(tool_lookup_key), }, - ensure_ascii=False, + ensure_ascii=True, sort_keys=True, separators=(",", ":"), - ).encode("utf-8") + ).encode("ascii") digest = hashlib.sha256(_ACTION_REF_DOMAIN + commitment).hexdigest() return f"act_v1_{digest}" @@ -160,7 +167,7 @@ def __init__( self.run_config = None self._action_ref = _compute_tool_action_ref( agent.name if agent is not None else None, - self.qualified_tool_name, + get_function_tool_lookup_key(self.tool_name, self.tool_namespace), self.tool_arguments, ) # Internal adapter hook used to attach SDK-only custom data to the emitted output item. @@ -343,4 +350,4 @@ def from_agent_context( ) context._share_tool_state_with(tool_context) set_agent_tool_state_scope(tool_context, get_agent_tool_state_scope(context)) - return tool_context + return tool_context \ No newline at end of file From 8ef0d77a9f47de7d8f73bfd0d3f62d6ad3b8c451 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:34:56 +0100 Subject: [PATCH 4/5] test: cover action ref identity and surrogate handling --- tests/test_tool_context_action_ref.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_tool_context_action_ref.py b/tests/test_tool_context_action_ref.py index fa613a7d37..cd2d0e0de7 100644 --- a/tests/test_tool_context_action_ref.py +++ b/tests/test_tool_context_action_ref.py @@ -38,6 +38,14 @@ def test_action_ref_is_stable_for_equivalent_json_arguments() -> None: assert first.action_ref == second.action_ref +def test_action_ref_handles_json_surrogate_escape() -> None: + first = _tool_context(tool_arguments='{"x":"\\ud800"}') + second = _tool_context(tool_arguments='{ "x": "\\ud800" }') + + assert first.action_ref is not None + assert first.action_ref == second.action_ref + + def test_action_ref_commits_agent_tool_and_request() -> None: baseline = _tool_context().action_ref @@ -48,6 +56,16 @@ def test_action_ref_commits_agent_tool_and_request() -> None: assert _tool_context(tool_namespace="billing").action_ref != baseline +def test_action_ref_distinguishes_bare_and_deferred_tool_identity() -> None: + bare = _tool_context(tool_name="lookup") + deferred = _tool_context(tool_name="lookup", tool_namespace="lookup") + + assert bare.qualified_tool_name == deferred.qualified_tool_name == "lookup" + assert bare.action_ref is not None + assert deferred.action_ref is not None + assert bare.action_ref != deferred.action_ref + + def test_action_ref_does_not_depend_on_opaque_tool_call_id() -> None: agent = Agent(name="Verifier") first: ToolContext[dict[str, object]] = ToolContext( From a00826e402b4745c3254260efac64c17ee0c3d74 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:36:12 +0100 Subject: [PATCH 5/5] chore: restore trailing newline --- src/agents/tool_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/tool_context.py b/src/agents/tool_context.py index 1cf349281f..ea8a6614cd 100644 --- a/src/agents/tool_context.py +++ b/src/agents/tool_context.py @@ -350,4 +350,4 @@ def from_agent_context( ) context._share_tool_state_with(tool_context) set_agent_tool_state_scope(tool_context, get_agent_tool_state_scope(context)) - return tool_context \ No newline at end of file + return tool_context