chore(eve): workflow - adopt official code-mode runtime (#2776)

Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
Andrew Barba
2026-08-31 11:53:49 -07:00
committed by GitHub
parent 450681ad42
commit 63f2e072bf
25 changed files with 673 additions and 683 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Run `Workflow` programs with the official AI SDK code-mode runtime while preserving eve's durable subagent accounting and event stream.
+72
View File
@@ -3,3 +3,75 @@ Copyright 2026 Vercel, Inc. and contributors
This product includes software developed at Vercel, Inc.
(https://vercel.com/).
The bundled code-mode runtime includes run, Copyright 2023 Vercel, Inc.,
licensed under the Apache License, Version 2.0.
run embeds devalue 5.8.2, quickjs-wasi 3.6.0, and QuickJS under the MIT
License. Their notices follow.
devalue
Copyright (c) 2018-19 devalue contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
quickjs-wasi
Copyright (c) quickjs-wasi contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
QuickJS Javascript Engine
Copyright (c) 2017-2026 Fabrice Bellard
Copyright (c) 2017-2025 Charlie Gordon
Copyright (c) 2023-2026 Ben Noordhuis
Copyright (c) 2023-2026 Saúl Ibarra Corretgé
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -2,18 +2,18 @@ import { defineEval } from "eve/evals";
/**
* Workflow subagent budget: the fixture configures `maxSubagents` as 2 on the
* Workflow tool, so a three-call fan-out spawns two children and the third call
* resolves inside the program with a `WORKFLOW_SUBAGENT_LIMIT_REACHED` error.
* Workflow tool, so three sequential calls spawn two children and the third
* call resolves with a `WORKFLOW_SUBAGENT_LIMIT_REACHED` error after replay.
*/
export default defineEval({
tags: ["real-model"],
description:
"Workflow calls beyond the tool's maxSubagents config resolve with WORKFLOW_SUBAGENT_LIMIT_REACHED instead of spawning children.",
"Sequential Workflow calls share one maxSubagents budget and resolve excess calls with WORKFLOW_SUBAGENT_LIMIT_REACHED.",
async test(t) {
await t.send(
[
"This is a deliberate test of the Workflow subagent budget, so ignore the advertised call limit and attempt every call.",
"Use the Workflow tool exactly once. In its JavaScript, fan out three echo-marker subagent calls with the messages 'limit alpha', 'limit beta', and 'limit gamma' inside one Promise.all, and return the resulting three-element array.",
"Use the Workflow tool exactly once. In its JavaScript, await three echo-marker subagent calls sequentially with the messages 'limit alpha', 'limit beta', and 'limit gamma', and return the resulting three-element array.",
"Do not call echo-marker outside Workflow and do not retry. Then reply with the returned array verbatim as JSON.",
].join(" "),
);
+2 -1
View File
@@ -400,6 +400,7 @@
"devDependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@ai-sdk/anthropic": "catalog:",
"@ai-sdk/code-mode": "1.0.39",
"@ai-sdk/google": "catalog:",
"@ai-sdk/mcp": "catalog:",
"@ai-sdk/openai": "catalog:",
@@ -447,7 +448,6 @@
"emulate": "0.6.0",
"env-runner": "0.1.16",
"eventsource-parser": "3.1.0",
"experimental-ai-sdk-code-mode": "1.0.16",
"gray-matter": "4.0.3",
"historical-eve-0-30-8": "npm:eve@0.30.8",
"jose": "6.2.3",
@@ -459,6 +459,7 @@
"picocolors": "catalog:",
"react": "catalog:",
"react-test-renderer": "19.2.6",
"run": "2.1.2",
"semver": "7.8.4",
"shadcn": "4.18.0",
"svelte": "^5.0.0",
+1
View File
@@ -26,6 +26,7 @@ const vendorCompiledDir = join(here, "vendor-compiled");
// is invalidated and vendoring re-runs.
const scriptFiles = [
fileURLToPath(import.meta.url),
join(packageRoot, "package.json"),
join(here, "nitro-rolldown.mjs"),
join(here, "vendor-warning-log.mjs"),
...(await collectFilesRecursively(vendorCompiledDir, [".mjs", ".d.ts"])),
@@ -1,18 +1,29 @@
import { relative, sep } from "node:path";
import { collectFilesRecursively, createDeclarationCopier } from "./_shared.mjs";
import { collectFilesRecursively, createDeclarationCopier } from "../_shared.mjs";
function toPosixPath(path) {
return path.split(sep).join("/");
}
function buildRunStub(names, moduleName) {
const unsupported = [...names].filter((name) => name !== "setMaxWorkers");
if (unsupported.length > 0) {
throw new Error(
`Vendor: unsupported ${moduleName} declaration imports: ${unsupported.join(", ")}`,
);
}
return "export declare function setMaxWorkers(maxWorkers?: number): void;\n";
}
export default {
packageName: "experimental-ai-sdk-code-mode",
compiledPath: "experimental-ai-sdk-code-mode",
packageName: "@ai-sdk/code-mode",
compiledPath: "@ai-sdk/code-mode",
bundling: "standalone",
copyDeclarations: createDeclarationCopier({
rewrites: {
ai: { kind: "external" },
run: { kind: "stub", stubBaseName: "_run", build: buildRunStub },
},
files: async ({ distDir }) =>
(await collectFilesRecursively(distDir, [".d.ts"]))
@@ -5,6 +5,7 @@
*/
import acpSdk from "./@agentclientprotocol/sdk.mjs";
import anthropic from "./@ai-sdk/anthropic.mjs";
import codeMode from "./@ai-sdk/code-mode.mjs";
import google from "./@ai-sdk/google.mjs";
import mcp from "./@ai-sdk/mcp.mjs";
import openai from "./@ai-sdk/openai.mjs";
@@ -39,7 +40,6 @@ import workflowWorldVercel from "./@workflow/world-vercel.mjs";
import chat from "./chat.mjs";
import chokidar from "./chokidar.mjs";
import commander from "./commander.mjs";
import experimentalAiSdkCodeMode from "./experimental-ai-sdk-code-mode.mjs";
import eventsourceParserStream from "./eventsource-parser-stream.mjs";
import envRunner from "./env-runner.mjs";
import grayMatter from "./gray-matter.mjs";
@@ -57,13 +57,13 @@ import zodValidationError from "./zod-validation-error.mjs";
export const MODULES = [
acpSdk,
anthropic,
codeMode,
chat,
chatAdapterSlack,
chatAdapterStateMemory,
chatAdapterTwilio,
chokidar,
commander,
experimentalAiSdkCodeMode,
eventsourceParserStream,
envRunner,
google,
@@ -7,7 +7,10 @@ import {
readDurableSession,
} from "#execution/durable-session-store.js";
import { hydrateDurableSession } from "#execution/session.js";
import { getPendingWorkflowInterrupt } from "#harness/workflow-interrupt-state.js";
import {
getPendingWorkflowInterrupt,
setPendingWorkflowUsedCalls,
} from "#harness/workflow-interrupt-state.js";
import { setPendingRuntimeActionBatch } from "#harness/runtime-actions.js";
import { buildRuntimeActionsFromWorkflowInterrupt } from "#harness/workflow-runtime-action-state.js";
import {
@@ -61,8 +64,8 @@ export async function dispatchWorkflowRuntimeActionsStep(input: {
const plan = planWorkflowSubagentDispatch({
actions,
interrupt: pending.interrupt,
maxSubagents: durableSession.workflowMaxSubagents,
usedCalls: pending.usedCalls,
});
const blockedResults = plan.blocked.map((action) => {
@@ -89,11 +92,15 @@ export async function dispatchWorkflowRuntimeActionsStep(input: {
turnAgent: effectiveAgent.turnAgent,
});
const sessionWithUsage = setPendingWorkflowUsedCalls({
session,
usedCalls: pending.usedCalls + plan.allowed.length,
});
const sessionWithBatch = setPendingRuntimeActionBatch({
actions: plan.allowed,
event: { sequence: 0, stepIndex: 0, turnId: "workflow-dispatch" },
responseMessages: [],
session,
session: sessionWithUsage,
});
// Interrupt-sourced batches obey the same mode split as model-authored
@@ -10,7 +10,6 @@ import {
} from "#harness/workflow-continuation-security.js";
import { applyWorkflowTool } from "#harness/workflow-sandbox.js";
import type { HarnessSession, HarnessToolMap } from "#harness/types.js";
import type { WorkflowSandboxLifecycle } from "#shared/workflow-sandbox.js";
type AdvertisedToolSession = Pick<HarnessSession, "rootSessionId" | "subagentDepth">;
@@ -32,10 +31,6 @@ type AdvertisedModelToolsInput = {
readonly session: HarnessSession;
readonly tools: HarnessToolMap;
readonly workflow?: {
readonly lifecycle?: (input: {
readonly session: HarnessSession;
readonly tools: HarnessToolMap;
}) => WorkflowSandboxLifecycle | undefined;
readonly maxSubagents?: number;
};
};
@@ -104,7 +99,6 @@ async function getAdvertisedModelTools(
const { modelTools } = await applyWorkflowTool({
continuationSecurity: getWorkflowContinuationSecurity(session),
harnessTools: workflowHostTools,
lifecycle: input.workflow.lifecycle?.({ session, tools: workflowHostTools }),
maxSubagents: input.workflow.maxSubagents,
tools: input.modelTools,
});
+44 -32
View File
@@ -81,7 +81,10 @@ import {
getWorkflowContinuationSecurity,
readWorkflowContinuationSecurity,
} from "#harness/workflow-continuation-security.js";
import { createWorkflowLifecycle } from "#harness/workflow-lifecycle.js";
import {
emitWorkflowActionResults,
emitWorkflowActionsRequested,
} from "#harness/workflow-lifecycle.js";
import {
clearPendingWorkflowInterrupt,
getPendingWorkflowInterrupt,
@@ -1274,19 +1277,8 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn {
flatTools[FINAL_OUTPUT_TOOL_NAME] = buildFinalOutputTool(session.outputSchema);
}
const workflowLifecycle =
emit !== undefined
? ({ tools }: { readonly tools: HarnessToolMap }) =>
createWorkflowLifecycle({
emit,
emissionState,
tools,
})
: undefined;
const workflowConfig =
config.workflow === true
? { lifecycle: workflowLifecycle, maxSubagents: config.workflowMaxSubagents }
: undefined;
config.workflow === true ? { maxSubagents: config.workflowMaxSubagents } : undefined;
const advertisedModelTools = await getAdvertisedTools({
delegatedCaller: taskUpdatesEnabled,
@@ -2356,12 +2348,15 @@ async function handleStepResult(input: {
if (!isWorkflowRuntimeActionInterrupt(workflowInterrupt)) {
throw new Error(`Unsupported Workflow interrupt kind "${workflowInterrupt.payload.kind}".`);
}
return parkOnWorkflowInterrupt({
return await parkOnWorkflowInterrupt({
baseSession,
emit,
emissionState,
interrupt: workflowInterrupt,
promptMessages,
responseMessages,
tools: input.runtimeActionTools,
usedCalls: 0,
});
}
}
@@ -2825,7 +2820,7 @@ async function finishConversationTurn(input: {
/** Replays a parked dynamic workflow with completed child-agent results. */
async function continuePendingWorkflowInterrupt(input: {
readonly childResults?: readonly { readonly output?: unknown }[];
readonly childResults?: readonly { readonly isError?: boolean; readonly output?: unknown }[];
readonly config: ToolLoopHarnessConfig;
readonly emit?: ToolLoopHarnessConfig["handleEvent"];
readonly emissionState: ReturnType<typeof getHarnessEmissionState>;
@@ -2841,15 +2836,17 @@ async function continuePendingWorkflowInterrupt(input: {
throw new Error(`Unsupported Workflow interrupt kind "${interrupt.payload.kind}".`);
}
const lifecycle =
input.emit === undefined
? undefined
: createWorkflowLifecycle({
emit: input.emit,
emissionState: input.emissionState,
skipReplayed: true,
tools: input.tools,
});
const childResults = input.childResults ?? [];
const pendingInterrupts = getWorkflowRuntimeActionInterrupts(interrupt);
if (input.emit !== undefined && childResults.length > 0) {
await emitWorkflowActionResults({
emit: input.emit,
emissionState: input.emissionState,
interrupts: pendingInterrupts,
results: childResults,
});
}
const continuationSecurity = getWorkflowContinuationSecurity(input.session);
let continuationOutput: unknown;
@@ -2858,11 +2855,10 @@ async function continuePendingWorkflowInterrupt(input: {
tools: input.tools,
});
const childResults = input.childResults ?? [];
let currentInterrupt = interrupt;
let resultIndex = 0;
// Promise.all can park several child calls together. Resolve one ledger
// entry per replay until every supplied child result has been consumed.
// Promise.all can park several child calls together. Resolve one pending
// interruption per replay until every supplied child result is consumed.
// eslint-disable-next-line no-constant-condition
while (true) {
continuationOutput = await continueWorkflowSandboxInterrupt({
@@ -2871,7 +2867,6 @@ async function continuePendingWorkflowInterrupt(input: {
),
continuationSecurity,
interrupt: currentInterrupt,
lifecycle,
resolution: childResults[resultIndex]?.output,
tools: hostTools,
});
@@ -2920,12 +2915,15 @@ async function continuePendingWorkflowInterrupt(input: {
const promptMessages = replacedMessages.slice(0, promptMessageCount);
const responseMessages = replacedMessages.slice(promptMessageCount);
session = { ...session, history: promptMessages };
return parkOnWorkflowInterrupt({
return await parkOnWorkflowInterrupt({
baseSession: session,
emit: input.emit,
emissionState: input.emissionState,
interrupt: unwrapped.interrupt,
promptMessages,
responseMessages,
tools: input.tools,
usedCalls: pending.usedCalls,
});
}
@@ -2954,18 +2952,31 @@ function replaceWorkflowToolResult(
}) as ModelMessage[];
}
function parkOnWorkflowInterrupt(input: {
async function parkOnWorkflowInterrupt(input: {
readonly baseSession: HarnessSession;
readonly emit?: ToolLoopHarnessConfig["handleEvent"];
readonly emissionState: ReturnType<typeof getHarnessEmissionState>;
readonly interrupt: WorkflowSandboxInterrupt;
readonly promptMessages: readonly ModelMessage[];
readonly responseMessages: readonly ModelMessage[];
}): StepResult {
const interrupt = getWorkflowRuntimeActionInterrupts(input.interrupt)[0];
readonly tools: HarnessToolMap;
readonly usedCalls: number;
}): Promise<StepResult> {
const interrupts = getWorkflowRuntimeActionInterrupts(input.interrupt);
const interrupt = interrupts[0];
if (interrupt === undefined) {
throw new Error("Workflow continuation contains no pending runtime-action interrupt.");
}
if (input.emit !== undefined) {
await emitWorkflowActionsRequested({
emit: input.emit,
emissionState: input.emissionState,
interrupts,
tools: input.tools,
});
}
const baseSession: HarnessSession = {
...input.baseSession,
history: [...input.promptMessages],
@@ -2975,6 +2986,7 @@ function parkOnWorkflowInterrupt(input: {
interrupt,
responseMessages: input.responseMessages,
session: baseSession,
usedCalls: input.usedCalls,
});
return { next: null, session: setHarnessEmissionState(parkedSession, input.emissionState) };
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { getPendingWorkflowInterrupt } from "#harness/workflow-interrupt-state.js";
import { WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND } from "#harness/workflow-runtime-action-state.js";
import type { SessionStateMap } from "#harness/types.js";
describe("pending workflow interrupt state", () => {
it("reads the persisted call budget with an official runtime continuation", () => {
const state = {
"eve.harness.pendingWorkflowInterrupt": {
interrupt: {
continuation: {
outerToolCallId: "workflow-call",
pendingInterruptions: [],
resolutions: [],
version: 2,
},
interruptId: "workflow-call:tool-1:interrupt",
outerToolCallId: "workflow-call",
payload: { kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND },
type: "code-mode-interrupt",
},
responseMessages: [],
usedCalls: 3,
},
} as SessionStateMap;
expect(getPendingWorkflowInterrupt(state)).toMatchObject({ usedCalls: 3 });
});
it("rejects pending state without a valid persisted call budget", () => {
const state = {
"eve.harness.pendingWorkflowInterrupt": {
interrupt: {
continuation: {
outerToolCallId: "workflow-call",
pendingInterruptions: [],
resolutions: [],
version: 2,
},
interruptId: "workflow-call:tool-1:interrupt",
outerToolCallId: "workflow-call",
payload: { kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND },
type: "code-mode-interrupt",
},
responseMessages: [],
},
} as SessionStateMap;
expect(getPendingWorkflowInterrupt(state)).toBeUndefined();
});
});
@@ -9,6 +9,7 @@ const PENDING_KEY = "eve.harness.pendingWorkflowInterrupt";
export interface PendingWorkflowInterrupt {
readonly interrupt: WorkflowSandboxInterrupt;
readonly responseMessages: readonly ModelMessage[];
readonly usedCalls: number;
}
export function getPendingWorkflowInterrupt(
@@ -19,9 +20,12 @@ export function getPendingWorkflowInterrupt(
if (!isWorkflowInterruptShape(value.interrupt) || !Array.isArray(value.responseMessages)) {
return undefined;
}
if (!Number.isSafeInteger(value.usedCalls) || (value.usedCalls as number) < 0) return undefined;
return {
interrupt: value.interrupt,
responseMessages: value.responseMessages as ModelMessage[],
usedCalls: value.usedCalls as number,
};
}
@@ -29,6 +33,7 @@ export function setPendingWorkflowInterrupt(input: {
readonly interrupt: WorkflowSandboxInterrupt;
readonly responseMessages: readonly ModelMessage[];
readonly session: HarnessSession;
readonly usedCalls: number;
}): HarnessSession {
return {
...input.session,
@@ -37,11 +42,25 @@ export function setPendingWorkflowInterrupt(input: {
[PENDING_KEY]: {
interrupt: input.interrupt,
responseMessages: input.responseMessages,
usedCalls: input.usedCalls,
} satisfies PendingWorkflowInterrupt,
},
};
}
export function setPendingWorkflowUsedCalls(input: {
readonly session: HarnessSession;
readonly usedCalls: number;
}): HarnessSession {
const pending = getPendingWorkflowInterrupt(input.session.state);
if (pending === undefined) return input.session;
return setPendingWorkflowInterrupt({
...pending,
session: input.session,
usedCalls: input.usedCalls,
});
}
export function clearPendingWorkflowInterrupt(session: HarnessSession): HarnessSession {
if (session.state?.[PENDING_KEY] === undefined) return session;
@@ -62,7 +81,10 @@ function isWorkflowInterruptShape(value: unknown): value is WorkflowSandboxInter
isRecord(value.payload) &&
value.payload.kind === WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND &&
isRecord(value.continuation) &&
typeof value.continuation.outerToolCallId === "string"
value.continuation.version === 2 &&
typeof value.continuation.outerToolCallId === "string" &&
Array.isArray(value.continuation.pendingInterruptions) &&
Array.isArray(value.continuation.resolutions)
);
}
@@ -3,10 +3,15 @@ import { describe, expect, it } from "vitest";
import { ContextContainer, contextStorage } from "#context/container.js";
import type { HarnessEmissionState } from "#harness/emission.js";
import { createWorkflowLifecycle } from "#harness/workflow-lifecycle.js";
import {
emitWorkflowActionResults,
emitWorkflowActionsRequested,
} from "#harness/workflow-lifecycle.js";
import { WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND } from "#harness/workflow-runtime-action-state.js";
import type { HarnessToolMap } from "#harness/types.js";
import { defineState } from "#public/definitions/state.js";
import type { UnstampedMessageStreamEvent } from "#protocol/message.js";
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
const emissionState: HarnessEmissionState = {
sequence: 2,
@@ -33,39 +38,71 @@ function createTools(): HarnessToolMap {
]);
}
function nestedCall(replayed = false) {
function workflowInterrupt(): WorkflowSandboxInterrupt {
const payload = {
kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND,
runtimeAction: {
kind: "subagent-call" as const,
nodeId: "subagents/researcher",
subagentName: "researcher",
},
toolInput: { message: "Investigate" },
toolName: "researcher",
};
return {
bridgeIndex: 1,
continuation: {
auth: {
alg: "HMAC-SHA256",
expiresAtMs: 2,
issuedAtMs: 1,
nonce: "nonce",
signature: "signature",
},
js: "return tools.researcher({ message: 'Investigate' })",
outerToolCallId: "outer-call",
pendingInterruptions: [
{
input: { message: "Investigate" },
interruptId: "outer-call:tool-1:interrupt",
payload,
runInterruptionId: "run-interruption-1",
toolCallId: "outer-call:tool-1",
toolName: "researcher",
},
],
resolutions: [],
token: "token",
toolNames: ["researcher"],
version: 2,
},
input: { message: "Investigate" },
inputBytes: 24,
invocationId: "workflow-1",
interruptId: "outer-call:tool-1:interrupt",
outerToolCallId: "outer-call",
replayed,
startedAtMs: 10,
payload,
toolCallId: "outer-call:tool-1",
toolName: "researcher",
type: "code-mode-interrupt",
};
}
describe("createWorkflowLifecycle", () => {
it("emits nested subagent calls and results as action events", async () => {
describe("workflow lifecycle projection", () => {
it("emits parked subagent calls and resumed results as action events", async () => {
const events: UnstampedMessageStreamEvent[] = [];
const lifecycle = createWorkflowLifecycle({
emit: async (event) => {
events.push(event);
},
const emit = async (event: UnstampedMessageStreamEvent) => {
events.push(event);
};
await emitWorkflowActionsRequested({
emit,
emissionState,
interrupts: [workflowInterrupt()],
tools: createTools(),
});
await lifecycle.onNestedToolCall?.(nestedCall());
await lifecycle.onNestedToolResult?.({
...nestedCall(),
completedAtMs: 20,
durationMs: 10,
output: { value: "ok" },
outputBytes: 14,
status: "fulfilled",
await emitWorkflowActionResults({
emit,
emissionState,
interrupts: [workflowInterrupt()],
results: [{ output: { value: "ok" } }],
});
expect(events[0]).toMatchObject({
@@ -95,22 +132,15 @@ describe("createWorkflowLifecycle", () => {
});
});
it("projects rejected child results through the shared result contract", async () => {
it("projects failed child results through the shared result contract", async () => {
const events: UnstampedMessageStreamEvent[] = [];
const lifecycle = createWorkflowLifecycle({
await emitWorkflowActionResults({
emit: async (event) => {
events.push(event);
},
emissionState,
tools: createTools(),
});
await lifecycle.onNestedToolResult?.({
...nestedCall(),
completedAtMs: 20,
durationMs: 10,
error: new Error("child failed"),
status: "rejected",
interrupts: [workflowInterrupt()],
results: [{ isError: true, output: "child failed" }],
});
expect(events[0]).toMatchObject({
@@ -122,40 +152,25 @@ describe("createWorkflowLifecycle", () => {
});
});
it("skips replayed calls during continuation", async () => {
const events: UnstampedMessageStreamEvent[] = [];
const lifecycle = createWorkflowLifecycle({
emit: async (event) => {
events.push(event);
},
emissionState,
skipReplayed: true,
tools: createTools(),
});
await lifecycle.onNestedToolCall?.(nestedCall(true));
expect(events).toEqual([]);
});
it("dispatches lifecycle events in the invoking context", async () => {
it("emits events in the invoking context", async () => {
const lifecycleDispatches = defineState<string[]>(
"test.workflow.lifecycle.dispatch-context",
() => [],
);
const buildSession = new ContextContainer();
const callSession = new ContextContainer();
const lifecycle = await contextStorage.run(buildSession, async () =>
createWorkflowLifecycle({
await contextStorage.run(callSession, () =>
emitWorkflowActionsRequested({
emit: async (event) => {
lifecycleDispatches.update((events) => [...events, event.type]);
},
emissionState,
interrupts: [workflowInterrupt()],
tools: createTools(),
}),
);
await contextStorage.run(callSession, () => lifecycle.onNestedToolCall?.(nestedCall()));
expect(contextStorage.run(callSession, () => lifecycleDispatches.get())).toEqual([
"actions.requested",
]);
+48 -55
View File
@@ -4,71 +4,64 @@ import { createRuntimeToolResultFromValue } from "#harness/action-result-helpers
import type { HarnessEmissionState } from "#harness/emission.js";
import { createRuntimeActionRequestFromToolCall } from "#harness/runtime-actions.js";
import type { HarnessToolMap } from "#harness/types.js";
import { createLogger } from "#internal/logging.js";
import {
createActionResultEvent,
createActionsRequestedEvent,
type UnstampedMessageStreamEvent,
} from "#protocol/message.js";
import { toErrorMessage } from "#shared/errors.js";
import type { WorkflowSandboxLifecycle } from "#shared/workflow-sandbox.js";
const log = createLogger("harness.workflow-lifecycle");
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
type EmitWorkflowLifecycleEvent = (event: UnstampedMessageStreamEvent) => Promise<void>;
/** Projects sandboxed subagent calls onto eve's existing action event stream. */
export function createWorkflowLifecycle(input: {
/** Projects newly parked workflow calls onto eve's existing action stream. */
export async function emitWorkflowActionsRequested(input: {
readonly emit: EmitWorkflowLifecycleEvent;
readonly emissionState: HarnessEmissionState;
readonly skipReplayed?: boolean;
readonly interrupts: readonly WorkflowSandboxInterrupt[];
readonly tools: HarnessToolMap;
}): WorkflowSandboxLifecycle {
return {
onHookError(error, event) {
log.warn("workflow lifecycle hook failed", {
error,
hook: event.hook,
});
},
async onNestedToolCall(event) {
if (input.skipReplayed === true && event.replayed) return;
}): Promise<void> {
for (const interrupt of input.interrupts) {
const toolCall = {
input: interrupt.input,
toolCallId: interrupt.toolCallId,
toolName: interrupt.toolName,
type: "tool-call",
} as TypedToolCall<ToolSet>;
const toolCall = {
input: event.input,
toolCallId: event.toolCallId,
toolName: event.toolName,
type: "tool-call",
} as TypedToolCall<ToolSet>;
await input.emit(
createActionsRequestedEvent({
actions: [createRuntimeActionRequestFromToolCall({ toolCall, tools: input.tools })],
sequence: input.emissionState.sequence,
stepIndex: input.emissionState.stepIndex,
turnId: input.emissionState.turnId,
}),
);
},
async onNestedToolResult(event) {
if (input.skipReplayed === true && event.replayed) return;
if (event.status === "interrupted") return;
const result = createRuntimeToolResultFromValue({
callId: event.toolCallId,
output: event.status === "rejected" ? toErrorMessage(event.error) : event.output,
toolName: event.toolName,
isError: event.status === "rejected",
});
await input.emit(
createActionResultEvent({
result,
sequence: input.emissionState.sequence,
stepIndex: input.emissionState.stepIndex,
turnId: input.emissionState.turnId,
}),
);
},
};
await input.emit(
createActionsRequestedEvent({
actions: [createRuntimeActionRequestFromToolCall({ toolCall, tools: input.tools })],
sequence: input.emissionState.sequence,
stepIndex: input.emissionState.stepIndex,
turnId: input.emissionState.turnId,
}),
);
}
}
/** Projects completed workflow children before replaying their continuation. */
export async function emitWorkflowActionResults(input: {
readonly emit: EmitWorkflowLifecycleEvent;
readonly emissionState: HarnessEmissionState;
readonly interrupts: readonly WorkflowSandboxInterrupt[];
readonly results: readonly { readonly isError?: boolean; readonly output?: unknown }[];
}): Promise<void> {
for (const [index, result] of input.results.entries()) {
const interrupt = input.interrupts[index];
if (interrupt === undefined) break;
await input.emit(
createActionResultEvent({
result: createRuntimeToolResultFromValue({
callId: interrupt.toolCallId,
isError: result.isError,
output: result.output,
toolName: interrupt.toolName,
}),
sequence: input.emissionState.sequence,
stepIndex: input.emissionState.stepIndex,
turnId: input.emissionState.turnId,
}),
);
}
}
@@ -9,6 +9,26 @@ import {
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
function concurrentWorkflowInterrupt(): WorkflowSandboxInterrupt {
const pendingInterruptions = ["alpha", "beta"].map((message, index) => {
const toolCallId = `workflow-call:tool-${index + 1}`;
return {
input: { message },
interruptId: `${toolCallId}:interrupt`,
payload: {
kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND,
runtimeAction: {
kind: "subagent-call" as const,
nodeId: "subagents/echo-marker",
subagentName: "echo-marker",
},
toolInput: { message },
toolName: "echo-marker",
},
runInterruptionId: `run-interruption-${index + 1}`,
toolCallId,
toolName: "echo-marker",
};
});
const continuation = {
auth: {
alg: "HMAC-SHA256" as const,
@@ -17,51 +37,30 @@ function concurrentWorkflowInterrupt(): WorkflowSandboxInterrupt {
nonce: "nonce",
signature: "signature",
},
determinism: {
dateNowMs: 1,
randomSeed: "00000000000000000000000000000000",
},
js: "return Promise.all([])",
ledger: ["alpha", "beta"].map((message, index) => {
const toolCallId = `workflow-call:tool-${index + 1}`;
return {
inputJson: JSON.stringify({ message }),
interruptId: `${toolCallId}:interrupt`,
interruptPayload: {
kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND,
runtimeAction: {
kind: "subagent-call" as const,
nodeId: "subagents/echo-marker",
subagentName: "echo-marker",
},
toolInput: { message },
toolName: "echo-marker",
},
kind: "tool" as const,
name: "echo-marker",
status: "interrupted" as const,
toolCallId,
};
}),
outerToolCallId: "workflow-call",
version: 1 as const,
pendingInterruptions,
resolutions: [],
token: "token",
toolNames: ["echo-marker"],
version: 2 as const,
};
const returned = continuation.ledger[1]!;
const returned = pendingInterruptions[1]!;
return {
continuation,
input: { message: "beta" },
interruptId: returned.interruptId,
outerToolCallId: continuation.outerToolCallId,
payload: returned.interruptPayload,
payload: returned.payload,
toolCallId: returned.toolCallId,
toolName: returned.name,
toolName: returned.toolName,
type: "code-mode-interrupt",
};
}
describe("workflow runtime action state", () => {
it("derives concurrent actions in ledger order when a later interrupt wins the race", () => {
it("derives concurrent actions in request order when a later interrupt wins the race", () => {
const interrupt = concurrentWorkflowInterrupt();
const pending = getWorkflowRuntimeActionInterrupts(interrupt);
@@ -93,4 +92,22 @@ describe("workflow runtime action state", () => {
"subagent-call:echo-marker:echo-marker_workflow-call_tool-2_interrupt",
]);
});
it("excludes interruptions already resolved in the current continuation batch", () => {
const interrupt = concurrentWorkflowInterrupt();
const resumed = {
...interrupt,
continuation: {
...interrupt.continuation,
resolutions: [{ runInterruptionId: "run-interruption-1", value: "alpha-result" }],
},
input: { message: "beta" },
interruptId: "workflow-call:tool-2:interrupt",
toolCallId: "workflow-call:tool-2",
};
expect(getWorkflowRuntimeActionInterrupts(resumed).map((entry) => entry.input)).toEqual([
{ message: "beta" },
]);
});
});
@@ -2,7 +2,10 @@ import type { HarnessRuntimeActionDefinition } from "#harness/execute-tool.js";
import { getRuntimeActionRequestKey } from "#runtime/actions/keys.js";
import type { RuntimeActionRequest } from "#shared/action-types.js";
import type { JsonObject } from "#shared/json.js";
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
import {
getWorkflowSandboxPendingInterrupts,
type WorkflowSandboxInterrupt,
} from "#shared/workflow-sandbox.js";
export const WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND = "eve.workflow-runtime-action";
@@ -53,32 +56,11 @@ export function buildRuntimeActionFromWorkflowInterrupt(
throw new Error(`Workflow runtime actions cannot carry "${runtimeAction.kind}" tools.`);
}
/** Returns every pending runtime-action interrupt in deterministic ledger order. */
/** Returns every pending runtime-action interrupt in deterministic request order. */
export function getWorkflowRuntimeActionInterrupts(
interrupt: WorkflowSandboxInterrupt,
): WorkflowSandboxInterrupt[] {
return interrupt.continuation.ledger.flatMap((entry) => {
if (
entry.kind !== "tool" ||
entry.status !== "interrupted" ||
entry.interruptPayload.kind !== WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND
) {
return [];
}
return [
{
continuation: interrupt.continuation,
input: entry.inputJson === "" ? undefined : JSON.parse(entry.inputJson),
interruptId: entry.interruptId,
outerToolCallId: interrupt.continuation.outerToolCallId,
payload: entry.interruptPayload,
toolCallId: entry.toolCallId,
toolName: entry.name,
type: "code-mode-interrupt" as const,
},
];
});
return getWorkflowSandboxPendingInterrupts(interrupt).filter(isWorkflowRuntimeActionInterrupt);
}
export function buildRuntimeActionsFromWorkflowInterrupt(
@@ -12,7 +12,6 @@ import type { HarnessToolMap } from "#harness/types.js";
import {
continueWorkflowSandboxInterrupt,
getWorkflowSandboxInterrupt,
type WorkflowSandboxLifecycle,
unwrapWorkflowSandboxResult,
} from "#shared/workflow-sandbox.js";
@@ -60,15 +59,9 @@ const continuationSecurity = {
describe("Workflow concurrent continuation", () => {
it("collects fan-out above code mode's default in-flight bridge limit", async () => {
const tools = orchestrationTools();
const lifecycle: WorkflowSandboxLifecycle = {
async onNestedToolCall() {
await new Promise((resolve) => setTimeout(resolve, 50));
},
};
const { modelTools } = await applyWorkflowTool({
continuationSecurity,
harnessTools: tools,
lifecycle,
maxSubagents: 100,
tools: buildToolSet({ tools }),
});
@@ -132,19 +125,11 @@ describe("Workflow concurrent continuation", () => {
]);
});
it("preserves and resolves sibling interrupts when a later call interrupts first", async () => {
it("preserves and resolves sibling interrupts in request order", async () => {
const tools = orchestrationTools();
const lifecycle: WorkflowSandboxLifecycle = {
async onNestedToolCall(event) {
if ((event.input as { message?: string }).message === "alpha") {
await new Promise((resolve) => setTimeout(resolve, 25));
}
},
};
const { hostTools, modelTools } = await applyWorkflowTool({
continuationSecurity,
harnessTools: tools,
lifecycle,
tools: buildToolSet({ tools }),
});
const execute = modelTools.Workflow?.execute as
@@ -157,7 +142,6 @@ describe("Workflow concurrent continuation", () => {
{ messages: [], toolCallId: "workflow-call" },
);
const racedInterrupt = await getWorkflowSandboxInterrupt(initialOutput, continuationSecurity);
expect(racedInterrupt?.input).toEqual({ message: "beta" });
const pending = getWorkflowRuntimeActionInterrupts(racedInterrupt!);
expect(pending.map((interrupt) => interrupt.input)).toEqual([
@@ -169,7 +153,6 @@ describe("Workflow concurrent continuation", () => {
bridgeRequestLimit: resolveWorkflowSandboxBridgeRequestLimit(),
continuationSecurity,
interrupt: pending[0]!,
lifecycle,
resolution: "alpha-result",
tools: hostTools,
});
@@ -187,7 +170,6 @@ describe("Workflow concurrent continuation", () => {
bridgeRequestLimit: resolveWorkflowSandboxBridgeRequestLimit(),
continuationSecurity,
interrupt: firstUnwrapped.interrupt,
lifecycle,
resolution: "beta-result",
tools: hostTools,
});
@@ -11,7 +11,6 @@ import {
readWorkflowSandboxResolution,
requestWorkflowSandboxInterrupt,
type WorkflowSandboxContinuationSecurity,
type WorkflowSandboxLifecycle,
WORKFLOW_TOOL_NAME,
} from "#shared/workflow-sandbox.js";
@@ -37,7 +36,6 @@ const workflowInputSchema = z.strictObject({
export async function applyWorkflowTool(input: {
readonly continuationSecurity: WorkflowSandboxContinuationSecurity;
readonly harnessTools: HarnessToolMap;
readonly lifecycle?: WorkflowSandboxLifecycle;
readonly maxSubagents?: number;
readonly tools: ToolSet;
}): Promise<WorkflowToolSet> {
@@ -51,7 +49,6 @@ export async function applyWorkflowTool(input: {
bridgeRequestLimit: resolveWorkflowSandboxBridgeRequestLimit(input.maxSubagents),
continuationSecurity: input.continuationSecurity,
hostTools,
lifecycle: input.lifecycle,
});
const generated = typeof workflowTool.description === "string" ? workflowTool.description : "";
const framing = workflowToolDescription(Object.keys(hostTools), {
@@ -1,75 +1,10 @@
import { describe, expect, it } from "vitest";
import { WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND } from "#harness/workflow-runtime-action-state.js";
import {
countResolvedWorkflowSubagentCalls,
DEFAULT_WORKFLOW_MAX_SUBAGENTS,
planWorkflowSubagentDispatch,
} from "#harness/workflow-subagent-limit.js";
import type { RuntimeActionRequest } from "#shared/action-types.js";
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
type LedgerEntry = WorkflowSandboxInterrupt["continuation"]["ledger"][number];
function fulfilledEntry(index: number): LedgerEntry {
return {
dateNowMs: 1,
inputJson: JSON.stringify({ message: `done-${index}` }),
kind: "tool",
name: "echo-marker",
status: "fulfilled",
toolCallId: `workflow-call:tool-${index}`,
valueJson: JSON.stringify({ output: "ok" }),
};
}
function interruptedEntry(index: number): LedgerEntry {
const toolCallId = `workflow-call:tool-${index}`;
return {
inputJson: JSON.stringify({ message: `pending-${index}` }),
interruptId: `${toolCallId}:interrupt`,
interruptPayload: {
kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND,
runtimeAction: {
kind: "subagent-call",
nodeId: "subagents/echo-marker",
subagentName: "echo-marker",
},
toolInput: { message: `pending-${index}` },
toolName: "echo-marker",
},
kind: "tool",
name: "echo-marker",
status: "interrupted",
toolCallId,
};
}
function createInterrupt(ledger: LedgerEntry[]): WorkflowSandboxInterrupt {
return {
continuation: {
auth: {
alg: "HMAC-SHA256",
expiresAtMs: 2,
issuedAtMs: 1,
nonce: "nonce",
signature: "signature",
},
determinism: { dateNowMs: 1, randomSeed: "00000000000000000000000000000000" },
js: "return 1",
ledger,
outerToolCallId: "workflow-call",
version: 1,
},
input: {},
interruptId: "workflow-call:tool-1:interrupt",
outerToolCallId: "workflow-call",
payload: { kind: WORKFLOW_RUNTIME_ACTION_INTERRUPT_KIND },
toolCallId: "workflow-call:tool-1",
toolName: "echo-marker",
type: "code-mode-interrupt",
};
}
function createAction(index: number): RuntimeActionRequest {
return {
@@ -83,33 +18,13 @@ function createAction(index: number): RuntimeActionRequest {
};
}
describe("countResolvedWorkflowSubagentCalls", () => {
it("counts fulfilled and rejected tool entries but not pending interrupts", () => {
const interrupt = createInterrupt([
fulfilledEntry(1),
{
dateNowMs: 1,
error: { message: "boom", name: "Error" },
inputJson: "{}",
kind: "tool",
name: "echo-marker",
status: "rejected",
toolCallId: "workflow-call:tool-2",
},
interruptedEntry(3),
]);
expect(countResolvedWorkflowSubagentCalls(interrupt)).toBe(2);
});
});
describe("planWorkflowSubagentDispatch", () => {
it("allows every pending action while the budget holds", () => {
const actions = [createAction(1), createAction(2)];
const plan = planWorkflowSubagentDispatch({
actions,
interrupt: createInterrupt([interruptedEntry(1), interruptedEntry(2)]),
maxSubagents: 3,
usedCalls: 0,
});
expect(plan.allowed).toEqual(actions);
@@ -117,15 +32,12 @@ describe("planWorkflowSubagentDispatch", () => {
expect(plan.usedCalls).toBe(0);
});
it("blocks the actions beyond the remaining budget, preserving ledger order", () => {
it("blocks actions beyond the remaining budget while preserving order", () => {
const actions = [createAction(1), createAction(2), createAction(3)];
const plan = planWorkflowSubagentDispatch({
actions,
interrupt: createInterrupt([
fulfilledEntry(0),
...actions.map((_, i) => interruptedEntry(i + 1)),
]),
maxSubagents: 3,
usedCalls: 1,
});
expect(plan.allowed).toEqual([actions[0], actions[1]]);
@@ -133,11 +45,11 @@ describe("planWorkflowSubagentDispatch", () => {
expect(plan.usedCalls).toBe(1);
});
it("blocks everything once the budget is spent", () => {
it("blocks everything once the persisted budget is spent", () => {
const plan = planWorkflowSubagentDispatch({
actions: [createAction(1)],
interrupt: createInterrupt([fulfilledEntry(1), fulfilledEntry(2), interruptedEntry(3)]),
maxSubagents: 2,
usedCalls: 2,
});
expect(plan.allowed).toEqual([]);
@@ -147,7 +59,7 @@ describe("planWorkflowSubagentDispatch", () => {
it("defaults the budget to DEFAULT_WORKFLOW_MAX_SUBAGENTS", () => {
const plan = planWorkflowSubagentDispatch({
actions: [createAction(1)],
interrupt: createInterrupt([interruptedEntry(1)]),
usedCalls: 0,
});
expect(plan.maxSubagents).toBe(DEFAULT_WORKFLOW_MAX_SUBAGENTS);
@@ -1,5 +1,4 @@
import type { RuntimeActionRequest } from "#shared/action-types.js";
import type { WorkflowSandboxInterrupt } from "#shared/workflow-sandbox.js";
/**
* Default maximum number of subagent (and remote-agent) calls one `Workflow`
@@ -19,37 +18,19 @@ export type WorkflowSubagentDispatchPlan = {
readonly usedCalls: number;
};
/**
* Counts the subagent calls this Workflow invocation has already resolved.
*
* Every host tool bridged into the workflow sandbox is a subagent or
* remote-agent call, so each fulfilled or rejected `tool` ledger entry is one
* consumed call. Entries still `interrupted` are the pending actions being
* planned, not consumed budget.
*/
export function countResolvedWorkflowSubagentCalls(interrupt: WorkflowSandboxInterrupt): number {
let count = 0;
for (const entry of interrupt.continuation.ledger) {
if (entry.kind === "tool" && entry.status !== "interrupted") {
count += 1;
}
}
return count;
}
/**
* Splits the pending actions of one workflow interrupt into the prefix that
* still fits the invocation's `maxSubagents` budget and the remainder that
* must be blocked. Actions keep ledger order so results pair with the
* sandbox program's call order.
* must be blocked. Actions keep request order so results pair with the sandbox
* program's call order.
*/
export function planWorkflowSubagentDispatch(input: {
readonly actions: readonly RuntimeActionRequest[];
readonly interrupt: WorkflowSandboxInterrupt;
readonly maxSubagents?: number;
readonly usedCalls: number;
}): WorkflowSubagentDispatchPlan {
const maxSubagents = input.maxSubagents ?? DEFAULT_WORKFLOW_MAX_SUBAGENTS;
const usedCalls = countResolvedWorkflowSubagentCalls(input.interrupt);
const usedCalls = input.usedCalls;
const remaining = Math.max(0, maxSubagents - usedCalls);
return {
@@ -1,10 +1,10 @@
import {
continueCodeModeInterrupt,
createCodeModeTool,
getCodeModeInterrupt,
requestCodeModeInterrupt,
unwrapCodeModeResult,
} from "#compiled/experimental-ai-sdk-code-mode/index.js";
experimental_continueCodeModeInterrupt as continueCodeModeInterrupt,
experimental_createCodeModeTool as createCodeModeTool,
experimental_getCodeModeInterrupt as getCodeModeInterrupt,
experimental_requestCodeModeInterrupt as requestCodeModeInterrupt,
experimental_unwrapCodeModeResult as unwrapCodeModeResult,
} from "#compiled/@ai-sdk/code-mode/index.js";
import { installWorkflowSandboxModule } from "#shared/workflow-sandbox.js";
installWorkflowSandboxModule({
+49 -32
View File
@@ -1,31 +1,27 @@
import type { ToolSet } from "ai";
import type * as CodeModeModule from "#compiled/experimental-ai-sdk-code-mode/index.js";
import type * as CodeModeModule from "#compiled/@ai-sdk/code-mode/index.js";
/** Model-facing tool name for eve's dynamic subagent orchestration tool. */
export const WORKFLOW_TOOL_NAME = "Workflow";
const WORKFLOW_SANDBOX_MODULE_KEY = Symbol.for("eve.workflowSandbox.module");
const WORKFLOW_SANDBOX_MODULE_SPECIFIER = [
"#compiled",
"experimental-ai-sdk-code-mode",
"index.js",
].join("/");
const WORKFLOW_SANDBOX_MODULE_SPECIFIER = ["#compiled", "@ai-sdk", "code-mode", "index.js"].join(
"/",
);
type WorkflowSandboxModule = Pick<
typeof CodeModeModule,
| "continueCodeModeInterrupt"
| "createCodeModeTool"
| "getCodeModeInterrupt"
| "requestCodeModeInterrupt"
| "unwrapCodeModeResult"
>;
type WorkflowSandboxModule = {
readonly continueCodeModeInterrupt: typeof CodeModeModule.experimental_continueCodeModeInterrupt;
readonly createCodeModeTool: typeof CodeModeModule.experimental_createCodeModeTool;
readonly getCodeModeInterrupt: typeof CodeModeModule.experimental_getCodeModeInterrupt;
readonly requestCodeModeInterrupt: typeof CodeModeModule.experimental_requestCodeModeInterrupt;
readonly unwrapCodeModeResult: typeof CodeModeModule.experimental_unwrapCodeModeResult;
};
type WorkflowSandboxGlobal = typeof globalThis & {
[WORKFLOW_SANDBOX_MODULE_KEY]?: WorkflowSandboxModule;
};
export type WorkflowSandboxInterrupt = CodeModeModule.CodeModeInterrupt;
export type WorkflowSandboxLifecycle = NonNullable<CodeModeModule.CodeModeOptions["lifecycle"]>;
export type WorkflowSandboxContinuationSecurity =
CodeModeModule.CodeModeContinuationSecurityOptions;
@@ -39,16 +35,11 @@ export async function createWorkflowSandboxTool(input: {
readonly bridgeRequestLimit: number;
readonly continuationSecurity: WorkflowSandboxContinuationSecurity;
readonly hostTools: ToolSet;
readonly lifecycle?: WorkflowSandboxLifecycle;
}): Promise<ToolSet[string]> {
const { createCodeModeTool } = await loadWorkflowSandboxModule();
return createCodeModeTool(
input.hostTools,
createWorkflowSandboxOptions(
input.bridgeRequestLimit,
input.continuationSecurity,
input.lifecycle,
),
createWorkflowSandboxOptions(input.bridgeRequestLimit, input.continuationSecurity),
) as ToolSet[string];
}
@@ -74,18 +65,13 @@ export async function continueWorkflowSandboxInterrupt(input: {
readonly bridgeRequestLimit: number;
readonly continuationSecurity: WorkflowSandboxContinuationSecurity;
readonly interrupt: WorkflowSandboxInterrupt;
readonly lifecycle?: WorkflowSandboxLifecycle;
readonly resolution: unknown;
readonly tools: ToolSet;
}): Promise<unknown> {
const { continueCodeModeInterrupt } = await loadWorkflowSandboxModule();
return continueCodeModeInterrupt({
interrupt: input.interrupt,
options: createWorkflowSandboxOptions(
input.bridgeRequestLimit,
input.continuationSecurity,
input.lifecycle,
),
options: createWorkflowSandboxOptions(input.bridgeRequestLimit, input.continuationSecurity),
resolution: input.resolution,
tools: input.tools,
} as never);
@@ -111,20 +97,44 @@ export function readWorkflowSandboxResolution(options: unknown): unknown {
return (interrupt as Record<string, unknown>).resolution;
}
/** Reconstructs every unresolved interruption from the authenticated continuation. */
export function getWorkflowSandboxPendingInterrupts(
interrupt: WorkflowSandboxInterrupt,
): WorkflowSandboxInterrupt[] {
const continuation = interrupt.continuation;
const resolved = new Set(
continuation.resolutions.map((resolution) => resolution.runInterruptionId),
);
return continuation.pendingInterruptions.flatMap((pending) =>
resolved.has(pending.runInterruptionId)
? []
: [
{
continuation,
input: pending.input,
interruptId: pending.interruptId,
outerToolCallId: continuation.outerToolCallId,
payload: pending.payload,
toolCallId: pending.toolCallId,
toolName: pending.toolName,
type: "code-mode-interrupt" as const,
},
],
);
}
function createWorkflowSandboxOptions(
bridgeRequestLimit: number,
continuationSecurity: WorkflowSandboxContinuationSecurity,
lifecycle: WorkflowSandboxLifecycle | undefined,
): CodeModeModule.CodeModeOptions {
const options: CodeModeModule.CodeModeOptions = {
return {
continuationSecurity,
executionPolicy: {
maxBridgeRequests: bridgeRequestLimit,
maxInFlightBridgeRequests: bridgeRequestLimit,
},
};
if (lifecycle !== undefined) options.lifecycle = lifecycle;
return options;
}
async function loadWorkflowSandboxModule(): Promise<WorkflowSandboxModule> {
@@ -136,5 +146,12 @@ async function loadWorkflowSandboxModule(): Promise<WorkflowSandboxModule> {
}
async function importWorkflowSandboxModule(specifier: string): Promise<WorkflowSandboxModule> {
return (await import(specifier)) as WorkflowSandboxModule;
const module = (await import(specifier)) as typeof CodeModeModule;
return {
continueCodeModeInterrupt: module.experimental_continueCodeModeInterrupt,
createCodeModeTool: module.experimental_createCodeModeTool,
getCodeModeInterrupt: module.experimental_getCodeModeInterrupt,
requestCodeModeInterrupt: module.experimental_requestCodeModeInterrupt,
unwrapCodeModeResult: module.experimental_unwrapCodeModeResult,
};
}
@@ -362,6 +362,10 @@ describe("app runtime dependency tracing", () => {
// The Workflow sandbox runtime ships bundled inline, never traced — and
// these apps do not declare the optional just-bash engine, so its
// quickjs dependency must not sneak into the trace either.
expect(disabledTracedPackageJson.dependencies).not.toHaveProperty("@ai-sdk/code-mode");
expect(tracedServerPackageJson.dependencies).not.toHaveProperty("@ai-sdk/code-mode");
expect(disabledTracedPackageJson.dependencies).not.toHaveProperty("run");
expect(tracedServerPackageJson.dependencies).not.toHaveProperty("run");
expect(disabledTracedPackageJson.dependencies).not.toHaveProperty("quickjs-emscripten");
expect(tracedServerPackageJson.dependencies).not.toHaveProperty("quickjs-emscripten");
}, 60_000);
+203 -291
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -44,7 +44,7 @@ catalog:
"@vercel/sandbox-delete": "npm:@vercel/sandbox@3.2.0"
"@vercel/sandbox-drives": "npm:@vercel/sandbox@2.10.0-beta.0"
"@workflow/world-postgres": "5.0.0-beta.35"
ai: "^7.0.58"
ai: "^7.0.82"
better-auth: "1.6.26"
marked: "17.0.6"
next: "16.3.0-preview.6"
@@ -70,4 +70,5 @@ minimumReleaseAgeExclude:
- "@vercel/*"
- "@workflow/*"
- nitro
- run
- workflow