Summary
When using WithForceReasoning(), the pickTool function frequently fails with "no tool selected" errors even when the LLM reasoning clearly indicates which tool should be used.
Reproduction
- Use
ExecuteTools() with WithForceReasoning() option
- Observe that sometimes tool selection fails with "no tool selected"
- Check logs - the reasoning text contains explicit tool choice (e.g., "Action: wait")
Root Cause
In tools.go line 443-445:
case "":
xlog.Debug("[pickTool] No tool selected")
return nil, reasoning, fmt.Errorf("no tool selected")
The LLM calls pick_tool but returns {"tool": "", "reasoning": "..."} - an empty tool field despite:
- Schema having
"required": ["tool"]
- Schema having
"enum": [available tools]
Some LLMs (especially smaller/faster ones via OpenRouter) do not strictly follow schema constraints.
Evidence from Production Logs
time=2025-12-11T05:54:12.114Z level=DEBUG msg="[pickTool] No tool selected"
time=2025-12-11T05:54:12.969Z level=DEBUG msg="[pickTool] No tool selected"
time=2025-12-11T05:54:13.261Z level=DEBUG msg="[pickTool] No tool selected"
time=2025-12-11T05:54:13.932Z level=DEBUG msg="[pickTool] No tool selected"
time=2025-12-11T05:54:14.166Z level=DEBUG msg="[pickTool] Tool selected via intention" tool=wait ✅
time=2025-12-11T05:54:15.595Z level=DEBUG msg="[pickTool] Tool selected via intention" tool=wait ✅
4 failures followed by 2 successes - the behavior is flaky.
Meanwhile, the reasoning text clearly shows the LLM chose "wait":
"**Action: wait**"
"Tool Selection: wait"
"The only appropriate action is to **wait**"
Proposed Fix
When intentionResponse.Tool == "", extract the tool name from the reasoning text as a fallback:
case "":
// Fallback: Extract tool from reasoning text
extractedTool := extractToolFromReasoning(reasoning, toolNames)
if extractedTool != "" {
xlog.Debug("[pickTool] Extracted tool from reasoning", "tool", extractedTool)
chosenTool := tools.Find(extractedTool)
if chosenTool != nil {
return &ToolChoice{Name: extractedTool, Arguments: make(map[string]any), Reasoning: reasoning}, reasoning, nil
}
}
xlog.Debug("[pickTool] No tool selected")
return nil, reasoning, fmt.Errorf("no tool selected")
With extraction function:
func extractToolFromReasoning(reasoning string, toolNames []string) string {
lowerReasoning := strings.ToLower(reasoning)
for _, tool := range toolNames {
lowerTool := strings.ToLower(tool)
patterns := []string{
"action: " + lowerTool,
"tool: " + lowerTool,
"decision: " + lowerTool,
"use the " + lowerTool + " tool",
"**" + lowerTool + "**",
}
for _, pattern := range patterns {
if strings.Contains(lowerReasoning, pattern) {
return tool
}
}
}
return ""
}
Environment
- cogito version: v0.7.0
- LLM providers: Various models via OpenRouter
- Use case: Trading agents with ~15 tools including "wait"
Impact
- High - Causes valid decisions to fail silently
- Agents that should be waiting are instead returning errors
- Production systems are affected (~30% failure rate on some models)
Workaround
Currently no workaround other than retrying failed evaluations.
Summary
When using
WithForceReasoning(), thepickToolfunction frequently fails with "no tool selected" errors even when the LLM reasoning clearly indicates which tool should be used.Reproduction
ExecuteTools()withWithForceReasoning()optionRoot Cause
In
tools.goline 443-445:The LLM calls
pick_toolbut returns{"tool": "", "reasoning": "..."}- an empty tool field despite:"required": ["tool"]"enum": [available tools]Some LLMs (especially smaller/faster ones via OpenRouter) do not strictly follow schema constraints.
Evidence from Production Logs
4 failures followed by 2 successes - the behavior is flaky.
Meanwhile, the reasoning text clearly shows the LLM chose "wait":
Proposed Fix
When
intentionResponse.Tool == "", extract the tool name from the reasoning text as a fallback:With extraction function:
Environment
Impact
Workaround
Currently no workaround other than retrying failed evaluations.