Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion src/agents/tool_context.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field, fields
from typing import TYPE_CHECKING, Any, cast

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,
Expand Down Expand Up @@ -35,9 +44,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=True,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve normalization for overflowing JSON numbers

For a valid JSON number that overflows Python's float range, such as {"x":1e400}, json.loads produces infinity and allow_nan=False makes json.dumps raise ValueError; the code then hashes the raw string. Consequently {"x":1e400} and { "x": 1e400 } receive different references even though they differ only by insignificant whitespace, violating the stated normalization behavior for valid JSON. Parse or serialize numeric tokens without float overflow so these requests remain canonicalized.

Useful? React with 👍 / 👎.

)
except (json.JSONDecodeError, TypeError, ValueError):
return b"raw\0" + tool_arguments.encode("utf-8", errors="surrogatepass")
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back safely when JSON nesting exceeds the decoder limit

When model-generated arguments contain sufficiently deep but syntactically valid nesting, json.loads or json.dumps raises RecursionError, which this exception list does not catch. ToolContext is constructed before the function-tool invocation and its existing input-error conversion, so action-ref calculation aborts the entire run instead of following the normal tool failure path. The surrogate case is fixed, but deeply nested JSON is fresh evidence of another parser-accepted input path that still escapes construction; route this exception through the raw commitment fallback as well.

Useful? React with 👍 / 👎.

return b"json\0" + canonical_arguments.encode("ascii")


def _compute_tool_action_ref(
agent_name: str | None,
tool_lookup_key: FunctionToolLookupKey | None,
tool_arguments: str,
) -> str | None:
if agent_name is None or tool_lookup_key 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_identity": serialize_function_tool_lookup_key(tool_lookup_key),
},
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("ascii")
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."""
Expand All @@ -63,6 +112,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,
Expand Down Expand Up @@ -114,6 +165,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,
get_function_tool_lookup_key(self.tool_name, self.tool_namespace),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Distinguish custom tools before computing action refs

When a Responses CustomTool is invoked, CustomToolAction.execute also constructs a ToolContext with agent metadata, so this line assigns it a function-tool ("bare", name) identity. Because the runtime routes function and custom calls by their call type, an agent can expose both families under the same name; identical input text then produces the same action_ref for different handlers. JSON-looking custom inputs are also normalized despite CustomTool explicitly receiving raw text, so inputs such as {"x":1} and { "x": 1 } can behave differently while sharing a reference. Include the tool family and raw-input semantics in the commitment, or leave action_ref unset for non-function tools.

Useful? React with 👍 / 👎.

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

Expand All @@ -122,6 +178,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,
Expand Down
144 changes: 144 additions & 0 deletions tests/test_tool_context_action_ref.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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_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

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_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(
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]