fix(extensions): wire invokeTool to the extension path as same-tool delegation

Addresses PR review. The initial version put invokeTool on AgentToolContext
via ToolContextStore, but the extension execute path (RegisteredToolAdapter)
builds its own ExtensionContext and never saw it, so the documented
registerTool wrapper use case did not work. It also allowed arbitrary
cross-tool targets (bypassing the target's approval policy), used a
session-global recursion counter that tripped on concurrent independent
delegations, and missed discoverable built-ins that xdev partitioning moves
out of the tool array.

Rework:
- Move invokeTool onto ExtensionContext, and bind it in RegisteredToolAdapter
  to the tool's own name, so a re-registered built-in actually receives it.
- Make delegation same-tool only: invokeTool takes just (params, options) and
  runs the native built-in of the caller's own name. It cannot reach an
  arbitrary target, so it cannot escalate past the approval already granted
  for the call, and the native call is not re-gated.
- Track recursion depth per call chain (threaded through invokeNativeTool and
  createContext) instead of session-global state, so concurrent delegations
  do not interfere.
- Seed the native resolver from the xdev registry when present (it retains
  discoverable built-ins like browser), else the built-in registry.

Replaces the ToolContextStore-level unit test with an end-to-end test that
registers a built-in wrapper through the extension/session path and asserts
the native tool runs the wrapper's delegated input.
This commit is contained in:
Larry Gordon
2026-07-28 11:05:02 -07:00
parent 6acf957dd8
commit a50bcd5fed
9 changed files with 231 additions and 195 deletions
+9 -8
View File
@@ -315,21 +315,22 @@ execute(
### Delegating to a native built-in (`ctx.invokeTool`)
A tool that re-registers a built-in name (e.g. wrapping `write` to add logging or a policy check) can
run the original instead of reimplementing it. The `ctx` passed to `execute` carries:
run the original instead of reimplementing it. When your registered tool shadows a built-in, the `ctx`
passed to `execute` carries:
```ts
ctx.invokeTool?<TDetails>(
name: string,
params: Record<string, unknown>,
options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback },
): Promise<AgentToolResult<TDetails> | undefined>
): Promise<AgentToolResult<TDetails>>
```
It runs the **native** built-in of `name` (bypassing your own re-registration, so it does not recurse
into your wrapper) and returns its result, including the native tool's own side effects and internal
bookkeeping. It resolves to `undefined` when there is no native tool of that name. The invoked tool's
approval gate is not re-run — your call already passed approval — and delegation depth is guarded
against accidental self-recursion.
It runs the **native** built-in of the same name as your tool (delegation is same-tool only, so it
cannot reach an arbitrary target or escalate past the approval already granted for this call) and
returns its result, including the native tool's own side effects and internal bookkeeping. It is
present only when a native built-in of that name exists — `ctx.invokeTool` is `undefined` for a
net-new tool that shadows no built-in. The native call is not re-gated, since it is the same tool you
are already approved as, and delegation depth is guarded against accidental self-recursion.
Template: