From cc0206ed3991d8e8d038a9528b3b941dd4b796c8 Mon Sep 17 00:00:00 2001 From: Arthur Teller Date: Sat, 27 Jun 2026 14:15:15 -0700 Subject: [PATCH] Expose tool call id on tool contexts --- .../eve/src/context/build-dynamic-tools.ts | 7 +- .../context/dynamic-tool-lifecycle.test.ts | 70 +++++++++++++++++++ .../eve/src/context/dynamic-tool-lifecycle.ts | 14 +++- .../execution/tool-auth.integration.test.ts | 23 +++++- packages/eve/src/execution/tool-auth.ts | 10 ++- packages/eve/src/harness/execute-tool.ts | 3 +- .../eve/src/harness/tool-interrupts.test.ts | 8 ++- packages/eve/src/harness/tools.test.ts | 39 ++++++++++- packages/eve/src/harness/tools.ts | 5 +- packages/eve/src/public/definitions/tool.ts | 2 + .../eve/src/shared/dynamic-tool-definition.ts | 5 +- 11 files changed, 168 insertions(+), 18 deletions(-) diff --git a/packages/eve/src/context/build-dynamic-tools.ts b/packages/eve/src/context/build-dynamic-tools.ts index 865c70e2e..f13c4c6fe 100644 --- a/packages/eve/src/context/build-dynamic-tools.ts +++ b/packages/eve/src/context/build-dynamic-tools.ts @@ -11,6 +11,7 @@ import type { DurableDynamicToolMetadata } from "#context/keys.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; import { createLogger } from "#internal/logging.js"; import type { ApprovalContext, ApprovalStatus } from "#public/definitions/approval.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; const log = createLogger("dynamic-tools"); @@ -50,7 +51,11 @@ function replayTools(metadata: readonly DurableDynamicToolMetadata[]): HarnessTo tools.push({ description: m.description, - execute: (input: unknown) => stepFn(m.closureVars, input, buildCallbackContext()), + execute: (input: unknown, options?: ToolExecuteOptions) => + stepFn(m.closureVars, input, { + ...buildCallbackContext(), + toolCallId: options?.toolCallId, + }), inputSchema: jsonSchema(m.inputSchema), name: m.name, approval: buildReplayedApproval(m), diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts index 975535583..bb3f9c1c5 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts @@ -289,6 +289,48 @@ describe("replayDynamicSessionTools", () => { } }); + it("exposes the AI SDK tool call id to replayed dynamic tool context", async () => { + const stepId = "eve:dynamic-tool//__eve_dynamic_exec_tool_call_id"; + const stepFn = vi.fn( + (_vars: unknown, _input: unknown, ctx: { readonly toolCallId?: string }) => ({ + toolCallId: ctx.toolCallId, + }), + ); + Object.assign(stepFn, { stepId }); + + const registrySym = Symbol.for("@workflow/core//registeredSteps"); + const registry = getOrCreateStepRegistry(registrySym); + registry.set(stepId, stepFn); + + try { + const metadata: DurableDynamicToolMetadata[] = [ + { + name: "replay-tool-call-id", + description: "Replayed tool call id", + inputSchema: { type: "object" }, + resolverSlug: "test", + entryKey: "tool", + executeStepFnName: stepId, + closureVars: {}, + }, + ]; + + const tools = replayDynamicSessionTools(metadata, []); + expect(tools[0]!.execute!({}, { messages: [], toolCallId: "call_replayed_dynamic" })).toEqual( + { + toolCallId: "call_replayed_dynamic", + }, + ); + expect(stepFn).toHaveBeenCalledWith( + {}, + {}, + expect.objectContaining({ toolCallId: "call_replayed_dynamic" }), + ); + } finally { + registry.delete(stepId); + } + }); + it("replayed tool passes stored closure vars, not live values", async () => { const stepId = "eve:dynamic-tool//__eve_dynamic_exec_snapshot"; const calls: unknown[] = []; @@ -516,6 +558,34 @@ describe("dispatchDynamicToolEvent", () => { expect(tools[0]!.name).toBe("forecast"); }); + it("exposes the AI SDK tool call id to live step-scoped dynamic tools", async () => { + const ctx = createCtx(); + const entry = defineTool({ + description: "call id probe", + inputSchema: { type: "object" }, + execute: async (_input: Record, toolCtx) => ({ + toolCallId: toolCtx.toolCallId, + }), + }); + const resolver = createResolver("call_id_probe", ["step.started"], () => ({ + probe: entry, + })); + + await dispatchDynamicToolEvent({ + ctx, + resolvers: [resolver], + messages: [], + event: makeEvent("step.started"), + }); + + const tools = buildDynamicTools(ctx); + await expect( + tools[0]!.execute!({}, { messages: [], toolCallId: "call_live_dynamic" }), + ).resolves.toEqual({ + toolCallId: "call_live_dynamic", + }); + }); + it("skips resolvers that do not match the event type", async () => { const ctx = createCtx(); const handler = vi.fn(() => ({ forecast: createReplayableTool() })); diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.ts b/packages/eve/src/context/dynamic-tool-lifecycle.ts index 372340ba3..5f74e16a7 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.ts @@ -22,6 +22,7 @@ import { } from "#context/keys.js"; import type { DurableDynamicToolMetadata } from "#context/keys.js"; import { buildResolveContext } from "#context/dynamic-resolve-context.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; const log = createLogger("dynamic-tools"); @@ -32,8 +33,11 @@ const log = createLogger("dynamic-tools"); function toHarnessToolDefinition(name: string, entry: DynamicToolEntry): HarnessToolDefinition { return { description: entry.description, - execute: (input: unknown) => - entry.execute(input as Record, buildCallbackContext()), + execute: (input: unknown, options?: ToolExecuteOptions) => + entry.execute(input as Record, { + ...buildCallbackContext(), + toolCallId: options?.toolCallId, + }), inputSchema: convertInputSchema(entry.inputSchema), name, approval: entry.approval, @@ -118,7 +122,11 @@ export function replayDynamicSessionTools( tools.push({ description: m.description, - execute: (input: unknown) => stepFn(m.closureVars, input, buildCallbackContext()), + execute: (input: unknown, options?: ToolExecuteOptions) => + stepFn(m.closureVars, input, { + ...buildCallbackContext(), + toolCallId: options?.toolCallId, + }), inputSchema: jsonSchema(m.inputSchema), name: m.name, outputSchema: m.outputSchema === undefined ? undefined : jsonSchema(m.outputSchema), diff --git a/packages/eve/src/execution/tool-auth.integration.test.ts b/packages/eve/src/execution/tool-auth.integration.test.ts index 38eac7777..92b83efd3 100644 --- a/packages/eve/src/execution/tool-auth.integration.test.ts +++ b/packages/eve/src/execution/tool-auth.integration.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { createToolExecuteWithAuth } from "#execution/tool-auth.js"; import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js"; -import { loadContext } from "#context/container.js"; -import { AuthKey, SessionIdKey } from "#context/keys.js"; +import { ContextContainer, contextStorage, loadContext } from "#context/container.js"; +import { AuthKey, SessionIdKey, SessionKey } from "#context/keys.js"; import { CallbackBaseUrlKey, PendingAuthorizationResultKey, @@ -72,6 +72,25 @@ function authoredTool(input: { } describe("tool-hosted authorization", () => { + it("exposes the AI SDK tool call id on authored ToolContext", async () => { + const execute = createToolExecuteWithAuth({ + scope: "create_record", + execute: (_input, ctx) => (ctx as ToolContext).toolCallId, + }); + const ctx = new ContextContainer(); + ctx.set(SessionKey, { + auth: { current: null, initiator: null }, + sessionId: "session_tool_call_id", + turn: { id: "turn_tool_call_id", sequence: 0 }, + }); + + await expect( + contextStorage.run(ctx, () => + execute({}, { messages: [], toolCallId: "call_create_record" }), + ), + ).resolves.toBe("call_create_record"); + }); + it("resolves and caches the bearer through ctx.getToken(provider)", async () => { let calls = 0; const auth: AuthorizationDefinition = { diff --git a/packages/eve/src/execution/tool-auth.ts b/packages/eve/src/execution/tool-auth.ts index 0b63839be..47e17a61e 100644 --- a/packages/eve/src/execution/tool-auth.ts +++ b/packages/eve/src/execution/tool-auth.ts @@ -34,6 +34,7 @@ import { startScopedAuthorization, type ScopedAuthorization, } from "#runtime/connections/scoped-authorization.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; /** * Wraps one authored tool's `execute` with a context that supports inline @@ -53,10 +54,10 @@ import { export function createToolExecuteWithAuth(input: { readonly scope: string; readonly execute: (toolInput: unknown, ctx: unknown) => unknown; -}): (toolInput: unknown) => Promise { +}): (toolInput: unknown, options?: ToolExecuteOptions) => Promise { const { scope, execute } = input; - return async (toolInput: unknown): Promise => { + return async (toolInput: unknown, options?: ToolExecuteOptions): Promise => { const justAuthorizedScopes = new Set(); try { @@ -66,6 +67,7 @@ export function createToolExecuteWithAuth(input: { inlineAuthState: {}, justAuthorizedScopes, scope, + toolCallId: options?.toolCallId, }), ); } catch (err) { @@ -82,11 +84,13 @@ function buildToolContext(input: { readonly scope: string; readonly justAuthorizedScopes: Set; readonly inlineAuthState: InlineAuthState; + readonly toolCallId?: string; }): ToolContext { - const { scope, justAuthorizedScopes, inlineAuthState } = input; + const { scope, justAuthorizedScopes, inlineAuthState, toolCallId } = input; const base = buildCallbackContext(); return { ...base, + toolCallId, async getToken(provider?: ToolAuthProvider, options?: ToolAuthOptions): Promise { if (provider === undefined) throw missingProviderError("ctx.getToken"); return await resolveInlineToken({ diff --git a/packages/eve/src/harness/execute-tool.ts b/packages/eve/src/harness/execute-tool.ts index 2712fabe2..eb7192d4d 100644 --- a/packages/eve/src/harness/execute-tool.ts +++ b/packages/eve/src/harness/execute-tool.ts @@ -1,6 +1,7 @@ import type { FlexibleSchema } from "ai"; import type { Approval } from "#public/definitions/approval.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; /** * Runtime-owned action metadata attached to one harness-visible tool. @@ -21,7 +22,7 @@ export type HarnessRuntimeActionDefinition = { export interface HarnessToolDefinition { readonly approvalKey?: (toolInput: Readonly>) => string; readonly description: string; - readonly execute?: (input: any) => any; + readonly execute?: (input: any, options?: ToolExecuteOptions) => any; readonly inputSchema: FlexibleSchema; readonly name: string; readonly approval?: Approval; diff --git a/packages/eve/src/harness/tool-interrupts.test.ts b/packages/eve/src/harness/tool-interrupts.test.ts index 72cb4ecac..a82f5b5cb 100644 --- a/packages/eve/src/harness/tool-interrupts.test.ts +++ b/packages/eve/src/harness/tool-interrupts.test.ts @@ -93,7 +93,9 @@ describe("wrapToolExecute", () => { const signal = signalWithVerifier(); const wrapped = wrapToolExecute({ ...baseDef, execute: async () => signal })!; const ctx = new ContextContainer(); - const output = await contextStorage.run(ctx, () => wrapped({}, { toolCallId: "call_1" })); + const output = await contextStorage.run(ctx, () => + wrapped({}, { messages: [], toolCallId: "call_1" }), + ); expect(isAuthorizationPendingModelOutput(output)).toBe(true); expect(output).toEqual(modelFacingAuthorizationOutput(signal)); @@ -105,7 +107,9 @@ describe("wrapToolExecute", () => { it("passes non-interrupt outputs through unchanged", async () => { const wrapped = wrapToolExecute({ ...baseDef, execute: async () => ({ ok: true }) })!; const ctx = new ContextContainer(); - const output = await contextStorage.run(ctx, () => wrapped({}, { toolCallId: "call_3" })); + const output = await contextStorage.run(ctx, () => + wrapped({}, { messages: [], toolCallId: "call_3" }), + ); expect(output).toEqual({ ok: true }); expect(readToolInterrupt(ctx, "call_3")).toBeUndefined(); diff --git a/packages/eve/src/harness/tools.test.ts b/packages/eve/src/harness/tools.test.ts index db86d8710..adb9dbe4b 100644 --- a/packages/eve/src/harness/tools.test.ts +++ b/packages/eve/src/harness/tools.test.ts @@ -1,5 +1,5 @@ import { type JSONSchema7, jsonSchema } from "ai"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; import { SessionKey, type Session } from "#context/keys.js"; @@ -59,12 +59,15 @@ async function executeSdkTool(input: { input.tool as { readonly execute?: ( toolInput: unknown, - options: { readonly toolCallId: string }, + options: { readonly messages: readonly unknown[]; readonly toolCallId: string }, ) => Promise | unknown; } ).execute; expect(execute).toBeTypeOf("function"); - return await execute!(input.toolInput ?? {}, { toolCallId: input.toolCallId ?? "call_1" }); + return await execute!(input.toolInput ?? {}, { + messages: [], + toolCallId: input.toolCallId ?? "call_1", + }); } async function projectSdkToolOutput(input: { @@ -113,6 +116,36 @@ describe("buildToolSet", () => { expect(getJsonSchema(result.echo_city)).toEqual(schema); }); + it("passes the AI SDK tool call id through to harness execute", async () => { + const execute = vi.fn(async (_input: unknown, options?: { readonly toolCallId: string }) => ({ + toolCallId: options?.toolCallId, + })); + const tools: HarnessToolMap = new Map([ + [ + "echo_city", + { + description: "Echo one city.", + execute, + inputSchema: jsonSchema({ type: "object" }), + name: "echo_city", + }, + ], + ]); + + const result = buildToolSet({ tools }); + await expect( + executeSdkTool({ + tool: result.echo_city, + toolCallId: "call_echo_city", + toolInput: { city: "Brooklyn" }, + }), + ).resolves.toEqual({ toolCallId: "call_echo_city" }); + expect(execute).toHaveBeenCalledWith( + { city: "Brooklyn" }, + { messages: [], toolCallId: "call_echo_city" }, + ); + }); + it("passes through the output schema to the SDK tool", () => { const outputSchema = { properties: { summary: { type: "string" } }, diff --git a/packages/eve/src/harness/tools.ts b/packages/eve/src/harness/tools.ts index 722510c1b..6280de13e 100644 --- a/packages/eve/src/harness/tools.ts +++ b/packages/eve/src/harness/tools.ts @@ -18,6 +18,7 @@ import { resolveWebSearchBackend, resolveWebSearchProviderTool } from "#harness/ import type { HarnessToolMap } from "#harness/types.js"; import { buildCallbackContext } from "#context/build-callback-context.js"; import { loadContext } from "#context/container.js"; +import type { ToolExecuteOptions } from "#shared/tool-definition.js"; import { authorizationPendingModelText, isAuthorizationPendingModelOutput, @@ -170,11 +171,11 @@ export function buildToolSetFromDefinitions(input: { */ export function wrapToolExecute( definition: HarnessToolDefinition, -): ((input: any, options: { readonly toolCallId: string }) => Promise) | undefined { +): ((input: any, options: ToolExecuteOptions) => Promise) | undefined { const execute = definition.execute; if (execute === undefined) return undefined; return async (input, options) => { - const output = await execute(input); + const output = await execute(input, options); if (isAuthorizationSignal(output)) { stashToolInterrupt(loadContext(), options.toolCallId, output); return modelFacingAuthorizationOutput(output); diff --git a/packages/eve/src/public/definitions/tool.ts b/packages/eve/src/public/definitions/tool.ts index 277899989..f8f533f86 100644 --- a/packages/eve/src/public/definitions/tool.ts +++ b/packages/eve/src/public/definitions/tool.ts @@ -72,6 +72,8 @@ export interface ToolAuthOptions { * resolves that provider inline, which lets one tool use multiple credentials. */ export type ToolContext = SessionContext & { + /** Stable AI SDK tool-call id for the currently executing tool. */ + readonly toolCallId?: string; /** * Resolves the bearer token for an inline provider. This accepts the same * auth shapes as a connection's `auth` field, including `connect("...")` diff --git a/packages/eve/src/shared/dynamic-tool-definition.ts b/packages/eve/src/shared/dynamic-tool-definition.ts index cfe3fc0ec..d97448f38 100644 --- a/packages/eve/src/shared/dynamic-tool-definition.ts +++ b/packages/eve/src/shared/dynamic-tool-definition.ts @@ -10,7 +10,10 @@ import type { Approval } from "#public/definitions/approval.js"; import type { SessionAuth } from "#context/keys.js"; import type { HandleMessageStreamEvent } from "#protocol/message.js"; -type ToolContext = SessionContext; +type ToolContext = SessionContext & { + /** Stable AI SDK tool-call id for the currently executing tool. */ + readonly toolCallId?: string; +}; /** * Stream event types allowed for dynamic tool resolvers. Dispatch