mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): expose the tool call id on ToolContext and ApprovalContext (#545)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"eve": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
`ToolContext` and `ApprovalContext` now expose `callId`, the tool call id carried by the call's stream events, so approval-gated tools can key records to one identity across proposal, rejection, and execution.
|
||||||
@@ -38,7 +38,7 @@ export default defineTool({
|
|||||||
|
|
||||||
By default, omitted `approval` behaves like `never()`, so tool calls may execute without human approval. Require human approval or other safeguards for sensitive, irreversible, regulated, financial, healthcare, employment, housing, legal, safety-impacting, user-impacting, or external side-effecting actions.
|
By default, omitted `approval` behaves like `never()`, so tool calls may execute without human approval. Require human approval or other safeguards for sensitive, irreversible, regulated, financial, healthcare, employment, housing, legal, safety-impacting, user-impacting, or external side-effecting actions.
|
||||||
|
|
||||||
When the decision depends on the input, pass your own policy instead of a helper. It receives the same session context as tool execution, plus `{ toolName, toolInput, approvedTools }`, and returns an AI SDK 7 approval status synchronously or as a promise. Use `ctx.session.auth.current` to guard by the caller of the current turn and `ctx.session.auth.initiator` to guard by the caller that created the session. Return `"user-approval"` to pause for a person or `"not-applicable"` to continue without a prompt. `toolInput` can be undefined, so guard the access. This policy denies cross-tenant calls, then requires approval only when an amount crosses a threshold:
|
When the decision depends on the input, pass your own policy instead of a helper. It receives the same session context as tool execution, plus `{ toolName, toolInput, approvedTools, callId }`, and returns an AI SDK 7 approval status synchronously or as a promise. Use `ctx.session.auth.current` to guard by the caller of the current turn and `ctx.session.auth.initiator` to guard by the caller that created the session. Return `"user-approval"` to pause for a person or `"not-applicable"` to continue without a prompt. `toolInput` can be undefined, so guard the access. This policy denies cross-tenant calls, then requires approval only when an amount crosses a threshold:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
approval: ({ session, toolInput }) => {
|
approval: ({ session, toolInput }) => {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ When a tool returns structured data, add an optional `outputSchema`. With Zod or
|
|||||||
`execute` gets a `ctx` carrying the runtime accessors:
|
`execute` gets a `ctx` carrying the runtime accessors:
|
||||||
|
|
||||||
- `ctx.session`: session metadata, turn, auth, parent lineage.
|
- `ctx.session`: session metadata, turn, auth, parent lineage.
|
||||||
|
- `ctx.callId`: the id of the current tool call, carried by the call's [stream events](/docs/concepts/sessions-runs-and-streaming) and approval context.
|
||||||
- `ctx.abortSignal`: aborts when the active turn is cancelled. Pass it to cancellation-aware work; sandbox sessions from `ctx.getSandbox()` are already bound to it.
|
- `ctx.abortSignal`: aborts when the active turn is cancelled. Pass it to cancellation-aware work; sandbox sessions from `ctx.getSandbox()` are already bound to it.
|
||||||
- `ctx.getSandbox()`: the live [sandbox](/docs/sandbox) handle.
|
- `ctx.getSandbox()`: the live [sandbox](/docs/sandbox) handle.
|
||||||
- `ctx.getSkill(id)`: read a packaged [skill](/docs/skills)'s metadata and files.
|
- `ctx.getSkill(id)`: read a packaged [skill](/docs/skills)'s metadata and files.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe("buildBaseToolContext – getSandbox abort binding", () => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
await runtime.runAsSession({ sandbox }, async () => {
|
await runtime.runAsSession({ sandbox }, async () => {
|
||||||
const ctx = buildBaseToolContext(controller.signal);
|
const ctx = buildBaseToolContext({ abortSignal: controller.signal, toolCallId: "call_1" });
|
||||||
const live = await ctx.getSandbox();
|
const live = await ctx.getSandbox();
|
||||||
await live.run({ command: "echo ready" });
|
await live.run({ command: "echo ready" });
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ describe("buildBaseToolContext – getSandbox abort binding", () => {
|
|||||||
const runtime = createTestRuntime();
|
const runtime = createTestRuntime();
|
||||||
|
|
||||||
await runtime.runAsSession({ sandbox }, async () => {
|
await runtime.runAsSession({ sandbox }, async () => {
|
||||||
const ctx = buildBaseToolContext(undefined);
|
const ctx = buildBaseToolContext({ toolCallId: "call_1" });
|
||||||
const live = await ctx.getSandbox();
|
const live = await ctx.getSandbox();
|
||||||
await live.run({ command: "echo ready" });
|
await live.run({ command: "echo ready" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { buildCallbackContext } from "#context/build-callback-context.js";
|
import { buildCallbackContext } from "#context/build-callback-context.js";
|
||||||
import type { SessionContext } from "#public/definitions/callback-context.js";
|
import type { SessionContext } from "#public/definitions/callback-context.js";
|
||||||
import { bindSandboxAbortSignal } from "#execution/sandbox/abort-bound-session.js";
|
import { bindSandboxAbortSignal } from "#execution/sandbox/abort-bound-session.js";
|
||||||
|
import type { ToolExecuteOptions } from "#shared/tool-definition.js";
|
||||||
|
|
||||||
/** Base context shared by tool executors. */
|
/** Base context shared by tool executors. */
|
||||||
export type BaseToolContext = SessionContext & {
|
export type BaseToolContext = SessionContext & {
|
||||||
readonly abortSignal: AbortSignal;
|
readonly abortSignal: AbortSignal;
|
||||||
|
readonly callId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Builds the base context for one tool execution. */
|
/** Builds the base context for one tool execution. */
|
||||||
export function buildBaseToolContext(abortSignal: AbortSignal | undefined): BaseToolContext {
|
export function buildBaseToolContext(
|
||||||
|
options: Pick<ToolExecuteOptions, "abortSignal" | "toolCallId">,
|
||||||
|
): BaseToolContext {
|
||||||
const callbackContext = buildCallbackContext();
|
const callbackContext = buildCallbackContext();
|
||||||
const signal = abortSignal ?? new AbortController().signal;
|
const signal = options.abortSignal ?? new AbortController().signal;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...callbackContext,
|
...callbackContext,
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
|
callId: options.toolCallId,
|
||||||
getSandbox: async () => bindSandboxAbortSignal(await callbackContext.getSandbox(), signal),
|
getSandbox: async () => bindSandboxAbortSignal(await callbackContext.getSandbox(), signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function replayTools(metadata: readonly DurableDynamicToolMetadata[]): HarnessTo
|
|||||||
tools.push({
|
tools.push({
|
||||||
description: m.description,
|
description: m.description,
|
||||||
execute: (input: unknown, options) =>
|
execute: (input: unknown, options) =>
|
||||||
stepFn(m.closureVars, input, buildBaseToolContext(options?.abortSignal)),
|
stepFn(m.closureVars, input, buildBaseToolContext(options)),
|
||||||
inputSchema: jsonSchema(m.inputSchema),
|
inputSchema: jsonSchema(m.inputSchema),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
approval: buildReplayedApproval(m),
|
approval: buildReplayedApproval(m),
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function qualifyDynamicToolNames(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executeOptions = { messages: [], toolCallId: "call_1" };
|
||||||
|
|
||||||
const stubEntry = defineTool({
|
const stubEntry = defineTool({
|
||||||
description: "test",
|
description: "test",
|
||||||
inputSchema: { type: "object" },
|
inputSchema: { type: "object" },
|
||||||
@@ -278,7 +280,7 @@ describe("replayDynamicSessionTools", () => {
|
|||||||
|
|
||||||
// Execute the replayed tool — mock provides the callback context
|
// Execute the replayed tool — mock provides the callback context
|
||||||
const tool = tools[0]!;
|
const tool = tools[0]!;
|
||||||
tool.execute!({ query: "test" });
|
tool.execute!({ query: "test" }, executeOptions);
|
||||||
expect(stepFn).toHaveBeenCalledWith(
|
expect(stepFn).toHaveBeenCalledWith(
|
||||||
{ apiUrl: "https://api.example.com", tenantName: "Acme" },
|
{ apiUrl: "https://api.example.com", tenantName: "Acme" },
|
||||||
{ query: "test" },
|
{ query: "test" },
|
||||||
@@ -319,11 +321,11 @@ describe("replayDynamicSessionTools", () => {
|
|||||||
const tools = replayDynamicSessionTools(metadata, []);
|
const tools = replayDynamicSessionTools(metadata, []);
|
||||||
|
|
||||||
const tool = tools[0]!;
|
const tool = tools[0]!;
|
||||||
tool.execute!({});
|
tool.execute!({}, executeOptions);
|
||||||
|
|
||||||
// Mutating the metadata object after replay should NOT affect calls
|
// Mutating the metadata object after replay should NOT affect calls
|
||||||
closureVars.counter = 999;
|
closureVars.counter = 999;
|
||||||
tool.execute!({});
|
tool.execute!({}, executeOptions);
|
||||||
|
|
||||||
// Both calls get the same closure vars reference from metadata.
|
// Both calls get the same closure vars reference from metadata.
|
||||||
// This documents current behavior: replay passes by reference.
|
// This documents current behavior: replay passes by reference.
|
||||||
@@ -451,6 +453,7 @@ function createApprovalContext(input: {
|
|||||||
}): ApprovalContext {
|
}): ApprovalContext {
|
||||||
return {
|
return {
|
||||||
approvedTools: new Set(),
|
approvedTools: new Set(),
|
||||||
|
callId: "call_1",
|
||||||
getSandbox: vi.fn(),
|
getSandbox: vi.fn(),
|
||||||
getSkill: vi.fn(),
|
getSkill: vi.fn(),
|
||||||
session: {
|
session: {
|
||||||
@@ -794,7 +797,7 @@ describe("framework dynamic tools (no bundler transform)", () => {
|
|||||||
expect(replayedTools[0]!.name).toBe("search");
|
expect(replayedTools[0]!.name).toBe("search");
|
||||||
|
|
||||||
// Execute the replayed tool — the original closure is invoked
|
// Execute the replayed tool — the original closure is invoked
|
||||||
await replayedTools[0]!.execute!({ query: "test" });
|
await replayedTools[0]!.execute!({ query: "test" }, executeOptions);
|
||||||
expect(executeFn).toHaveBeenCalledWith({ query: "test" });
|
expect(executeFn).toHaveBeenCalledWith({ query: "test" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -822,7 +825,7 @@ describe("framework dynamic tools (no bundler transform)", () => {
|
|||||||
expect(tools).toHaveLength(1);
|
expect(tools).toHaveLength(1);
|
||||||
expect(tools[0]!.name).toBe("assist");
|
expect(tools[0]!.name).toBe("assist");
|
||||||
|
|
||||||
await tools[0]!.execute!({ action: "help" });
|
await tools[0]!.execute!({ action: "help" }, executeOptions);
|
||||||
expect(executeFn).toHaveBeenCalledWith({ action: "help" });
|
expect(executeFn).toHaveBeenCalledWith({ action: "help" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1006,7 +1009,7 @@ describe("framework dynamic tools (no bundler transform)", () => {
|
|||||||
|
|
||||||
ctx.clearVirtualContext();
|
ctx.clearVirtualContext();
|
||||||
let tools = buildDynamicTools(ctx);
|
let tools = buildDynamicTools(ctx);
|
||||||
const result1 = await tools[0]!.execute!({});
|
const result1 = await tools[0]!.execute!({}, executeOptions);
|
||||||
expect(result1).toEqual({ version: 1 });
|
expect(result1).toEqual({ version: 1 });
|
||||||
|
|
||||||
// Re-dispatch overwrites the resolver's slot
|
// Re-dispatch overwrites the resolver's slot
|
||||||
@@ -1020,7 +1023,7 @@ describe("framework dynamic tools (no bundler transform)", () => {
|
|||||||
ctx.clearVirtualContext();
|
ctx.clearVirtualContext();
|
||||||
tools = buildDynamicTools(ctx);
|
tools = buildDynamicTools(ctx);
|
||||||
expect(tools[0]!.description).toBe("v2");
|
expect(tools[0]!.description).toBe("v2");
|
||||||
const result2 = await tools[0]!.execute!({});
|
const result2 = await tools[0]!.execute!({}, executeOptions);
|
||||||
expect(result2).toEqual({ version: 2 });
|
expect(result2).toEqual({ version: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function toHarnessToolDefinition(name: string, entry: DynamicToolEntry): Harness
|
|||||||
return {
|
return {
|
||||||
description: entry.description,
|
description: entry.description,
|
||||||
execute: (input: unknown, options) =>
|
execute: (input: unknown, options) =>
|
||||||
entry.execute(input as Record<string, unknown>, buildBaseToolContext(options?.abortSignal)),
|
entry.execute(input as Record<string, unknown>, buildBaseToolContext(options)),
|
||||||
inputSchema: convertInputSchema(entry.inputSchema),
|
inputSchema: convertInputSchema(entry.inputSchema),
|
||||||
name,
|
name,
|
||||||
approval: entry.approval,
|
approval: entry.approval,
|
||||||
@@ -119,7 +119,7 @@ export function replayDynamicSessionTools(
|
|||||||
tools.push({
|
tools.push({
|
||||||
description: m.description,
|
description: m.description,
|
||||||
execute: (input: unknown, options) =>
|
execute: (input: unknown, options) =>
|
||||||
stepFn(m.closureVars, input, buildBaseToolContext(options?.abortSignal)),
|
stepFn(m.closureVars, input, buildBaseToolContext(options)),
|
||||||
inputSchema: jsonSchema(m.inputSchema),
|
inputSchema: jsonSchema(m.inputSchema),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
outputSchema: m.outputSchema === undefined ? undefined : jsonSchema(m.outputSchema),
|
outputSchema: m.outputSchema === undefined ? undefined : jsonSchema(m.outputSchema),
|
||||||
|
|||||||
@@ -98,6 +98,21 @@ describe("tool-hosted authorization", () => {
|
|||||||
expect(calls).toBe(1);
|
expect(calls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes the tool call id on the authored context", async () => {
|
||||||
|
const tool = authoredTool({
|
||||||
|
name: "observe_call_id",
|
||||||
|
execute(_input, ctx) {
|
||||||
|
return { callId: ctx.callId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const runtime = createTestRuntime({ tools: [tool] });
|
||||||
|
|
||||||
|
const result = await runtime.runAsSession(undefined, async () => runtime.executeTool(tool, {}));
|
||||||
|
|
||||||
|
// The test harness dispatches every executeTool call as "call_test".
|
||||||
|
expect(result).toEqual({ callId: "call_test" });
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves and caches an inline provider on a plain tool", async () => {
|
it("resolves and caches an inline provider on a plain tool", async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const inlineAuth: AuthorizationDefinition = {
|
const inlineAuth: AuthorizationDefinition = {
|
||||||
|
|||||||
@@ -54,19 +54,19 @@ import type { ToolExecuteOptions } from "#shared/tool-definition.js";
|
|||||||
export function createToolExecuteWithAuth(input: {
|
export function createToolExecuteWithAuth(input: {
|
||||||
readonly scope: string;
|
readonly scope: string;
|
||||||
readonly execute: (toolInput: unknown, ctx: unknown) => unknown;
|
readonly execute: (toolInput: unknown, ctx: unknown) => unknown;
|
||||||
}): (toolInput: unknown, options?: ToolExecuteOptions) => Promise<unknown> {
|
}): (toolInput: unknown, options: ToolExecuteOptions) => Promise<unknown> {
|
||||||
const { scope, execute } = input;
|
const { scope, execute } = input;
|
||||||
|
|
||||||
return async (toolInput: unknown, options?: ToolExecuteOptions): Promise<unknown> => {
|
return async (toolInput: unknown, options: ToolExecuteOptions): Promise<unknown> => {
|
||||||
const justAuthorizedScopes = new Set<string>();
|
const justAuthorizedScopes = new Set<string>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await execute(
|
return await execute(
|
||||||
toolInput,
|
toolInput,
|
||||||
buildToolContext({
|
buildToolContext({
|
||||||
abortSignal: options?.abortSignal,
|
|
||||||
inlineAuthState: {},
|
inlineAuthState: {},
|
||||||
justAuthorizedScopes,
|
justAuthorizedScopes,
|
||||||
|
options,
|
||||||
scope,
|
scope,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -81,13 +81,13 @@ export function createToolExecuteWithAuth(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildToolContext(input: {
|
function buildToolContext(input: {
|
||||||
readonly abortSignal: AbortSignal | undefined;
|
readonly options: ToolExecuteOptions;
|
||||||
readonly scope: string;
|
readonly scope: string;
|
||||||
readonly justAuthorizedScopes: Set<string>;
|
readonly justAuthorizedScopes: Set<string>;
|
||||||
readonly inlineAuthState: InlineAuthState;
|
readonly inlineAuthState: InlineAuthState;
|
||||||
}): ToolContext {
|
}): ToolContext {
|
||||||
const { scope, justAuthorizedScopes, inlineAuthState } = input;
|
const { scope, justAuthorizedScopes, inlineAuthState } = input;
|
||||||
const base = buildBaseToolContext(input.abortSignal);
|
const base = buildBaseToolContext(input.options);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise<TokenResult> {
|
async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise<TokenResult> {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type HarnessRuntimeActionDefinition = {
|
|||||||
export interface HarnessToolDefinition {
|
export interface HarnessToolDefinition {
|
||||||
readonly approvalKey?: (toolInput: Readonly<Record<string, unknown>>) => string;
|
readonly approvalKey?: (toolInput: Readonly<Record<string, unknown>>) => string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly execute?: (input: any, options?: ToolExecuteOptions) => any;
|
readonly execute?: (input: any, options: ToolExecuteOptions) => any;
|
||||||
readonly inputSchema: FlexibleSchema;
|
readonly inputSchema: FlexibleSchema;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly approval?: Approval;
|
readonly approval?: Approval;
|
||||||
|
|||||||
@@ -198,6 +198,40 @@ describe("buildToolSet", () => {
|
|||||||
expect(receivedSignal?.aborted).toBe(false);
|
expect(receivedSignal?.aborted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the AI SDK toolCallId to the authored tool context as callId", async () => {
|
||||||
|
let receivedCallId: string | undefined;
|
||||||
|
const tools: HarnessToolMap = new Map<string, HarnessToolDefinition>([
|
||||||
|
[
|
||||||
|
"observe_call_id",
|
||||||
|
{
|
||||||
|
description: "Observe the tool call id.",
|
||||||
|
execute: createToolExecuteWithAuth({
|
||||||
|
execute(_input, ctx) {
|
||||||
|
receivedCallId = (ctx as ToolContext).callId;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
scope: "observe_call_id",
|
||||||
|
}),
|
||||||
|
inputSchema: jsonSchema({ type: "object" }),
|
||||||
|
name: "observe_call_id",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const ctx = new ContextContainer();
|
||||||
|
ctx.set(SessionKey, {
|
||||||
|
auth: { current: null, initiator: null },
|
||||||
|
sessionId: "session-1",
|
||||||
|
turn: { id: "turn-1", sequence: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildToolSet({ tools });
|
||||||
|
await contextStorage.run(ctx, () =>
|
||||||
|
executeSdkTool({ tool: result.observe_call_id, toolCallId: "call_observe" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(receivedCallId).toBe("call_observe");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes through the input schema to the SDK tool", () => {
|
it("passes through the input schema to the SDK tool", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
properties: { city: { type: "string" } },
|
properties: { city: { type: "string" } },
|
||||||
@@ -815,6 +849,30 @@ describe("buildToolSet", () => {
|
|||||||
expect(capturedInput).toEqual(toolInput);
|
expect(capturedInput).toEqual(toolInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes the callId from the AI SDK into approval", async () => {
|
||||||
|
let capturedCallId: string | undefined;
|
||||||
|
const tools: HarnessToolMap = new Map<string, HarnessToolDefinition>([
|
||||||
|
[
|
||||||
|
"vercel__list_projects",
|
||||||
|
{
|
||||||
|
description: "List projects in the team.",
|
||||||
|
execute: async () => "ok",
|
||||||
|
inputSchema: jsonSchema({}),
|
||||||
|
name: "vercel__list_projects",
|
||||||
|
approval: (ctx) => {
|
||||||
|
capturedCallId = ctx.callId;
|
||||||
|
return "user-approval";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = buildToolSet({ tools });
|
||||||
|
await resolveApproval(result, "vercel__list_projects", {});
|
||||||
|
|
||||||
|
expect(capturedCallId).toBe("call_1");
|
||||||
|
});
|
||||||
|
|
||||||
it("passes the active caller and session context into approval", async () => {
|
it("passes the active caller and session context into approval", async () => {
|
||||||
let capturedCtx: Parameters<NonNullable<HarnessToolDefinition["approval"]>>[0] | undefined;
|
let capturedCtx: Parameters<NonNullable<HarnessToolDefinition["approval"]>>[0] | undefined;
|
||||||
const tools: HarnessToolMap = new Map<string, HarnessToolDefinition>([
|
const tools: HarnessToolMap = new Map<string, HarnessToolDefinition>([
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ type ToolModelOutputValue =
|
|||||||
|
|
||||||
type NativeApprovalStatus = Exclude<ApprovalStatus, boolean>;
|
type NativeApprovalStatus = Exclude<ApprovalStatus, boolean>;
|
||||||
|
|
||||||
const toolApprovals = new WeakMap<object, (toolInput: unknown) => Promise<NativeApprovalStatus>>();
|
const toolApprovals = new WeakMap<
|
||||||
|
object,
|
||||||
|
(toolInput: unknown, callId: string) => Promise<NativeApprovalStatus>
|
||||||
|
>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an AI SDK `ToolSet` from unified harness tool definitions.
|
* Builds an AI SDK `ToolSet` from unified harness tool definitions.
|
||||||
@@ -302,8 +305,8 @@ export async function buildToolSetWithProviderTools(input: {
|
|||||||
function buildApprovalFn(
|
function buildApprovalFn(
|
||||||
definition: HarnessToolDefinition,
|
definition: HarnessToolDefinition,
|
||||||
input: { readonly approvedTools?: ReadonlySet<string> },
|
input: { readonly approvedTools?: ReadonlySet<string> },
|
||||||
): (toolInput: unknown) => Promise<NativeApprovalStatus> {
|
): (toolInput: unknown, callId: string) => Promise<NativeApprovalStatus> {
|
||||||
return async (toolInput: unknown) => {
|
return async (toolInput: unknown, callId: string) => {
|
||||||
if (definition.approval === undefined) return undefined;
|
if (definition.approval === undefined) return undefined;
|
||||||
|
|
||||||
const toolInputRecord = isObject(toolInput) ? toolInput : undefined;
|
const toolInputRecord = isObject(toolInput) ? toolInput : undefined;
|
||||||
@@ -311,6 +314,7 @@ function buildApprovalFn(
|
|||||||
const status = await definition.approval({
|
const status = await definition.approval({
|
||||||
...buildCallbackContext(),
|
...buildCallbackContext(),
|
||||||
approvedTools: input.approvedTools ?? new Set(),
|
approvedTools: input.approvedTools ?? new Set(),
|
||||||
|
callId,
|
||||||
toolInput: toolInputRecord,
|
toolInput: toolInputRecord,
|
||||||
toolName: definition.name,
|
toolName: definition.name,
|
||||||
});
|
});
|
||||||
@@ -327,6 +331,6 @@ export function buildToolApproval(
|
|||||||
if (toolDefinition === undefined) return undefined;
|
if (toolDefinition === undefined) return undefined;
|
||||||
|
|
||||||
const approval = toolApprovals.get(toolDefinition);
|
const approval = toolApprovals.get(toolDefinition);
|
||||||
return (await approval?.(toolCall.input)) as ToolApprovalStatus;
|
return (await approval?.(toolCall.input, toolCall.toolCallId)) as ToolApprovalStatus;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function createTestRuntime(descriptor: TestAppDescriptor = {}): TestRunti
|
|||||||
throw new Error(`Tool "${tool.name}" is not executable.`);
|
throw new Error(`Tool "${tool.name}" is not executable.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await execute(input);
|
return await execute(input, { messages: [], toolCallId: "call_test" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export default defineTool({
|
|||||||
...bash,
|
...bash,
|
||||||
description: "Run a vetted shell command in the project sandbox.",
|
description: "Run a vetted shell command in the project sandbox.",
|
||||||
approval: always(),
|
approval: always(),
|
||||||
async execute(input) {
|
async execute(input, ctx) {
|
||||||
return bash.execute(input);
|
return bash.execute(input, ctx);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ type ApprovalToolInput<TInput> = TInput extends object ? Readonly<TInput> : TInp
|
|||||||
* `approvedTools` is the set of tool names (or compound approval keys)
|
* `approvedTools` is the set of tool names (or compound approval keys)
|
||||||
* already approved at least once in the current session. `toolName` is the
|
* already approved at least once in the current session. `toolName` is the
|
||||||
* runtime name of the tool being evaluated. `toolInput` is the raw input the
|
* runtime name of the tool being evaluated. `toolInput` is the raw input the
|
||||||
* model passed, available for input-aware decisions.
|
* model passed, available for input-aware decisions. `callId` is the id of
|
||||||
|
* the call being evaluated — the same `callId` carried by the call's stream
|
||||||
|
* events and its `execute` context.
|
||||||
*/
|
*/
|
||||||
export interface ApprovalContext<TInput = Record<string, unknown>> extends SessionContext {
|
export interface ApprovalContext<TInput = Record<string, unknown>> extends SessionContext {
|
||||||
readonly approvedTools: ReadonlySet<string>;
|
readonly approvedTools: ReadonlySet<string>;
|
||||||
|
readonly callId: string;
|
||||||
readonly toolInput?: ApprovalToolInput<TInput>;
|
readonly toolInput?: ApprovalToolInput<TInput>;
|
||||||
readonly toolName: string;
|
readonly toolName: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export interface ToolAuthOptions {
|
|||||||
export type ToolContext = SessionContext & {
|
export type ToolContext = SessionContext & {
|
||||||
/** Aborts when the active turn is cancelled. */
|
/** Aborts when the active turn is cancelled. */
|
||||||
readonly abortSignal: AbortSignal;
|
readonly abortSignal: AbortSignal;
|
||||||
|
/**
|
||||||
|
* Id of the current tool call — the same `callId` carried by the call's
|
||||||
|
* stream events and its {@link ApprovalContext}.
|
||||||
|
*/
|
||||||
|
readonly callId: string;
|
||||||
/**
|
/**
|
||||||
* Resolves the bearer token for an inline provider. This accepts the same
|
* Resolves the bearer token for an inline provider. This accepts the same
|
||||||
* auth shapes as a connection's `auth` field, including `connect("...")`
|
* auth shapes as a connection's `auth` field, including `connect("...")`
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import type { ToolDefinition } from "#public/definitions/tool.js";
|
|||||||
* {@link ResolvedToolDefinition} so it can be re-exported as a public
|
* {@link ResolvedToolDefinition} so it can be re-exported as a public
|
||||||
* {@link ToolDefinition}.
|
* {@link ToolDefinition}.
|
||||||
*
|
*
|
||||||
* Framework tools have the internal `(input) => output` signature.
|
* Framework tools have the internal `(input, options) => output` signature.
|
||||||
* The public {@link ToolDefinition.execute} expects `(input, ctx)`.
|
* The public {@link ToolDefinition.execute} expects `(input, ctx)`.
|
||||||
* This wrapper bridges the gap — `ctx` is trailing and omitted.
|
* This wrapper bridges the gap — the public `ctx` is mapped back onto the
|
||||||
|
* internal execute options.
|
||||||
*/
|
*/
|
||||||
export function toPublicToolDefinition(definition: ResolvedToolDefinition): ToolDefinition {
|
export function toPublicToolDefinition(definition: ResolvedToolDefinition): ToolDefinition {
|
||||||
if (!definition.execute) {
|
if (!definition.execute) {
|
||||||
@@ -21,7 +22,14 @@ export function toPublicToolDefinition(definition: ResolvedToolDefinition): Tool
|
|||||||
const inputSchema = definition.inputSchema;
|
const inputSchema = definition.inputSchema;
|
||||||
const publicDefinition: ToolDefinition = {
|
const publicDefinition: ToolDefinition = {
|
||||||
description: definition.description,
|
description: definition.description,
|
||||||
execute: (input) => internalExecute(input),
|
execute: (input, ctx) =>
|
||||||
|
internalExecute(input, {
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
// The public context carries no model history, so the bridged
|
||||||
|
// options cannot reproduce the AI SDK's `messages`.
|
||||||
|
messages: [],
|
||||||
|
toolCallId: ctx.callId,
|
||||||
|
}),
|
||||||
inputSchema: (inputSchema ?? {}) as unknown as StandardJSONSchemaV1<unknown>,
|
inputSchema: (inputSchema ?? {}) as unknown as StandardJSONSchemaV1<unknown>,
|
||||||
outputSchema: definition.outputSchema,
|
outputSchema: definition.outputSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ describe("load_skill executor", () => {
|
|||||||
if (execute === undefined) throw new Error("load_skill tool is missing an execute function");
|
if (execute === undefined) throw new Error("load_skill tool is missing an execute function");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
contextStorage.run(ctx, () => execute({ skill: "talk-like-a-dog" })),
|
contextStorage.run(ctx, () =>
|
||||||
|
execute({ skill: "talk-like-a-dog" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
),
|
||||||
).rejects.toThrow("Available skills: custom__bark, custom__talk-like-a-dog.");
|
).rejects.toThrow("Available skills: custom__bark, custom__talk-like-a-dog.");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +59,11 @@ describe("load_skill executor", () => {
|
|||||||
const execute = SKILL_TOOL_DEFINITION.execute;
|
const execute = SKILL_TOOL_DEFINITION.execute;
|
||||||
if (execute === undefined) throw new Error("load_skill tool is missing an execute function");
|
if (execute === undefined) throw new Error("load_skill tool is missing an execute function");
|
||||||
|
|
||||||
await expect(contextStorage.run(ctx, () => execute({ skill: "linear" }))).rejects.toThrow(
|
await expect(
|
||||||
|
contextStorage.run(ctx, () =>
|
||||||
|
execute({ skill: "linear" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
'"linear" is an installed connection, not a skill. Use connection_search with connection "linear" to find its tools.',
|
'"linear" is an installed connection, not a skill. Use connection_search with connection "linear" to find its tools.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type ToolExecuteOptions = Omit<ToolExecutionOptions<unknown>, "context">;
|
|||||||
|
|
||||||
export type ToolExecuteFn<TInput = unknown, TOutput = unknown> = (
|
export type ToolExecuteFn<TInput = unknown, TOutput = unknown> = (
|
||||||
input: TInput,
|
input: TInput,
|
||||||
options?: ToolExecuteOptions,
|
options: ToolExecuteOptions,
|
||||||
) => Promise<TOutput> | TOutput;
|
) => Promise<TOutput> | TOutput;
|
||||||
|
|
||||||
interface ToolDefinitionBase {
|
interface ToolDefinitionBase {
|
||||||
|
|||||||
@@ -269,7 +269,9 @@ describe("resolveAgent", () => {
|
|||||||
sourceId: "tools/get-weather.mjs",
|
sourceId: "tools/get-weather.mjs",
|
||||||
sourceKind: "module",
|
sourceKind: "module",
|
||||||
});
|
});
|
||||||
expect(resolved.tools[0]?.execute?.({ city: "Brooklyn" })).toEqual({
|
expect(
|
||||||
|
resolved.tools[0]?.execute?.({ city: "Brooklyn" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
).toEqual({
|
||||||
city: "Brooklyn",
|
city: "Brooklyn",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -423,7 +423,12 @@ describe("runtime compiled artifact loaders", () => {
|
|||||||
}
|
}
|
||||||
).default.description,
|
).default.description,
|
||||||
).toBe("Get the weather.");
|
).toBe("Get the weather.");
|
||||||
await expect(resolvedAgent.tools[0]?.execute?.({ city: "Brooklyn" })).resolves.toEqual({
|
await expect(
|
||||||
|
resolvedAgent.tools[0]?.execute?.(
|
||||||
|
{ city: "Brooklyn" },
|
||||||
|
{ messages: [], toolCallId: "call_1" },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
city: "Brooklyn",
|
city: "Brooklyn",
|
||||||
source: "lib",
|
source: "lib",
|
||||||
});
|
});
|
||||||
@@ -547,7 +552,12 @@ describe("runtime compiled artifact loaders", () => {
|
|||||||
(tool as { name?: string }).name?.endsWith("_sandbox"),
|
(tool as { name?: string }).name?.endsWith("_sandbox"),
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await expect(researcherNode?.agent.tools[0]?.execute?.({ query: "climate" })).resolves.toEqual({
|
await expect(
|
||||||
|
researcherNode?.agent.tools[0]?.execute?.(
|
||||||
|
{ query: "climate" },
|
||||||
|
{ messages: [], toolCallId: "call_1" },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({
|
||||||
query: "climate",
|
query: "climate",
|
||||||
source: "subagent-lib",
|
source: "subagent-lib",
|
||||||
});
|
});
|
||||||
@@ -576,7 +586,9 @@ describe("runtime compiled artifact loaders", () => {
|
|||||||
throw new Error("Expected the get_weather tool to be available.");
|
throw new Error("Expected the get_weather tool to be available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(getWeatherTool.execute?.({ city: "Brooklyn" })).resolves.toEqual({
|
await expect(
|
||||||
|
getWeatherTool.execute?.({ city: "Brooklyn" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
).resolves.toEqual({
|
||||||
city: "Brooklyn",
|
city: "Brooklyn",
|
||||||
route: "@/ path alias",
|
route: "@/ path alias",
|
||||||
source: "alias-lib",
|
source: "alias-lib",
|
||||||
@@ -605,7 +617,9 @@ describe("runtime compiled artifact loaders", () => {
|
|||||||
throw new Error("Expected one compiled tool before the source update.");
|
throw new Error("Expected one compiled tool before the source update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(firstTool.execute?.({ city: "Brooklyn" })).resolves.toEqual({
|
await expect(
|
||||||
|
firstTool.execute?.({ city: "Brooklyn" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
).resolves.toEqual({
|
||||||
city: "Brooklyn",
|
city: "Brooklyn",
|
||||||
source: "lib",
|
source: "lib",
|
||||||
});
|
});
|
||||||
@@ -631,7 +645,9 @@ describe("runtime compiled artifact loaders", () => {
|
|||||||
throw new Error("Expected one compiled tool after the source update.");
|
throw new Error("Expected one compiled tool after the source update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(secondTool.execute?.({ city: "Brooklyn" })).resolves.toEqual({
|
await expect(
|
||||||
|
secondTool.execute?.({ city: "Brooklyn" }, { messages: [], toolCallId: "call_1" }),
|
||||||
|
).resolves.toEqual({
|
||||||
city: "Brooklyn",
|
city: "Brooklyn",
|
||||||
source: "updated-lib",
|
source: "updated-lib",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user