Skip to content

Commit 24badcf

Browse files
xumapleclaude
andauthored
AI-249: Support CustomTool in OpenAI Agents plugin tool dispatch (#1570)
* TDD: failing test * AI-249: Support CustomTool in OpenAI Agents plugin tool dispatch Fixes #1561. Adds a CustomToolInput dataclass and CustomTool dispatch branch (mirroring the existing HostedMCPTool precedent), so Workflows exposing any CustomTool subclass — notably SandboxApplyPatchTool, which the default Filesystem capability registers on every SandboxAgent — no longer fail with `ValueError: Unsupported tool type: apply_patch` at Activity input construction. Also round-trips `defer_loading` through `tool_config`, so direct `CustomTool(defer_loading=True)` callers continue to work with `ToolSearchTool()` lazy tool discovery. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AI-249: Type-annotate defer_loading test stub basedpyright in CI flagged the inline async stub callable for missing type annotations and unused-parameter warnings. Adds Any/str annotations and underscore-prefixes the params. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PR comments --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d8675f5 commit 24badcf

3 files changed

Lines changed: 168 additions & 1 deletion

File tree

temporalio/contrib/openai_agents/_invoke_model_activity.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from agents.items import TResponseStreamEvent
3131
from agents.tool import (
3232
ApplyPatchTool,
33+
CustomTool,
3334
LocalShellTool,
3435
ShellTool,
3536
ShellToolEnvironment,
@@ -39,6 +40,7 @@
3940
APIStatusError,
4041
AsyncOpenAI,
4142
)
43+
from openai.types.responses import CustomToolParam
4244
from openai.types.responses.tool_param import Mcp
4345
from typing_extensions import Required, TypedDict
4446

@@ -112,6 +114,15 @@ class ApplyPatchToolInput:
112114
name: str = "apply_patch"
113115

114116

117+
@dataclass
118+
class CustomToolInput:
119+
"""Data conversion friendly representation of a CustomTool. Contains only the fields which are needed by the model
120+
execution to determine what tool to call, not the actual tool invocation, which remains in the workflow context.
121+
"""
122+
123+
tool_config: CustomToolParam
124+
125+
115126
ToolInput = (
116127
FunctionToolInput
117128
| FileSearchTool
@@ -122,6 +133,7 @@ class ApplyPatchToolInput:
122133
| ShellToolInput
123134
| LocalShellTool
124135
| ApplyPatchToolInput
136+
| CustomToolInput
125137
| ToolSearchTool
126138
)
127139

@@ -235,6 +247,14 @@ def _build_tool(tool: ToolInput) -> Tool:
235247
return ApplyPatchTool(name=tool.name, editor=_NoopApplyPatchEditor())
236248
elif isinstance(tool, HostedMCPToolInput):
237249
return HostedMCPTool(tool_config=tool.tool_config)
250+
elif isinstance(tool, CustomToolInput):
251+
return CustomTool(
252+
name=tool.tool_config["name"],
253+
description=tool.tool_config.get("description", ""),
254+
on_invoke_tool=_empty_on_invoke_tool,
255+
format=tool.tool_config.get("format"),
256+
defer_loading=tool.tool_config.get("defer_loading", False),
257+
)
238258
elif isinstance(tool, FunctionToolInput):
239259
return FunctionTool(
240260
name=tool.name,

temporalio/contrib/openai_agents/_temporal_model_stub.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,21 @@
2222
WebSearchTool,
2323
)
2424
from agents.items import TResponseStreamEvent
25-
from agents.tool import ApplyPatchTool, LocalShellTool, ShellTool, ToolSearchTool
25+
from agents.tool import (
26+
ApplyPatchTool,
27+
CustomTool,
28+
LocalShellTool,
29+
ShellTool,
30+
ToolSearchTool,
31+
)
2632
from openai.types.responses.response_prompt_param import ResponsePromptParam
2733

2834
from temporalio import workflow
2935
from temporalio.contrib.openai_agents._invoke_model_activity import (
3036
ActivityModelInput,
3137
AgentOutputSchemaInput,
3238
ApplyPatchToolInput,
39+
CustomToolInput,
3340
FunctionToolInput,
3441
HandoffInput,
3542
HostedMCPToolInput,
@@ -92,6 +99,8 @@ def make_tool_info(tool: Tool) -> ToolInput:
9299
return ApplyPatchToolInput(name=tool.name)
93100
elif isinstance(tool, HostedMCPTool):
94101
return HostedMCPToolInput(tool_config=tool.tool_config)
102+
elif isinstance(tool, CustomTool):
103+
return CustomToolInput(tool_config=tool.tool_config)
95104
elif isinstance(tool, FunctionTool):
96105
return FunctionToolInput(
97106
name=tool.name,

tests/contrib/openai_agents/test_openai.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,13 @@
5858
TResponseStreamEvent,
5959
)
6060
from agents.mcp import MCPServer, MCPServerStdio
61+
from agents.sandbox.capabilities.tools import SandboxApplyPatchTool
62+
from agents.tool import CustomTool
63+
from agents.tool_context import ToolContext
6164
from openai import APIStatusError, AsyncOpenAI, BaseModel
6265
from openai.types.responses import (
6366
ResponseCodeInterpreterToolCall,
67+
ResponseCustomToolCall,
6468
ResponseFileSearchToolCall,
6569
ResponseFunctionWebSearch,
6670
)
@@ -83,6 +87,7 @@
8387
StatefulMCPServerProvider,
8488
StatelessMCPServerProvider,
8589
)
90+
from temporalio.contrib.openai_agents._invoke_model_activity import _build_tool
8691
from temporalio.contrib.openai_agents._model_parameters import ModelSummaryProvider
8792
from temporalio.contrib.openai_agents._openai_runner import _convert_agent
8893
from temporalio.contrib.openai_agents._temporal_model_stub import (
@@ -1996,6 +2001,66 @@ async def test_hosted_mcp_tool(client: Client):
19962001
assert result == "Some language"
19972002

19982003

2004+
def custom_tool_mock_model():
2005+
return TestModel.returning_responses(
2006+
[
2007+
ModelResponse(
2008+
output=[
2009+
ResponseCustomToolCall(
2010+
call_id="c1",
2011+
input="ping",
2012+
name="echo",
2013+
type="custom_tool_call",
2014+
)
2015+
],
2016+
usage=Usage(),
2017+
response_id=None,
2018+
),
2019+
ResponseBuilders.output_message("done"),
2020+
]
2021+
)
2022+
2023+
2024+
@workflow.defn
2025+
class CustomToolWorkflow:
2026+
@workflow.run
2027+
async def run(self) -> str:
2028+
captured: list[str] = []
2029+
2030+
async def echo(ctx: ToolContext[Any], input: str) -> str: # type: ignore[reportUnusedParameter]
2031+
captured.append(input)
2032+
return input
2033+
2034+
agent = Agent[str](
2035+
name="custom-tool-agent",
2036+
instructions="Use the echo tool.",
2037+
tools=[
2038+
CustomTool(
2039+
name="echo",
2040+
description="Echo the input string back.",
2041+
on_invoke_tool=echo,
2042+
)
2043+
],
2044+
)
2045+
result = await Runner.run(starting_agent=agent, input="say something")
2046+
return f"{result.final_output}:{captured[0]}"
2047+
2048+
2049+
async def test_custom_tool_workflow(client: Client):
2050+
async with AgentEnvironment(model=custom_tool_mock_model()) as env:
2051+
client = env.applied_on_client(client)
2052+
2053+
async with new_worker(client, CustomToolWorkflow) as worker:
2054+
workflow_handle = await client.start_workflow(
2055+
CustomToolWorkflow.run,
2056+
id=f"custom-tool-workflow-{uuid.uuid4()}",
2057+
task_queue=worker.task_queue,
2058+
execution_timeout=timedelta(seconds=30),
2059+
)
2060+
result = await workflow_handle.result()
2061+
assert result == "done:ping"
2062+
2063+
19992064
class AssertDifferentModelProvider(ModelProvider):
20002065
model_names: set[str | None]
20012066

@@ -2538,6 +2603,79 @@ async def test_model_conversion_loops():
25382603
assert isinstance(triage_agent.model, _TemporalModelStub)
25392604

25402605

2606+
def test_sandbox_apply_patch_tool_round_trips_through_activity_input():
2607+
class FakeSandboxSession:
2608+
pass
2609+
2610+
tool = SandboxApplyPatchTool(session=FakeSandboxSession()) # type: ignore[arg-type]
2611+
2612+
stub = _TemporalModelStub(
2613+
model_name="gpt-5",
2614+
model_params=ModelActivityParameters(),
2615+
agent=None,
2616+
)
2617+
2618+
activity_input, _summary = stub._build_activity_input(
2619+
system_instructions=None,
2620+
input="hi",
2621+
model_settings=ModelSettings(),
2622+
tools=[tool],
2623+
output_schema=None,
2624+
handoffs=[],
2625+
tracing=ModelTracing.DISABLED,
2626+
previous_response_id=None,
2627+
conversation_id=None,
2628+
prompt=None,
2629+
)
2630+
2631+
tool_inputs = activity_input.get("tools") or []
2632+
assert len(tool_inputs) == 1
2633+
rebuilt = _build_tool(tool_inputs[0])
2634+
assert isinstance(rebuilt, CustomTool)
2635+
assert rebuilt.name == tool.name
2636+
assert rebuilt.description == tool.description
2637+
assert rebuilt.format == tool.format
2638+
assert rebuilt.tool_config == tool.tool_config
2639+
2640+
2641+
def test_custom_tool_with_defer_loading_round_trips_through_activity_input():
2642+
async def stub(_ctx: Any, _payload: str) -> str:
2643+
return ""
2644+
2645+
tool = CustomTool(
2646+
name="deferred_tool",
2647+
description="A custom tool with defer_loading enabled",
2648+
on_invoke_tool=stub,
2649+
defer_loading=True,
2650+
)
2651+
2652+
stub_model = _TemporalModelStub(
2653+
model_name="gpt-5",
2654+
model_params=ModelActivityParameters(),
2655+
agent=None,
2656+
)
2657+
2658+
activity_input, _summary = stub_model._build_activity_input(
2659+
system_instructions=None,
2660+
input="hi",
2661+
model_settings=ModelSettings(),
2662+
tools=[tool],
2663+
output_schema=None,
2664+
handoffs=[],
2665+
tracing=ModelTracing.DISABLED,
2666+
previous_response_id=None,
2667+
conversation_id=None,
2668+
prompt=None,
2669+
)
2670+
2671+
tool_inputs = activity_input.get("tools") or []
2672+
assert len(tool_inputs) == 1
2673+
rebuilt = _build_tool(tool_inputs[0])
2674+
assert isinstance(rebuilt, CustomTool)
2675+
assert rebuilt.tool_config == tool.tool_config
2676+
assert rebuilt.defer_loading is True
2677+
2678+
25412679
async def test_local_hello_world_agent(client: Client):
25422680
async with AgentEnvironment(
25432681
model=hello_mock_model(),

0 commit comments

Comments
 (0)