preToolUse silently dropped after mid-loop extensions_reload (snapshot staleness in runAgenticLoop)
- Lingua principale
- Shell
- Stelle
- 11.2k
- Fork
- 1.9k
- Merge medio
- 14h 16m
- PR unite (30g)
- 6
Descrizione
## Summary
The agent's main reaction loop captures the session's hooks **once** at loop start and then uses that snapshot for every subsequent iteration. After `extensions_reload` is called from inside the loop, the snapshot still references the now-dead old extension's IPC proxy. The proxy's calls fail with `EPIPE` / `"connection reset"` / `"stream was destroyed"`, which are caught and silently swallowed by the proxy's own error handler.
The result: `preToolUse`, `userPromptSubmitted`, and `sessionStart` invocations registered via that proxy are silently dropped for the rest of the loop. Meanwhile `postToolUse` (and other paths that call `getEffectiveHooks()` live each time) continue to fire on the freshly-launched extension instance.
For an extension that observes both pre and post hooks, every tool call that happens after a mid-loop `extensions_reload` becomes a `postToolUse`-only event with no matching `preToolUse`. The behavior persists until the user submits the next prompt and a fresh `runAgenticLoop` retakes the snapshot.
## Repro
Minimal extension that registers `preToolUse` and `postToolUse` and logs both:
```js
import { joinSession } from "@github/copilot-sdk/extension";
const session = await joinSession({
hooks: {
onPreToolUse: async (input) => { console.error("PRE", input.toolName); },
onPostToolUse: async (input) => { console.error("POST", input.toolName); },
},
});
```
Then in a Copilot CLI session with the extension installed:
1. Send a prompt that triggers a tool call. Both `PRE` and `POST` fire.
2. Send a prompt that asks the agent to call `extensions_reload` and then run another tool in the same response (e.g. *"reload extensions, then list the files in the current directory"*).
3. Observe extension logs (or the CLI's own debug logs).
**Expected:** `PRE` and `POST` both fire for the post-reload tool call.
**Actual:** Only `POST` fires for the post-reload tool call. `PRE` is silently dropped. Every subsequent tool call in the same agentic loop has the same behavior. The next user-submitted prompt restarts the loop and the behavior self-heals.
## Root cause (reading shipped CLI `1.0.43-0`)
Path: `/app.js`.
Inside `runAgenticLoop` (one call per user prompt; iterates internally through many LLM-tool cycles):
```js
async runAgenticLoop(...) {
...
let _ = this.getEffectiveHooks(); // ← SNAPSHOT taken once
try {
let T = await h1(_?.userPromptSubmitted, ...); // uses snapshot
...
new XIr(_, this.workingDir, this.sessionId, ...) // PreToolUseHooksProcessor uses snapshot
...
}
}
```
Vs. `processToolExecutionResult` (one call per tool result, anywhere in the same loop):
```js
async processToolExecutionResult(toolName, toolArgs, toolResult) {
let s = (toolResult.resultType === "success"
? await h1(this.getEffectiveHooks()?.postToolUse, ...) // ← LIVE per call
: void 0);
...
}
```
Hook proxies for connected extensions are built via `createHooksProxy(sessionId, connection)`. Each proxy closes over the IPC `connection` to that specific extension instance:
```js
createHooksProxy(sessionId, connection) {
let n = async (hookType, input) => {
...
try {
return (await connection.sendRequest(c0.HOOKS_INVOKE, { sessionId, hookType, input })).output;
} catch (a) {
let l = q(a), c = l.toLowerCase();
// EPIPE / broken pipe / EOF / closed / connection reset / write after end /
// stream was destroyed / shutting down → just log; otherwise warn.
...
return; // ← swallowed
}
};
return {
preToolUse: [o => n("preToolUse", o)],
postToolUse: [o => n("postToolUse", o)],
...
};
}
```
When `extensions_reload` runs `stopAllExtensions()`, the old child process is killed (`SIGTERM` then `SIGKILL` after 5 s) and its connection is disposed. The new extension is launched and registers fresh hooks via `addAdHocHooks(connectionId, createHooksProxy(sessionId, newConnection))`.
But the snapshot `_` captured at the top of `runAgenticLoop` still holds the **old** proxy in its hook arrays. `preToolUse` calls on the snapshot route through the dead proxy → `sendRequest` rejects → catch swallows → returns `undefined` → no extension sees the hook.
## Suggested fix (one of)
1. **Move the snapshot to live look-up.** Replace `_=this.getEffectiveHooks()` with live calls at each hook invocation site (mirroring `processToolExecutionResult`). This is the smallest semantic change.
2. **Refresh the snapshot reactively.** When an extension connection drops or a new one is registered (`addAdHocHooks` / `removeAdHocHooks`), invalidate any in-flight `runAgenticLoop` snapshot.
3. **Refresh the snapshot after `extensions_reload` specifically.** Smaller scope, but doesn't cover other ways an extension might disconnect mid-loop (crashes, network issues for remote sessions, etc.).
Of these, (1) is the most consistent with how `postToolUse` already works.
## Impact
- Extensions that observe `preToolUse` for tool tracking, telemetry, audit logs, fault injection, or UI live-update have visible gaps after any mid-loop `extensions_reload`.
- `userPromptSubmitted` is also affected (same snapshot), so any extension that relies on it for memory / state could miss prompts after a mid-loop reload (though the reload itself is rarely between submission and tool execution, so this is less observable).
- `sessionStart` is fired through the same captured snapshot earlier in the loop, but is gated by `!this._sessionStartHooksFired`, so its impact is bounded to the very first iteration of the very first loop.
- Symptoms are silent — no warning is surfaced to the user, the agent, or the extension. Extensions just stop receiving certain hooks.
## Environment
- `@github/copilot` `1.0.43-0` (Windows `win32-x64` package).
- Reproduced on Windows 11.
- Source confirmed by reading the bundled `app.js` shipped in the CLI package.
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.