# Same-turn tool activation race — findings

> Historical upstream document from snapshot `v0.3.6`. The locally maintained implementation no longer uses the old hidden steer/retry mitigation described below. Current behavior is documented in [`dynamic-tool-loading.md`](./dynamic-tool-loading.md).

## Bug

When the LLM emits parallel tool calls in a single assistant message — for
example `tool_search(["subagent"])` together with `subagent(...)` — the second
call fails with `Tool subagent not found`.

Live repro (observed in this project, 2026-04-23):

1. Turn A: `tool_search(["subagent"])` → `Enabled: subagent`; `subagent(...)`
   in the same reply → ❌ `Tool not found`.
2. Turn B (agent continuation, no user input): `subagent(...)` → ❌ still not
   found.
3. Turn C (after fresh user message): `subagent(...)` → ✅ works.

## Root cause

Two compounding issues in pi-coding-agent core.

### 1. Tool schema is frozen per provider request

`setActiveTools(["tool_search", ...unlocked])` updates `agent.state.tools`, but
the outgoing LLM request payload (`tools: [...]`) is built **before**
`tool_search` executes. The model that produced the parallel call never saw
`subagent` in its tool schema — it guessed based on `tool_search`'s
description.

### 2. `before_agent_start` only fires on user-initiated turns

The extension's hook that re-applies `setActiveTools` runs once per fresh user
turn. Agentic-loop continuations (model → tool → model → tool…) do **not**
re-trigger it. So even after `tool_search` mutates state in Turn A, the next
LLM call in the same loop still uses the stale schema from Turn A's start.

## What we checked

- `pi-coding-agent/dist/core/agent-session.js:550` —
  `setActiveToolsByName` assigns `this.agent.state.tools = tools`. Pure state
  mutation, no schema re-send.
- `pi-coding-agent/dist/core/agent-session.js:172` — `beforeToolCall` hook
  fires only for resolved tools; unknown names never reach it.
- `pi-coding-agent/dist/core/sdk.js:195` — `onPayload(payload, model)`
  receives the **already-built, provider-specific** request. Tools are baked
  in.
- `ExtensionAPI.getAllTools(): ToolInfo[]` — `ToolInfo = Pick<ToolDefinition,
  "name"|"description"|"parameters"> & { sourceInfo }`. **No `execute`.**
- `ExtensionAPI.on("tool_call", ...)` result type is `{ block?, reason? }` —
  cannot replace the tool result.

## Extension-only fixes considered

| Option | Fixes parallel calls | Fixes mid-loop | Fragile | Viable |
|---|---|---|---|---|
| Prompt mitigation (tell model to call `tool_search` alone) | ❌ | ❌ | — | ✅ shipped |
| `before_provider_request` payload rewrite | ❌ (too late) | ✅ | ✅ yes | ⚠️ |
| Proxy `invoke(tool, args)` tool | ✅ | ✅ | — | ❌ dead |

### Why the proxy `invoke` pattern is dead

`pi.getAllTools()` returns metadata only. The callable `execute()` lives on
`ToolDefinition` / `RegisteredTool`, but no public API returns those. An
extension cannot dispatch another extension's tool programmatically.

### Why `before_provider_request` rewrite is fragile

The hook receives the raw provider payload. To inject unlocked tool schemas
mid-loop you would need per-provider surgery:

- OpenAI: `payload.tools = [{ type: "function", function: { name, description, parameters } }]`
- Anthropic: `payload.tools = [{ name, description, input_schema }]`
- Gemini: `payload.tools = [{ functionDeclarations: [...] }]`
- plus Azure / Copilot / Codex adapters, plus TypeBox → JSON-Schema
  conversion with provider-specific quirks (Gemini's restricted subset, etc.).

Breaks every time a provider is added or its payload shape changes. Duplicates
logic pi-coding-agent already has internally.

It also does **not** fix the parallel-call case — by the time
`before_provider_request` fires, the model has already emitted both calls.

## Current mitigation (shipped)

`extensions/index.ts` updates:

- `tool_search` description explicitly says: "STOP after calling
  tool_search. Do NOT call newly-enabled tools in same response. Tool
  schema is frozen for current response."
- `promptSnippet` reinforces rule.
- Active tools now refresh on every `turn_start`, not only fresh user prompts,
  so unlocked tools stay available during agent-loop continuations.
- Successful `tool_search` queues hidden steer hint telling model to continue
  original task in next turn and retry any same-response failure immediately.

This does not make same-response parallel call succeed, but it removes need for
fresh user message in common recovery path.

## Real fix (upstream)

The structural fix belongs in `pi-coding-agent`:

1. **Re-resolve active tools per batch iteration.** Don't snapshot at batch
   start; look up each tool name when it's about to be dispatched.
2. **Sequential dispatch when a mutating tool is in the batch.** Or always
   sequential — parallel `Promise.all` dispatch races `setActiveTools`.
3. **Re-send schema after mid-loop `setActiveTools`.** Or mark the active-tool
   set as dirty and rebuild payload on next provider request.
4. **Graceful unknown-tool result.** When a dispatched tool name is not in the
   active set, return a structured tool_result (`"tool X not active this
   turn — retry next turn"`) instead of falling through to MCP or throwing.

With (1)+(2) alone, the parallel-call case works. With (3), mid-loop
continuations also work. (4) makes the failure mode recoverable by the model
when it does speculate.

## Recommended action

- Keep prompt mitigation + per-turn refresh + hidden retry hint in
  `extensions/index.ts`.
- Document remaining limitation in README (same-response parallel call with
  `tool_search` can still fail until core is fixed).
- File upstream issue against `pi-coding-agent` referencing this doc.

## File map

| Path | Role |
|---|---|
| `extensions/index.ts` | This extension; prompt mitigation lives here |
| `pi-coding-agent/dist/core/agent-session.js:550` | `setActiveToolsByName` — state-only mutation |
| `pi-coding-agent/dist/core/agent-session.js:172` | `beforeToolCall` hook — only fires for resolved tools |
| `pi-coding-agent/dist/core/sdk.js:195` | `onPayload` — already-built payload; provider-specific |
| `pi-coding-agent/dist/core/extensions/types.d.ts` | `ToolInfo` (metadata only), `ToolCallEventResult` (`{block, reason}`) |
