Skip to content

Dynamic function tool is still executed after is_enabled becomes false #3115

@Aphroq

Description

@Aphroq

Please read this first

  • Have you read the docs? Yes.
  • Have you searched for related issues? Yes. I searched existing issues and PRs.

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions