Skip to content

Commit 47ecb38

Browse files
seratchhabema
andauthored
feat: #2228 persist tool origin metadata in run items (#2654)
Co-authored-by: Hassan Abu Alhaj <136383052+habema@users.noreply.github.com>
1 parent 48ad4aa commit 47ecb38

13 files changed

Lines changed: 717 additions & 13 deletions

File tree

src/agents/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@
160160
ShellToolLocalSkill,
161161
ShellToolSkillReference,
162162
Tool,
163+
ToolOrigin,
164+
ToolOriginType,
163165
ToolOutputFileContent,
164166
ToolOutputFileContentDict,
165167
ToolOutputImage,
@@ -393,6 +395,8 @@ def enable_verbose_stdout_logging():
393395
"MCPApprovalResponseItem",
394396
"ToolCallItem",
395397
"ToolCallOutputItem",
398+
"ToolOrigin",
399+
"ToolOriginType",
396400
"ReasoningItem",
397401
"ItemHelpers",
398402
"RunHooks",

src/agents/agent.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
FunctionToolResult,
4747
Tool,
4848
ToolErrorFunction,
49+
ToolOrigin,
50+
ToolOriginType,
4951
_build_handled_function_tool_error_handler,
5052
_build_wrapped_function_tool,
5153
_log_function_tool_invocation,
@@ -886,6 +888,11 @@ async def dispatch_stream_events() -> None:
886888
strict_json_schema=True,
887889
is_enabled=is_enabled,
888890
needs_approval=needs_approval,
891+
tool_origin=ToolOrigin(
892+
type=ToolOriginType.AGENT_AS_TOOL,
893+
agent_name=self.name,
894+
agent_tool_name=tool_name_resolved,
895+
),
889896
)
890897
run_agent_tool._is_agent_tool = True
891898
run_agent_tool._agent_instance = self

src/agents/items.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from .exceptions import AgentsException, ModelBehaviorError
5555
from .logger import logger
5656
from .tool import (
57+
ToolOrigin,
5758
ToolOutputFileContent,
5859
ToolOutputImage,
5960
ToolOutputText,
@@ -358,6 +359,9 @@ class ToolCallItem(RunItemBase[Any]):
358359
title: str | None = None
359360
"""Optional short display label if known at item creation time."""
360361

362+
tool_origin: ToolOrigin | None = None
363+
"""Optional metadata describing the source of a function-tool-backed item."""
364+
361365

362366
ToolCallOutputTypes: TypeAlias = (
363367
FunctionCallOutput
@@ -382,6 +386,9 @@ class ToolCallOutputItem(RunItemBase[Any]):
382386

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

389+
tool_origin: ToolOrigin | None = None
390+
"""Optional metadata describing the source of a function-tool-backed item."""
391+
385392
def to_input_item(self) -> TResponseInputItem:
386393
"""Converts the tool output into an input item for the next model turn.
387394
@@ -489,6 +496,9 @@ class ToolApprovalItem(RunItemBase[Any]):
489496
tool_namespace: str | None = None
490497
"""Optional Responses API namespace for function-tool approvals."""
491498

499+
tool_origin: ToolOrigin | None = None
500+
"""Optional metadata describing where the approved tool call came from."""
501+
492502
tool_lookup_key: FunctionToolLookupKey | None = field(
493503
default=None,
494504
kw_only=True,

src/agents/mcp/util.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
FunctionTool,
2828
Tool,
2929
ToolErrorFunction,
30+
ToolOrigin,
31+
ToolOriginType,
3032
ToolOutputImageDict,
3133
ToolOutputTextDict,
3234
_build_handled_function_tool_error_handler,
@@ -314,6 +316,10 @@ def to_function_tool(
314316
strict_json_schema=is_strict,
315317
needs_approval=needs_approval,
316318
mcp_title=resolve_mcp_tool_title(tool),
319+
tool_origin=ToolOrigin(
320+
type=ToolOriginType.MCP,
321+
mcp_server_name=server.name,
322+
),
317323
)
318324
return function_tool
319325

src/agents/run_internal/approvals.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from ..agent import Agent
1515
from ..items import ItemHelpers, RunItem, ToolApprovalItem, ToolCallOutputItem, TResponseInputItem
16+
from ..tool import ToolOrigin
1617
from .items import ReasoningItemIdPolicy, run_item_to_input_item
1718

1819
# --------------------------
@@ -28,6 +29,7 @@ def append_approval_error_output(
2829
tool_name: str,
2930
call_id: str | None,
3031
message: str,
32+
tool_origin: ToolOrigin | None = None,
3133
) -> None:
3234
"""Emit a synthetic tool output so users see why an approval failed."""
3335
error_tool_call = _build_function_tool_call_for_approval_error(tool_call, tool_name, call_id)
@@ -36,6 +38,7 @@ def append_approval_error_output(
3638
output=message,
3739
raw_item=ItemHelpers.tool_call_output_item(error_tool_call, message),
3840
agent=agent,
41+
tool_origin=tool_origin,
3942
)
4043
)
4144

src/agents/run_internal/items.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ def function_rejection_item(
308308
*,
309309
rejection_message: str = REJECTION_MESSAGE,
310310
scope_id: str | None = None,
311+
tool_origin: Any = None,
311312
) -> ToolCallOutputItem:
312313
"""Build a ToolCallOutputItem representing a rejected function tool call."""
313314
if isinstance(tool_call, ResponseFunctionToolCall):
@@ -316,6 +317,7 @@ def function_rejection_item(
316317
output=rejection_message,
317318
raw_item=ItemHelpers.tool_call_output_item(tool_call, rejection_message),
318319
agent=agent,
320+
tool_origin=tool_origin,
319321
)
320322

321323

src/agents/run_internal/run_loop.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
from collections.abc import Awaitable, Callable, Mapping
1212
from typing import Any, TypeVar, cast
1313

14-
from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputItemDoneEvent
14+
from openai.types.responses import (
15+
Response,
16+
ResponseCompletedEvent,
17+
ResponseFunctionToolCall,
18+
ResponseOutputItemDoneEvent,
19+
)
1520
from openai.types.responses.response_output_item import McpCall, McpListTools
1621
from openai.types.responses.response_prompt_param import ResponsePromptParam
1722
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
@@ -64,7 +69,14 @@
6469
RawResponsesStreamEvent,
6570
RunItemStreamEvent,
6671
)
67-
from ..tool import FunctionTool, Tool, dispose_resolved_computers
72+
from ..tool import (
73+
FunctionTool,
74+
Tool,
75+
ToolOrigin,
76+
ToolOriginType,
77+
dispose_resolved_computers,
78+
get_function_tool_origin,
79+
)
6880
from ..tracing import Span, SpanError, agent_span, get_current_trace, task_span, turn_span
6981
from ..tracing.model_tracing import get_model_tracing_impl
7082
from ..tracing.span_data import AgentSpanData, TaskSpanData
@@ -138,6 +150,7 @@
138150
from .streaming import stream_step_items_to_queue, stream_step_result_to_queue
139151
from .tool_actions import ApplyPatchAction, ComputerAction, LocalShellAction, ShellAction
140152
from .tool_execution import (
153+
build_litellm_json_tool_call,
141154
coerce_shell_call,
142155
execute_apply_patch_calls,
143156
execute_computer_actions,
@@ -1540,24 +1553,38 @@ async def rewind_model_request() -> None:
15401553
matched_tool = (
15411554
tool_map.get(tool_lookup_key) if tool_lookup_key is not None else None
15421555
)
1556+
if (
1557+
matched_tool is None
1558+
and output_schema is not None
1559+
and isinstance(output_item, ResponseFunctionToolCall)
1560+
and output_item.name == "json_tool_call"
1561+
):
1562+
matched_tool = build_litellm_json_tool_call(output_item)
15431563
tool_description: str | None = None
15441564
tool_title: str | None = None
1565+
tool_origin = None
15451566
if isinstance(output_item, McpCall):
15461567
metadata = hosted_mcp_tool_metadata.get(
15471568
(output_item.server_label, output_item.name)
15481569
)
15491570
if metadata is not None:
15501571
tool_description = metadata.description
15511572
tool_title = metadata.title
1573+
tool_origin = ToolOrigin(
1574+
type=ToolOriginType.MCP,
1575+
mcp_server_name=output_item.server_label,
1576+
)
15521577
elif matched_tool is not None:
15531578
tool_description = getattr(matched_tool, "description", None)
15541579
tool_title = getattr(matched_tool, "_mcp_title", None)
1580+
tool_origin = get_function_tool_origin(matched_tool)
15551581

15561582
tool_item = ToolCallItem(
15571583
raw_item=cast(ToolCallItemTypes, output_item),
15581584
agent=public_agent,
15591585
description=tool_description,
15601586
title=tool_title,
1587+
tool_origin=tool_origin,
15611588
)
15621589
streamed_result._event_queue.put_nowait(
15631590
RunItemStreamEvent(item=tool_item, name="tool_called")

src/agents/run_internal/tool_execution.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
ShellCallOutcome,
7272
ShellCommandOutput,
7373
Tool,
74+
ToolOrigin,
75+
get_function_tool_origin,
7476
invoke_function_tool,
7577
maybe_invoke_function_tool_failure_error_function,
7678
resolve_computer,
@@ -1050,6 +1052,7 @@ async def on_invoke_tool(_ctx: ToolContext[Any], value: Any) -> Any:
10501052
on_invoke_tool=on_invoke_tool,
10511053
strict_json_schema=True,
10521054
is_enabled=True,
1055+
_emit_tool_origin=False,
10531056
)
10541057

10551058

@@ -1062,6 +1065,7 @@ async def resolve_approval_status(
10621065
context_wrapper: RunContextWrapper[Any],
10631066
tool_namespace: str | None = None,
10641067
tool_lookup_key: FunctionToolLookupKey | None = None,
1068+
tool_origin: ToolOrigin | None = None,
10651069
on_approval: Callable[[RunContextWrapper[Any], ToolApprovalItem], Any] | None = None,
10661070
) -> tuple[bool | None, ToolApprovalItem]:
10671071
"""Build approval item, run on_approval hook if needed, and return latest approval status."""
@@ -1070,6 +1074,7 @@ async def resolve_approval_status(
10701074
raw_item=raw_item,
10711075
tool_name=tool_name,
10721076
tool_namespace=tool_namespace,
1077+
tool_origin=tool_origin,
10731078
tool_lookup_key=tool_lookup_key,
10741079
)
10751080
approval_status = context_wrapper.get_approval_status(
@@ -1609,6 +1614,7 @@ async def _maybe_execute_tool_approval(
16091614
raw_item=raw_tool_call,
16101615
tool_name=func_tool.name,
16111616
tool_namespace=tool_namespace,
1617+
tool_origin=get_function_tool_origin(func_tool),
16121618
tool_lookup_key=tool_lookup_key,
16131619
_allow_bare_name_alias=should_allow_bare_name_approval_alias(
16141620
func_tool,
@@ -1649,6 +1655,7 @@ async def _maybe_execute_tool_approval(
16491655
tool_call,
16501656
rejection_message=rejection_message,
16511657
scope_id=self.tool_state_scope_id,
1658+
tool_origin=get_function_tool_origin(func_tool),
16521659
),
16531660
)
16541661

@@ -1844,6 +1851,7 @@ def _build_function_tool_results(self) -> list[FunctionToolResult]:
18441851
output=result,
18451852
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, result),
18461853
agent=self.public_agent,
1854+
tool_origin=get_function_tool_origin(tool_run.function_tool),
18471855
)
18481856
else:
18491857
# Skip tool output until nested interruptions are resolved.
@@ -2060,14 +2068,22 @@ async def execute_approved_tools(
20602068
if isinstance(tool_name, str) and tool_name:
20612069
tool_map[tool_name] = tool
20622070

2063-
def _append_error(message: str, *, tool_call: Any, tool_name: str, call_id: str) -> None:
2071+
def _append_error(
2072+
message: str,
2073+
*,
2074+
tool_call: Any,
2075+
tool_name: str,
2076+
call_id: str,
2077+
tool_origin: ToolOrigin | None = None,
2078+
) -> None:
20642079
append_approval_error_output(
20652080
message=message,
20662081
tool_call=tool_call,
20672082
tool_name=tool_name,
20682083
call_id=call_id,
20692084
generated_items=generated_items,
20702085
agent=agent,
2086+
tool_origin=tool_origin,
20712087
)
20722088

20732089
async def _resolve_tool_run(
@@ -2095,14 +2111,25 @@ async def _resolve_tool_run(
20952111

20962112
call_id = extract_tool_call_id(tool_call)
20972113
if not call_id:
2114+
resolved_tool = tool_map.get(approval_key) if approval_key is not None else None
2115+
if resolved_tool is None and tool_namespace is None:
2116+
resolved_tool = tool_map.get(tool_name)
20982117
_append_error(
20992118
message="Tool approval item missing call ID.",
21002119
tool_call=tool_call,
21012120
tool_name=tool_name,
21022121
call_id="unknown",
2122+
tool_origin=(
2123+
get_function_tool_origin(resolved_tool)
2124+
if isinstance(resolved_tool, FunctionTool)
2125+
else None
2126+
),
21032127
)
21042128
return None
21052129

2130+
resolved_tool = tool_map.get(approval_key) if approval_key is not None else None
2131+
if resolved_tool is None and tool_namespace is None:
2132+
resolved_tool = tool_map.get(tool_name)
21062133
approval_status = context_wrapper.get_approval_status(
21072134
tool_name,
21082135
call_id,
@@ -2111,9 +2138,6 @@ async def _resolve_tool_run(
21112138
tool_lookup_key=tool_lookup_key,
21122139
)
21132140
if approval_status is False:
2114-
resolved_tool = tool_map.get(approval_key) if approval_key is not None else None
2115-
if resolved_tool is None and tool_namespace is None:
2116-
resolved_tool = tool_map.get(tool_name)
21172141
message = REJECTION_MESSAGE
21182142
if isinstance(resolved_tool, FunctionTool):
21192143
message = await resolve_approval_rejection_message(
@@ -2131,6 +2155,11 @@ async def _resolve_tool_run(
21312155
tool_call=tool_call,
21322156
tool_name=tool_name,
21332157
call_id=call_id,
2158+
tool_origin=(
2159+
get_function_tool_origin(resolved_tool)
2160+
if isinstance(resolved_tool, FunctionTool)
2161+
else None
2162+
),
21342163
)
21352164
return None
21362165

@@ -2140,12 +2169,15 @@ async def _resolve_tool_run(
21402169
tool_call=tool_call,
21412170
tool_name=tool_name,
21422171
call_id=call_id,
2172+
tool_origin=(
2173+
get_function_tool_origin(resolved_tool)
2174+
if isinstance(resolved_tool, FunctionTool)
2175+
else None
2176+
),
21432177
)
21442178
return None
21452179

2146-
tool = tool_map.get(approval_key) if approval_key is not None else None
2147-
if tool is None and tool_namespace is None:
2148-
tool = tool_map.get(tool_name)
2180+
tool = resolved_tool
21492181
if tool is None:
21502182
_append_error(
21512183
message=f"Tool '{display_tool_name}' not found.",

src/agents/run_internal/tool_planning.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
ToolCallOutputItem,
2323
)
2424
from ..run_context import RunContextWrapper
25-
from ..tool import FunctionTool, MCPToolApprovalRequest
25+
from ..tool import FunctionTool, MCPToolApprovalRequest, get_function_tool_origin
2626
from ..tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
2727
from .agent_bindings import AgentBindings
2828
from .run_steps import (
@@ -427,11 +427,17 @@ async def _collect_runs_by_approval(
427427
if approval_status is True:
428428
approved_runs.append(run)
429429
else:
430+
function_tool = get_mapping_or_attr(run, "function_tool")
430431
pending_item = existing_pending or ToolApprovalItem(
431432
agent=agent,
432433
raw_item=get_mapping_or_attr(run, "tool_call"),
433434
tool_name=tool_name,
434435
tool_namespace=get_tool_call_namespace(get_mapping_or_attr(run, "tool_call")),
436+
tool_origin=(
437+
get_function_tool_origin(function_tool)
438+
if isinstance(function_tool, FunctionTool)
439+
else None
440+
),
435441
tool_lookup_key=get_function_tool_lookup_key_for_call(
436442
get_mapping_or_attr(run, "tool_call")
437443
),

0 commit comments

Comments
 (0)