Please read this first
I found related historical discussions and feature work around is_enabled, including #2232, #1877, #1097, #808, and #1193, but I did not find an issue or open PR for the specific case where a function tool is visible to the model, then dynamically disabled before execution, and still executes.
Describe the bug
A function tool with a dynamic is_enabled callback can still execute after the callback starts returning False.
The model only sees the tool when is_enabled initially returns True, which is expected. However, after the model returns a tool call, the function tool executor resolves enabled tools again and then appends every ToolRunFunction.function_tool back into available_function_tools:
self.available_function_tools = await resolve_enabled_function_tools(...)
for tool_run in self.tool_runs:
if tool_run.function_tool not in self.available_function_tools:
self.available_function_tools.append(tool_run.function_tool)
As a result, a tool that was enabled when exposed to the model can still run even if its dynamic is_enabled callback returns False before execution.
This matters when is_enabled is used as a runtime authorization, feature-flag, quota, tenant, or policy gate for tools with side effects or sensitive data access.
Debug information
- Agents SDK version:
0.15.1
- Repository commit:
9b57f057b43d
- Python version:
Python 3.12.1
Repro steps
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputText,
)
from openai.types.responses.response_prompt_param import ResponsePromptParam
from agents import Agent, Runner, function_tool
from agents.agent_output import AgentOutputSchemaBase
from agents.handoffs import Handoff
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
from agents.model_settings import ModelSettings
from agents.models.interface import Model, ModelTracing
from agents.tool import Tool
from agents.usage import Usage
class ScriptedModel(Model):
def __init__(self) -> None:
self.calls = 0
self.tool_names_seen: list[list[str]] = []
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
self.calls += 1
self.tool_names_seen.append([getattr(tool, "name", type(tool).__name__) for tool in tools])
if self.calls == 1:
return ModelResponse(
output=[
ResponseFunctionToolCall(
id="fc_1",
call_id="call_1",
type="function_call",
name="lookup_secret",
arguments="{}",
)
],
usage=Usage(),
response_id="resp_1",
)
return ModelResponse(
output=[
ResponseOutputMessage(
id="msg_1",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="done",
annotations=[],
logprobs=[],
)
],
)
],
usage=Usage(),
response_id="resp_2",
)
def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
raise NotImplementedError
async def main() -> None:
enabled_checks: list[bool] = []
tool_invocations = 0
def is_enabled(ctx: Any, agent: Any) -> bool:
# Expose the tool to the model once, then disable it before execution re-checks tools.
enabled = len(enabled_checks) == 0
enabled_checks.append(enabled)
return enabled
@function_tool(name_override="lookup_secret", is_enabled=is_enabled)
def lookup_secret() -> str:
nonlocal tool_invocations
tool_invocations += 1
return "secret-result"
model = ScriptedModel()
agent = Agent(name="test", model=model, tools=[lookup_secret])
result = await Runner.run(agent, "hi")
print(f"final_output={result.final_output!r}")
print(f"model_tool_names_seen={model.tool_names_seen}")
print(f"enabled_checks={enabled_checks}")
print(f"tool_invocations={tool_invocations}")
asyncio.run(main())
Actual output on current main:
final_output='done'
model_tool_names_seen=[['lookup_secret'], []]
enabled_checks=[True, False, False, False]
tool_invocations=1
OPENAI_API_KEY is not set, skipping trace export
The important part is tool_invocations=1 even though the execution-time is_enabled checks are False.
Expected behavior
If a function tool's dynamic is_enabled callback returns False before execution, the SDK should not execute that tool call.
Possible acceptable behaviors:
- Fail fast with a
ModelBehaviorError indicating that the model called a currently disabled tool.
- Return a tool error item for the disabled call.
- Keep an explicit approval/resume exception if that path intentionally needs to execute a tool that is no longer exposed, but avoid bypassing dynamic
is_enabled for normal tool execution.
Please read this first
I found related historical discussions and feature work around
is_enabled, including #2232, #1877, #1097, #808, and #1193, but I did not find an issue or open PR for the specific case where a function tool is visible to the model, then dynamically disabled before execution, and still executes.Describe the bug
A function tool with a dynamic
is_enabledcallback can still execute after the callback starts returningFalse.The model only sees the tool when
is_enabledinitially returnsTrue, which is expected. However, after the model returns a tool call, the function tool executor resolves enabled tools again and then appends everyToolRunFunction.function_toolback intoavailable_function_tools:As a result, a tool that was enabled when exposed to the model can still run even if its dynamic
is_enabledcallback returnsFalsebefore execution.This matters when
is_enabledis used as a runtime authorization, feature-flag, quota, tenant, or policy gate for tools with side effects or sensitive data access.Debug information
0.15.19b57f057b43dPython 3.12.1Repro steps
Actual output on current
main:The important part is
tool_invocations=1even though the execution-timeis_enabledchecks areFalse.Expected behavior
If a function tool's dynamic
is_enabledcallback returnsFalsebefore execution, the SDK should not execute that tool call.Possible acceptable behaviors:
ModelBehaviorErrorindicating that the model called a currently disabled tool.is_enabledfor normal tool execution.