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..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,6 +75,73 @@ def test_model_binding_preserves_kwargs_with_tools(bind_kwargs: dict[str, Any]) assert bound.kwargs == {"tools": tool_schemas, **bind_kwargs} +@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( + ("tool_kwargs", "expected"), + [ + ({"tool_choice": "any"}, {"tool_choice": {"type": "any"}}), + ( + {"parallel_tool_calls": False}, + {"tool_choice": {"type": "auto", "disable_parallel_tool_use": True}}, + ), + ], +) +def test_tool_options_use_provider_format( + 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. + 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"}}, + } + 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