From 214fa34864b12b8b094e9964284800a9e67acec2 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 31 Jul 2026 15:19:48 +0300 Subject: [PATCH 1/2] feat(agent): forward model settings and skip forced tool_choice under Claude thinking --- pyproject.toml | 16 ++++++++-------- src/uipath_langchain/agent/react/agent.py | 2 ++ src/uipath_langchain/agent/react/llm_node.py | 14 ++++++++++---- src/uipath_langchain/agent/react/router.py | 9 ++++++++- src/uipath_langchain/agent/react/types.py | 6 ++++++ src/uipath_langchain/chat/chat_model_factory.py | 3 +++ tests/agent/react/test_router.py | 13 +++++++++++++ 7 files changed, 50 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc93a7151..a85117b3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.14.17" +version = "0.14.18" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -26,7 +26,7 @@ dependencies = [ "pillow>=12.1.1", "rdflib>=7.0.0, <8.0.0", "a2a-sdk>=0.2.0,<1.0.0", - "uipath-langchain-client[openai]>=1.17.1,<1.18.0", + "uipath-langchain-client[openai]>=1.18.0,<1.19.0", ] classifiers = [ @@ -43,21 +43,21 @@ maintainers = [ [project.optional-dependencies] anthropic = [ - "uipath-langchain-client[anthropic]>=1.17.1,<1.18.0", + "uipath-langchain-client[anthropic]>=1.18.0,<1.19.0", ] vertex = [ - "uipath-langchain-client[google]>=1.17.1,<1.18.0", - "uipath-langchain-client[vertexai]>=1.17.1,<1.18.0", + "uipath-langchain-client[google]>=1.18.0,<1.19.0", + "uipath-langchain-client[vertexai]>=1.18.0,<1.19.0", ] bedrock = [ - "uipath-langchain-client[bedrock]>=1.17.1,<1.18.0", + "uipath-langchain-client[bedrock]>=1.18.0,<1.19.0", "boto3-stubs>=1.41.4", ] fireworks = [ - "uipath-langchain-client[fireworks]>=1.17.1,<1.18.0", + "uipath-langchain-client[fireworks]>=1.18.0,<1.19.0", ] all = [ - "uipath-langchain-client[all]>=1.17.1,<1.18.0", + "uipath-langchain-client[all]>=1.18.0,<1.19.0", ] [project.entry-points."uipath.middlewares"] diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index cee6c231f..3e3a303a6 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -191,6 +191,7 @@ def create_agent( tool_choice=config.tool_choice, parallel_tool_calls=config.parallel_tool_calls, strict_mode=config.strict_mode, + reasoning_enabled=config.reasoning_enabled, ) llm_with_guardrails_subgraph = create_llm_guardrails_subgraph( (AgentGraphNode.LLM, llm_node), guardrails, input_schema=input_schema @@ -220,6 +221,7 @@ def create_agent( route_agent = create_route_agent( valid_targets=target_node_names, thinking_messages_limit=config.thinking_messages_limit, + reasoning_enabled=config.reasoning_enabled, ) builder.add_conditional_edges( diff --git a/src/uipath_langchain/agent/react/llm_node.py b/src/uipath_langchain/agent/react/llm_node.py index 7a7a8fe24..5bad27ac7 100644 --- a/src/uipath_langchain/agent/react/llm_node.py +++ b/src/uipath_langchain/agent/react/llm_node.py @@ -67,6 +67,7 @@ def create_llm_node( tool_choice: Literal["auto", "any"] = "auto", parallel_tool_calls: bool = True, strict_mode: bool = False, + reasoning_enabled: bool = False, ): """Create LLM node with dynamic tool_choice enforcement. @@ -104,10 +105,15 @@ async def llm_node(state: StateT): bindable_tools, state, input_schema or type(state) ) current_tool_choice: Literal["auto", "any"] = tool_choice - if current_tool_choice == "auto" and ( - not is_conversational - and bindable_tools - and count_consecutive_thinking_messages(messages) >= thinking_messages_limit + if ( + current_tool_choice == "auto" + and not reasoning_enabled + and ( + not is_conversational + and bindable_tools + and count_consecutive_thinking_messages(messages) + >= thinking_messages_limit + ) ): current_tool_choice = "any" diff --git a/src/uipath_langchain/agent/react/router.py b/src/uipath_langchain/agent/react/router.py index 9d83ee743..665da7f3b 100644 --- a/src/uipath_langchain/agent/react/router.py +++ b/src/uipath_langchain/agent/react/router.py @@ -17,12 +17,16 @@ def create_route_agent( thinking_messages_limit: int = 0, valid_targets: Container[str] | None = None, + reasoning_enabled: bool = False, ): """Create a routing function configured with thinking_messages_limit. Args: thinking_messages_limit: Max consecutive thinking messages before error valid_targets: Allowed routing destinations + reasoning_enabled: When True the model runs with extended thinking and tool + calls cannot be forced, so tool-less thinking turns are not treated as + an error (the llm_messages_limit still bounds the loop). Returns: Routing function for LangGraph conditional edges """ @@ -61,7 +65,10 @@ def route_agent( messages ) - if consecutive_thinking_messages > thinking_messages_limit: + if ( + not reasoning_enabled + and consecutive_thinking_messages > thinking_messages_limit + ): raise AgentRuntimeError( code=AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED, title="Agent exceeded consecutive completions limit without producing tool calls.", diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 9a890e8c5..1b4ae1d96 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -125,6 +125,12 @@ class AgentGraphConfig(BaseModel): default="auto", description="The tool choice to use for the LLM. 'auto' means the LLM will choose the tool, 'any' means the LLM will return multiple tool calls in a single response.", ) + reasoning_enabled: bool = Field( + default=False, + description="If set, the model runs with extended thinking, which is " + "incompatible with forcing tool_choice on Anthropic/Bedrock. The agent then " + "never forces tool_choice and does not error on tool-less thinking turns.", + ) parallel_tool_calls: bool = Field( default=True, description="Allow the LLM to return multiple tool calls in a single response.", diff --git a/src/uipath_langchain/chat/chat_model_factory.py b/src/uipath_langchain/chat/chat_model_factory.py index 112ebb4ca..77f370860 100644 --- a/src/uipath_langchain/chat/chat_model_factory.py +++ b/src/uipath_langchain/chat/chat_model_factory.py @@ -10,6 +10,7 @@ before the ``uipath_langchain_client`` migration. """ +from collections.abc import Mapping from typing import Any, Final from langchain_core.callbacks import BaseCallbackHandler, Callbacks @@ -87,6 +88,7 @@ def get_chat_model( callbacks: Callbacks = _UNSET, agenthub_config: str | None = None, use_new_llm_clients: bool = True, + model_settings: Mapping[str, Any] | None = None, **kwargs: Any, ) -> BaseChatModel: """Create and configure a chat model, dispatching legacy vs new clients. @@ -164,6 +166,7 @@ def get_chat_model( api_flavor=api_flavor, custom_class=custom_class, agenthub_config=agenthub_config, + model_settings=model_settings, **optional_kwargs, **kwargs, ) diff --git a/tests/agent/react/test_router.py b/tests/agent/react/test_router.py index 6627cd42d..eb419a575 100644 --- a/tests/agent/react/test_router.py +++ b/tests/agent/react/test_router.py @@ -226,6 +226,19 @@ def test_excessive_thinking_messages_raises_exception( AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED ) + def test_reasoning_enabled_does_not_raise_on_thinking_turns( + self, state_excessive_thinking + ): + """With reasoning_enabled, tool-less thinking turns don't error (tool_choice + can't be forced under extended thinking); the loop is bounded elsewhere.""" + route_func = create_route_agent( + valid_targets=_VALID_TARGETS, + thinking_messages_limit=0, + reasoning_enabled=True, + ) + result = route_func(state_excessive_thinking) + assert result == AgentGraphNode.AGENT + def test_thinking_messages_limit_zero_forbids_thinking(self): """Should not allow any thinking messages when limit is 0.""" route_func = create_route_agent( From 4d4e32947267c5dce502d6b8ff36726523b0da36 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 7 Aug 2026 14:31:02 +0300 Subject: [PATCH 2/2] handle anthropic thinking edge case --- src/uipath_langchain/agent/react/agent.py | 3 - .../agent/react/forced_extraction.py | 103 +++++++++++++ src/uipath_langchain/agent/react/llm_node.py | 30 ++-- src/uipath_langchain/agent/react/router.py | 38 ++--- src/uipath_langchain/agent/react/types.py | 6 - src/uipath_langchain/agent/react/utils.py | 19 +++ .../chat/handlers/anthropic.py | 22 +++ src/uipath_langchain/chat/handlers/bedrock.py | 39 ++--- tests/agent/react/test_forced_extraction.py | 141 ++++++++++++++++++ tests/agent/react/test_llm_node.py | 108 +++++++++++++- tests/agent/react/test_router.py | 100 +++---------- .../chat/handlers/test_tool_binding_kwargs.py | 18 +++ tests/chat/test_bedrock_payload_handler.py | 84 ++++++++++- 13 files changed, 566 insertions(+), 145 deletions(-) create mode 100644 src/uipath_langchain/agent/react/forced_extraction.py create mode 100644 tests/agent/react/test_forced_extraction.py diff --git a/src/uipath_langchain/agent/react/agent.py b/src/uipath_langchain/agent/react/agent.py index 3e3a303a6..0c5a080fc 100644 --- a/src/uipath_langchain/agent/react/agent.py +++ b/src/uipath_langchain/agent/react/agent.py @@ -191,7 +191,6 @@ def create_agent( tool_choice=config.tool_choice, parallel_tool_calls=config.parallel_tool_calls, strict_mode=config.strict_mode, - reasoning_enabled=config.reasoning_enabled, ) llm_with_guardrails_subgraph = create_llm_guardrails_subgraph( (AgentGraphNode.LLM, llm_node), guardrails, input_schema=input_schema @@ -220,8 +219,6 @@ def create_agent( ] route_agent = create_route_agent( valid_targets=target_node_names, - thinking_messages_limit=config.thinking_messages_limit, - reasoning_enabled=config.reasoning_enabled, ) builder.add_conditional_edges( diff --git a/src/uipath_langchain/agent/react/forced_extraction.py b/src/uipath_langchain/agent/react/forced_extraction.py new file mode 100644 index 000000000..452bc4d0c --- /dev/null +++ b/src/uipath_langchain/agent/react/forced_extraction.py @@ -0,0 +1,103 @@ +"""Force a structured end_execution out of a thinking model that stalled. + +Anthropic won't honor a forced tool_choice while thinking is on, so a thinking model can +answer in plain text and never call end_execution. build_extraction_call retries that +turn with thinking off and the tool call forced, which every provider honors. +""" + +from typing import Any + +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage +from uipath.agent.react import END_EXECUTION_TOOL + +_REASONING_BLOCK_TYPES = {"reasoning_content", "reasoning", "thinking"} +_END_EXECUTION_NAME = getattr( + END_EXECUTION_TOOL.name, "value", str(END_EXECUTION_TOOL.name) +) + + +def _without_thinking(model: BaseChatModel) -> BaseChatModel: + """Copy of the model with thinking config stripped, so forcing is honored. + + Thinking lives in a different place per transport: native `thinking`, Bedrock Invoke + `model_kwargs`, Bedrock Converse `additional_model_request_fields`. + """ + updates: dict[str, object] = {} + request_fields = getattr(model, "additional_model_request_fields", None) + if isinstance(request_fields, dict) and ( + "thinking" in request_fields or "output_config" in request_fields + ): + updates["additional_model_request_fields"] = { + k: v + for k, v in request_fields.items() + if k not in ("thinking", "output_config") + } + model_kwargs = getattr(model, "model_kwargs", None) + if isinstance(model_kwargs, dict) and "thinking" in model_kwargs: + updates["model_kwargs"] = { + k: v for k, v in model_kwargs.items() if k != "thinking" + } + if getattr(model, "thinking", None) is not None: + updates["thinking"] = None + if not updates: + return model + try: + return model.model_copy(update=updates) + except Exception: + return model + + +def _strip_reasoning_blocks(messages: list[AnyMessage]) -> list[AnyMessage]: + """Drop reasoning blocks from AI messages (keep text + tool calls). + + They can't be replayed on a thinking-off call — an orphaned thinking block 400s. + """ + stripped: list[AnyMessage] = [] + for message in messages: + if isinstance(message, AIMessage) and isinstance(message.content, list): + kept = [ + block + for block in message.content + if not ( + isinstance(block, dict) + and block.get("type") in _REASONING_BLOCK_TYPES + ) + ] + if len(kept) != len(message.content): + # a turn that was only reasoning is now empty — drop it + if not kept and not message.tool_calls: + continue + message = message.model_copy(update={"content": kept}) + stripped.append(message) + return stripped + + +def _with_extraction_nudge(messages: list[AnyMessage]) -> list[AnyMessage]: + """Append (or merge into) a trailing user turn telling the model to call end_execution. + + Has to end on a user turn: native/Vertex rejects a forced call that ends on the + stalled assistant turn (a prefill). Merge instead of appending so roles stay + alternating even if the stalled turn was dropped as empty. + """ + nudge = f"Provide the final result now by calling the {_END_EXECUTION_NAME} tool." + if messages and isinstance(messages[-1], HumanMessage): + last = messages[-1] + if isinstance(last.content, str): + merged: Any = f"{last.content}\n\n{nudge}" if last.content else nudge + elif isinstance(last.content, list): + merged = list(last.content) + [{"type": "text", "text": nudge}] + else: + merged = nudge + return list(messages[:-1]) + [HumanMessage(content=merged)] + return list(messages) + [HumanMessage(content=nudge)] + + +def build_extraction_call( + model: BaseChatModel, messages: list[AnyMessage] +) -> tuple[BaseChatModel, list[AnyMessage]]: + """The (model, messages) for the extraction call: thinking off, reasoning blocks + dropped, and a nudge to call end_execution — the caller then forces tool_choice.""" + return _without_thinking(model), _with_extraction_nudge( + _strip_reasoning_blocks(messages) + ) diff --git a/src/uipath_langchain/agent/react/llm_node.py b/src/uipath_langchain/agent/react/llm_node.py index 5bad27ac7..50a0072d9 100644 --- a/src/uipath_langchain/agent/react/llm_node.py +++ b/src/uipath_langchain/agent/react/llm_node.py @@ -16,6 +16,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain.chat.handlers import get_payload_handler +from uipath_langchain.chat.handlers.anthropic import anthropic_thinking_type from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode from ..exceptions.licensing import raise_for_provider_http_error @@ -26,6 +27,7 @@ DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES, DEFAULT_MAX_LLM_MESSAGES, ) +from .forced_extraction import build_extraction_call from .types import FLOW_CONTROL_TOOLS, AgentGraphState from .utils import count_consecutive_thinking_messages @@ -67,7 +69,6 @@ def create_llm_node( tool_choice: Literal["auto", "any"] = "auto", parallel_tool_calls: bool = True, strict_mode: bool = False, - reasoning_enabled: bool = False, ): """Create LLM node with dynamic tool_choice enforcement. @@ -105,29 +106,34 @@ async def llm_node(state: StateT): bindable_tools, state, input_schema or type(state) ) current_tool_choice: Literal["auto", "any"] = tool_choice + uses_anthropic_thinking = anthropic_thinking_type(model) is not None + consecutive_thinking = count_consecutive_thinking_messages(messages) + effective_limit = thinking_messages_limit if uses_anthropic_thinking else 0 + thinking_model_stalled = uses_anthropic_thinking and consecutive_thinking > 0 + call_model: BaseChatModel = model + call_messages: list[AnyMessage] = messages + handler = payload_handler if ( current_tool_choice == "auto" - and not reasoning_enabled - and ( - not is_conversational - and bindable_tools - and count_consecutive_thinking_messages(messages) - >= thinking_messages_limit - ) + and not is_conversational + and bindable_tools + and consecutive_thinking >= effective_limit ): current_tool_choice = "any" + if thinking_model_stalled: + call_model, call_messages = build_extraction_call(model, messages) + handler = get_payload_handler(call_model) - binding_kwargs = payload_handler.get_tool_binding_kwargs( + binding_kwargs = handler.get_tool_binding_kwargs( tools=static_schema_tools, tool_choice=current_tool_choice, parallel_tool_calls=parallel_tool_calls, strict_mode=strict_mode, ) - - llm = model.bind_tools(static_schema_tools, **binding_kwargs) + llm = call_model.bind_tools(static_schema_tools, **binding_kwargs) try: - response = await llm.ainvoke(messages) + response = await llm.ainvoke(call_messages) except UiPathAPIError as e: # New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly. raise_for_provider_http_error(e) diff --git a/src/uipath_langchain/agent/react/router.py b/src/uipath_langchain/agent/react/router.py index 665da7f3b..615584ae1 100644 --- a/src/uipath_langchain/agent/react/router.py +++ b/src/uipath_langchain/agent/react/router.py @@ -8,25 +8,20 @@ from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode from .types import FLOW_CONTROL_TOOLS, AgentGraphNode, AgentGraphState from .utils import ( - count_consecutive_thinking_messages, extract_current_tool_call_index, find_latest_ai_message, + has_reasoning_block, ) def create_route_agent( - thinking_messages_limit: int = 0, valid_targets: Container[str] | None = None, - reasoning_enabled: bool = False, ): - """Create a routing function configured with thinking_messages_limit. + """Create the conditional-edge routing function. Args: - thinking_messages_limit: Max consecutive thinking messages before error valid_targets: Allowed routing destinations - reasoning_enabled: When True the model runs with extended thinking and tool - calls cannot be forced, so tool-less thinking turns are not treated as - an error (the llm_messages_limit still bounds the loop). + Returns: Routing function for LangGraph conditional edges """ @@ -61,31 +56,26 @@ def route_agent( ) if not last_message.tool_calls: - consecutive_thinking_messages = count_consecutive_thinking_messages( - messages - ) + # reasoning stall — loop so the next turn can force the extraction + if has_reasoning_block(last_message): + return AgentGraphNode.AGENT - if ( - not reasoning_enabled - and consecutive_thinking_messages > thinking_messages_limit - ): + # content but no tool call and no reasoning: the model ignored tool_choice + if last_message.content: raise AgentRuntimeError( code=AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED, - title="Agent exceeded consecutive completions limit without producing tool calls.", - detail=f"Completions: {consecutive_thinking_messages}, max: {thinking_messages_limit}. " - f"This should not happen as tool_choice='required' is enforced at the limit." - "If you are using a BYOM configuration, verify your model deployment respects tool_choice or equivalent.", + title="Agent produced a response without calling a tool.", + detail="The model returned content but no tool call and no reasoning, " + "despite a forced tool_choice. If you are using a BYOM configuration, " + "verify your model deployment respects tool_choice.", category=UiPathErrorCategory.SYSTEM, ) - if last_message.content: - return AgentGraphNode.AGENT - raise AgentRuntimeError( code=AgentRuntimeErrorCode.ROUTING_ERROR, title="Agent produced empty response without tool calls.", - detail=f"Consecutive completions: {consecutive_thinking_messages}, has_content: False." - "If you are using a BYOM configuration, verify your model deployment", + detail="The model returned no content and no tool calls. " + "If you are using a BYOM configuration, verify your model deployment.", category=UiPathErrorCategory.SYSTEM, ) diff --git a/src/uipath_langchain/agent/react/types.py b/src/uipath_langchain/agent/react/types.py index 1b4ae1d96..9a890e8c5 100644 --- a/src/uipath_langchain/agent/react/types.py +++ b/src/uipath_langchain/agent/react/types.py @@ -125,12 +125,6 @@ class AgentGraphConfig(BaseModel): default="auto", description="The tool choice to use for the LLM. 'auto' means the LLM will choose the tool, 'any' means the LLM will return multiple tool calls in a single response.", ) - reasoning_enabled: bool = Field( - default=False, - description="If set, the model runs with extended thinking, which is " - "incompatible with forcing tool_choice on Anthropic/Bedrock. The agent then " - "never forces tool_choice and does not error on tool-less thinking turns.", - ) parallel_tool_calls: bool = Field( default=True, description="Allow the LLM to return multiple tool calls in a single response.", diff --git a/src/uipath_langchain/agent/react/utils.py b/src/uipath_langchain/agent/react/utils.py index e2fa6d2c7..04106aaee 100644 --- a/src/uipath_langchain/agent/react/utils.py +++ b/src/uipath_langchain/agent/react/utils.py @@ -105,6 +105,25 @@ def extract_input_data_from_state( return input_model.model_validate(filtered_state, from_attributes=True).model_dump() +_REASONING_BLOCK_TYPES = {"reasoning_content", "reasoning", "thinking"} + + +def has_reasoning_block(message: BaseMessage) -> bool: + """Whether an AI message carries a reasoning/thinking content block. + + Lets the router tell a real reasoning stall (loop) from a model that just ignored a + forced tool_choice (error). A thinking turn always has the block, even with the + display omitted (empty text + signature); plain text has none. + """ + content = getattr(message, "content", None) + if not isinstance(content, list): + return False + return any( + isinstance(block, dict) and block.get("type") in _REASONING_BLOCK_TYPES + for block in content + ) + + def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int: """Count consecutive AIMessages without tool calls at end of message history.""" if not messages: diff --git a/src/uipath_langchain/chat/handlers/anthropic.py b/src/uipath_langchain/chat/handlers/anthropic.py index 5da49927a..85f292780 100644 --- a/src/uipath_langchain/chat/handlers/anthropic.py +++ b/src/uipath_langchain/chat/handlers/anthropic.py @@ -10,6 +10,28 @@ from ..exceptions import ChatModelError, ChatModelErrorCode from .base import ModelPayloadHandler + +def anthropic_thinking_type(model: Any) -> str | None: + """The Anthropic thinking mode for a model ("enabled"/"adaptive"/...), or None. + + Reads the `thinking` dict wherever the transport puts it: native `thinking`, Bedrock + Invoke `model_kwargs`, Bedrock Converse `additional_model_request_fields`. Only + Anthropic uses this shape — OpenAI/Gemini use other knobs — so non-Anthropic models + return None. Null-safe. + """ + invoke = getattr(model, "model_kwargs", None) or {} + converse = getattr(model, "additional_model_request_fields", None) or {} + candidates = ( + getattr(model, "thinking", None), + invoke.get("thinking") if isinstance(invoke, dict) else None, + converse.get("thinking") if isinstance(converse, dict) else None, + ) + for thinking in candidates: + if isinstance(thinking, dict) and isinstance(thinking.get("type"), str): + return thinking["type"] + return None + + FAULTY_STOP_REASONS: set[str] = { "max_tokens", "refusal", diff --git a/src/uipath_langchain/chat/handlers/bedrock.py b/src/uipath_langchain/chat/handlers/bedrock.py index 7fe2ee180..f37d71a06 100644 --- a/src/uipath_langchain/chat/handlers/bedrock.py +++ b/src/uipath_langchain/chat/handlers/bedrock.py @@ -9,11 +9,22 @@ from uipath.runtime.errors import UiPathErrorCategory from ..exceptions import ChatModelError, ChatModelErrorCode +from .anthropic import anthropic_thinking_type from .base import ModelPayloadHandler logger = logging.getLogger(__name__) +def bedrock_rejects_forced_tool_choice(thinking_type: str | None) -> bool: + """Whether to drop forced tool_choice to 'auto' on Bedrock. + + Bedrock rejects forcing under any thinking mode, so we downgrade whenever thinking is + on. Termination is still guaranteed by the thinking-off extraction fallback (see + agent/react/forced_extraction.py). + """ + return thinking_type is not None + + # --- Converse API constants --- CONVERSE_FAULTY_REASONS: set[str] = { @@ -86,15 +97,12 @@ def get_tool_binding_kwargs( parallel_tool_calls: bool | None = None, strict_mode: bool | None = None, ) -> dict[str, Any]: - _thinking = (getattr(self.model, "model_kwargs", None) or {}).get("thinking") - thinking_enabled = ( - isinstance(_thinking, dict) and _thinking.get("type") == "enabled" - ) - # Anthropic models via Invoke API don't support forced tool use with extended thinking - if thinking_enabled and tool_choice == "any": + if tool_choice == "any" and bedrock_rejects_forced_tool_choice( + anthropic_thinking_type(self.model) + ): logger.warning( - "Thinking is enabled for the model, but tool_choice is 'any'. " - "Changing tool_choice to 'auto' to keep the same behaviour as ChatAnthropicBedrock." + "Bedrock rejects forced tool_choice while thinking is active; " + "downgrading tool_choice 'any' -> 'auto'." ) tool_choice = "auto" kwargs: dict[str, Any] = {"tool_choice": tool_choice} @@ -142,17 +150,12 @@ def get_tool_binding_kwargs( parallel_tool_calls: bool | None = None, strict_mode: bool | None = None, ) -> dict[str, Any]: - _thinking = ( - getattr(self.model, "additional_model_request_fields", None) or {} - ).get("thinking") - thinking_enabled = ( - isinstance(_thinking, dict) and _thinking.get("type") == "enabled" - ) - # Anthropic models via Converse API don't support forced tool use with extended thinking - if thinking_enabled and tool_choice == "any": + if tool_choice == "any" and bedrock_rejects_forced_tool_choice( + anthropic_thinking_type(self.model) + ): logger.warning( - "Thinking is enabled for the model, but tool_choice is 'any'. " - "Changing tool_choice to 'auto' to keep the same behaviour as ChatAnthropicBedrock." + "Bedrock rejects forced tool_choice while thinking is active; " + "downgrading tool_choice 'any' -> 'auto'." ) tool_choice = "auto" kwargs: dict[str, Any] = {"tool_choice": tool_choice} diff --git a/tests/agent/react/test_forced_extraction.py b/tests/agent/react/test_forced_extraction.py new file mode 100644 index 000000000..0d9412a19 --- /dev/null +++ b/tests/agent/react/test_forced_extraction.py @@ -0,0 +1,141 @@ +"""Tests for the forced-extraction helpers.""" + +from typing import Any + +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages.content import create_tool_call +from pydantic import BaseModel, ConfigDict + +from uipath_langchain.agent.react.forced_extraction import ( + _strip_reasoning_blocks, + _with_extraction_nudge, + _without_thinking, +) + + +class _FakeConverse(BaseModel): + """Stand-in for ChatBedrockConverse: thinking lives in additional_model_request_fields.""" + + model_config = ConfigDict(extra="allow") + additional_model_request_fields: dict[str, Any] | None = None + + +class _FakeInvoke(BaseModel): + """Stand-in for ChatBedrock (Invoke): thinking lives in model_kwargs.""" + + model_config = ConfigDict(extra="allow") + model_kwargs: dict[str, Any] = {} + + +class _FakeNative(BaseModel): + """Stand-in for ChatAnthropic: thinking is a top-level attribute.""" + + model_config = ConfigDict(extra="allow") + thinking: dict[str, Any] | None = None + + +class TestWithoutThinking: + """_without_thinking removes reasoning config across transports, keeping the rest.""" + + def test_converse_removes_thinking_keeps_others(self) -> None: + model = _FakeConverse( + additional_model_request_fields={ + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "anthropic_beta": ["x"], + } + ) + result = _without_thinking(model) # type: ignore[arg-type] + assert result.additional_model_request_fields == {"anthropic_beta": ["x"]} + + def test_converse_removes_thinking_and_output_config(self) -> None: + model = _FakeConverse( + additional_model_request_fields={ + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + } + ) + result = _without_thinking(model) # type: ignore[arg-type] + assert result.additional_model_request_fields == {} + + def test_invoke_removes_thinking_from_model_kwargs(self) -> None: + model = _FakeInvoke(model_kwargs={"thinking": {"type": "enabled"}, "top_p": 0.9}) + result = _without_thinking(model) # type: ignore[arg-type] + assert result.model_kwargs == {"top_p": 0.9} + + def test_native_clears_thinking_attribute(self) -> None: + model = _FakeNative(thinking={"type": "adaptive"}) + result = _without_thinking(model) # type: ignore[arg-type] + assert result.thinking is None + + def test_no_thinking_returns_same_instance(self) -> None: + model = _FakeConverse(additional_model_request_fields={"anthropic_beta": ["x"]}) + assert _without_thinking(model) is model # type: ignore[arg-type] + + +class TestStripReasoningBlocks: + """_strip_reasoning_blocks drops reasoning blocks, keeps text and tool calls.""" + + def test_keeps_text_drops_reasoning(self) -> None: + msg = AIMessage( + content=[ + {"type": "reasoning_content", "reasoning_content": {"text": "think"}}, + {"type": "text", "text": "answer"}, + ] + ) + out = _strip_reasoning_blocks([msg]) + assert len(out) == 1 + assert out[0].content == [{"type": "text", "text": "answer"}] + + def test_drops_reasoning_only_turn(self) -> None: + human = HumanMessage(content="q") + reasoning_only = AIMessage( + content=[{"type": "reasoning_content", "reasoning_content": {"text": "t"}}] + ) + out = _strip_reasoning_blocks([human, reasoning_only]) + assert out == [human] + + def test_keeps_turn_with_tool_call_even_if_content_empties(self) -> None: + msg = AIMessage( + content=[{"type": "reasoning_content", "reasoning_content": {"text": "t"}}], + tool_calls=[create_tool_call(name="end_execution", args={}, id="call_1")], + ) + out = _strip_reasoning_blocks([msg]) + assert len(out) == 1 + assert out[0].content == [] + assert out[0].tool_calls[0]["name"] == "end_execution" + + def test_string_content_untouched(self) -> None: + msg = AIMessage(content="plain answer") + out = _strip_reasoning_blocks([msg]) + assert out[0] is msg + + def test_non_ai_messages_untouched(self) -> None: + human = HumanMessage(content="q") + out = _strip_reasoning_blocks([human]) + assert out == [human] + + +class TestExtractionNudge: + """_with_extraction_nudge ends on a user turn without creating consecutive user turns.""" + + def test_appends_user_turn_after_assistant(self) -> None: + msgs = [HumanMessage(content="q"), AIMessage(content="answer")] + out = _with_extraction_nudge(msgs) + assert isinstance(out[-1], HumanMessage) + assert "end_execution" in out[-1].content + assert isinstance(out[-2], AIMessage) + + def test_merges_into_trailing_user_turn(self) -> None: + msgs = [HumanMessage(content="the task")] + out = _with_extraction_nudge(msgs) + assert len(out) == 1 + assert isinstance(out[-1], HumanMessage) + assert "the task" in out[-1].content + assert "end_execution" in out[-1].content + + def test_merges_into_list_content_user_turn(self) -> None: + msgs = [HumanMessage(content=[{"type": "text", "text": "the task"}])] + out = _with_extraction_nudge(msgs) + assert len(out) == 1 + assert out[-1].content[-1]["type"] == "text" + assert "end_execution" in out[-1].content[-1]["text"] diff --git a/tests/agent/react/test_llm_node.py b/tests/agent/react/test_llm_node.py index 12eb5967d..c2adaaf10 100644 --- a/tests/agent/react/test_llm_node.py +++ b/tests/agent/react/test_llm_node.py @@ -1,7 +1,7 @@ """Tests for LLM node tool call filtering functionality.""" from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import httpx import openai @@ -441,3 +441,109 @@ async def test_non_http_error_propagates_unchanged(self): with pytest.raises(ValueError, match="boom"): await node(self.state) + + +class TestForcedExtractionEscalation: + """The forced-extraction fallback fires for any thinking model that stalls, + regardless of transport — including native-style handlers that don't downgrade.""" + + def _thinking_model(self) -> Any: + model = _StubAzureChatOpenAI.model_construct() + model.thinking = {"type": "adaptive"} # thinking active; OpenAI MRO won't downgrade + model.bind_tools = Mock(return_value=model) + model.bind = Mock(return_value=model) + return model + + def _plain_model(self) -> Any: + model = _StubAzureChatOpenAI.model_construct() + model.bind_tools = Mock(return_value=model) + model.bind = Mock(return_value=model) + return model + + def _stalled_state(self) -> AgentGraphState: + prior = AIMessage( + content=[ + {"type": "reasoning_content", "reasoning_content": {"text": "think"}}, + {"type": "text", "text": "answer"}, + ] + ) + return AgentGraphState(messages=[HumanMessage(content="q"), prior]) + + async def _run_capture(self, model: Any) -> list[Any]: + captured: dict[str, Any] = {} + + async def fake_ainvoke(msgs: Any) -> AIMessage: + captured["msgs"] = msgs + return AIMessage( + content="", + tool_calls=[ + create_tool_call(name=END_EXECUTION_TOOL.name, args={}, id="c1") + ], + ) + + model.ainvoke = AsyncMock(side_effect=fake_ainvoke) + tool = Mock(spec=BaseTool) + tool.name = "t" + node = create_llm_node(model, [tool]) + await node(self._stalled_state()) + return captured["msgs"] + + @pytest.mark.asyncio + async def test_thinking_model_stall_strips_reasoning_and_nudges(self) -> None: + """Escalation fires: reasoning stripped from the stalled turn, and the request + ends on a user nudge (not an assistant prefill) so native accepts the forced call.""" + msgs = await self._run_capture(self._thinking_model()) + # stalled assistant turn: reasoning block gone, text kept + assert msgs[-2].content == [{"type": "text", "text": "answer"}] + # request ends on a user instruction to emit the terminal tool call + assert isinstance(msgs[-1], HumanMessage) + assert "end_execution" in msgs[-1].content + + @pytest.mark.asyncio + async def test_non_thinking_model_does_not_escalate(self) -> None: + """No thinking => no escalation => history passed through untouched.""" + msgs = await self._run_capture(self._plain_model()) + assert msgs[-1].content == [ + {"type": "reasoning_content", "reasoning_content": {"text": "think"}}, + {"type": "text", "text": "answer"}, + ] + + @pytest.mark.asyncio + async def test_no_stall_does_not_escalate(self) -> None: + """First turn (no prior tool-less turn) must not force extraction.""" + model = self._thinking_model() + model.ainvoke = AsyncMock(return_value=AIMessage(content="reasoning...")) + state = AgentGraphState(messages=[HumanMessage(content="q")]) + tool = Mock(spec=BaseTool) + tool.name = "t" + with patch( + "uipath_langchain.agent.react.llm_node.build_extraction_call" + ) as spy: + await create_llm_node(model, [tool])(state) + spy.assert_not_called() + + @pytest.mark.asyncio + async def test_non_thinking_model_forces_from_first_turn_despite_limit( + self, + ) -> None: + """A non-reasoning model ignores the thinking buffer: it forces tool_choice + from the first turn even when the configured limit is > 0.""" + model = self._plain_model() + model.ainvoke = AsyncMock(return_value=AIMessage(content="done")) + tool = Mock(spec=BaseTool) + tool.name = "t" + node = create_llm_node(model, [tool], thinking_messages_limit=2) + await node(AgentGraphState(messages=[HumanMessage(content="q")])) + assert model.bind_tools.call_args.kwargs["tool_choice"] == "any" + + @pytest.mark.asyncio + async def test_thinking_model_gets_buffer_before_forcing(self) -> None: + """A thinking model keeps its configured buffer of tool-less turns before the + node forces tool_choice, so early reasoning turns aren't suppressed.""" + model = self._thinking_model() + model.ainvoke = AsyncMock(return_value=AIMessage(content="reasoning...")) + tool = Mock(spec=BaseTool) + tool.name = "t" + node = create_llm_node(model, [tool], thinking_messages_limit=2) + await node(AgentGraphState(messages=[HumanMessage(content="q")])) + assert model.bind_tools.call_args.kwargs["tool_choice"] == "auto" diff --git a/tests/agent/react/test_router.py b/tests/agent/react/test_router.py index eb419a575..4e9617d5b 100644 --- a/tests/agent/react/test_router.py +++ b/tests/agent/react/test_router.py @@ -45,14 +45,14 @@ class MockAgentGraphState(BaseModel): @pytest.fixture def route_function_no_limit(): - """Fixture for routing function with no thinking messages limit.""" - return create_route_agent(valid_targets=_VALID_TARGETS, thinking_messages_limit=0) + """Routing function. Tool-less turns loop back; llm_messages_limit bounds them.""" + return create_route_agent(valid_targets=_VALID_TARGETS) @pytest.fixture def route_function_with_limit(): - """Fixture for routing function with thinking messages limit of 2.""" - return create_route_agent(valid_targets=_VALID_TARGETS, thinking_messages_limit=2) + """Alias kept for existing tests; routing no longer takes a thinking limit.""" + return create_route_agent(valid_targets=_VALID_TARGETS) @pytest.fixture @@ -141,18 +141,6 @@ def state_no_tool_calls(): return MockAgentGraphState(messages=[HumanMessage(content="query"), ai_message]) -@pytest.fixture -def state_excessive_thinking(): - """Fixture for state with excessive consecutive thinking messages.""" - messages = [ - HumanMessage(content="query"), - AIMessage(content="thinking 1"), - AIMessage(content="thinking 2"), - AIMessage(content="thinking 3"), - ] - return MockAgentGraphState(messages=messages) - - @pytest.fixture def empty_state(): """Fixture for state with no messages.""" @@ -206,75 +194,35 @@ def test_flow_control_tool_terminates( class TestRouteAgentThinkingMessages: - """Test thinking messages and consecutive completions logic.""" - - def test_no_tool_calls_within_limit_routes_to_agent( - self, route_function_with_limit, state_no_tool_calls - ): - """Should route to AGENT when no tool calls and within thinking limit.""" - result = route_function_with_limit(state_no_tool_calls) - assert result == AgentGraphNode.AGENT - - def test_excessive_thinking_messages_raises_exception( - self, route_function_with_limit, state_excessive_thinking - ): - """Should raise exception when exceeding thinking messages limit.""" - with pytest.raises(AgentRuntimeError) as exc_info: - route_function_with_limit(state_excessive_thinking) - - assert exc_info.value.error_info.code == AgentRuntimeError.full_code( - AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED - ) - - def test_reasoning_enabled_does_not_raise_on_thinking_turns( - self, state_excessive_thinking - ): - """With reasoning_enabled, tool-less thinking turns don't error (tool_choice - can't be forced under extended thinking); the loop is bounded elsewhere.""" - route_func = create_route_agent( - valid_targets=_VALID_TARGETS, - thinking_messages_limit=0, - reasoning_enabled=True, - ) - result = route_func(state_excessive_thinking) - assert result == AgentGraphNode.AGENT + """A tool-less turn loops back only when it carries a reasoning block (an + expected thinking stall, which the LLM node's extraction terminates next turn). + A tool-less turn with plain content and no reasoning means the model was forced + but didn't call a tool, which fails fast.""" - def test_thinking_messages_limit_zero_forbids_thinking(self): - """Should not allow any thinking messages when limit is 0.""" - route_func = create_route_agent( - valid_targets=_VALID_TARGETS, thinking_messages_limit=0 + def test_reasoning_stall_routes_to_agent(self, route_function_no_limit): + """A tool-less turn carrying a thinking block loops back to AGENT.""" + ai_message = AIMessage( + content=[ + {"type": "thinking", "thinking": "", "signature": "sig"}, + {"type": "text", "text": "the answer is 42"}, + ] ) - ai_message = AIMessage(content="thinking") state = MockAgentGraphState( messages=[HumanMessage(content="query"), ai_message] ) + assert route_function_no_limit(state) == AgentGraphNode.AGENT + def test_forced_tool_less_without_reasoning_raises( + self, route_function_no_limit, state_no_tool_calls + ): + """Content but no tool call and no reasoning block => the model ignored forced + tool_choice => THINKING_LIMIT_EXCEEDED, rather than looping to max iterations.""" with pytest.raises(AgentRuntimeError) as exc_info: - route_func(state) - + route_function_no_limit(state_no_tool_calls) assert exc_info.value.error_info.code == AgentRuntimeError.full_code( AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED ) - def test_thinking_messages_after_tool_execution_resets_count(self): - """Should reset thinking count after tool execution.""" - route_func = create_route_agent( - valid_targets=_VALID_TARGETS, thinking_messages_limit=1 - ) - messages = [ - HumanMessage(content="query"), - AIMessage( - content="using tool", - tool_calls=[{"name": "test_tool", "args": {}, "id": "call_1"}], - ), - ToolMessage(content="result", tool_call_id="call_1"), - AIMessage(content="thinking after tool"), # This should be allowed - ] - state = MockAgentGraphState(messages=messages) - - result = route_func(state) - assert result == AgentGraphNode.AGENT - class TestRouteAgentErrorHandling: """Test error handling and edge cases.""" @@ -323,7 +271,6 @@ def test_unknown_target_raises_routing_error(self): """Should raise ROUTING_ERROR (SYSTEM) when the routed tool is unwired.""" route_func = create_route_agent( valid_targets=[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE, "real_tool"], - thinking_messages_limit=0, ) ai_message = AIMessage( content="routing", @@ -343,7 +290,6 @@ def test_known_target_returns_tool_name(self): """Should return the tool name when it is in the valid target set.""" route_func = create_route_agent( valid_targets=[AgentGraphNode.AGENT, AgentGraphNode.TERMINATE, "real_tool"], - thinking_messages_limit=0, ) ai_message = AIMessage( content="routing", @@ -359,7 +305,7 @@ def test_default_valid_targets_skips_guard(self): Backwards-compatible contract: callers predating valid_targets must keep the old unguarded behavior, returning any routed tool name as-is. """ - route_func = create_route_agent(thinking_messages_limit=0) + route_func = create_route_agent() ai_message = AIMessage( content="routing", tool_calls=[{"name": "unwired_tool", "args": {}, "id": "call_1"}], diff --git a/tests/chat/handlers/test_tool_binding_kwargs.py b/tests/chat/handlers/test_tool_binding_kwargs.py index 59fee0115..ce0af6bd0 100644 --- a/tests/chat/handlers/test_tool_binding_kwargs.py +++ b/tests/chat/handlers/test_tool_binding_kwargs.py @@ -178,6 +178,24 @@ def test_all_keys_present(self): assert set(result.keys()) == {"tool_choice", "parallel_tool_calls", "strict"} +class TestAnthropicKeepsForcingUnderThinking: + """Native Anthropic accepts forced tool_choice under thinking (unlike Bedrock), + so it must NOT downgrade 'any' regardless of the thinking mode.""" + + def _model(self, thinking: object) -> object: + return type("FakeChatAnthropic", (), {"thinking": thinking})() + + def test_extended_thinking_keeps_any(self): + handler = AnthropicPayloadHandler(self._model({"type": "enabled"})) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs(tools=[], tool_choice="any") + assert result["tool_choice"] == "any" + + def test_adaptive_thinking_keeps_any(self): + handler = AnthropicPayloadHandler(self._model({"type": "adaptive"})) # type: ignore[arg-type] + result = handler.get_tool_binding_kwargs(tools=[], tool_choice="any") + assert result["tool_choice"] == "any" + + # --------------------------------------------------------------------------- # Gemini handler # --------------------------------------------------------------------------- diff --git a/tests/chat/test_bedrock_payload_handler.py b/tests/chat/test_bedrock_payload_handler.py index 02fdca468..1b92c6d81 100644 --- a/tests/chat/test_bedrock_payload_handler.py +++ b/tests/chat/test_bedrock_payload_handler.py @@ -6,9 +6,11 @@ from langchain_core.messages import AIMessage from uipath_langchain.chat.exceptions import ChatModelError +from uipath_langchain.chat.handlers.anthropic import anthropic_thinking_type from uipath_langchain.chat.handlers.bedrock import ( BedrockConversePayloadHandler, BedrockInvokePayloadHandler, + bedrock_rejects_forced_tool_choice, ) # --------------------------------------------------------------------------- @@ -16,19 +18,23 @@ # --------------------------------------------------------------------------- -def make_invoke_model(**model_kwargs_override: object) -> object: - """Return a ChatBedrock-like model with optional model_kwargs.""" +def make_invoke_model(model_id: str | None = None, **model_kwargs_override: object) -> object: + """Return a ChatBedrock-like model with optional model_kwargs + model_id.""" model = type("FakeChatBedrock", (), {"model_kwargs": {}})() model.model_kwargs = model_kwargs_override + if model_id is not None: + model.model_id = model_id return model -def make_converse_model(**fields_override: object) -> object: - """Return a ChatBedrockConverse-like model with optional additional_model_request_fields.""" +def make_converse_model(model_id: str | None = None, **fields_override: object) -> object: + """Return a ChatBedrockConverse-like model with optional request fields + model_id.""" model = type( "FakeChatBedrockConverse", (), {"additional_model_request_fields": {}} )() model.additional_model_request_fields = fields_override + if model_id is not None: + model.model_id = model_id return model @@ -298,3 +304,73 @@ def test_additional_fields_attribute_missing(self) -> None: handler = BedrockConversePayloadHandler(model) result = handler.get_tool_binding_kwargs([], "any") assert result["tool_choice"] == "any" + + +# --------------------------------------------------------------------------- +# Bedrock downgrades forced tool_choice whenever thinking is active — any mode, +# any version (no version check). The ReAct loop's forced-extraction fallback then +# guarantees termination. Native keeps forcing; see test_tool_binding_kwargs.py. +# --------------------------------------------------------------------------- + + +class TestBedrockThinkingDowngrade: + """Any thinking mode downgrades forced 'any' -> 'auto' on both Bedrock APIs.""" + + @pytest.mark.parametrize("mode", ["enabled", "adaptive"]) + def test_converse_downgrades(self, mode: str) -> None: + handler = BedrockConversePayloadHandler( + make_converse_model(thinking={"type": mode}) # type: ignore[arg-type] + ) + assert handler.get_tool_binding_kwargs([], "any")["tool_choice"] == "auto" + + @pytest.mark.parametrize("mode", ["enabled", "adaptive"]) + def test_invoke_downgrades(self, mode: str) -> None: + handler = BedrockInvokePayloadHandler( + make_invoke_model(thinking={"type": mode}) # type: ignore[arg-type] + ) + assert handler.get_tool_binding_kwargs([], "any")["tool_choice"] == "auto" + + def test_no_thinking_keeps_forcing(self) -> None: + handler = BedrockConversePayloadHandler(make_converse_model()) # type: ignore[arg-type] + assert handler.get_tool_binding_kwargs([], "any")["tool_choice"] == "any" + + +class TestBedrockRejectsForcedToolChoice: + """The downgrade predicate: forcing is rejected whenever thinking is active.""" + + @pytest.mark.parametrize( + "thinking_type,expected", + [ + ("enabled", True), + ("adaptive", True), + ("interleaved", True), + (None, False), + ], + ) + def test_rejects_when_thinking_active( + self, thinking_type: str | None, expected: bool + ) -> None: + assert bedrock_rejects_forced_tool_choice(thinking_type) is expected + + +class TestThinkingTypeDetection: + """Unit tests for anthropic_thinking_type across transports.""" + + def test_type_from_native_attribute(self) -> None: + model = type("FakeChatAnthropic", (), {"thinking": {"type": "adaptive"}})() + assert anthropic_thinking_type(model) == "adaptive" + + def test_type_from_invoke_model_kwargs(self) -> None: + assert anthropic_thinking_type( + make_invoke_model(thinking={"type": "enabled"}) + ) == ("enabled") + + def test_type_from_converse_request_fields(self) -> None: + model = make_converse_model(thinking={"type": "enabled"}) + assert anthropic_thinking_type(model) == "enabled" + + def test_type_none_when_absent(self) -> None: + assert anthropic_thinking_type(make_invoke_model()) is None + + def test_type_none_when_thinking_not_dict(self) -> None: + assert anthropic_thinking_type(make_invoke_model(thinking="enabled")) is None