From 585ac76f3985ea1bc9b812223cc78f7dbc37e1cb Mon Sep 17 00:00:00 2001 From: Kagura Date: Mon, 30 Mar 2026 16:30:53 +0800 Subject: [PATCH] fix(claude-code): implement tool_choice support for forced tool calls (#733) * fix(claude-code): implement tool_choice support for forced tool calls The call_with_tools() method now properly handles the tool_choice parameter to force specific tool calls. Previously, the parameter was accepted but ignored, causing the reflect agent to fail when trying to force specific tools on each iteration. Fixes #732 Changes: - When tool_choice forces a specific function: filter allowed_tools to only that tool (with mcp prefix) and add a strong system prompt instruction - When tool_choice is 'required': add instruction that model must call at least one tool - When tool_choice is 'none': clear allowed_tools and mcp_servers to disable all tools - When tool_choice is 'auto' (default): no change (existing behavior) This matches the approach used in the OpenAI provider while adapting to the Claude Agent SDK's lack of native tool_choice parameter by using allowed_tools filtering and system prompt instructions. Co-Authored-By: Claude Opus 4.6 * style: fix ruff formatting in alembic migration --------- Co-authored-by: Claude Opus 4.6 --- ...5d6e7f8a9b0_add_bank_id_to_memory_links.py | 1 - .../engine/providers/claude_code_llm.py | 46 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/c5d6e7f8a9b0_add_bank_id_to_memory_links.py b/hindsight-api-slim/hindsight_api/alembic/versions/c5d6e7f8a9b0_add_bank_id_to_memory_links.py index 21bb6849..3135cfdc 100644 --- a/hindsight-api-slim/hindsight_api/alembic/versions/c5d6e7f8a9b0_add_bank_id_to_memory_links.py +++ b/hindsight-api-slim/hindsight_api/alembic/versions/c5d6e7f8a9b0_add_bank_id_to_memory_links.py @@ -43,7 +43,6 @@ def upgrade() -> None: op.execute(f"ALTER TABLE {schema}memory_links ALTER COLUMN bank_id SET NOT NULL") - def downgrade() -> None: schema = _get_schema_prefix() op.execute(f"ALTER TABLE {schema}memory_links DROP COLUMN IF EXISTS bank_id") diff --git a/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py index c91d2947..38df858b 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py @@ -331,7 +331,11 @@ class ClaudeCodeLLM(LLMInterface): max_retries: Maximum retry attempts. initial_backoff: Initial backoff time in seconds. max_backoff: Maximum backoff time in seconds. - tool_choice: How to choose tools (not used by Claude Agent SDK). + tool_choice: How to choose tools - "auto", "none", "required", or specific function dict. + - "auto": Model decides whether to call tools (default) + - "required": Model must call at least one tool + - "none": Model must not call any tools + - {"type": "function", "function": {"name": "..."}}: Force specific tool call Returns: LLMToolCallResult with content and/or tool_calls. @@ -410,16 +414,52 @@ class ClaudeCodeLLM(LLMInterface): tool_call_id = msg.get("tool_call_id", "") user_content += f"\n\n[Tool result for {tool_call_id}: {content}]" + # Handle tool_choice parameter to filter tools and adjust instructions + # The Claude Agent SDK doesn't have a native tool_choice parameter, so we + # enforce it via allowed_tools filtering and system prompt instructions. + # Format tool names for SDK MCP servers: mcp__{server_name}__{tool_name} # This is required by the Claude Agent SDK for MCP server tools allowed_tool_names = [f"mcp__hindsight_tools__{name}" for name in tool_names] + mcp_servers_config = {"hindsight_tools": mcp_server} if sdk_tools else {} + + # Process tool_choice + if isinstance(tool_choice, dict) and tool_choice.get("type") == "function": + # Force a specific tool: filter allowed_tools to only that tool and add instruction + forced_name = tool_choice.get("function", {}).get("name") + if forced_name: + # Filter to only the forced tool (with MCP prefix) + forced_tool_mcp_name = f"mcp__hindsight_tools__{forced_name}" + if forced_tool_mcp_name in allowed_tool_names: + allowed_tool_names = [forced_tool_mcp_name] + # Add strong instruction to system prompt + force_instruction = ( + f"\n\nIMPORTANT: You MUST call the '{forced_name}' tool. Do not respond with text only." + ) + system_prompt += force_instruction + logger.debug(f"Claude Code: Forcing tool call to '{forced_name}'") + else: + logger.warning(f"Claude Code: Forced tool '{forced_name}' not found in available tools") + elif tool_choice == "required": + # Must call at least one tool + tool_instruction = ( + "\n\nIMPORTANT: You MUST call at least one of the available tools. Do not respond with text only." + ) + system_prompt += tool_instruction + logger.debug("Claude Code: Tool call required") + elif tool_choice == "none": + # No tools should be called - disable all tools + allowed_tool_names = [] + mcp_servers_config = {} + logger.debug("Claude Code: Tools disabled (tool_choice=none)") + # else: tool_choice == "auto" or unspecified - use default behavior (no changes needed) # Configure SDK options with MCP server options = ClaudeAgentOptions( system_prompt=system_prompt if system_prompt else None, max_turns=1, # Single-turn for API-style interactions - mcp_servers={"hindsight_tools": mcp_server} if sdk_tools else {}, - allowed_tools=allowed_tool_names if allowed_tool_names else [], + mcp_servers=mcp_servers_config, + allowed_tools=allowed_tool_names, ) # Call Claude Agent SDK with retry logic