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 <noreply@anthropic.com>

* style: fix ruff formatting in alembic migration

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kagura 2026-03-30 16:30:53 +08:00 committed by GitHub
parent b32767caa8
commit 585ac76f39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 43 additions and 4 deletions

View file

@ -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")

View file

@ -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