Skip to content

Fix tool-mediation loop: agents never executed tools#83

Merged
lezama merged 1 commit into
mainfrom
fix/tool-mediation-loop
Jun 3, 2026
Merged

Fix tool-mediation loop: agents never executed tools#83
lezama merged 1 commit into
mainfrom
fix/tool-mediation-loop

Conversation

@lezama

@lezama lezama commented Jun 3, 2026

Copy link
Copy Markdown
Owner

The bug

Tool-using agents ran one turn, executed zero tools, and returned an empty reply — autonomous tool use was fully broken with every provider (Ollama and Anthropic). The model emitted a tool call (tool_call_count:1) but the loop completed with tool_results:0.

Root causes (two)

1. Declaration naming. The agents-api conversation loop validates client tool declarations against ^[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*$ and derives the declaration source from the segment before the slash, which must equal client (WP_Agent_Tool_Declaration::validate()). openclaWP handed the loop the provider-sanitized name (openclawp__get-recent-posts — no slash, source openclawp), so every declaration was dropped → mediation_enabled became false → tools never executed.

2. Message round-trip. to_ai_client_messages() dropped tool_call messages and passed tool_result content as plain user text. The model never saw its own call/result in the protocol shape, so it re-issued the same call every turn until max_turns, never answering.

The fix

  • New OpenclaWP_Tools_Resolver::loop_tools() — the loop-only variant that re-keys declarations + executor maps under client/<name> (source client). The runner uses it; for_agent() stays unprefixed so the MCP tool surface and provider function names are unchanged (verified).
  • The runner maps the model's returned tool-call names back through loop_name().
  • to_ai_client_messages() now converts tool_call → model message with a FunctionCall part and tool_result → user message with a FunctionResponse part (provider name, client/ stripped, matched by tool_call_id).
  • sanitize_name() lowercases so client/<name> satisfies the loop's lowercase-only pattern.

Verification

End-to-end on two local Studio sites (Anthropic claude-haiku-4-5): an orchestrator agent autonomously calls a cross-site A2A tool → the peer agent autonomously calls get-recent-posts → real data flows back, in a single tool-call cycle.

tool_call   client/a2a__siteb {"prompt":"What is your latest post title?"}
tool_result success
completed   turn:2 tool_results:1
=> "Site B's latest post is titled "Site B Genesis Post"…"
  • tests/unit/ToolsResolverTest.php — guards the client/ naming contract against the exact agents-api regex (14 assertions).
  • tests/smoke.php — asserts the tool_call/tool_resultFunctionCall/FunctionResponse round-trip in real WP.
  • Confirmed for_agent()/MCP translator names stay openclawp__… (no client/ leak).

Local PHPUnit/PHPCS PHARs emit zero bytes on PHP 8.4 here; verified via php -l, PHPStan, a bootstrap-backed harness, and live Studio runs. CI runs the real gates.

🤖 Generated with Claude Code

Tool-using agents ran one turn, executed zero tools, and returned an empty
reply — autonomous tool use was fully broken with every provider. Two causes:

1. Declaration naming. The agents-api conversation loop validates client tool
   declarations against `^[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*$` and derives the
   declaration source from the segment before the slash, which MUST equal
   `client`. openclaWP handed the loop provider-sanitized names
   (`openclawp__get-recent-posts`, no slash, source `openclawp`), so every
   declaration was dropped, `mediation_enabled` flipped to false, and tools
   never executed. Add OpenclaWP_Tools_Resolver::loop_tools() — the loop-only
   variant that re-keys declarations + the executor maps under `client/<name>`
   (source `client`); the runner maps the model's returned tool-call names back
   through loop_name(). for_agent() stays unprefixed so the MCP tool surface and
   the provider-facing function names are unchanged.

2. Message round-trip. to_ai_client_messages() dropped tool_call messages and
   passed tool_result content as plain user text, so the model never saw its
   own call or the result in the protocol shape — it re-issued the same call
   every turn until max_turns, never synthesizing an answer. Convert tool_call
   -> model message with a FunctionCall part and tool_result -> user message
   with a FunctionResponse part (provider name, client/ prefix stripped, matched
   by tool_call_id).

Also lowercase sanitize_name() output so the client/<name> form satisfies the
loop's lowercase-only name pattern.

Verified end-to-end on two local Studio sites (Anthropic claude-haiku): an
orchestrator agent autonomously calls a cross-site A2A tool, the peer agent
autonomously calls get-recent-posts, and real data flows back — single
tool-call cycle, no re-calling.

Tests: tests/unit/ToolsResolverTest.php guards the client/ naming contract;
tests/smoke.php asserts the tool_call/tool_result -> FunctionCall/FunctionResponse
round-trip in a real WP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lezama lezama merged commit d393716 into main Jun 3, 2026
6 checks passed
@lezama lezama deleted the fix/tool-mediation-loop branch June 3, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant