mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
chore(eve): workflow - adopt official code-mode runtime (#2776)
Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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(" "),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
+14
-3
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+203
-291
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user