Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/eve/src/context/build-dynamic-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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),
Expand Down
70 changes: 70 additions & 0 deletions packages/eve/src/context/dynamic-tool-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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<string, unknown>, 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() }));
Expand Down
14 changes: 11 additions & 3 deletions packages/eve/src/context/dynamic-tool-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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<string, unknown>, buildCallbackContext()),
execute: (input: unknown, options?: ToolExecuteOptions) =>
entry.execute(input as Record<string, unknown>, {
...buildCallbackContext(),
toolCallId: options?.toolCallId,
}),
inputSchema: convertInputSchema(entry.inputSchema),
name,
approval: entry.approval,
Expand Down Expand Up @@ -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),
Expand Down
23 changes: 21 additions & 2 deletions packages/eve/src/execution/tool-auth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 7 additions & 3 deletions packages/eve/src/execution/tool-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,10 +54,10 @@ import {
export function createToolExecuteWithAuth(input: {
readonly scope: string;
readonly execute: (toolInput: unknown, ctx: unknown) => unknown;
}): (toolInput: unknown) => Promise<unknown> {
}): (toolInput: unknown, options?: ToolExecuteOptions) => Promise<unknown> {
const { scope, execute } = input;

return async (toolInput: unknown): Promise<unknown> => {
return async (toolInput: unknown, options?: ToolExecuteOptions): Promise<unknown> => {
const justAuthorizedScopes = new Set<string>();

try {
Expand All @@ -66,6 +67,7 @@ export function createToolExecuteWithAuth(input: {
inlineAuthState: {},
justAuthorizedScopes,
scope,
toolCallId: options?.toolCallId,
}),
);
} catch (err) {
Expand All @@ -82,11 +84,13 @@ function buildToolContext(input: {
readonly scope: string;
readonly justAuthorizedScopes: Set<string>;
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<TokenResult> {
if (provider === undefined) throw missingProviderError("ctx.getToken");
return await resolveInlineToken({
Expand Down
3 changes: 2 additions & 1 deletion packages/eve/src/harness/execute-tool.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -21,7 +22,7 @@ export type HarnessRuntimeActionDefinition = {
export interface HarnessToolDefinition {
readonly approvalKey?: (toolInput: Readonly<Record<string, unknown>>) => 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;
Expand Down
8 changes: 6 additions & 2 deletions packages/eve/src/harness/tool-interrupts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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();
Expand Down
39 changes: 36 additions & 3 deletions packages/eve/src/harness/tools.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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> | 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: {
Expand Down Expand Up @@ -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<string, HarnessToolDefinition>([
[
"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" } },
Expand Down
5 changes: 3 additions & 2 deletions packages/eve/src/harness/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -170,11 +171,11 @@ export function buildToolSetFromDefinitions(input: {
*/
export function wrapToolExecute(
definition: HarnessToolDefinition,
): ((input: any, options: { readonly toolCallId: string }) => Promise<any>) | undefined {
): ((input: any, options: ToolExecuteOptions) => Promise<any>) | 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);
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/public/definitions/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("...")`
Expand Down
5 changes: 4 additions & 1 deletion packages/eve/src/shared/dynamic-tool-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down