Merge PR #6840: feat(extensions): add ctx.invokeTool for native built-in delegation (@psyrendust)
This commit is contained in:
@@ -312,6 +312,26 @@ execute(
|
||||
): Promise<AgentToolResult>
|
||||
```
|
||||
|
||||
### 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. When your registered tool shadows a built-in, the `ctx`
|
||||
passed to `execute` carries:
|
||||
|
||||
```ts
|
||||
ctx.invokeTool?<TDetails>(
|
||||
params: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback },
|
||||
): Promise<AgentToolResult<TDetails>>
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
- Fixed the Python RPC client dropping current context, compaction, OAuth URL, and terminal-settlement fields, and made additive notification variants observable without stopping the stdout reader.
|
||||
- Fixed the browser tool silently ignoring `url` when opening a new tab on an attached browser (`app.cdp_url` or `app.path`), so the tab now navigates on open exactly as it already did on reuse and in headless mode.
|
||||
- Fixed browser automation disrupting a browser it attached to over `app.cdp_url`: the tool now adopts the tab the user actually has in the foreground and no longer raises its own tab when taking a screenshot. Owned and headless browsers keep activating the target before capture.
|
||||
- Added `ctx.invokeTool(params, options?)` to a re-registered built-in's extension context, letting a wrapper run the native tool of the same name instead of reimplementing it, and inheriting the caller's context, abort signal, and progress updates.
|
||||
|
||||
## [17.2.1] - 2026-07-30
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* Extension runner - executes extensions and manages their lifecycle.
|
||||
*/
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolContext,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import type { CredentialDisabledEvent, ImageContent, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai";
|
||||
import type { KeyId } from "@oh-my-pi/pi-tui";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -407,6 +413,67 @@ export class ExtensionRunner {
|
||||
return this.#emittedToolCalls.delete(`${toolCallId}:${toolName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tool NAME to its native built-in implementation (the pre-extension-override,
|
||||
* unwrapped tool) plus a factory for the `AgentToolContext` that native tool expects, or
|
||||
* undefined when no native built-in of that name exists. Set by the SDK; backs same-tool
|
||||
* `invokeTool`. The context factory is the same one the agent loop uses for tool execution, so a
|
||||
* delegated native call sees the ordinary session tool context (ui, cwd, snapshot state, etc.).
|
||||
*/
|
||||
#nativeToolResolver?: (name: string) => { tool: AgentTool; makeContext: () => AgentToolContext } | undefined;
|
||||
|
||||
/** Wires the native-tool resolver used by {@link invokeNativeTool}. */
|
||||
setNativeToolResolver(
|
||||
resolve: (name: string) => { tool: AgentTool; makeContext: () => AgentToolContext } | undefined,
|
||||
): void {
|
||||
this.#nativeToolResolver = resolve;
|
||||
}
|
||||
|
||||
/** Whether a native built-in of `name` is available to delegate to. */
|
||||
hasNativeTool(name: string): boolean {
|
||||
return this.#nativeToolResolver?.(name) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the native built-in of `name` with `params` and return its result — the delegation target
|
||||
* of a same-tool `ctx.invokeTool`. Calls the unwrapped native `execute` directly with the loop's
|
||||
* ordinary tool context, so it inherits the caller's already-granted approval (the caller is the
|
||||
* same tool) rather than re-running the gate. `depth` guards a wrapper that recurses into itself;
|
||||
* it is per call chain (threaded from the caller), not session-global, so concurrent independent
|
||||
* delegations do not interfere.
|
||||
*/
|
||||
async invokeNativeTool<TDetails = unknown>(
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>;
|
||||
depth?: number;
|
||||
/**
|
||||
* The caller tool's own context. Reused for the native call so metadata the native tool
|
||||
* reads — `toolCall` (write/edit LSP batch flushing) and provider metadata /
|
||||
* `providerSafetyApproved` (computer) — is preserved. Falls back to a fresh session tool
|
||||
* context only when the caller had none.
|
||||
*/
|
||||
callerContext?: AgentToolContext;
|
||||
},
|
||||
): Promise<AgentToolResult<TDetails>> {
|
||||
const resolved = this.#nativeToolResolver?.(name);
|
||||
if (!resolved) throw new Error(`invokeTool: no native built-in named "${name}" to delegate to`);
|
||||
const depth = options?.depth ?? 0;
|
||||
if (depth >= 8) {
|
||||
throw new Error(`invokeTool: delegation depth exceeded 8 (recursive invokeTool for "${name}"?)`);
|
||||
}
|
||||
const toolCallId = `invoke-${name}-${Date.now().toString(36)}-${depth}`;
|
||||
return (await resolved.tool.execute(
|
||||
toolCallId,
|
||||
params as never,
|
||||
options?.signal,
|
||||
options?.onUpdate as never,
|
||||
options?.callerContext ?? resolved.makeContext(),
|
||||
)) as AgentToolResult<TDetails>;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly extensions: Extension[],
|
||||
private readonly runtime: ExtensionRuntime,
|
||||
@@ -747,8 +814,27 @@ export class ExtensionRunner {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Creates an extension context, optionally scoped to a provider request model. */
|
||||
createContext(model?: Model): ExtensionContext {
|
||||
/**
|
||||
* Creates an extension context, optionally scoped to a provider request model.
|
||||
*
|
||||
* `delegation` wires the same-tool `ctx.invokeTool` for a re-registered built-in: when `toolName`
|
||||
* names an existing native built-in, the context carries an `invokeTool` that runs it (see
|
||||
* {@link invokeNativeTool}). The rest inherits the wrapper's own call so a bare
|
||||
* `ctx.invokeTool(params)` behaves like the outer call — `context` preserves `toolCall`/provider
|
||||
* metadata, `signal`/`onUpdate` default to the wrapper's own channels so aborting the outer tool
|
||||
* call stops the native one and native progress still streams, and `depth` bounds recursion per
|
||||
* call chain. Explicit options passed to `invokeTool` override the inherited `signal`/`onUpdate`.
|
||||
*/
|
||||
createContext(
|
||||
model?: Model,
|
||||
delegation?: {
|
||||
toolName: string;
|
||||
depth?: number;
|
||||
context?: AgentToolContext;
|
||||
signal?: AbortSignal;
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
},
|
||||
): ExtensionContext {
|
||||
const getModel = model ? () => model : this.#getModel;
|
||||
return {
|
||||
ui: this.#uiContext,
|
||||
@@ -773,6 +859,18 @@ export class ExtensionRunner {
|
||||
setInterval: (callback, ms, ...args) => this.#managedTimers.setInterval(callback, ms, ...args),
|
||||
setTimeout: (callback, ms, ...args) => this.#managedTimers.setTimeout(callback, ms, ...args),
|
||||
clearTimer: timer => this.#managedTimers.clear(timer),
|
||||
invokeTool:
|
||||
delegation !== undefined && this.hasNativeTool(delegation.toolName)
|
||||
? (params, options) =>
|
||||
this.invokeNativeTool(delegation.toolName, params, {
|
||||
// Inherit the wrapper's own channels so a bare `ctx.invokeTool(params)` aborts
|
||||
// and streams with the outer call. Explicit options win.
|
||||
signal: options?.signal ?? delegation.signal,
|
||||
onUpdate: options?.onUpdate ?? delegation.onUpdate,
|
||||
depth: (delegation.depth ?? 0) + 1,
|
||||
callerContext: delegation.context,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -464,6 +464,21 @@ export interface ExtensionContext {
|
||||
setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
|
||||
/** Clear a timer scheduled via {@link setInterval} or {@link setTimeout}. */
|
||||
clearTimer(timer: Timer): void;
|
||||
/**
|
||||
* Run the NATIVE built-in implementation of the tool this handler re-registered, with `params`,
|
||||
* and return its result. Lets a tool that re-registers a built-in (e.g. wrapping `write` to add
|
||||
* logging or a policy check) delegate to the original instead of reimplementing it — the native
|
||||
* tool performs its own side effects and internal bookkeeping.
|
||||
*
|
||||
* Delegation is same-tool only: it invokes the built-in of the SAME name as the registering tool,
|
||||
* never an arbitrary target, so it cannot escalate past the approval already granted for this
|
||||
* call. Present only when a native built-in of that name exists (undefined otherwise, e.g. for a
|
||||
* net-new tool that shadows no built-in). Recursion is depth-guarded per call chain.
|
||||
*/
|
||||
invokeTool?<TDetails = unknown>(
|
||||
params: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback<TDetails> },
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,9 +64,26 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
|
||||
params: any,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<any>,
|
||||
_context?: AgentToolContext,
|
||||
context?: AgentToolContext,
|
||||
) {
|
||||
return this.registeredTool.definition.execute(toolCallId, params, signal, onUpdate, this.runner.createContext());
|
||||
// Bind the extension context to this tool's own name so `ctx.invokeTool` delegates to the
|
||||
// native built-in of the same name (present only when this tool re-registers a built-in). The
|
||||
// wrapper's own context, abort signal, and progress callback are inherited by the delegated
|
||||
// call, so a bare `ctx.invokeTool(params)` keeps the caller's `toolCall`/provider metadata
|
||||
// (write/edit LSP batching, computer safety acknowledgement), stops when the outer call is
|
||||
// aborted, and still streams native progress.
|
||||
return this.registeredTool.definition.execute(
|
||||
toolCallId,
|
||||
params,
|
||||
signal,
|
||||
onUpdate,
|
||||
this.runner.createContext(undefined, {
|
||||
toolName: this.registeredTool.definition.name,
|
||||
context,
|
||||
signal,
|
||||
onUpdate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2572,6 +2572,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
autoApprove: options.autoApprove ?? false,
|
||||
});
|
||||
const toolContextStore = new ToolContextStore(getSessionContext);
|
||||
// Native built-in implementations backing same-tool `ctx.invokeTool`, so a tool that
|
||||
// re-registers a built-in (e.g. wrapping `write`) can delegate to the original — reaching the
|
||||
// unwrapped native execute, which inherits the caller's already-granted approval rather than
|
||||
// re-running the gate. Seeded from the xdev registry when present (it retains discoverable
|
||||
// built-ins like `browser` that xdev partitioning removes from the active tool array), else
|
||||
// from the built-in registry; captured before the ExtensionToolWrapper pass so the natives
|
||||
// stay unwrapped. The extension runner exposes it to re-registered tools via createContext.
|
||||
const nativeToolsByName = new Map<string, Tool>(toolSession.xdev?.tools ?? undefined);
|
||||
|
||||
const registeredTools = restrictToolNames ? [] : extensionRunner.getAllRegisteredTools();
|
||||
const sdkCustomTools =
|
||||
@@ -2595,17 +2603,32 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
|
||||
// All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
|
||||
const builtInRegistryToolNames = toolSession.xdev?.builtInNames ?? new Set(toolRegistry.keys());
|
||||
// Capture the native built-in implementations before extension re-registration replaces registry
|
||||
// entries and before the ExtensionToolWrapper pass below, so `ctx.invokeTool` reaches the
|
||||
// unwrapped native execute (inheriting the caller's already-granted approval, not re-gating).
|
||||
for (const [name, tool] of toolRegistry) {
|
||||
nativeToolsByName.set(name, tool);
|
||||
}
|
||||
if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
|
||||
const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
|
||||
if (goalTool) {
|
||||
toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
|
||||
const wrapped = wrapToolWithMetaNotice(goalTool);
|
||||
toolRegistry.set(goalTool.name, wrapped);
|
||||
builtInRegistryToolNames.add(goalTool.name);
|
||||
nativeToolsByName.set(goalTool.name, wrapped);
|
||||
}
|
||||
}
|
||||
for (const tool of wrappedExtensionTools) {
|
||||
toolRegistry.set(tool.name, tool);
|
||||
builtInRegistryToolNames.delete(tool.name);
|
||||
}
|
||||
// Expose the native built-ins to same-tool `ctx.invokeTool` on re-registered tools. Set after
|
||||
// the override loop so the map holds the natives, not the extension replacements. The context
|
||||
// factory is the loop's own tool context, so a delegated native call sees ordinary session state.
|
||||
extensionRunner.setNativeToolResolver(name => {
|
||||
const tool = nativeToolsByName.get(name);
|
||||
return tool ? { tool, makeContext: () => toolContextStore.getContext() } : undefined;
|
||||
});
|
||||
if (deferMCPDiscoveryForUI && mcpManager) {
|
||||
for (const name of collectPendingMCPToolNames(options.toolNames)) {
|
||||
if (!toolRegistry.has(name)) {
|
||||
@@ -2668,11 +2691,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
writeRegistration ??= (async () => {
|
||||
const writeTool = await logger.time("createTools:write:session", BUILTIN_TOOLS.write, toolSession);
|
||||
if (!writeTool || toolRegistry.has("write")) return builtInRegistryToolNames.has("write");
|
||||
toolRegistry.set(
|
||||
writeTool.name,
|
||||
new ExtensionToolWrapper(wrapToolWithMetaNotice(writeTool), extensionRunner) as Tool,
|
||||
);
|
||||
const nativeWrite = wrapToolWithMetaNotice(writeTool);
|
||||
toolRegistry.set(writeTool.name, new ExtensionToolWrapper(nativeWrite, extensionRunner) as Tool);
|
||||
builtInRegistryToolNames.add(writeTool.name);
|
||||
nativeToolsByName.set(writeTool.name, nativeWrite);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
writeRegistration = undefined;
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as memoryBackend from "@oh-my-pi/pi-coding-agent/memory-backend";
|
||||
import type { MemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend/types";
|
||||
import { type MnemopiSessionState, setMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
||||
import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
import { createAgentSession, type ExtensionContext, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
import { obfuscateProviderContext, SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
|
||||
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
@@ -945,6 +945,111 @@ describe("AgentSession message pipeline", () => {
|
||||
authStorage.close();
|
||||
}
|
||||
});
|
||||
it("exposes ctx.invokeTool to a re-registered built-in so it can delegate to the native tool", async () => {
|
||||
// End-to-end for the extension path: a tool that re-registers `bash` receives ctx.invokeTool
|
||||
// (bound to its own name), delegates to the native bash, and the native output flows back.
|
||||
using tempDir = TempDir.createSync("@pi-invoke-tool-");
|
||||
const api = "test-invoke-tool";
|
||||
let requests = 0;
|
||||
registerCustomApi(api, () => {
|
||||
requests++;
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (requests === 1) {
|
||||
const message = createAssistantMessage("");
|
||||
const toolCall = {
|
||||
type: "toolCall",
|
||||
id: "call-invoke-1",
|
||||
name: "bash",
|
||||
arguments: { command: "echo from-model" },
|
||||
} as const;
|
||||
message.content = [toolCall];
|
||||
message.stopReason = "toolUse";
|
||||
stream.push({ type: "toolcall_start", contentIndex: 0, partial: message });
|
||||
stream.push({ type: "toolcall_end", contentIndex: 0, toolCall: toolCall as never, partial: message });
|
||||
stream.push({ type: "done", reason: "toolUse", message });
|
||||
} else {
|
||||
const message = createAssistantMessage("done");
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
});
|
||||
const model = buildModel({
|
||||
id: "local-invoke-model",
|
||||
name: "Local Invoke Model",
|
||||
api,
|
||||
provider: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
} as ModelSpec<Api>) as Model<Api>;
|
||||
let invokeToolPresent = false;
|
||||
let delegatedText = "";
|
||||
// Re-register `bash`: the wrapper ignores the model's args, delegates to the native bash with
|
||||
// its own command via ctx.invokeTool, and returns the native result.
|
||||
const wrapBash: ExtensionFactory = pi => {
|
||||
pi.registerTool({
|
||||
name: "bash",
|
||||
label: "Bash",
|
||||
description: "wrapped bash",
|
||||
parameters: pi.zod.object({ command: pi.zod.string() }),
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
_params: unknown,
|
||||
_signal: unknown,
|
||||
_onUpdate: unknown,
|
||||
ctx: ExtensionContext,
|
||||
) {
|
||||
invokeToolPresent = typeof ctx.invokeTool === "function";
|
||||
const native = await ctx.invokeTool?.({ command: "echo from-wrapper" });
|
||||
const textBlock = native?.content.find(b => b.type === "text");
|
||||
delegatedText = textBlock?.type === "text" ? textBlock.text : "";
|
||||
return native ?? { content: [{ type: "text" as const, text: "no invokeTool" }], details: {} };
|
||||
},
|
||||
});
|
||||
};
|
||||
const authStorage = await AuthStorage.create(tempDir.join("auth.db"));
|
||||
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
|
||||
const { session } = await createAgentSession({
|
||||
cwd: tempDir.path(),
|
||||
agentDir: tempDir.path(),
|
||||
sessionManager: SessionManager.inMemory(tempDir.path()),
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"bash.autoBackground.enabled": false,
|
||||
"bashInterceptor.enabled": false,
|
||||
"tools.xdev": false,
|
||||
}),
|
||||
model,
|
||||
disableExtensionDiscovery: true,
|
||||
extensions: [wrapBash],
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
skipPythonPreflight: true,
|
||||
toolNames: ["bash"],
|
||||
});
|
||||
try {
|
||||
await session.sendUserMessage("run it");
|
||||
|
||||
expect(invokeToolPresent).toBe(true);
|
||||
// The native bash actually ran the wrapper's command, not the model's.
|
||||
expect(delegatedText).toContain("from-wrapper");
|
||||
expect(delegatedText).not.toContain("from-model");
|
||||
} finally {
|
||||
await session.dispose();
|
||||
authStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears promoted memory from the base prompt when switching sessions", async () => {
|
||||
using tempDir = TempDir.createSync("@pi-injected-memory-switch-");
|
||||
|
||||
@@ -3126,4 +3126,91 @@ describe("ExtensionRunner", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invokeTool same-tool delegation", () => {
|
||||
// Records what the native tool actually received, so the inherited abort/progress channels and
|
||||
// the caller context are observable.
|
||||
function nativeProbe(seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown }): AgentTool {
|
||||
return {
|
||||
name: "bash",
|
||||
label: "Bash",
|
||||
description: "native bash",
|
||||
parameters: Type.Object({ command: Type.String() }),
|
||||
execute: async (_id: string, params: unknown, signal?: AbortSignal, onUpdate?: unknown) => {
|
||||
seen.params = params;
|
||||
seen.signal = signal;
|
||||
seen.onUpdate = onUpdate;
|
||||
return { content: [{ type: "text", text: "native ran" }], details: {} };
|
||||
},
|
||||
} as AgentTool;
|
||||
}
|
||||
|
||||
const runnerWithNative = async (native: AgentTool) => {
|
||||
const result = await loadTestExtensions();
|
||||
const runner = new ExtensionRunner(
|
||||
result.extensions,
|
||||
result.runtime,
|
||||
tempDir.path(),
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
runner.setNativeToolResolver(name =>
|
||||
name === native.name ? { tool: native, makeContext: () => ({}) as never } : undefined,
|
||||
);
|
||||
return runner;
|
||||
};
|
||||
|
||||
it("inherits the wrapper call's signal and onUpdate for a bare invokeTool", async () => {
|
||||
const seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown } = {};
|
||||
const runner = await runnerWithNative(nativeProbe(seen));
|
||||
const controller = new AbortController();
|
||||
const onUpdate = () => {};
|
||||
|
||||
const ctx = runner.createContext(undefined, {
|
||||
toolName: "bash",
|
||||
signal: controller.signal,
|
||||
onUpdate,
|
||||
});
|
||||
await ctx.invokeTool?.({ command: "echo hi" });
|
||||
|
||||
// Aborting the outer tool call must reach the native one, and native progress must stream.
|
||||
expect(seen.signal).toBe(controller.signal);
|
||||
expect(seen.onUpdate).toBe(onUpdate);
|
||||
expect(seen.params).toEqual({ command: "echo hi" });
|
||||
});
|
||||
|
||||
it("lets explicit invokeTool options override the inherited channels", async () => {
|
||||
const seen: { signal?: AbortSignal; onUpdate?: unknown; params?: unknown } = {};
|
||||
const runner = await runnerWithNative(nativeProbe(seen));
|
||||
const outer = new AbortController();
|
||||
const inner = new AbortController();
|
||||
const innerOnUpdate = () => {};
|
||||
|
||||
const ctx = runner.createContext(undefined, {
|
||||
toolName: "bash",
|
||||
signal: outer.signal,
|
||||
onUpdate: () => {},
|
||||
});
|
||||
await ctx.invokeTool?.({ command: "echo hi" }, { signal: inner.signal, onUpdate: innerOnUpdate });
|
||||
|
||||
expect(seen.signal).toBe(inner.signal);
|
||||
expect(seen.onUpdate).toBe(innerOnUpdate);
|
||||
});
|
||||
|
||||
it("omits invokeTool when no native built-in of that name exists", async () => {
|
||||
const runner = await runnerWithNative(nativeProbe({}));
|
||||
expect(runner.createContext(undefined, { toolName: "not_a_builtin" }).invokeTool).toBeUndefined();
|
||||
// Also absent when the context is not scoped to a tool at all.
|
||||
expect(runner.createContext().invokeTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bounds recursion per call chain", async () => {
|
||||
const runner = await runnerWithNative(nativeProbe({}));
|
||||
await expect(runner.invokeNativeTool("bash", { command: "echo hi" }, { depth: 8 })).rejects.toThrow(
|
||||
/delegation depth exceeded/,
|
||||
);
|
||||
// A fresh chain at depth 0 is unaffected by another chain's depth.
|
||||
await expect(runner.invokeNativeTool("bash", { command: "echo hi" }, { depth: 0 })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user