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