From 0722877423c323b262c5abfb99e413f186b799c2 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 24 Dec 2025 17:04:31 -0700 Subject: [PATCH] fix: validate parallel tool calls with tool rules at create/update time (#8060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: validate parallel tool calls with tool rules at create/update time Move validation from runtime to agent create/update time for better UX. Add client-side enforcement to truncate parallel tool calls when disabled (handles providers like Gemini that ignore the setting). 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta * update apis * undo --------- Co-authored-by: Letta --- letta/agents/letta_agent_v3.py | 21 +++++++++++++-------- letta/services/agent_manager.py | 21 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/letta/agents/letta_agent_v3.py b/letta/agents/letta_agent_v3.py index be84b54f..abd583fb 100644 --- a/letta/agents/letta_agent_v3.py +++ b/letta/agents/letta_agent_v3.py @@ -726,6 +726,15 @@ class LettaAgentV3(LettaAgentV2): else: tool_calls = [] + # Enforce parallel_tool_calls=false by truncating to first tool call + # Some providers (e.g. Gemini) don't respect this setting via API, so we enforce it client-side + if len(tool_calls) > 1 and not self.agent_state.llm_config.parallel_tool_calls: + self.logger.warning( + f"LLM returned {len(tool_calls)} tool calls but parallel_tool_calls=false. " + f"Truncating to first tool call: {tool_calls[0].function.name}" + ) + tool_calls = [tool_calls[0]] + # get the new generated `Message` objects from handling the LLM response new_messages, self.should_continue, self.stop_reason = await self._handle_ai_response( tool_calls=tool_calls, @@ -1037,15 +1046,11 @@ class LettaAgentV3(LettaAgentV2): # 5. Unified tool execution path (works for both single and multiple tools) - # 5a. Validate parallel tool calling constraints - if len(tool_calls) > 1: - # No parallel tool calls with tool rules - if self.agent_state.tool_rules and len([r for r in self.agent_state.tool_rules if r.type != "requires_approval"]) > 0: - raise ValueError( - "Parallel tool calling is not allowed when tool rules are present. Disable tool rules to use parallel tool calls." - ) + # 5. Unified tool execution path (works for both single and multiple tools) + # Note: Parallel tool calling with tool rules is validated at agent create/update time. + # At runtime, we trust that if tool_rules exist, parallel_tool_calls=false is enforced earlier. - # 5b. Prepare execution specs for all tools + # 5a. Prepare execution specs for all tools exec_specs = [] for tc in tool_calls: call_id = tc.id or f"call_{uuid.uuid4().hex[:8]}" diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 1be471b4..1f22478c 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -25,7 +25,7 @@ from letta.constants import ( INCLUDE_MODEL_KEYWORDS_BASE_TOOL_RULES, RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE, ) -from letta.errors import LettaAgentNotFoundError +from letta.errors import LettaAgentNotFoundError, LettaInvalidArgumentError from letta.helpers import ToolRulesSolver from letta.helpers.datetime_helpers import get_utc_time from letta.llm_api.llm_client import LLMClient @@ -481,6 +481,15 @@ class AgentManager: if tool_rules: check_supports_structured_output(model=agent_create.llm_config.model, tool_rules=tool_rules) + # Validate parallel tool calling with tool rules + # Tool rules (excluding requires_approval) require sequential execution + non_approval_rules = [r for r in tool_rules if r.type != "requires_approval"] + if non_approval_rules and agent_create.llm_config.parallel_tool_calls: + raise LettaInvalidArgumentError( + "Parallel tool calling is not allowed when tool rules are present. " + "Set `parallel_tool_calls=False` in llm_config or remove tool rules." + ) + new_agent = AgentModel( name=agent_create.name, system=derive_system_message( @@ -772,6 +781,16 @@ class AgentManager: if val is not None: setattr(agent, col, val) + # Validate parallel tool calling with tool rules after updates + # Tool rules (excluding requires_approval) require sequential execution + if agent.tool_rules and agent.llm_config.parallel_tool_calls: + non_approval_rules = [r for r in agent.tool_rules if r.type != "requires_approval"] + if non_approval_rules: + raise LettaInvalidArgumentError( + "Parallel tool calling is not allowed when tool rules are present. " + "Set `parallel_tool_calls=False` in llm_config or remove tool rules." + ) + if agent_update.metadata is not None: agent.metadata_ = agent_update.metadata