@vercel/factory 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +356 -0
- package/README.md +49 -261
- package/dist/agent-routes.d.mts +47 -3
- package/dist/agent-routes.mjs +28 -1
- package/dist/agent-routes.mjs.map +1 -1
- package/dist/api-contracts.d.mts +20 -1
- package/dist/api-contracts.mjs +2 -1
- package/dist/api-contracts.mjs.map +1 -1
- package/dist/api.d.mts +45 -2
- package/dist/api.mjs +199 -10
- package/dist/api.mjs.map +1 -1
- package/dist/approval-contracts.d.mts +6 -0
- package/dist/blob/index.d.mts +51 -14
- package/dist/blob/index.mjs +26 -10
- package/dist/blob/index.mjs.map +1 -1
- package/dist/budget.d.mts +7 -0
- package/dist/budget.mjs +6 -0
- package/dist/budget.mjs.map +1 -1
- package/dist/build-factory.d.mts +1 -0
- package/dist/change-verification/dispatch.d.mts +16 -3
- package/dist/change-verification/dispatch.mjs +45 -7
- package/dist/change-verification/dispatch.mjs.map +1 -1
- package/dist/change-verification/eve-tool.d.mts +2 -1
- package/dist/change-verification/eve-tool.mjs +35 -47
- package/dist/change-verification/eve-tool.mjs.map +1 -1
- package/dist/change-verification/result.mjs +130 -0
- package/dist/change-verification/result.mjs.map +1 -0
- package/dist/changes/eve-record-change.d.mts +2 -2
- package/dist/changes/eve-record-change.mjs +43 -9
- package/dist/changes/eve-record-change.mjs.map +1 -1
- package/dist/changes.d.mts +4 -3
- package/dist/changes.mjs +2 -2
- package/dist/changes.mjs.map +1 -1
- package/dist/client-events.d.mts +10 -3
- package/dist/client-events.mjs +6 -2
- package/dist/client-events.mjs.map +1 -1
- package/dist/client-stream.mjs +8 -2
- package/dist/client-stream.mjs.map +1 -1
- package/dist/client-transcript.mjs +5 -1
- package/dist/client-transcript.mjs.map +1 -1
- package/dist/client.d.mts +121 -12
- package/dist/client.mjs +117 -9
- package/dist/client.mjs.map +1 -1
- package/dist/code-review/contracts.d.mts +1 -0
- package/dist/code-review/eve-post-review.d.mts +4 -4
- package/dist/code-review/eve-post-review.mjs +29 -17
- package/dist/code-review/eve-post-review.mjs.map +1 -1
- package/dist/code-review/eve-review-comments.d.mts +13 -2
- package/dist/code-review/eve-review-comments.mjs +45 -11
- package/dist/code-review/eve-review-comments.mjs.map +1 -1
- package/dist/code-review/github-reporter.d.mts +2 -0
- package/dist/code-review/github-reporter.mjs +7 -5
- package/dist/code-review/github-reporter.mjs.map +1 -1
- package/dist/code-review.d.mts +3 -2
- package/dist/deepsec/eve-tool.mjs +3 -1
- package/dist/deepsec/eve-tool.mjs.map +1 -1
- package/dist/dispatch.d.mts +79 -8
- package/dist/dispatch.mjs +68 -9
- package/dist/dispatch.mjs.map +1 -1
- package/dist/eve/index.d.mts +70 -10
- package/dist/eve/index.mjs +93 -22
- package/dist/eve/index.mjs.map +1 -1
- package/dist/eve/invoke.mjs +24 -11
- package/dist/eve/invoke.mjs.map +1 -1
- package/dist/eve/session-client.d.mts +122 -4
- package/dist/eve/session-client.mjs +127 -13
- package/dist/eve/session-client.mjs.map +1 -1
- package/dist/eve/task-execution.d.mts +380 -0
- package/dist/eve/task-execution.mjs +57 -2
- package/dist/eve/task-execution.mjs.map +1 -1
- package/dist/eve/task-session.d.mts +44 -2
- package/dist/eve/task-session.mjs +44 -2
- package/dist/eve/task-session.mjs.map +1 -1
- package/dist/eve/transcript.mjs +5 -1
- package/dist/eve/transcript.mjs.map +1 -1
- package/dist/execution.d.mts +117 -9
- package/dist/execution.mjs +76 -6
- package/dist/execution.mjs.map +1 -1
- package/dist/finding-remediation/admission.d.mts +2 -0
- package/dist/finding-remediation/admission.mjs +4 -1
- package/dist/finding-remediation/admission.mjs.map +1 -1
- package/dist/findings.d.mts +1 -0
- package/dist/github-publication.d.mts +1 -0
- package/dist/github-publication.mjs +97 -84
- package/dist/github-publication.mjs.map +1 -1
- package/dist/github-transfer.d.mts +15 -6
- package/dist/github-transfer.mjs +214 -66
- package/dist/github-transfer.mjs.map +1 -1
- package/dist/github.d.mts +51 -11
- package/dist/github.mjs +126 -24
- package/dist/github.mjs.map +1 -1
- package/dist/inbox-activity.d.mts +53 -0
- package/dist/inbox-activity.mjs +41 -0
- package/dist/inbox-activity.mjs.map +1 -0
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -2
- package/dist/intake-contracts.d.mts +0 -1
- package/dist/integrations/deepsec.d.mts +1 -0
- package/dist/integrations/github.d.mts +2 -2
- package/dist/integrations/github.mjs +2 -2
- package/dist/integrations/slack.d.mts +3 -1
- package/dist/integrations/slack.mjs +3 -1
- package/dist/integrations/vercel.d.mts +4 -2
- package/dist/integrations/vercel.mjs +3 -2
- package/dist/merge-resolution/eve-tools.d.mts +1 -0
- package/dist/merge-resolution/eve-tools.mjs +7 -2
- package/dist/merge-resolution/eve-tools.mjs.map +1 -1
- package/dist/model-settings.d.mts +41 -0
- package/dist/model-settings.mjs +35 -0
- package/dist/model-settings.mjs.map +1 -0
- package/dist/planning/reconcile.mjs +6 -0
- package/dist/planning/reconcile.mjs.map +1 -1
- package/dist/postgres/index.d.mts +43 -2
- package/dist/postgres/index.mjs +40 -2
- package/dist/postgres/index.mjs.map +1 -1
- package/dist/presets/software-development/dispatch.d.mts +4 -1
- package/dist/presets/software-development/dispatch.mjs +2 -1
- package/dist/presets/software-development/dispatch.mjs.map +1 -1
- package/dist/presets/software-development/task-communication.d.mts +1 -0
- package/dist/presets/software-development/task-communication.mjs +48 -11
- package/dist/presets/software-development/task-communication.mjs.map +1 -1
- package/dist/presets/software-development.d.mts +1 -0
- package/dist/pull-requests/github-publisher.d.mts +15 -1
- package/dist/pull-requests/github-publisher.mjs +61 -1
- package/dist/pull-requests/github-publisher.mjs.map +1 -1
- package/dist/pull-requests.d.mts +1 -0
- package/dist/sandbox/index.d.mts +1 -0
- package/dist/schema/agent-route.d.mts +19 -1
- package/dist/schema/agent-route.mjs +19 -1
- package/dist/schema/agent-route.mjs.map +1 -1
- package/dist/schema/factory-config.d.mts +27 -0
- package/dist/schema/factory-config.mjs +33 -3
- package/dist/schema/factory-config.mjs.map +1 -1
- package/dist/schema/repository.d.mts +4 -0
- package/dist/schema/repository.mjs +5 -1
- package/dist/schema/repository.mjs.map +1 -1
- package/dist/schema/session.d.mts +1 -0
- package/dist/schema/session.mjs +1 -0
- package/dist/schema/session.mjs.map +1 -1
- package/dist/schema/slack-pr-notifications.d.mts +12 -0
- package/dist/schema/slack-pr-notifications.mjs +11 -0
- package/dist/schema/slack-pr-notifications.mjs.map +1 -0
- package/dist/schema/task-graph.d.mts +39 -0
- package/dist/schema/task.d.mts +1 -0
- package/dist/schema/task.mjs +2 -1
- package/dist/schema/task.mjs.map +1 -1
- package/dist/schema/transcript.d.mts +6 -0
- package/dist/schema/transcript.mjs +2 -1
- package/dist/schema/transcript.mjs.map +1 -1
- package/dist/schema/work.d.mts +52 -3
- package/dist/schema/work.mjs.map +1 -1
- package/dist/session-previews.d.mts +76 -0
- package/dist/session-previews.mjs +55 -0
- package/dist/session-previews.mjs.map +1 -0
- package/dist/session-review.d.mts +120 -0
- package/dist/session-review.mjs +79 -0
- package/dist/session-review.mjs.map +1 -0
- package/dist/signal-triage.mjs +1 -1
- package/dist/signals.d.mts +1 -0
- package/dist/stall.d.mts +4 -1
- package/dist/stall.mjs +6 -2
- package/dist/stall.mjs.map +1 -1
- package/dist/store/driver.d.mts +1 -1
- package/dist/store/driver.mjs.map +1 -1
- package/dist/store/engine.d.mts +206 -8
- package/dist/store/engine.mjs +147 -13
- package/dist/store/engine.mjs.map +1 -1
- package/dist/store/memory.d.mts +18 -1
- package/dist/store/memory.mjs +18 -1
- package/dist/store/memory.mjs.map +1 -1
- package/dist/store/slack-pr-notifications.d.mts +44 -0
- package/dist/store/slack-pr-notifications.mjs +121 -0
- package/dist/store/slack-pr-notifications.mjs.map +1 -0
- package/dist/store/task-work.d.mts +121 -6
- package/dist/store/task-work.mjs +7 -4
- package/dist/store/task-work.mjs.map +1 -1
- package/dist/sweep.d.mts +28 -6
- package/dist/sweep.mjs +34 -6
- package/dist/sweep.mjs.map +1 -1
- package/dist/task-graph-view.d.mts +3 -0
- package/dist/tasks.d.mts +3 -3
- package/dist/tasks.mjs +3 -3
- package/dist/vercel-git.d.mts +35 -3
- package/dist/vercel-git.mjs +265 -33
- package/dist/vercel-git.mjs.map +1 -1
- package/dist/vercel-github-api.d.mts +103 -0
- package/dist/vercel-github-api.mjs +363 -0
- package/dist/vercel-github-api.mjs.map +1 -0
- package/dist/vercel.d.mts +3 -2
- package/dist/vercel.mjs +3 -2
- package/dist/vercel.mjs.map +1 -1
- package/dist/work-triage.d.mts +1 -0
- package/dist/workflows.d.mts +102 -4
- package/dist/workflows.mjs +55 -2
- package/dist/workflows.mjs.map +1 -1
- package/dist/workspace-files-git.d.mts +15 -0
- package/dist/workspace-files-git.mjs +61 -0
- package/dist/workspace-files-git.mjs.map +1 -0
- package/dist/workspace-files.d.mts +107 -0
- package/dist/workspace-files.mjs +74 -0
- package/dist/workspace-files.mjs.map +1 -0
- package/docs/getting-started.md +104 -0
- package/docs/index.md +100 -0
- package/docs/recipes/cancellation.md +215 -0
- package/docs/recipes/custom-workflow.md +153 -0
- package/docs/recipes/dependent-tasks.md +207 -0
- package/docs/recipes/eve-agent.md +277 -0
- package/docs/recipes/human-input.md +204 -0
- package/docs/recipes/persistence-recovery.md +268 -0
- package/docs/recipes/retry-recovery.md +241 -0
- package/docs/recipes/task-messaging.md +215 -0
- package/docs/recipes/typed-eve-result.md +161 -0
- package/docs/runtime-integration.md +137 -0
- package/package.json +17 -6
package/dist/dispatch.mjs
CHANGED
|
@@ -3,12 +3,18 @@ import { InvalidTransitionError } from "./schema/transitions.mjs";
|
|
|
3
3
|
import { routeKey } from "./agent-routes.mjs";
|
|
4
4
|
import { RecordNotFoundError, VersionConflictError } from "./store/driver.mjs";
|
|
5
5
|
//#region src/dispatch.ts
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The provider may have accepted the request, so the running attempt must remain available for
|
|
8
|
+
* recovery with its original launch key. Catchers must not queue a replacement attempt blindly.
|
|
9
|
+
*/
|
|
7
10
|
var ExecutionLaunchPendingError = class extends Error {};
|
|
8
11
|
/** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */
|
|
9
12
|
var DispatchRefusedError = class extends Error {
|
|
13
|
+
/** Exact Task whose current dispatch attempt was refused. */
|
|
10
14
|
taskId;
|
|
15
|
+
/** Stable gate or race category suitable for branching and telemetry. */
|
|
11
16
|
code;
|
|
17
|
+
/** Human-readable refusal context; canonical Task state remains authoritative. */
|
|
12
18
|
reason;
|
|
13
19
|
constructor(taskId, code, reason) {
|
|
14
20
|
super(`dispatch of "${taskId}" refused: ${reason}`);
|
|
@@ -24,8 +30,11 @@ var DispatchRefusedError = class extends Error {
|
|
|
24
30
|
* the sweep. Operator retry recovers it once budget frees up.
|
|
25
31
|
*/
|
|
26
32
|
var BudgetExhaustedError = class extends Error {
|
|
33
|
+
/** Exact Task already transitioned to `failed` by the exhausted admission. */
|
|
27
34
|
taskId;
|
|
35
|
+
/** UTC budget month whose limit refused the reservation. */
|
|
28
36
|
month;
|
|
37
|
+
/** Counted settled cost and live reservations observed for that month. */
|
|
29
38
|
spendUsd;
|
|
30
39
|
constructor(taskId, month, spendUsd) {
|
|
31
40
|
super(`dispatch of "${taskId}" refused: budget for ${month} is exhausted at $${spendUsd}`);
|
|
@@ -38,11 +47,61 @@ var BudgetExhaustedError = class extends Error {
|
|
|
38
47
|
/** Default launch-attempt allowance covering delayed identity provisioning. */
|
|
39
48
|
const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;
|
|
40
49
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
50
|
+
* Claims one queued Task and starts its exact execution mode.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Dispatch first checks route, approval, dependency, and optional budget gates, then atomically
|
|
54
|
+
* transitions the Task from `queued` to `running`. Only the winner invokes `launch`. A successful
|
|
55
|
+
* provider dispatch returns only after its execution reference is stored; it does not mean the
|
|
56
|
+
* Task completed. `workflowOwned: true` returns the claimed running Task without starting a
|
|
57
|
+
* provider session.
|
|
58
|
+
*
|
|
59
|
+
* An ordinary launch failure is rethrown after a best-effort transition back to `queued`, or to
|
|
60
|
+
* `failed` once `maxLaunchAttempts` is exhausted. `ExecutionLaunchPendingError` instead leaves the
|
|
61
|
+
* attempt running because the provider may have accepted the request; recovery must inspect or
|
|
62
|
+
* repeat the launch with the original idempotency key. Budget exhaustion fails and receipts the
|
|
63
|
+
* Task before throwing `BudgetExhaustedError`. Since compensating transitions are best-effort,
|
|
64
|
+
* callers handling any thrown launch error should reread canonical Task state before deciding
|
|
65
|
+
* whether to schedule another attempt.
|
|
66
|
+
*
|
|
67
|
+
* @param stores - Task, graph, and receipt stores that own the dispatch gates.
|
|
68
|
+
* @param input - Exact Task identity plus either a launcher or workflow-owned marker.
|
|
69
|
+
* @returns The running Task, including its durably recorded execution when a launcher was used.
|
|
70
|
+
* @throws `RecordNotFoundError` when the Task is absent.
|
|
71
|
+
* @throws `DispatchRefusedError` when routing, approval, dependencies, or a budget-admission race refuses dispatch.
|
|
72
|
+
* @throws `InvalidTransitionError` when the Task is no longer queued when this caller claims it.
|
|
73
|
+
* @throws `BudgetExhaustedError` after the Task has been failed because no reservation fits.
|
|
74
|
+
* @throws `ExecutionLaunchPendingError` when provider acceptance is ambiguous and requires recovery.
|
|
75
|
+
* @see The shipped `docs/recipes/eve-agent.md` and `docs/recipes/retry-recovery.md` recipes.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```ts
|
|
79
|
+
* import {
|
|
80
|
+
* DispatchRefusedError,
|
|
81
|
+
* dispatchTask,
|
|
82
|
+
* type DispatchStores,
|
|
83
|
+
* } from "@vercel/factory/execution";
|
|
84
|
+
* import type { TaskId } from "@vercel/factory/tasks";
|
|
85
|
+
* import { parseAgentRouteBinding } from "@vercel/factory/workflows";
|
|
86
|
+
*
|
|
87
|
+
* declare const stores: DispatchStores;
|
|
88
|
+
* declare const taskId: TaskId;
|
|
89
|
+
* const route = parseAgentRouteBinding({ id: "worker", version: 1 });
|
|
90
|
+
*
|
|
91
|
+
* try {
|
|
92
|
+
* await dispatchTask(stores, {
|
|
93
|
+
* taskId,
|
|
94
|
+
* route,
|
|
95
|
+
* launch: async () => ({ provider: "eve", sessionId: "ses_example" }),
|
|
96
|
+
* });
|
|
97
|
+
* } catch (error) {
|
|
98
|
+
* if (error instanceof DispatchRefusedError) {
|
|
99
|
+
* // `error.code` identifies the gate; canonical Task state remains authoritative.
|
|
100
|
+
* } else {
|
|
101
|
+
* throw error;
|
|
102
|
+
* }
|
|
103
|
+
* }
|
|
104
|
+
* ```
|
|
46
105
|
*/
|
|
47
106
|
async function dispatchTask(stores, input) {
|
|
48
107
|
const { taskId } = input;
|
|
@@ -62,13 +121,13 @@ async function dispatchTask(stores, input) {
|
|
|
62
121
|
if (options.budget !== void 0 && task.state === "queued") {
|
|
63
122
|
const budgetNow = (options.budget.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
64
123
|
const month = options.budget.month ?? budgetMonthOf(budgetNow);
|
|
65
|
-
const rows = (options.budget.tasks ?? await stores.tasks.list()).map((row) => row.id === taskId ? {
|
|
124
|
+
const rows = (options.budget.enforce === false ? [] : options.budget.tasks ?? await stores.tasks.list()).map((row) => row.id === taskId ? {
|
|
66
125
|
...row,
|
|
67
126
|
reservedUsd: void 0
|
|
68
127
|
} : row);
|
|
69
128
|
const spendUsd = computeMonthSpendUsd(rows, month);
|
|
70
129
|
const { totalUsd, perTaskReservationUsd } = options.budget.limits;
|
|
71
|
-
if (!canReserve(spendUsd, options.budget.limits)) {
|
|
130
|
+
if (options.budget.enforce !== false && !canReserve(spendUsd, options.budget.limits)) {
|
|
72
131
|
const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;
|
|
73
132
|
try {
|
|
74
133
|
await stores.tasks.transition(taskId, "failed", {
|
|
@@ -103,7 +162,7 @@ async function dispatchTask(stores, input) {
|
|
|
103
162
|
reservedUsd: reservation.reservedUsd,
|
|
104
163
|
reservedAt: reservation.reservedAt
|
|
105
164
|
} },
|
|
106
|
-
...task.attempt > 1 ? { phase: "retry" } : {}
|
|
165
|
+
...task.attempt > 1 ? { phase: options.continuationAttempt === task.attempt ? "workflow" : "retry" } : {}
|
|
107
166
|
});
|
|
108
167
|
if (options.workflowOwned === true) return running;
|
|
109
168
|
let execution;
|
package/dist/dispatch.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.mjs","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import { budgetMonthOf, canReserve, computeMonthSpendUsd } from \"./budget\";\nimport type { AgentRouteBinding } from \"./agent-routes\";\nimport type { BudgetLimits, BudgetMonth } from \"./schema/budget\";\nimport type { DateClock } from \"./schema/common\";\nimport type { TaskId } from \"./schema/id\";\nimport type { ExecutionReference, Task } from \"./schema/task\";\nimport { routeKey } from \"./agent-routes\";\nimport { InvalidTransitionError } from \"./schema/transitions\";\nimport { RecordNotFoundError, VersionConflictError } from \"./store/driver\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/** Minimal persistence capabilities required to gate and record one dispatch. */\nexport type DispatchStores = Pick<FactoryStores, \"graphs\" | \"receipts\" | \"tasks\">;\n\n/** The provider may have accepted the request. Keep this attempt for recovery using its original launch key. */\nexport class ExecutionLaunchPendingError extends Error {}\n\n/** Stable machine-readable reason a Task could not be dispatched. */\nexport type DispatchRefusalCode =\n | \"route_mismatch\"\n | \"awaiting_approval\"\n | \"approval_denied\"\n | \"dependencies_unsatisfied\"\n | \"dispatch_race\";\n\n/** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */\nexport class DispatchRefusedError extends Error {\n readonly taskId: TaskId;\n readonly code: DispatchRefusalCode;\n readonly reason: string;\n\n constructor(taskId: TaskId, code: DispatchRefusalCode, reason: string) {\n super(`dispatch of \"${taskId}\" refused: ${reason}`);\n this.name = \"DispatchRefusedError\";\n this.taskId = taskId;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/**\n * An exhausted budget is definitive, not a retryable refusal: the task has\n * already been failed and receipted when this throws, so it never re-enters\n * the sweep. Operator retry recovers it once budget frees up.\n */\nexport class BudgetExhaustedError extends Error {\n readonly taskId: TaskId;\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n\n constructor(taskId: TaskId, month: BudgetMonth, spendUsd: number) {\n super(`dispatch of \"${taskId}\" refused: budget for ${month} is exhausted at $${spendUsd}`);\n this.name = \"BudgetExhaustedError\";\n this.taskId = taskId;\n this.month = month;\n this.spendUsd = spendUsd;\n }\n}\n\n/** Factory-wide reservation limits and snapshot used to gate one dispatch. */\nexport interface DispatchBudget {\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** A dispatcher-wide snapshot used to calculate spend without another list. */\n tasks?: readonly Task[];\n}\n\ninterface DispatchContext {\n /** How many launches to try before the task fails for good; a failure re-queues for the next sweep. */\n maxLaunchAttempts?: number;\n /** Reserve-before-execution policy for this dispatch. */\n budget?: DispatchBudget;\n}\n\n/** Exact provider launch or explicit workflow-owned execution for one Task dispatch. */\nexport type DispatchOptions = DispatchContext &\n (\n | {\n /** Required exact binding when the Task declares an agent route. */\n route?: AgentRouteBinding;\n /**\n * Starts the durable execution for the task (an Eve agent run). Runs after\n * the task has won the queued -> running transition. Its provider reference\n * is mandatory so recovery can always find an accepted run.\n */\n launch: (task: Task) => Promise<ExecutionReference>;\n workflowOwned?: never;\n }\n | {\n /** Marks work whose workflow advances the running Task without a provider session. */\n workflowOwned: true;\n route?: never;\n launch?: never;\n }\n );\n\n/** Task identity and exact launch mode supplied to one dispatch attempt. */\nexport type DispatchTaskInput = { readonly taskId: TaskId } & DispatchOptions;\n\n// A fresh Vercel project rejects its own agents' sessions until its identity\n// finishes provisioning; at one sweep per minute the budget must outlast that.\n/** Default launch-attempt allowance covering delayed identity provisioning. */\nexport const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;\n\n/**\n * The handshake between recorded work and execution: win the store-gated\n * transition (at most one dispatcher can succeed), then start the run. A\n * launch failure re-queues the task (bumping its attempt) until the attempt\n * limit, then fails it; a crash between the transition and the launch leaves\n * a running task with no execution, which the sweep reaps.\n */\nexport async function dispatchTask(\n stores: DispatchStores,\n input: DispatchTaskInput,\n): Promise<Task> {\n const { taskId } = input;\n const options: DispatchOptions = input;\n const task = await stores.tasks.get(taskId);\n if (task === null) {\n throw new RecordNotFoundError(\"tasks\", taskId);\n }\n const route = task.work.route;\n if (route !== undefined && options.workflowOwned === true) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `agent route ${routeKey(route)} requires a launcher`,\n );\n }\n if (\n route !== undefined &&\n (options.route?.id !== route.id || options.route?.version !== route.version)\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `launch requires exact agent route ${routeKey(route)}`,\n );\n }\n if (task.approval === \"required\") {\n throw new DispatchRefusedError(taskId, \"awaiting_approval\", \"awaiting human approval\");\n }\n if (task.approval === \"denied\") {\n throw new DispatchRefusedError(taskId, \"approval_denied\", \"approval was denied\");\n }\n if (\n !(await stores.graphs.dependenciesSatisfied({\n rootTaskId: task.rootTaskId,\n taskId: task.id,\n }))\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"dependencies_unsatisfied\",\n \"graph dependencies must all be succeeded before dispatch\",\n );\n }\n\n // The reservation gates queued -> running only; any other state falls\n // through to the transition below, which rejects it as invalid.\n let reservation: { reservedUsd: number; reservedAt: string } | undefined;\n if (options.budget !== undefined && task.state === \"queued\") {\n const budgetNow = (options.budget.now ?? (() => new Date()))().toISOString();\n const month = options.budget.month ?? budgetMonthOf(budgetNow);\n // This task's own retained reservation from a prior attempt is excluded,\n // because re-reserving overwrites it. Its settled cost stays counted:\n // that money was actually spent and a retry must not reopen it.\n const rows = (options.budget.tasks ?? (await stores.tasks.list())).map((row) =>\n row.id === taskId ? { ...row, reservedUsd: undefined } : row,\n );\n const spendUsd = computeMonthSpendUsd(rows, month);\n const { totalUsd, perTaskReservationUsd } = options.budget.limits;\n if (!canReserve(spendUsd, options.budget.limits)) {\n const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;\n // Fail first, so BudgetExhaustedError's contract (task already failed)\n // holds; losing the task to a concurrent dispatcher surfaces as a\n // refusal, never as a false exhaustion with a misleading receipt.\n try {\n await stores.tasks.transition(taskId, \"failed\", { reason, expectFrom: \"queued\" });\n } catch (error) {\n if (error instanceof InvalidTransitionError || error instanceof VersionConflictError) {\n throw new DispatchRefusedError(\n taskId,\n \"dispatch_race\",\n \"another dispatcher took the task\",\n );\n }\n throw error;\n }\n // Best-effort: the failed transition above already wrote its own\n // receipt carrying this reason, so losing this supplementary one\n // cannot lose the evidence.\n await stores.receipts\n .append({\n repositoryIds: task.repositoryIds,\n taskId,\n kind: task.kind,\n state: \"reservation_refused\",\n phase: \"budget\",\n reason,\n attempt: task.attempt,\n dedupeKey: `task:${taskId}:reservation_refused:${task.attempt}`,\n })\n .catch(() => undefined);\n throw new BudgetExhaustedError(taskId, month, spendUsd);\n }\n reservation = { reservedUsd: perTaskReservationUsd, reservedAt: budgetNow };\n }\n\n const running = await stores.tasks.transition(taskId, \"running\", {\n expectFrom: \"queued\",\n expectAttempt: task.attempt,\n ...(reservation === undefined\n ? {}\n : {\n patch: {\n reservedUsd: reservation.reservedUsd,\n reservedAt: reservation.reservedAt,\n },\n }),\n ...(task.attempt > 1 ? { phase: \"retry\" } : {}),\n });\n if (options.workflowOwned === true) return running;\n let execution: ExecutionReference;\n try {\n execution = await options.launch(running);\n } catch (error) {\n if (error instanceof ExecutionLaunchPendingError) throw error;\n const reason = (error instanceof Error ? error.message : String(error)).slice(0, 500);\n const limit = options.maxLaunchAttempts ?? DEFAULT_MAX_LAUNCH_ATTEMPTS;\n if (running.attempt < limit) {\n const retryReason = `launch failed (attempt ${running.attempt} of ${limit}), will retry: ${reason}`;\n await stores.tasks\n .transition(taskId, \"queued\", {\n reason: retryReason,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n } else {\n await stores.tasks\n .transition(taskId, \"failed\", {\n reason: `launch failed after ${running.attempt} attempts: ${reason}`,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n }\n throw error;\n }\n // Surface a failed binding so a durable caller can recover the same launch;\n // never report acceptance with an unrecorded or stale execution.\n return stores.tasks.recordExecution(taskId, {\n execution,\n expectAttempt: running.attempt,\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"dispatch.mjs","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import { budgetMonthOf, canReserve, computeMonthSpendUsd } from \"./budget\";\nimport type { AgentRouteBinding } from \"./agent-routes\";\nimport type { BudgetLimits, BudgetMonth } from \"./schema/budget\";\nimport type { DateClock } from \"./schema/common\";\nimport type { TaskId } from \"./schema/id\";\nimport type { ExecutionReference, Task } from \"./schema/task\";\nimport { routeKey } from \"./agent-routes\";\nimport { InvalidTransitionError } from \"./schema/transitions\";\nimport { RecordNotFoundError, VersionConflictError } from \"./store/driver\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/** Minimal persistence capabilities required to gate and record one dispatch. */\nexport type DispatchStores = Pick<FactoryStores, \"graphs\" | \"receipts\" | \"tasks\">;\n\n/**\n * The provider may have accepted the request, so the running attempt must remain available for\n * recovery with its original launch key. Catchers must not queue a replacement attempt blindly.\n */\nexport class ExecutionLaunchPendingError extends Error {}\n\n/** Stable machine-readable reason a Task could not be dispatched. */\nexport type DispatchRefusalCode =\n | \"route_mismatch\"\n | \"awaiting_approval\"\n | \"approval_denied\"\n | \"dependencies_unsatisfied\"\n | \"dispatch_race\";\n\n/** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */\nexport class DispatchRefusedError extends Error {\n /** Exact Task whose current dispatch attempt was refused. */\n readonly taskId: TaskId;\n /** Stable gate or race category suitable for branching and telemetry. */\n readonly code: DispatchRefusalCode;\n /** Human-readable refusal context; canonical Task state remains authoritative. */\n readonly reason: string;\n\n constructor(taskId: TaskId, code: DispatchRefusalCode, reason: string) {\n super(`dispatch of \"${taskId}\" refused: ${reason}`);\n this.name = \"DispatchRefusedError\";\n this.taskId = taskId;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/**\n * An exhausted budget is definitive, not a retryable refusal: the task has\n * already been failed and receipted when this throws, so it never re-enters\n * the sweep. Operator retry recovers it once budget frees up.\n */\nexport class BudgetExhaustedError extends Error {\n /** Exact Task already transitioned to `failed` by the exhausted admission. */\n readonly taskId: TaskId;\n /** UTC budget month whose limit refused the reservation. */\n readonly month: BudgetMonth;\n /** Counted settled cost and live reservations observed for that month. */\n readonly spendUsd: number;\n\n constructor(taskId: TaskId, month: BudgetMonth, spendUsd: number) {\n super(`dispatch of \"${taskId}\" refused: budget for ${month} is exhausted at $${spendUsd}`);\n this.name = \"BudgetExhaustedError\";\n this.taskId = taskId;\n this.month = month;\n this.spendUsd = spendUsd;\n }\n}\n\n/** Factory-wide reservation limits and snapshot used to gate one dispatch. */\nexport interface DispatchBudget {\n /** Factory-wide monthly ceiling and amount reserved for each admitted Task. */\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">;\n /** Defaults to true. False records reservations without enforcing the monthly ceiling. */\n enforce?: boolean;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n /** Clock used only to derive the reservation month and timestamp. Defaults to `new Date()`. */\n now?: DateClock;\n /** A dispatcher-wide snapshot used to calculate spend without another list. */\n tasks?: readonly Task[];\n}\n\ninterface DispatchContext {\n /** Exact workflow-confirmed continuation attempt; other replacement attempts count as retries. */\n continuationAttempt?: number;\n /**\n * Maximum Task attempt number allowed for launch failure retries. Defaults to 45. A normal\n * failure below the limit requeues the Task, which increments its attempt for the next sweep.\n */\n maxLaunchAttempts?: number;\n /** Reserve-before-execution policy for this dispatch. */\n budget?: DispatchBudget;\n}\n\n/** Exact provider launch or explicit workflow-owned execution for one Task dispatch. */\nexport type DispatchOptions = DispatchContext &\n (\n | {\n /** Required exact binding when the Task declares an agent route. */\n route?: AgentRouteBinding;\n /**\n * Starts the durable execution for the task (an Eve agent run). Runs after\n * the task has won the queued -> running transition. Its provider reference\n * is mandatory so recovery can always find an accepted run.\n */\n launch: (task: Task) => Promise<ExecutionReference>;\n workflowOwned?: never;\n }\n | {\n /**\n * Marks route-less work whose workflow advances the running Task without a provider\n * session. The caller must schedule that workflow after this admission returns.\n */\n workflowOwned: true;\n route?: never;\n launch?: never;\n }\n );\n\n/** Task identity and exact launch mode supplied to one dispatch attempt. */\nexport type DispatchTaskInput = { readonly taskId: TaskId } & DispatchOptions;\n\n// A fresh Vercel project rejects its own agents' sessions until its identity\n// finishes provisioning; at one sweep per minute the budget must outlast that.\n/** Default launch-attempt allowance covering delayed identity provisioning. */\nexport const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;\n\n/**\n * Claims one queued Task and starts its exact execution mode.\n *\n * @remarks\n * Dispatch first checks route, approval, dependency, and optional budget gates, then atomically\n * transitions the Task from `queued` to `running`. Only the winner invokes `launch`. A successful\n * provider dispatch returns only after its execution reference is stored; it does not mean the\n * Task completed. `workflowOwned: true` returns the claimed running Task without starting a\n * provider session.\n *\n * An ordinary launch failure is rethrown after a best-effort transition back to `queued`, or to\n * `failed` once `maxLaunchAttempts` is exhausted. `ExecutionLaunchPendingError` instead leaves the\n * attempt running because the provider may have accepted the request; recovery must inspect or\n * repeat the launch with the original idempotency key. Budget exhaustion fails and receipts the\n * Task before throwing `BudgetExhaustedError`. Since compensating transitions are best-effort,\n * callers handling any thrown launch error should reread canonical Task state before deciding\n * whether to schedule another attempt.\n *\n * @param stores - Task, graph, and receipt stores that own the dispatch gates.\n * @param input - Exact Task identity plus either a launcher or workflow-owned marker.\n * @returns The running Task, including its durably recorded execution when a launcher was used.\n * @throws `RecordNotFoundError` when the Task is absent.\n * @throws `DispatchRefusedError` when routing, approval, dependencies, or a budget-admission race refuses dispatch.\n * @throws `InvalidTransitionError` when the Task is no longer queued when this caller claims it.\n * @throws `BudgetExhaustedError` after the Task has been failed because no reservation fits.\n * @throws `ExecutionLaunchPendingError` when provider acceptance is ambiguous and requires recovery.\n * @see The shipped `docs/recipes/eve-agent.md` and `docs/recipes/retry-recovery.md` recipes.\n *\n * @example\n * ```ts\n * import {\n * DispatchRefusedError,\n * dispatchTask,\n * type DispatchStores,\n * } from \"@vercel/factory/execution\";\n * import type { TaskId } from \"@vercel/factory/tasks\";\n * import { parseAgentRouteBinding } from \"@vercel/factory/workflows\";\n *\n * declare const stores: DispatchStores;\n * declare const taskId: TaskId;\n * const route = parseAgentRouteBinding({ id: \"worker\", version: 1 });\n *\n * try {\n * await dispatchTask(stores, {\n * taskId,\n * route,\n * launch: async () => ({ provider: \"eve\", sessionId: \"ses_example\" }),\n * });\n * } catch (error) {\n * if (error instanceof DispatchRefusedError) {\n * // `error.code` identifies the gate; canonical Task state remains authoritative.\n * } else {\n * throw error;\n * }\n * }\n * ```\n */\nexport async function dispatchTask(\n stores: DispatchStores,\n input: DispatchTaskInput,\n): Promise<Task> {\n const { taskId } = input;\n const options: DispatchOptions = input;\n const task = await stores.tasks.get(taskId);\n if (task === null) {\n throw new RecordNotFoundError(\"tasks\", taskId);\n }\n const route = task.work.route;\n if (route !== undefined && options.workflowOwned === true) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `agent route ${routeKey(route)} requires a launcher`,\n );\n }\n if (\n route !== undefined &&\n (options.route?.id !== route.id || options.route?.version !== route.version)\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `launch requires exact agent route ${routeKey(route)}`,\n );\n }\n if (task.approval === \"required\") {\n throw new DispatchRefusedError(taskId, \"awaiting_approval\", \"awaiting human approval\");\n }\n if (task.approval === \"denied\") {\n throw new DispatchRefusedError(taskId, \"approval_denied\", \"approval was denied\");\n }\n if (\n !(await stores.graphs.dependenciesSatisfied({\n rootTaskId: task.rootTaskId,\n taskId: task.id,\n }))\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"dependencies_unsatisfied\",\n \"graph dependencies must all be succeeded before dispatch\",\n );\n }\n\n // The reservation gates queued -> running only; any other state falls\n // through to the transition below, which rejects it as invalid.\n let reservation: { reservedUsd: number; reservedAt: string } | undefined;\n if (options.budget !== undefined && task.state === \"queued\") {\n const budgetNow = (options.budget.now ?? (() => new Date()))().toISOString();\n const month = options.budget.month ?? budgetMonthOf(budgetNow);\n // This task's own retained reservation from a prior attempt is excluded,\n // because re-reserving overwrites it. Its settled cost stays counted:\n // that money was actually spent and a retry must not reopen it.\n const rows = (\n options.budget.enforce === false ? [] : (options.budget.tasks ?? (await stores.tasks.list()))\n ).map((row) => (row.id === taskId ? { ...row, reservedUsd: undefined } : row));\n const spendUsd = computeMonthSpendUsd(rows, month);\n const { totalUsd, perTaskReservationUsd } = options.budget.limits;\n if (options.budget.enforce !== false && !canReserve(spendUsd, options.budget.limits)) {\n const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;\n // Fail first, so BudgetExhaustedError's contract (task already failed)\n // holds; losing the task to a concurrent dispatcher surfaces as a\n // refusal, never as a false exhaustion with a misleading receipt.\n try {\n await stores.tasks.transition(taskId, \"failed\", { reason, expectFrom: \"queued\" });\n } catch (error) {\n if (error instanceof InvalidTransitionError || error instanceof VersionConflictError) {\n throw new DispatchRefusedError(\n taskId,\n \"dispatch_race\",\n \"another dispatcher took the task\",\n );\n }\n throw error;\n }\n // Best-effort: the failed transition above already wrote its own\n // receipt carrying this reason, so losing this supplementary one\n // cannot lose the evidence.\n await stores.receipts\n .append({\n repositoryIds: task.repositoryIds,\n taskId,\n kind: task.kind,\n state: \"reservation_refused\",\n phase: \"budget\",\n reason,\n attempt: task.attempt,\n dedupeKey: `task:${taskId}:reservation_refused:${task.attempt}`,\n })\n .catch(() => undefined);\n throw new BudgetExhaustedError(taskId, month, spendUsd);\n }\n reservation = { reservedUsd: perTaskReservationUsd, reservedAt: budgetNow };\n }\n\n const running = await stores.tasks.transition(taskId, \"running\", {\n expectFrom: \"queued\",\n expectAttempt: task.attempt,\n ...(reservation === undefined\n ? {}\n : {\n patch: {\n reservedUsd: reservation.reservedUsd,\n reservedAt: reservation.reservedAt,\n },\n }),\n ...(task.attempt > 1\n ? { phase: options.continuationAttempt === task.attempt ? \"workflow\" : \"retry\" }\n : {}),\n });\n if (options.workflowOwned === true) return running;\n let execution: ExecutionReference;\n try {\n execution = await options.launch(running);\n } catch (error) {\n if (error instanceof ExecutionLaunchPendingError) throw error;\n const reason = (error instanceof Error ? error.message : String(error)).slice(0, 500);\n const limit = options.maxLaunchAttempts ?? DEFAULT_MAX_LAUNCH_ATTEMPTS;\n if (running.attempt < limit) {\n const retryReason = `launch failed (attempt ${running.attempt} of ${limit}), will retry: ${reason}`;\n await stores.tasks\n .transition(taskId, \"queued\", {\n reason: retryReason,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n } else {\n await stores.tasks\n .transition(taskId, \"failed\", {\n reason: `launch failed after ${running.attempt} attempts: ${reason}`,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n }\n throw error;\n }\n // Surface a failed binding so a durable caller can recover the same launch;\n // never report acceptance with an unrecorded or stale execution.\n return stores.tasks.recordExecution(taskId, {\n execution,\n expectAttempt: running.attempt,\n });\n}\n"],"mappings":"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"}
|
package/dist/eve/index.d.mts
CHANGED
|
@@ -108,18 +108,45 @@ interface AppendReceiptTool extends ToolDefinition<AppendReceiptToolInput, Recei
|
|
|
108
108
|
}
|
|
109
109
|
/** The provider-neutral Task, Signal, question, and Receipt tools exposed to an Eve agent. */
|
|
110
110
|
interface TaskTools {
|
|
111
|
+
/** Read one Task by ID; applications decide which agents may expose this cross-Task lookup. */
|
|
111
112
|
readonly get_task: GetTaskTool;
|
|
113
|
+
/** Record the one final decision for a pending Signal. */
|
|
112
114
|
readonly decide_signal: DecideSignalTool;
|
|
115
|
+
/** Complete generic `task@1` work after authenticating the current Eve execution. */
|
|
113
116
|
readonly finish_task: FinishTaskTool;
|
|
117
|
+
/** Persist and present one human question for the authenticated running Task. */
|
|
114
118
|
readonly request_human_input: RequestHumanInputTool;
|
|
119
|
+
/** Answer a question only from its authenticated reply conversation. */
|
|
115
120
|
readonly answer_question: AnswerQuestionTool;
|
|
121
|
+
/** Append immutable application audit evidence with optional deduplication. */
|
|
116
122
|
readonly append_receipt: AppendReceiptTool;
|
|
117
123
|
}
|
|
118
124
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* the
|
|
125
|
+
* Wraps provider-neutral Factory store operations as Eve tools.
|
|
126
|
+
*
|
|
127
|
+
* @remarks
|
|
128
|
+
* Expose only the returned tools each agent role needs. Every write goes through the validating
|
|
129
|
+
* engine, but tool visibility remains application authorization policy. `finish_task` and
|
|
130
|
+
* `request_human_input` recheck the current authenticated Task attempt and Eve session before
|
|
131
|
+
* mutating state. `get_task`, signal decisions, and generic receipts are broader capabilities and
|
|
132
|
+
* should be mounted only for agents allowed to use them.
|
|
133
|
+
*
|
|
134
|
+
* The generic `finish_task` records `{ summary }` for `task@1`. Schema-specific workflows should
|
|
135
|
+
* define a narrow tool that calls `requireTaskExecution` and `stores.work.completeWorkflow`, as in
|
|
136
|
+
* the shipped `docs/recipes/typed-eve-result.md` recipe.
|
|
137
|
+
*
|
|
138
|
+
* @param stores - Receipt, Signal, and Task capabilities available to the tool bundle.
|
|
139
|
+
* @param options - Optional question presentation and generic-completion policy.
|
|
140
|
+
* @returns A new named Eve tool bundle ready for selective mounting under `agent/tools/`.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* import { createTaskTools, type TaskToolsStores } from "@vercel/factory";
|
|
145
|
+
*
|
|
146
|
+
* declare const stores: TaskToolsStores;
|
|
147
|
+
* const tools = createTaskTools(stores);
|
|
148
|
+
* export default tools.get_task;
|
|
149
|
+
* ```
|
|
123
150
|
*/
|
|
124
151
|
declare function createTaskTools(stores: TaskToolsStores, options?: TaskToolsOptions): TaskTools;
|
|
125
152
|
/** Completed conversation output, or `false` when the Task is not a conversation. */
|
|
@@ -134,6 +161,11 @@ interface FactoryHooksOptions {
|
|
|
134
161
|
taskAttemptAttribute?: string;
|
|
135
162
|
/** Attempts allowed before an explicit provider failure becomes terminal. Default: 3. */
|
|
136
163
|
maxAttempts?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Recover a saved workflow result for the exact bound execution before failure retry or
|
|
166
|
+
* unresolved-turn escalation. Return true when handled; a throw defers generic lifecycle changes.
|
|
167
|
+
*/
|
|
168
|
+
recoverResult?: (task: Task) => Promise<boolean>;
|
|
137
169
|
/** Present the recovery question created when a task turn ends without an outcome. */
|
|
138
170
|
presentQuestion: PresentQuestion;
|
|
139
171
|
/** Classify discussion Tasks on failure or cancellation without collecting a successful response. */
|
|
@@ -150,30 +182,58 @@ type TranscriptSnapshotHook = HookDefinition<"session.waiting" | "session.comple
|
|
|
150
182
|
type FactoryHookEvent = "step.completed" | "turn.failed" | "session.failed" | "session.waiting" | "session.completed" | "turn.cancelled" | "turn.completed";
|
|
151
183
|
/** The Eve lifecycle hook projected onto durable Factory Task state. */
|
|
152
184
|
interface FactoryHooks {
|
|
185
|
+
/** Hook definition to export from the target Eve agent's `hooks/` directory. */
|
|
153
186
|
readonly factory: HookDefinition<FactoryHookEvent>;
|
|
154
187
|
}
|
|
155
188
|
/**
|
|
156
|
-
*
|
|
157
|
-
* Mount by re-exporting from agent/hooks/, e.g.
|
|
158
|
-
* `export default createFactoryHooks(stores, { presentQuestion }).factory;`.
|
|
189
|
+
* Creates observe-only Eve hooks that project session outcomes onto durable Factory state.
|
|
159
190
|
*
|
|
191
|
+
* @remarks
|
|
160
192
|
* step.completed: records the step's tokens and AI Gateway cost on the bound
|
|
161
|
-
* task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
|
|
193
|
+
* task. Delivery is at least once; recordUsage dedupes by (sessionId, turnId, stepIndex).
|
|
162
194
|
*
|
|
163
|
-
* turn.failed/session.failed:
|
|
195
|
+
* turn.failed/session.failed: first recovers an opted-in saved workflow result, then re-queues the current attempt, or
|
|
164
196
|
* fails it when its configured attempts are exhausted. turn.cancelled marks
|
|
165
197
|
* the current Task cancelled. Late events from prior attempts are ignored.
|
|
166
198
|
*
|
|
167
|
-
* turn.completed:
|
|
199
|
+
* turn.completed: recovers an opted-in saved workflow result before generic escalation, and
|
|
200
|
+
* settles the bound task's recorded usage if the task is
|
|
168
201
|
* already terminal, and enforces the turn contract otherwise: a still-running
|
|
169
202
|
* bound task (or a still-running task at this session's reply address, for
|
|
170
203
|
* conversational continuations that never got their own bound session)
|
|
171
204
|
* escalates immediately to a surfaced human question instead of waiting out
|
|
172
205
|
* the stall window, because the agent finished without recording an outcome.
|
|
206
|
+
* When the bound session matches the persisted execution, that question's work input includes
|
|
207
|
+
* `systemReason: "unresolved_turn"` and `completedSessionId`. Application recovery can distinguish
|
|
208
|
+
* this completed execution from an agent's genuine request for human input.
|
|
173
209
|
*
|
|
174
210
|
* Every store call is swallowed: a hook throw fails the turn.
|
|
175
211
|
* Transcript projection writes at session response boundaries are likewise
|
|
176
212
|
* best-effort; the API can rebuild a missing projection from durable events.
|
|
213
|
+
*
|
|
214
|
+
* Export the returned `factory` hook from the target agent's `hooks/` directory. The hook reads
|
|
215
|
+
* the Task ID and attempt written by `withFactoryTask`; late events from replaced attempts cannot
|
|
216
|
+
* advance current work. Provider failure recovery requeues below `maxAttempts` and fails the Task
|
|
217
|
+
* at the limit. A clean turn that records no outcome creates a human question instead of silently
|
|
218
|
+
* retrying the same brief.
|
|
219
|
+
*
|
|
220
|
+
* @param stores - Task store used for usage, recovery, cancellation, and completion projection.
|
|
221
|
+
* @param options - Attempt limits plus application-owned question and conversation callbacks.
|
|
222
|
+
* @returns An Eve hook definition under the `factory` property.
|
|
223
|
+
* @see The shipped `docs/recipes/retry-recovery.md` recipe for bounded replacement attempts.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```ts
|
|
227
|
+
* import { createFactoryHooks, type FactoryHooksStores } from "@vercel/factory";
|
|
228
|
+
*
|
|
229
|
+
* declare const stores: FactoryHooksStores;
|
|
230
|
+
* const hooks = createFactoryHooks(stores, {
|
|
231
|
+
* presentQuestion: async (questionTask) => {
|
|
232
|
+
* // Deliver the persisted question through the application's operator channel.
|
|
233
|
+
* },
|
|
234
|
+
* });
|
|
235
|
+
* export default hooks.factory;
|
|
236
|
+
* ```
|
|
177
237
|
*/
|
|
178
238
|
declare function createFactoryHooks(stores: FactoryHooksStores, options: FactoryHooksOptions): FactoryHooks;
|
|
179
239
|
//#endregion
|
package/dist/eve/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { changeIdSchema, repositoryIdsSchema, signalIdSchema, taskIdSchema } from "../schema/id.mjs";
|
|
2
2
|
import { taskWork } from "../schema/work.mjs";
|
|
3
3
|
import { taskQuestionSchema } from "../schema/task.mjs";
|
|
4
|
-
import { receiptKindSchema, receiptStateSchema } from "../schema/receipt.mjs";
|
|
5
4
|
import { conversationBinding, conversationBindingForSession, conversationHeaders, factoryReplyAddressAttribute, factoryReplyAddressHeader, factoryReplyChannelAttribute, factoryReplyChannelHeader, factoryRepositoryIdsAttribute, factoryRepositoryIdsHeader, factoryTaskAttemptAttribute, factoryTaskAttemptHeader, factoryTaskAttribute, factoryTaskHeader, hasFactoryTaskBinding, optionalTaskIdForSession, replyToForSession, repositoryIdsForSession, requireTaskIdForSession, taskBindingForSession, taskHeaders, taskSessionAuth, withFactoryTask, withOptionalFactoryTask } from "./task-session.mjs";
|
|
6
5
|
import { requireTaskExecution } from "./task-execution.mjs";
|
|
6
|
+
import { receiptKindSchema, receiptStateSchema } from "../schema/receipt.mjs";
|
|
7
7
|
import { createEveTranscriptMaterializer } from "./transcript.mjs";
|
|
8
8
|
import { cancelEveSession, forwardEveSessionRequest, readCompletedEveTurn, startEveSession } from "./session-client.mjs";
|
|
9
9
|
import { createDelegateAgentTool, createInvokeAgentTool } from "./invoke.mjs";
|
|
@@ -18,7 +18,7 @@ const decidedOutcomeSchema = z.enum([
|
|
|
18
18
|
"low_confidence",
|
|
19
19
|
"ignored"
|
|
20
20
|
]);
|
|
21
|
-
async function createAndPresentQuestion(stores, parent, question, presentQuestion) {
|
|
21
|
+
async function createAndPresentQuestion(stores, parent, question, presentQuestion, completedSessionId) {
|
|
22
22
|
const needsHumanAction = question.text.trim();
|
|
23
23
|
if (needsHumanAction === "") throw new Error("A human question must contain non-blank text.");
|
|
24
24
|
const task = await stores.tasks.create({
|
|
@@ -26,7 +26,13 @@ async function createAndPresentQuestion(stores, parent, question, presentQuestio
|
|
|
26
26
|
kind: "question",
|
|
27
27
|
work: taskWork({
|
|
28
28
|
title: needsHumanAction.slice(0, 256),
|
|
29
|
-
input: {
|
|
29
|
+
input: {
|
|
30
|
+
question,
|
|
31
|
+
...completedSessionId ? {
|
|
32
|
+
systemReason: "unresolved_turn",
|
|
33
|
+
completedSessionId
|
|
34
|
+
} : {}
|
|
35
|
+
}
|
|
30
36
|
}),
|
|
31
37
|
parentTaskId: parent.id,
|
|
32
38
|
parentTaskAttempt: parent.attempt,
|
|
@@ -68,10 +74,31 @@ async function createAndPresentQuestion(stores, parent, question, presentQuestio
|
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* the
|
|
77
|
+
* Wraps provider-neutral Factory store operations as Eve tools.
|
|
78
|
+
*
|
|
79
|
+
* @remarks
|
|
80
|
+
* Expose only the returned tools each agent role needs. Every write goes through the validating
|
|
81
|
+
* engine, but tool visibility remains application authorization policy. `finish_task` and
|
|
82
|
+
* `request_human_input` recheck the current authenticated Task attempt and Eve session before
|
|
83
|
+
* mutating state. `get_task`, signal decisions, and generic receipts are broader capabilities and
|
|
84
|
+
* should be mounted only for agents allowed to use them.
|
|
85
|
+
*
|
|
86
|
+
* The generic `finish_task` records `{ summary }` for `task@1`. Schema-specific workflows should
|
|
87
|
+
* define a narrow tool that calls `requireTaskExecution` and `stores.work.completeWorkflow`, as in
|
|
88
|
+
* the shipped `docs/recipes/typed-eve-result.md` recipe.
|
|
89
|
+
*
|
|
90
|
+
* @param stores - Receipt, Signal, and Task capabilities available to the tool bundle.
|
|
91
|
+
* @param options - Optional question presentation and generic-completion policy.
|
|
92
|
+
* @returns A new named Eve tool bundle ready for selective mounting under `agent/tools/`.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* import { createTaskTools, type TaskToolsStores } from "@vercel/factory";
|
|
97
|
+
*
|
|
98
|
+
* declare const stores: TaskToolsStores;
|
|
99
|
+
* const tools = createTaskTools(stores);
|
|
100
|
+
* export default tools.get_task;
|
|
101
|
+
* ```
|
|
75
102
|
*/
|
|
76
103
|
function createTaskTools(stores, options = {}) {
|
|
77
104
|
return {
|
|
@@ -224,27 +251,54 @@ const UNRESOLVED_TURN_QUESTION = {
|
|
|
224
251
|
closingOptions: ["Close the task"]
|
|
225
252
|
};
|
|
226
253
|
/**
|
|
227
|
-
*
|
|
228
|
-
* Mount by re-exporting from agent/hooks/, e.g.
|
|
229
|
-
* `export default createFactoryHooks(stores, { presentQuestion }).factory;`.
|
|
254
|
+
* Creates observe-only Eve hooks that project session outcomes onto durable Factory state.
|
|
230
255
|
*
|
|
256
|
+
* @remarks
|
|
231
257
|
* step.completed: records the step's tokens and AI Gateway cost on the bound
|
|
232
|
-
* task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
|
|
258
|
+
* task. Delivery is at least once; recordUsage dedupes by (sessionId, turnId, stepIndex).
|
|
233
259
|
*
|
|
234
|
-
* turn.failed/session.failed:
|
|
260
|
+
* turn.failed/session.failed: first recovers an opted-in saved workflow result, then re-queues the current attempt, or
|
|
235
261
|
* fails it when its configured attempts are exhausted. turn.cancelled marks
|
|
236
262
|
* the current Task cancelled. Late events from prior attempts are ignored.
|
|
237
263
|
*
|
|
238
|
-
* turn.completed:
|
|
264
|
+
* turn.completed: recovers an opted-in saved workflow result before generic escalation, and
|
|
265
|
+
* settles the bound task's recorded usage if the task is
|
|
239
266
|
* already terminal, and enforces the turn contract otherwise: a still-running
|
|
240
267
|
* bound task (or a still-running task at this session's reply address, for
|
|
241
268
|
* conversational continuations that never got their own bound session)
|
|
242
269
|
* escalates immediately to a surfaced human question instead of waiting out
|
|
243
270
|
* the stall window, because the agent finished without recording an outcome.
|
|
271
|
+
* When the bound session matches the persisted execution, that question's work input includes
|
|
272
|
+
* `systemReason: "unresolved_turn"` and `completedSessionId`. Application recovery can distinguish
|
|
273
|
+
* this completed execution from an agent's genuine request for human input.
|
|
244
274
|
*
|
|
245
275
|
* Every store call is swallowed: a hook throw fails the turn.
|
|
246
276
|
* Transcript projection writes at session response boundaries are likewise
|
|
247
277
|
* best-effort; the API can rebuild a missing projection from durable events.
|
|
278
|
+
*
|
|
279
|
+
* Export the returned `factory` hook from the target agent's `hooks/` directory. The hook reads
|
|
280
|
+
* the Task ID and attempt written by `withFactoryTask`; late events from replaced attempts cannot
|
|
281
|
+
* advance current work. Provider failure recovery requeues below `maxAttempts` and fails the Task
|
|
282
|
+
* at the limit. A clean turn that records no outcome creates a human question instead of silently
|
|
283
|
+
* retrying the same brief.
|
|
284
|
+
*
|
|
285
|
+
* @param stores - Task store used for usage, recovery, cancellation, and completion projection.
|
|
286
|
+
* @param options - Attempt limits plus application-owned question and conversation callbacks.
|
|
287
|
+
* @returns An Eve hook definition under the `factory` property.
|
|
288
|
+
* @see The shipped `docs/recipes/retry-recovery.md` recipe for bounded replacement attempts.
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* ```ts
|
|
292
|
+
* import { createFactoryHooks, type FactoryHooksStores } from "@vercel/factory";
|
|
293
|
+
*
|
|
294
|
+
* declare const stores: FactoryHooksStores;
|
|
295
|
+
* const hooks = createFactoryHooks(stores, {
|
|
296
|
+
* presentQuestion: async (questionTask) => {
|
|
297
|
+
* // Deliver the persisted question through the application's operator channel.
|
|
298
|
+
* },
|
|
299
|
+
* });
|
|
300
|
+
* export default hooks.factory;
|
|
301
|
+
* ```
|
|
248
302
|
*/
|
|
249
303
|
function createFactoryHooks(stores, options) {
|
|
250
304
|
const taskIdAttribute = options.taskIdAttribute ?? "factoryTaskId";
|
|
@@ -263,9 +317,19 @@ function createFactoryHooks(stores, options) {
|
|
|
263
317
|
attempt: attempt.data
|
|
264
318
|
} : void 0;
|
|
265
319
|
}
|
|
266
|
-
async function
|
|
320
|
+
async function recoverBoundResult(task, ctx) {
|
|
321
|
+
const binding = boundExecution(ctx);
|
|
322
|
+
if (binding?.taskId !== task.id || binding.attempt !== task.attempt || task.execution?.provider !== "eve" || task.execution.sessionId !== ctx.session.id) return false;
|
|
323
|
+
return await options.recoverResult?.(task) ?? false;
|
|
324
|
+
}
|
|
325
|
+
async function recoverFailedAttempt(binding, failure, ctx) {
|
|
267
326
|
const task = await stores.tasks.get(binding.taskId);
|
|
268
|
-
if (task
|
|
327
|
+
if (!task || task.attempt !== binding.attempt || options.recoverResult && task.execution && (task.execution.provider !== "eve" || task.execution.sessionId !== ctx.session.id)) return;
|
|
328
|
+
if (await recoverBoundResult(task, ctx)) {
|
|
329
|
+
await settleIfTerminal(task.id);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (task.state !== "running") return;
|
|
269
333
|
const exhausted = task.attempt >= maxAttempts;
|
|
270
334
|
await stores.tasks.transition(task.id, exhausted ? "failed" : "queued", {
|
|
271
335
|
expectFrom: "running",
|
|
@@ -299,8 +363,8 @@ function createFactoryHooks(stores, options) {
|
|
|
299
363
|
reason: "measured usage settled at turn end"
|
|
300
364
|
});
|
|
301
365
|
}
|
|
302
|
-
async function escalateUnresolvedTurn(task) {
|
|
303
|
-
await createAndPresentQuestion(stores, task, UNRESOLVED_TURN_QUESTION, options.presentQuestion).catch(() => void 0);
|
|
366
|
+
async function escalateUnresolvedTurn(task, completedSessionId) {
|
|
367
|
+
await createAndPresentQuestion(stores, task, UNRESOLVED_TURN_QUESTION, options.presentQuestion, completedSessionId).catch(() => void 0);
|
|
304
368
|
}
|
|
305
369
|
return { factory: defineHook({ events: {
|
|
306
370
|
async "step.completed"(event, ctx) {
|
|
@@ -312,7 +376,7 @@ function createFactoryHooks(stores, options) {
|
|
|
312
376
|
if ((await stores.tasks.get(taskId).catch(() => null))?.attempt !== binding.attempt) return;
|
|
313
377
|
}
|
|
314
378
|
await stores.tasks.recordUsage(taskId, { step: {
|
|
315
|
-
turnId: event.data.turnId,
|
|
379
|
+
turnId: JSON.stringify([ctx.session.id, event.data.turnId]),
|
|
316
380
|
stepIndex: event.data.stepIndex,
|
|
317
381
|
...usage
|
|
318
382
|
} }).catch(() => void 0);
|
|
@@ -320,7 +384,7 @@ function createFactoryHooks(stores, options) {
|
|
|
320
384
|
async "turn.failed"(event, ctx) {
|
|
321
385
|
const binding = boundExecution(ctx);
|
|
322
386
|
if (binding !== void 0) {
|
|
323
|
-
await recoverFailedAttempt(binding, `provider turn failed (${event.data.code}): ${event.data.message}
|
|
387
|
+
await recoverFailedAttempt(binding, `provider turn failed (${event.data.code}): ${event.data.message}`, ctx).catch(() => void 0);
|
|
324
388
|
return;
|
|
325
389
|
}
|
|
326
390
|
const taskId = boundTaskId(ctx);
|
|
@@ -335,7 +399,7 @@ function createFactoryHooks(stores, options) {
|
|
|
335
399
|
},
|
|
336
400
|
async "session.failed"(event, ctx) {
|
|
337
401
|
const binding = boundExecution(ctx);
|
|
338
|
-
if (binding !== void 0) await recoverFailedAttempt(binding, `provider session failed (${event.data.code}): ${event.data.message}
|
|
402
|
+
if (binding !== void 0) await recoverFailedAttempt(binding, `provider session failed (${event.data.code}): ${event.data.message}`, ctx).catch(() => void 0);
|
|
339
403
|
await options.materializeTranscript?.(materializationInput(ctx)).catch(() => void 0);
|
|
340
404
|
},
|
|
341
405
|
async "session.waiting"(_event, ctx) {
|
|
@@ -366,7 +430,14 @@ function createFactoryHooks(stores, options) {
|
|
|
366
430
|
if (taskId !== void 0) {
|
|
367
431
|
const task = await stores.tasks.get(taskId).catch(() => null);
|
|
368
432
|
if (binding !== void 0 && task?.attempt !== binding.attempt) return;
|
|
369
|
-
if (task?.
|
|
433
|
+
if (binding && options.recoverResult && task?.execution && (task.execution.provider !== "eve" || task.execution.sessionId !== ctx.session.id)) return;
|
|
434
|
+
let recovered;
|
|
435
|
+
try {
|
|
436
|
+
recovered = task !== null && await recoverBoundResult(task, ctx);
|
|
437
|
+
} catch {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!recovered && task?.state === "running" && !task.waitingForMessages) {
|
|
370
441
|
const completion = await options.completeConversation?.(task).catch(() => false);
|
|
371
442
|
if (completion) await stores.tasks.transition(taskId, "succeeded", {
|
|
372
443
|
expectFrom: "running",
|
|
@@ -374,7 +445,7 @@ function createFactoryHooks(stores, options) {
|
|
|
374
445
|
reason: "Conversation turn completed",
|
|
375
446
|
output: completion.output
|
|
376
447
|
}).catch(() => void 0);
|
|
377
|
-
else await escalateUnresolvedTurn(task);
|
|
448
|
+
else await escalateUnresolvedTurn(task, binding && task.execution?.provider === "eve" && task.execution.sessionId === ctx.session.id ? ctx.session.id : void 0);
|
|
378
449
|
}
|
|
379
450
|
await settleIfTerminal(taskId).catch(() => void 0);
|
|
380
451
|
}
|