From 1dab3c47415009febc2876d8c9b5568c0e519d9f Mon Sep 17 00:00:00 2001 From: sohomchakraborty <16609933+sohom-cs@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:21:56 -0400 Subject: [PATCH 1/2] Pass Deep Agents tool_choice through the provider's bind_tools The model activity bound tool schemas with bind_tools() and then applied tool_choice and parallel_tool_calls with .bind(). Providers convert these options only inside bind_tools(), so the raw values reached the API: create_agent's tool_choice="any" went to Anthropic as "any" instead of {"type": "any"}, and to OpenAI-compatible servers as "any" instead of "required", which vLLM rejects with a 400. Pass both options to bind_tools() on the worker. The activity input is unchanged, so existing histories are not affected. Fixes #248 Signed-off-by: sohomchakraborty <16609933+sohom-cs@users.noreply.github.com> --- .../src/temporalio/deepagents/_activity.py | 16 +++++-- .../deepagents/tests/test_model_activity.py | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/python/deepagents/src/temporalio/deepagents/_activity.py b/python/deepagents/src/temporalio/deepagents/_activity.py index b14d1726..63c60824 100644 --- a/python/deepagents/src/temporalio/deepagents/_activity.py +++ b/python/deepagents/src/temporalio/deepagents/_activity.py @@ -256,6 +256,12 @@ def __call__(self, argument: Any) -> Any: return self._factory() +# Binding options that a provider's ``bind_tools`` converts to its own format +# (Anthropic turns ``"any"`` into ``{"type": "any"}``, OpenAI into +# ``"required"``). Passing them to ``.bind()`` instead skips that conversion. +_TOOL_BIND_KWARGS = ("tool_choice", "parallel_tool_calls") + + def _default_model_provider(model_name: str) -> Any: """Build a chat model from a name string with LLM-SDK retries disabled. @@ -295,10 +301,14 @@ def __init__( def _build_bound_model(self, input: ModelActivityInput) -> Any: model = self._model_provider(input.model_name) + bind_kwargs = dict(input.bind_kwargs) if input.tool_schemas: - model = model.bind_tools(input.tool_schemas) - if input.bind_kwargs: - model = model.bind(**input.bind_kwargs) + tool_kwargs = { + k: bind_kwargs.pop(k) for k in _TOOL_BIND_KWARGS if k in bind_kwargs + } + model = model.bind_tools(input.tool_schemas, **tool_kwargs) + if bind_kwargs: + model = model.bind(**bind_kwargs) return model @activity.defn(name=INVOKE_MODEL) diff --git a/python/deepagents/tests/test_model_activity.py b/python/deepagents/tests/test_model_activity.py index c58456e7..5e41323d 100644 --- a/python/deepagents/tests/test_model_activity.py +++ b/python/deepagents/tests/test_model_activity.py @@ -74,6 +74,50 @@ def test_model_binding_preserves_kwargs_with_tools(bind_kwargs: dict[str, Any]) assert bound.kwargs == {"tools": tool_schemas, **bind_kwargs} +def _offline_chat_model(provider: str) -> Any: + if provider == "anthropic": + langchain_anthropic = pytest.importorskip("langchain_anthropic") + return langchain_anthropic.ChatAnthropic(model="test-model", api_key="test") + langchain_openai = pytest.importorskip("langchain_openai") + return langchain_openai.ChatOpenAI( + model="Qwen/Qwen3-8B", base_url="http://localhost:8000/v1", api_key="test" + ) + + +@pytest.mark.parametrize( + ("provider", "tool_kwargs", "expected"), + [ + ("anthropic", {"tool_choice": "any"}, {"tool_choice": {"type": "any"}}), + ( + "anthropic", + {"parallel_tool_calls": False}, + {"tool_choice": {"type": "auto", "disable_parallel_tool_use": True}}, + ), + ("openai", {"tool_choice": "any"}, {"tool_choice": "required"}), + ], +) +def test_tool_options_use_provider_format( + provider: str, tool_kwargs: dict[str, Any], expected: dict[str, Any] +) -> None: + # create_agent binds tool_choice="any" for structured output; the provider's + # bind_tools must convert it, as it does without Temporal. + chat_model = _offline_chat_model(provider) + tool = { + "type": "function", + "function": {"name": "lookup", "parameters": {"type": "object"}}, + } + messages = [HumanMessage(content="hi")] + binding = TemporalModel(model="provider:model").bind_tools([tool], **tool_kwargs) + activity_input = binding.bound._build_input(messages, None, **binding.kwargs) + activities = DeepAgentActivities(model_provider=lambda _: chat_model) + + model = activities._build_bound_model(activity_input) + payload = model.bound._get_request_payload(messages, **model.kwargs) + + keys = ("tool_choice", "parallel_tool_calls") + assert {k: payload[k] for k in keys if k in payload} == expected + + @workflow.defn class ModelWorkflow: @workflow.run From 670e0da84dde8edf0af6c1ce12c492c749920f3d Mon Sep 17 00:00:00 2001 From: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:44:26 -0400 Subject: [PATCH 2/2] Test the Deep Agents tool-option forwarding without langchain-openai --- .../deepagents/tests/test_model_activity.py | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/python/deepagents/tests/test_model_activity.py b/python/deepagents/tests/test_model_activity.py index 5e41323d..bac5118d 100644 --- a/python/deepagents/tests/test_model_activity.py +++ b/python/deepagents/tests/test_model_activity.py @@ -11,6 +11,7 @@ from collections.abc import Sequence from datetime import timedelta from typing import Any +from unittest.mock import Mock import pytest @@ -74,34 +75,57 @@ def test_model_binding_preserves_kwargs_with_tools(bind_kwargs: dict[str, Any]) assert bound.kwargs == {"tools": tool_schemas, **bind_kwargs} -def _offline_chat_model(provider: str) -> Any: - if provider == "anthropic": - langchain_anthropic = pytest.importorskip("langchain_anthropic") - return langchain_anthropic.ChatAnthropic(model="test-model", api_key="test") - langchain_openai = pytest.importorskip("langchain_openai") - return langchain_openai.ChatOpenAI( - model="Qwen/Qwen3-8B", base_url="http://localhost:8000/v1", api_key="test" +@pytest.mark.parametrize( + "tool_kwargs", + [ + {"tool_choice": "any"}, + {"parallel_tool_calls": False}, + {"tool_choice": "any", "parallel_tool_calls": False}, + ], +) +def test_tool_options_go_to_bind_tools(tool_kwargs: dict[str, Any]) -> None: + # Providers convert these options only inside bind_tools, so the activity + # must pass them there; other options still go through .bind(). + tool_schemas = [ + { + "type": "function", + "function": {"name": "lookup", "parameters": {"type": "object"}}, + } + ] + other_kwargs = {"stop": ["Observation:"]} + model = Mock(wraps=_ToolBindingModel(responses=["answer"])) + activities = DeepAgentActivities(model_provider=lambda _: model) + + bound = activities._build_bound_model( + ModelActivityInput( + model_name="fake:model", + messages=[], + tool_schemas=tool_schemas, + bind_kwargs={**tool_kwargs, **other_kwargs}, + ) ) + model.bind_tools.assert_called_once_with(tool_schemas, **tool_kwargs) + assert bound.kwargs == {"tools": tool_schemas, **tool_kwargs, **other_kwargs} + @pytest.mark.parametrize( - ("provider", "tool_kwargs", "expected"), + ("tool_kwargs", "expected"), [ - ("anthropic", {"tool_choice": "any"}, {"tool_choice": {"type": "any"}}), + ({"tool_choice": "any"}, {"tool_choice": {"type": "any"}}), ( - "anthropic", {"parallel_tool_calls": False}, {"tool_choice": {"type": "auto", "disable_parallel_tool_use": True}}, ), - ("openai", {"tool_choice": "any"}, {"tool_choice": "required"}), ], ) def test_tool_options_use_provider_format( - provider: str, tool_kwargs: dict[str, Any], expected: dict[str, Any] + tool_kwargs: dict[str, Any], expected: dict[str, Any] ) -> None: # create_agent binds tool_choice="any" for structured output; the provider's # bind_tools must convert it, as it does without Temporal. - chat_model = _offline_chat_model(provider) + langchain_anthropic = pytest.importorskip("langchain_anthropic") + chat_model = langchain_anthropic.ChatAnthropic(model="test-model", api_key="test") tool = { "type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}},