mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
135 lines
6.2 KiB
Markdown
135 lines
6.2 KiB
Markdown
# 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}`) |
|