Skip to content
Draft
4 changes: 4 additions & 0 deletions src/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@
ShellToolLocalSkill,
ShellToolSkillReference,
Tool,
ToolOrigin,
ToolOriginType,
ToolOutputFileContent,
ToolOutputFileContentDict,
ToolOutputImage,
Expand Down Expand Up @@ -408,6 +410,8 @@ def enable_verbose_stdout_logging():
"ApplyPatchResult",
"ApplyPatchTool",
"Tool",
"ToolOrigin",
"ToolOriginType",
"WebSearchTool",
"HostedMCPTool",
"MCPToolApprovalFunction",
Expand Down
7 changes: 7 additions & 0 deletions src/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
FunctionToolResult,
Tool,
ToolErrorFunction,
ToolOrigin,
ToolOriginType,
_extract_tool_argument_json_error,
default_tool_error_function,
)
Expand Down Expand Up @@ -851,6 +853,11 @@ async def _run_agent_tool(context: ToolContext, input_json: str) -> Any:
)
run_agent_tool._is_agent_tool = True
run_agent_tool._agent_instance = self
# Set origin tracking on run_agent (the FunctionTool returned by @function_tool)
run_agent_tool._tool_origin = ToolOrigin(
type=ToolOriginType.AGENT_AS_TOOL,
agent_as_tool=self,
)

return run_agent_tool

Expand Down
28 changes: 28 additions & 0 deletions src/agents/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from .exceptions import AgentsException, ModelBehaviorError
from .logger import logger
from .tool import (
ToolOrigin,
ToolOutputFileContent,
ToolOutputImage,
ToolOutputText,
Expand Down Expand Up @@ -250,6 +251,15 @@ class ToolCallItem(RunItemBase[Any]):
description: str | None = None
"""Optional tool description if known at item creation time."""

tool_origin: ToolOrigin | None = field(default=None, repr=False)
"""Information about the origin/source of the tool call. Only set for FunctionTool calls."""

def release_agent(self) -> None:
"""Release agent references including tool_origin.agent_as_tool."""
super().release_agent()
if self.tool_origin is not None:
self.tool_origin.release_agent()


ToolCallOutputTypes: TypeAlias = Union[
FunctionCallOutput,
Expand All @@ -274,6 +284,15 @@ class ToolCallOutputItem(RunItemBase[Any]):

type: Literal["tool_call_output_item"] = "tool_call_output_item"

tool_origin: ToolOrigin | None = field(default=None, repr=False)
"""Information about the origin/source of the tool call. Only set for FunctionTool calls."""

def release_agent(self) -> None:
"""Release agent references including tool_origin.agent_as_tool."""
super().release_agent()
if self.tool_origin is not None:
self.tool_origin.release_agent()

def to_input_item(self) -> TResponseInputItem:
"""Converts the tool output into an input item for the next model turn.

Expand Down Expand Up @@ -375,8 +394,17 @@ class ToolApprovalItem(RunItemBase[Any]):
tool_name: str | None = None
"""Tool name for approval tracking; falls back to raw_item.name when absent."""

tool_origin: ToolOrigin | None = field(default=None, repr=False)
"""Information about the origin/source of the tool. Only set for FunctionTool calls."""

type: Literal["tool_approval_item"] = "tool_approval_item"

def release_agent(self) -> None:
"""Release agent references including tool_origin.agent_as_tool."""
super().release_agent()
if self.tool_origin is not None:
self.tool_origin.release_agent()

def __post_init__(self) -> None:
"""Populate tool_name from the raw item if not provided."""
if self.tool_name is None:
Expand Down
9 changes: 8 additions & 1 deletion src/agents/mcp/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
FunctionTool,
Tool,
ToolErrorFunction,
ToolOrigin,
ToolOriginType,
ToolOutputImageDict,
ToolOutputTextDict,
default_tool_error_function,
Expand Down Expand Up @@ -302,14 +304,19 @@ async def invoke_func(ctx: ToolContext[Any], input_json: str) -> ToolOutput:
bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]]
) = server._get_needs_approval_for_tool(tool, agent)

return FunctionTool(
function_tool = FunctionTool(
name=tool.name,
description=tool.description or "",
params_json_schema=schema,
on_invoke_tool=invoke_func,
strict_json_schema=is_strict,
needs_approval=needs_approval,
)
function_tool._tool_origin = ToolOrigin(
type=ToolOriginType.MCP,
mcp_server=server,
)
return function_tool

@staticmethod
def _merge_mcp_meta(
Expand Down
9 changes: 8 additions & 1 deletion src/agents/realtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,14 @@ def _build_tool_approval_item(
"call_id": tool_call.call_id,
"arguments": tool_call.arguments,
}
return ToolApprovalItem(agent=cast(Any, agent), raw_item=raw_item, tool_name=tool.name)
from ..tool import _get_tool_origin_info

return ToolApprovalItem(
agent=cast(Any, agent),
raw_item=raw_item,
tool_name=tool.name,
tool_origin=_get_tool_origin_info(tool),
)

async def _maybe_request_tool_approval(
self,
Expand Down
4 changes: 3 additions & 1 deletion src/agents/run_internal/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ..agent_tool_state import drop_agent_tool_run_result
from ..items import ItemHelpers, RunItem, ToolCallOutputItem, TResponseInputItem
from ..models.fake_id import FAKE_RESPONSES_ID
from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE
from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE, ToolOrigin

REJECTION_MESSAGE = DEFAULT_APPROVAL_REJECTION_MESSAGE
_TOOL_CALL_TO_OUTPUT_TYPE: dict[str, str] = {
Expand Down Expand Up @@ -246,6 +246,7 @@ def function_rejection_item(
tool_call: Any,
*,
rejection_message: str = REJECTION_MESSAGE,
tool_origin: ToolOrigin | None = None,
scope_id: str | None = None,
) -> ToolCallOutputItem:
"""Build a ToolCallOutputItem representing a rejected function tool call."""
Expand All @@ -255,6 +256,7 @@ def function_rejection_item(
output=rejection_message,
raw_item=ItemHelpers.tool_call_output_item(tool_call, rejection_message),
agent=agent,
tool_origin=tool_origin,
)


Expand Down
32 changes: 29 additions & 3 deletions src/agents/run_internal/run_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar, cast

from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputItemDoneEvent
from openai.types.responses import (
Response,
ResponseCompletedEvent,
ResponseFunctionToolCall,
ResponseOutputItemDoneEvent,
)
from openai.types.responses.response_prompt_param import ResponsePromptParam
from openai.types.responses.response_reasoning_item import ResponseReasoningItem

Expand Down Expand Up @@ -49,7 +54,7 @@
RawResponsesStreamEvent,
RunItemStreamEvent,
)
from ..tool import Tool, dispose_resolved_computers
from ..tool import FunctionTool, Tool, _get_tool_origin_info, dispose_resolved_computers
from ..tracing import Span, SpanError, agent_span, get_current_trace
from ..tracing.model_tracing import get_model_tracing_impl
from ..tracing.span_data import AgentSpanData
Expand Down Expand Up @@ -113,6 +118,7 @@
from .streaming import stream_step_items_to_queue, stream_step_result_to_queue
from .tool_actions import ApplyPatchAction, ComputerAction, LocalShellAction, ShellAction
from .tool_execution import (
build_litellm_json_tool_call,
coerce_shell_call,
execute_apply_patch_calls,
execute_computer_actions,
Expand Down Expand Up @@ -1063,6 +1069,8 @@ async def run_single_turn_streamed(
# execution in process_model_response, so duplicate names (e.g., MCP + local tool)
# stream the same description that execution uses.
tool_map = {t.name: t for t in all_tools if hasattr(t, "name") and t.name}
# FunctionTool-only map for tool_origin; matches process_model_response's last-wins.
function_map = {t.name: t for t in all_tools if isinstance(t, FunctionTool)}

try:
turn_input = ItemHelpers.input_to_new_input_list(streamed_result.input)
Expand Down Expand Up @@ -1250,13 +1258,31 @@ async def run_single_turn_streamed(
# execution behavior in process_model_response).
tool_name = getattr(output_item, "name", None)
tool_description: str | None = None
tool_origin = None
if isinstance(tool_name, str) and tool_name in tool_map:
tool_description = getattr(tool_map[tool_name], "description", None)
tool = tool_map[tool_name]
tool_description = getattr(tool, "description", None)
# Use function_map for tool_origin to match process_model_response's
# last-wins semantics when multiple FunctionTools share a name.
func_tool = function_map.get(tool_name)
if func_tool is not None:
tool_origin = _get_tool_origin_info(func_tool)
elif (
isinstance(tool_name, str)
and tool_name == "json_tool_call"
and output_schema is not None
and isinstance(output_item, ResponseFunctionToolCall)
):
# json_tool_call is synthesized dynamically and not in tool_map.
# Synthesize it here to get tool_origin, matching process_model_response.
json_tool = build_litellm_json_tool_call(output_item)
tool_origin = _get_tool_origin_info(json_tool)

tool_item = ToolCallItem(
raw_item=cast(ToolCallItemTypes, output_item),
agent=agent,
description=tool_description,
tool_origin=tool_origin,
)
streamed_result._event_queue.put_nowait(
RunItemStreamEvent(item=tool_item, name="tool_called")
Expand Down
11 changes: 10 additions & 1 deletion src/agents/run_internal/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
ShellCallOutcome,
ShellCommandOutput,
Tool,
_get_tool_origin_info,
invoke_function_tool,
resolve_computer,
)
Expand Down Expand Up @@ -872,8 +873,12 @@ async def run_single_tool(func_tool: FunctionTool, tool_call: ResponseFunctionTo
)

if approval_status is None:
tool_origin = _get_tool_origin_info(func_tool)
approval_item = ToolApprovalItem(
agent=agent, raw_item=tool_call, tool_name=func_tool.name
agent=agent,
raw_item=tool_call,
tool_name=func_tool.name,
tool_origin=tool_origin,
)
return FunctionToolResult(
tool=func_tool, output=None, run_item=approval_item
Expand Down Expand Up @@ -901,13 +906,15 @@ async def run_single_tool(func_tool: FunctionTool, tool_call: ResponseFunctionTo
)
result = rejection_message
span_fn.span_data.output = result
tool_origin = _get_tool_origin_info(func_tool)
return FunctionToolResult(
tool=func_tool,
output=result,
run_item=function_rejection_item(
agent,
tool_call,
rejection_message=rejection_message,
tool_origin=tool_origin,
scope_id=tool_state_scope_id,
),
)
Expand Down Expand Up @@ -1024,10 +1031,12 @@ async def run_single_tool(func_tool: FunctionTool, tool_call: ResponseFunctionTo

run_item: RunItem | None = None
if not nested_interruptions:
tool_origin = _get_tool_origin_info(tool_run.function_tool)
run_item = ToolCallOutputItem(
output=result,
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, result),
agent=agent,
tool_origin=tool_origin,
)
else:
# Skip tool output until nested interruptions are resolved.
Expand Down
2 changes: 1 addition & 1 deletion src/agents/run_internal/tool_use_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def hydrate_tool_use_tracker(
if not snapshot:
return

agent_map = _build_agent_map(starting_agent)
agent_map, _ = _build_agent_map(starting_agent)
for agent_name, tool_names in snapshot.items():
agent = agent_map.get(agent_name)
if agent is None:
Expand Down
30 changes: 26 additions & 4 deletions src/agents/run_internal/turn_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
LocalShellTool,
ShellTool,
Tool,
_get_tool_origin_info,
)
from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
from ..tracing import SpanError, handoff_span
Expand Down Expand Up @@ -696,11 +697,13 @@ async def _record_function_rejection(
tool_name=function_tool.name,
call_id=call_id,
)
tool_origin = _get_tool_origin_info(function_tool)
rejected_function_outputs.append(
function_rejection_item(
agent,
tool_call,
rejection_message=rejection_message,
tool_origin=tool_origin,
scope_id=tool_state_scope_id,
)
)
Expand Down Expand Up @@ -979,7 +982,12 @@ def _add_unmatched_pending(approval: ToolApprovalItem) -> None:
output_exists_checker=_function_output_exists,
record_rejection=_record_function_rejection,
pending_interruption_adder=_add_pending_interruption,
pending_item_builder=lambda run: ToolApprovalItem(agent=agent, raw_item=run.tool_call),
pending_item_builder=lambda run: ToolApprovalItem(
agent=agent,
raw_item=run.tool_call,
tool_name=run.function_tool.name,
tool_origin=_get_tool_origin_info(run.function_tool),
),
)

rebuilt_function_tool_runs = await _rebuild_function_runs_from_approvals()
Expand Down Expand Up @@ -1536,11 +1544,19 @@ def process_model_response(
else:
if output.name not in function_map:
if output_schema is not None and output.name == "json_tool_call":
items.append(ToolCallItem(raw_item=output, agent=agent))
json_tool = build_litellm_json_tool_call(output)
tool_origin = _get_tool_origin_info(json_tool)
items.append(
ToolCallItem(
raw_item=output,
agent=agent,
tool_origin=tool_origin,
)
)
functions.append(
ToolRunFunction(
tool_call=output,
function_tool=build_litellm_json_tool_call(output),
function_tool=json_tool,
)
)
continue
Expand All @@ -1554,8 +1570,14 @@ def process_model_response(
raise ModelBehaviorError(error)

func_tool = function_map[output.name]
tool_origin = _get_tool_origin_info(func_tool)
items.append(
ToolCallItem(raw_item=output, agent=agent, description=func_tool.description)
ToolCallItem(
raw_item=output,
agent=agent,
description=func_tool.description,
tool_origin=tool_origin,
)
)
functions.append(
ToolRunFunction(
Expand Down
Loading