@agent-native/core 0.70.2 → 0.71.0
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +19 -0
- package/corpus/core/docs/content/multi-app-workspace.md +2 -2
- package/corpus/core/docs/design/durable-agent-runs.md +458 -4
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +21 -10
- package/corpus/core/src/agent/durable-background.ts +192 -0
- package/corpus/core/src/agent/production-agent.ts +330 -13
- package/corpus/core/src/agent/run-manager.ts +66 -3
- package/corpus/core/src/agent/run-store.ts +129 -23
- package/corpus/core/src/agent/types.ts +20 -0
- package/corpus/core/src/client/AgentPanel.tsx +12 -9
- package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
- package/corpus/core/src/deploy/build.ts +96 -0
- package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
- package/corpus/core/src/mcp/build-server.ts +22 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
- package/corpus/templates/analytics/AGENTS.md +8 -0
- package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
- package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
- package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +1 -0
- package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
- package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
- package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
- package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
- package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
- package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
- package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
- package/corpus/templates/clips/desktop/src/styles.css +33 -5
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
- package/corpus/templates/clips/shared/upload-limits.ts +5 -2
- package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
- package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
- package/dist/action.js +21 -10
- package/dist/action.js.map +1 -1
- package/dist/agent/durable-background.d.ts +60 -0
- package/dist/agent/durable-background.d.ts.map +1 -0
- package/dist/agent/durable-background.js +144 -0
- package/dist/agent/durable-background.js.map +1 -0
- package/dist/agent/production-agent.d.ts +20 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +292 -14
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +48 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +45 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +30 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +124 -24
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +20 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -2
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
- package/dist/client/blocks/library/FileTreeBlock.js +37 -4
- package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
- package/dist/deploy/build.d.ts +29 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +85 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/deploy/workspace-deploy.d.ts.map +1 -1
- package/dist/deploy/workspace-deploy.js +108 -0
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js +23 -9
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +68 -13
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/docs/content/multi-app-workspace.md +2 -2
- package/docs/design/durable-agent-runs.md +458 -4
- package/package.json +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable background agent-chat runs (Netlify background functions).
|
|
3
|
+
*
|
|
4
|
+
* Off by default. When enabled, a long in-app agent-chat turn is dispatched
|
|
5
|
+
* into a Netlify *background* function (15-min budget) instead of completing
|
|
6
|
+
* synchronously under the ~40s soft-timeout. The foreground POST claims the
|
|
7
|
+
* run slot, inserts the run row, fires an HMAC-signed self-dispatch to
|
|
8
|
+
* `AGENT_CHAT_PROCESS_RUN_PATH`, and returns the existing SSE subscription so
|
|
9
|
+
* the client streams the same events (via the cross-isolate SQL-poll path)
|
|
10
|
+
* with no client change.
|
|
11
|
+
*
|
|
12
|
+
* This module owns ONLY the gating decision + shared constants so both the
|
|
13
|
+
* HTTP handler (`production-agent.ts`) and the processor route
|
|
14
|
+
* (`agent-chat-plugin.ts`) agree on when the path is active without a circular
|
|
15
|
+
* import. The actual run machinery is reused verbatim from run-manager /
|
|
16
|
+
* run-store / self-dispatch / internal-token.
|
|
17
|
+
*
|
|
18
|
+
* GUARDRAIL: when `isAgentChatDurableBackgroundEnabled()` returns false, the
|
|
19
|
+
* agent-chat handler must behave byte-for-byte like the current synchronous
|
|
20
|
+
* path. The flag is only ever true when ALL of these hold:
|
|
21
|
+
* 1. `AGENT_CHAT_DURABLE_BACKGROUND` env is a truthy value.
|
|
22
|
+
* 2. The runtime is hosted/serverless (local dev keeps the inline path so SSE
|
|
23
|
+
* stays a single live stream and no second function is needed).
|
|
24
|
+
* 3. `A2A_SECRET` is configured (the HMAC handoff is required to authenticate
|
|
25
|
+
* the background dispatch; without it the dispatch can't be trusted).
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
hasConfiguredA2ASecret,
|
|
29
|
+
isA2AProductionRuntime,
|
|
30
|
+
} from "../a2a/auth-policy.js";
|
|
31
|
+
import {
|
|
32
|
+
extractBearerToken,
|
|
33
|
+
verifyInternalToken,
|
|
34
|
+
} from "../integrations/internal-token.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Framework route the background function actually runs — sibling to
|
|
38
|
+
* `AGENT_TEAM_PROCESS_RUN_PATH`. Reached *through* the Netlify background
|
|
39
|
+
* function, so it inherits the 15-min budget.
|
|
40
|
+
*/
|
|
41
|
+
export const AGENT_CHAT_PROCESS_RUN_PATH =
|
|
42
|
+
"/_agent-native/agent-chat/_process-run";
|
|
43
|
+
|
|
44
|
+
/** Env flag (off by default) that opts an app into durable background runs. */
|
|
45
|
+
export const AGENT_CHAT_DURABLE_BACKGROUND_ENV =
|
|
46
|
+
"AGENT_CHAT_DURABLE_BACKGROUND";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Body field the foreground handler injects when self-dispatching to the
|
|
50
|
+
* background processor. Its presence is how the re-entered handler knows it is
|
|
51
|
+
* the background worker (run inline with the background soft-timeout; do NOT
|
|
52
|
+
* re-claim the slot or re-dispatch). Untrusted on its own — the route also
|
|
53
|
+
* verifies the HMAC token before invoking the handler.
|
|
54
|
+
*/
|
|
55
|
+
export const AGENT_CHAT_BACKGROUND_RUN_FIELD = "__backgroundRun";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mirror of run-manager's private `isHostedRuntime`. Kept in sync deliberately:
|
|
59
|
+
* the durable-background gate must agree with the soft-timeout regime about
|
|
60
|
+
* what "hosted" means.
|
|
61
|
+
*/
|
|
62
|
+
export function isHostedRuntimeForDurableBackground(): boolean {
|
|
63
|
+
if (
|
|
64
|
+
process.env.NETLIFY &&
|
|
65
|
+
process.env.NETLIFY !== "false" &&
|
|
66
|
+
process.env.NETLIFY_LOCAL !== "true"
|
|
67
|
+
) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
process.env.AWS_LAMBDA_FUNCTION_NAME &&
|
|
72
|
+
process.env.NETLIFY_LOCAL !== "true"
|
|
73
|
+
) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return Boolean(
|
|
77
|
+
process.env.CF_PAGES ||
|
|
78
|
+
process.env.VERCEL ||
|
|
79
|
+
process.env.VERCEL_ENV ||
|
|
80
|
+
process.env.RENDER ||
|
|
81
|
+
process.env.FLY_APP_NAME ||
|
|
82
|
+
process.env.K_SERVICE,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isFlagEnabled(): boolean {
|
|
87
|
+
// Read the literal key (not `process.env[CONST]`) so guard:no-env-credentials
|
|
88
|
+
// can statically verify it against the allowlisted `AGENT_*` prefix. Keep this
|
|
89
|
+
// in sync with AGENT_CHAT_DURABLE_BACKGROUND_ENV.
|
|
90
|
+
const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
|
|
91
|
+
if (raw == null) return false;
|
|
92
|
+
const normalized = raw.trim().toLowerCase();
|
|
93
|
+
return (
|
|
94
|
+
normalized === "1" ||
|
|
95
|
+
normalized === "true" ||
|
|
96
|
+
normalized === "yes" ||
|
|
97
|
+
normalized === "on"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The single gate. True only when the flag is on AND the runtime is hosted AND
|
|
103
|
+
* A2A_SECRET is configured. False otherwise — and false means the current
|
|
104
|
+
* synchronous behavior is used, unchanged.
|
|
105
|
+
*/
|
|
106
|
+
export function isAgentChatDurableBackgroundEnabled(): boolean {
|
|
107
|
+
return (
|
|
108
|
+
isFlagEnabled() &&
|
|
109
|
+
isHostedRuntimeForDurableBackground() &&
|
|
110
|
+
hasConfiguredA2ASecret()
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Decision returned by `prepareProcessRunRequest`. */
|
|
115
|
+
export type ProcessRunPreparation =
|
|
116
|
+
| {
|
|
117
|
+
ok: true;
|
|
118
|
+
/** The pre-claimed run id the background worker must reuse. */
|
|
119
|
+
runId: string;
|
|
120
|
+
/** Body to stash for the re-entered handler (marker guaranteed present). */
|
|
121
|
+
body: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
ok: false;
|
|
125
|
+
/** HTTP status the route should return. */
|
|
126
|
+
status: number;
|
|
127
|
+
/** Error payload. */
|
|
128
|
+
error: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pure, transport-agnostic core of the `_process-run` route: validate the body,
|
|
133
|
+
* authenticate the HMAC self-dispatch, and produce the body the re-entered
|
|
134
|
+
* agent-chat handler should run as the background worker.
|
|
135
|
+
*
|
|
136
|
+
* Auth policy mirrors the agent-teams processor exactly:
|
|
137
|
+
* - `A2A_SECRET` set → require a valid `verifyInternalToken(runId, token)`.
|
|
138
|
+
* - no secret but a production runtime → refuse (503) — never run unsigned in
|
|
139
|
+
* prod.
|
|
140
|
+
* - no secret + non-prod (local dev) → allow unsigned; the SQL atomic claim
|
|
141
|
+
* in the worker still prevents double-processing.
|
|
142
|
+
*
|
|
143
|
+
* Extracted from the route handler so the auth + marker-prep decision is unit
|
|
144
|
+
* testable without booting the whole Nitro plugin. The route only adds body
|
|
145
|
+
* reading and the final handler invocation around this.
|
|
146
|
+
*/
|
|
147
|
+
export function prepareProcessRunRequest(
|
|
148
|
+
body: unknown,
|
|
149
|
+
authHeader: string | undefined,
|
|
150
|
+
): ProcessRunPreparation {
|
|
151
|
+
if (!body || typeof body !== "object") {
|
|
152
|
+
return { ok: false, status: 400, error: "Invalid request body" };
|
|
153
|
+
}
|
|
154
|
+
const record = body as Record<string, unknown>;
|
|
155
|
+
const marker = record[AGENT_CHAT_BACKGROUND_RUN_FIELD] as
|
|
156
|
+
| { runId?: unknown }
|
|
157
|
+
| undefined;
|
|
158
|
+
const runId =
|
|
159
|
+
marker && typeof marker.runId === "string"
|
|
160
|
+
? marker.runId
|
|
161
|
+
: typeof record.taskId === "string"
|
|
162
|
+
? (record.taskId as string)
|
|
163
|
+
: "";
|
|
164
|
+
if (!runId) {
|
|
165
|
+
return { ok: false, status: 400, error: "runId required" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (hasConfiguredA2ASecret()) {
|
|
169
|
+
const token = extractBearerToken(authHeader);
|
|
170
|
+
if (!verifyInternalToken(runId, token ?? "")) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
status: 401,
|
|
174
|
+
error: "Invalid or expired processor token",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
} else if (isA2AProductionRuntime()) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
status: 503,
|
|
181
|
+
error:
|
|
182
|
+
"Agent chat background processor not configured — set A2A_SECRET on this deployment.",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ensure the marker is present so the re-entered handler runs as the
|
|
187
|
+
// background worker (reuses runId/turnId, no re-claim, no re-dispatch).
|
|
188
|
+
if (!marker || typeof marker.runId !== "string") {
|
|
189
|
+
record[AGENT_CHAT_BACKGROUND_RUN_FIELD] = { runId };
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, runId, body: record };
|
|
192
|
+
}
|
|
@@ -64,6 +64,12 @@ import {
|
|
|
64
64
|
tryClaimRunSlot,
|
|
65
65
|
} from "./run-manager.js";
|
|
66
66
|
import type { ActiveRun } from "./run-manager.js";
|
|
67
|
+
import {
|
|
68
|
+
AGENT_CHAT_PROCESS_RUN_PATH,
|
|
69
|
+
AGENT_CHAT_BACKGROUND_RUN_FIELD,
|
|
70
|
+
isAgentChatDurableBackgroundEnabled,
|
|
71
|
+
} from "./durable-background.js";
|
|
72
|
+
import { fireInternalDispatch } from "../server/self-dispatch.js";
|
|
67
73
|
import { readBody } from "../server/h3-helpers.js";
|
|
68
74
|
import { isReadOnlyShellCommand } from "../coding-tools/index.js";
|
|
69
75
|
import {
|
|
@@ -98,6 +104,10 @@ import {
|
|
|
98
104
|
readLedgerEntry,
|
|
99
105
|
clearLedgerForThread,
|
|
100
106
|
getCurrentTurnEventsForThread,
|
|
107
|
+
insertRun,
|
|
108
|
+
updateRunHeartbeat,
|
|
109
|
+
updateRunStatusIfRunning,
|
|
110
|
+
claimBackgroundRun,
|
|
101
111
|
} from "./run-store.js";
|
|
102
112
|
import {
|
|
103
113
|
classifyToolCallJournal,
|
|
@@ -3559,6 +3569,35 @@ function endsAtInternalContinuationBoundary(run: ActiveRun): boolean {
|
|
|
3559
3569
|
return last.type === "error" && isRecoverableContinuationError(last);
|
|
3560
3570
|
}
|
|
3561
3571
|
|
|
3572
|
+
/**
|
|
3573
|
+
* Hard cap on server-driven background→background continuation chunks for a
|
|
3574
|
+
* single logical turn. A `backgroundFunction` run gets a ~13-min soft timeout,
|
|
3575
|
+
* so reaching this boundary at all is the rare exception (most turns finish in
|
|
3576
|
+
* one chunk). The cap bounds a pathological turn that would otherwise chain
|
|
3577
|
+
* background invocations forever, mirroring `MAX_AGENT_TEAM_CONTINUATIONS`.
|
|
3578
|
+
*/
|
|
3579
|
+
export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
|
|
3580
|
+
|
|
3581
|
+
/**
|
|
3582
|
+
* Whether the background worker should self-fire the next server-driven
|
|
3583
|
+
* continuation chunk. True only when this is a background worker run that ended
|
|
3584
|
+
* at a recoverable soft-timeout boundary (not aborted/stopped) and the chain is
|
|
3585
|
+
* still under its budget. Extracted so the decision is unit testable without
|
|
3586
|
+
* booting the whole handler.
|
|
3587
|
+
*/
|
|
3588
|
+
export function shouldChainBackgroundContinuation(opts: {
|
|
3589
|
+
isBackgroundWorker: boolean;
|
|
3590
|
+
run: ActiveRun;
|
|
3591
|
+
continuationCount: number;
|
|
3592
|
+
}): boolean {
|
|
3593
|
+
return (
|
|
3594
|
+
opts.isBackgroundWorker &&
|
|
3595
|
+
opts.run.status !== "aborted" &&
|
|
3596
|
+
endsAtInternalContinuationBoundary(opts.run) &&
|
|
3597
|
+
opts.continuationCount < MAX_BACKGROUND_RUN_CONTINUATIONS
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3562
3601
|
function progressStepFromAgentChatEvent(event: AgentChatEvent): string | null {
|
|
3563
3602
|
switch (event.type) {
|
|
3564
3603
|
case "activity":
|
|
@@ -3622,11 +3661,21 @@ export function createProductionAgentHandler(
|
|
|
3622
3661
|
}
|
|
3623
3662
|
|
|
3624
3663
|
let body: AgentChatRequest;
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3664
|
+
// The durable-background `_process-run` route already consumed and verified
|
|
3665
|
+
// the request body (h3 v2's web Request body stream is single-use, so a
|
|
3666
|
+
// second readBody would fail). It stashes the verified+augmented body here
|
|
3667
|
+
// so this re-entered handler reads it instead of the spent stream.
|
|
3668
|
+
const preInjectedBody = (event as any)?.context
|
|
3669
|
+
?.__agentChatBackgroundBody as AgentChatRequest | undefined;
|
|
3670
|
+
if (preInjectedBody && typeof preInjectedBody === "object") {
|
|
3671
|
+
body = preInjectedBody;
|
|
3672
|
+
} else {
|
|
3673
|
+
try {
|
|
3674
|
+
body = await readBody(event);
|
|
3675
|
+
} catch {
|
|
3676
|
+
setResponseStatus(event, 400);
|
|
3677
|
+
return { error: "Invalid request body" };
|
|
3678
|
+
}
|
|
3630
3679
|
}
|
|
3631
3680
|
|
|
3632
3681
|
const {
|
|
@@ -3646,6 +3695,31 @@ export function createProductionAgentHandler(
|
|
|
3646
3695
|
scope,
|
|
3647
3696
|
trackInRunsTray,
|
|
3648
3697
|
} = body;
|
|
3698
|
+
|
|
3699
|
+
// Durable-background marker. Present ONLY when this handler was re-entered
|
|
3700
|
+
// as the Netlify background worker via the `_process-run` self-dispatch
|
|
3701
|
+
// (the route HMAC-verifies the dispatch before invoking us). When set, we
|
|
3702
|
+
// run the loop inline with the background soft-timeout, reusing the
|
|
3703
|
+
// pre-claimed runId/turnId — we must NOT re-claim the slot or re-dispatch.
|
|
3704
|
+
const backgroundRunMarker =
|
|
3705
|
+
body[AGENT_CHAT_BACKGROUND_RUN_FIELD] &&
|
|
3706
|
+
typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD] === "object" &&
|
|
3707
|
+
typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD]!.runId === "string"
|
|
3708
|
+
? body[AGENT_CHAT_BACKGROUND_RUN_FIELD]!
|
|
3709
|
+
: null;
|
|
3710
|
+
const isBackgroundWorker = backgroundRunMarker !== null;
|
|
3711
|
+
// How many server-driven background continuations have already chained into
|
|
3712
|
+
// this logical turn (0 on the first chunk). Used to bound the chain.
|
|
3713
|
+
const backgroundContinuationCount =
|
|
3714
|
+
isBackgroundWorker &&
|
|
3715
|
+
typeof backgroundRunMarker?.continuationCount === "number" &&
|
|
3716
|
+
Number.isFinite(backgroundRunMarker.continuationCount)
|
|
3717
|
+
? Math.max(0, Math.floor(backgroundRunMarker.continuationCount))
|
|
3718
|
+
: 0;
|
|
3719
|
+
// The foreground POST decides whether to dispatch into a background
|
|
3720
|
+
// function. The background worker itself never re-dispatches.
|
|
3721
|
+
const dispatchToBackground =
|
|
3722
|
+
!isBackgroundWorker && isAgentChatDurableBackgroundEnabled();
|
|
3649
3723
|
const requestBrowserTabId = normalizeBrowserTabId(browserTabId);
|
|
3650
3724
|
const requestChatScope = normalizeChatScope(scope);
|
|
3651
3725
|
const requestRunCtx = ensureRequestRunContext();
|
|
@@ -4169,7 +4243,11 @@ export function createProductionAgentHandler(
|
|
|
4169
4243
|
// different serverless isolates both see the correct state — a plain
|
|
4170
4244
|
// read-then-act check races on multi-isolate deployments because both
|
|
4171
4245
|
// reads see no running row before either insert commits.
|
|
4172
|
-
|
|
4246
|
+
//
|
|
4247
|
+
// The background worker SKIPS this: the foreground POST already claimed the
|
|
4248
|
+
// slot and inserted the run row before dispatching, so re-claiming here
|
|
4249
|
+
// would falsely 409 against the row the foreground holds.
|
|
4250
|
+
if (threadId && !isBackgroundWorker) {
|
|
4173
4251
|
const slot = await tryClaimRunSlot(threadId);
|
|
4174
4252
|
if (!slot.claimed) {
|
|
4175
4253
|
setResponseStatus(event, 409);
|
|
@@ -4180,19 +4258,67 @@ export function createProductionAgentHandler(
|
|
|
4180
4258
|
}
|
|
4181
4259
|
}
|
|
4182
4260
|
|
|
4183
|
-
// Start agent loop in background via run-manager
|
|
4184
|
-
|
|
4261
|
+
// Start agent loop in background via run-manager. The background worker
|
|
4262
|
+
// reuses the runId carried in the marker (signed into the dispatch token):
|
|
4263
|
+
// - First background chunk (count 0): the foreground generated + INSERTED
|
|
4264
|
+
// this runId, so the event stream the client is already subscribed to is
|
|
4265
|
+
// the one we write to.
|
|
4266
|
+
// - Chained continuation chunk (count > 0): the prior chunk minted a FRESH
|
|
4267
|
+
// runId for this one (a reused runId would restart `startRun`'s in-memory
|
|
4268
|
+
// seq log at 0 and collide with the prior chunk's persisted seqs, which
|
|
4269
|
+
// insertRunEvent's ON CONFLICT would drop — making the continuation
|
|
4270
|
+
// invisible). A fresh runId on the SAME thread + SAME turnId folds onto
|
|
4271
|
+
// one assistant message and is surfaced by the existing
|
|
4272
|
+
// `/runs/active?threadId` reconnect path. The continuation worker inserts
|
|
4273
|
+
// its own background row below (the foreground only inserted chunk-0's).
|
|
4274
|
+
const isChainedBackgroundContinuation =
|
|
4275
|
+
isBackgroundWorker && backgroundContinuationCount > 0;
|
|
4276
|
+
const runId = backgroundRunMarker?.runId ?? generateRunId();
|
|
4185
4277
|
const effectiveThreadId = threadId ?? runId;
|
|
4186
4278
|
const effectiveTurnId =
|
|
4187
|
-
typeof
|
|
4188
|
-
|
|
4189
|
-
|
|
4279
|
+
typeof backgroundRunMarker?.turnId === "string" &&
|
|
4280
|
+
backgroundRunMarker.turnId.trim()
|
|
4281
|
+
? backgroundRunMarker.turnId.trim()
|
|
4282
|
+
: typeof requestTurnId === "string" && requestTurnId.trim()
|
|
4283
|
+
? requestTurnId.trim()
|
|
4284
|
+
: runId;
|
|
4190
4285
|
const messageToPersist =
|
|
4191
4286
|
typeof requestDisplayMessage === "string" &&
|
|
4192
4287
|
requestDisplayMessage.trim().length > 0
|
|
4193
4288
|
? requestDisplayMessage
|
|
4194
4289
|
: requestMessage;
|
|
4195
|
-
|
|
4290
|
+
|
|
4291
|
+
// Server-driven background continuation: when the background worker re-fired
|
|
4292
|
+
// itself at a soft-timeout boundary (a chained continuation chunk), rebuild
|
|
4293
|
+
// the conversation from the persisted thread_data so the next chunk resumes
|
|
4294
|
+
// from committed progress instead of restarting from the original user
|
|
4295
|
+
// message (which would re-do work — the re-hydration thrash the design doc
|
|
4296
|
+
// calls out). Mirrors the agent-teams continuation, which seeds from
|
|
4297
|
+
// thread_data + appends a continuation nudge. Falls back to the body-derived
|
|
4298
|
+
// `messages` if thread_data is empty/unreadable — a continuation that
|
|
4299
|
+
// restarts is worse than one that resumes, but both are correct.
|
|
4300
|
+
if (isChainedBackgroundContinuation && effectiveThreadId) {
|
|
4301
|
+
try {
|
|
4302
|
+
const { getThread } = await import("../chat-threads/store.js");
|
|
4303
|
+
const { threadDataToEngineMessages } =
|
|
4304
|
+
await import("./thread-data-builder.js");
|
|
4305
|
+
const priorThreadData = (await getThread(effectiveThreadId))
|
|
4306
|
+
?.threadData;
|
|
4307
|
+
const resumed = threadDataToEngineMessages(priorThreadData);
|
|
4308
|
+
if (resumed.length > 0) {
|
|
4309
|
+
appendAgentLoopContinuation(resumed, "run_timeout");
|
|
4310
|
+
messages.length = 0;
|
|
4311
|
+
messages.push(...resumed);
|
|
4312
|
+
}
|
|
4313
|
+
} catch {
|
|
4314
|
+
// Keep the body-derived messages — never drop the run.
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
// Persist the user's turn exactly once. The foreground POST does this
|
|
4319
|
+
// before dispatching; the background worker must NOT repeat it (it re-enters
|
|
4320
|
+
// with the same body, which would double-persist the user message).
|
|
4321
|
+
if (options.onRunPrepared && !internalContinuation && !isBackgroundWorker) {
|
|
4196
4322
|
await options.onRunPrepared({
|
|
4197
4323
|
runId,
|
|
4198
4324
|
threadId,
|
|
@@ -4201,6 +4327,80 @@ export function createProductionAgentHandler(
|
|
|
4201
4327
|
});
|
|
4202
4328
|
}
|
|
4203
4329
|
|
|
4330
|
+
// ─── Durable-background dispatch decision ──────────────────────────────
|
|
4331
|
+
// Flag active (hosted + A2A_SECRET + AGENT_CHAT_DURABLE_BACKGROUND) and we
|
|
4332
|
+
// are the FOREGROUND POST: insert the run row (marked background), fire an
|
|
4333
|
+
// HMAC-signed self-dispatch into the Netlify background function (15-min
|
|
4334
|
+
// budget), and return the SSE subscription immediately. The client streams
|
|
4335
|
+
// the same events via the cross-isolate SQL-poll path with no client
|
|
4336
|
+
// change. With the flag OFF this whole branch is skipped and the inline
|
|
4337
|
+
// `startRun` path below runs exactly as before (byte-for-byte).
|
|
4338
|
+
if (dispatchToBackground) {
|
|
4339
|
+
try {
|
|
4340
|
+
// Insert the run row up front so /runs/active sees it immediately and
|
|
4341
|
+
// the slot stays held while the background function cold-starts. Mark
|
|
4342
|
+
// it background-dispatched so the stale reaper uses the wider window.
|
|
4343
|
+
await insertRun(runId, effectiveThreadId, effectiveTurnId, {
|
|
4344
|
+
dispatchMode: "background",
|
|
4345
|
+
});
|
|
4346
|
+
} catch (err) {
|
|
4347
|
+
// A duplicate-PK collision means the row already exists (retried POST);
|
|
4348
|
+
// any other failure means we can't safely hand off — fall back to the
|
|
4349
|
+
// inline path rather than dropping the turn.
|
|
4350
|
+
console.error(
|
|
4351
|
+
"[agent-chat] background insertRun failed; falling back to inline:",
|
|
4352
|
+
err instanceof Error ? err.message : err,
|
|
4353
|
+
);
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
let dispatched = false;
|
|
4357
|
+
try {
|
|
4358
|
+
await fireInternalDispatch({
|
|
4359
|
+
event,
|
|
4360
|
+
path: AGENT_CHAT_PROCESS_RUN_PATH,
|
|
4361
|
+
taskId: runId,
|
|
4362
|
+
body: {
|
|
4363
|
+
...body,
|
|
4364
|
+
// Carry the pre-claimed identity so the worker reuses this run.
|
|
4365
|
+
[AGENT_CHAT_BACKGROUND_RUN_FIELD]: {
|
|
4366
|
+
runId,
|
|
4367
|
+
turnId: effectiveTurnId,
|
|
4368
|
+
},
|
|
4369
|
+
},
|
|
4370
|
+
});
|
|
4371
|
+
dispatched = true;
|
|
4372
|
+
} catch (err) {
|
|
4373
|
+
console.error(
|
|
4374
|
+
"[agent-chat] background dispatch failed; falling back to inline:",
|
|
4375
|
+
err instanceof Error ? err.message : err,
|
|
4376
|
+
);
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
if (dispatched) {
|
|
4380
|
+
const stream = subscribeToRun(runId, 0);
|
|
4381
|
+
if (stream) {
|
|
4382
|
+
setResponseHeader(event, "Content-Type", "text/event-stream");
|
|
4383
|
+
setResponseHeader(event, "Cache-Control", "no-cache");
|
|
4384
|
+
setResponseHeader(event, "Connection", "keep-alive");
|
|
4385
|
+
setResponseHeader(event, "X-Run-Id", runId);
|
|
4386
|
+
return stream;
|
|
4387
|
+
}
|
|
4388
|
+
// Subscription failed even though dispatch landed — surface an error
|
|
4389
|
+
// rather than silently running inline (the background worker is already
|
|
4390
|
+
// processing this runId, so an inline second run would double-execute).
|
|
4391
|
+
setResponseStatus(event, 500);
|
|
4392
|
+
return { error: "Failed to subscribe to background run" };
|
|
4393
|
+
}
|
|
4394
|
+
// Dispatch failed before any worker could claim the run. Fail loud: flip
|
|
4395
|
+
// the row terminal so the held slot is released (and any reconnect sees a
|
|
4396
|
+
// terminal status instead of spinning), then 500 so the client can retry.
|
|
4397
|
+
// We do NOT silently fall through to inline here — that risks a later,
|
|
4398
|
+
// delayed background delivery double-executing the same runId.
|
|
4399
|
+
await updateRunStatusIfRunning(runId, "errored").catch(() => {});
|
|
4400
|
+
setResponseStatus(event, 500);
|
|
4401
|
+
return { error: "Failed to dispatch background run" };
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4204
4404
|
const trackedProgressOwner =
|
|
4205
4405
|
trackInRunsTray === true && ownerEmail ? ownerEmail : null;
|
|
4206
4406
|
const trackedProgressRunId = trackedProgressOwner
|
|
@@ -4276,7 +4476,19 @@ export function createProductionAgentHandler(
|
|
|
4276
4476
|
}).catch(() => {});
|
|
4277
4477
|
}
|
|
4278
4478
|
|
|
4279
|
-
|
|
4479
|
+
// The background worker must AWAIT the run to completion before returning,
|
|
4480
|
+
// or Netlify freezes/kills the function the instant the handler returns and
|
|
4481
|
+
// the detached run dies mid-turn (mirrors the agent-teams processor, which
|
|
4482
|
+
// wraps startRun in `await new Promise(resolve => startRun(..., onComplete:
|
|
4483
|
+
// () => resolve()))`). We resolve this when the run's onComplete fires.
|
|
4484
|
+
let resolveBackgroundRunDone: (() => void) | null = null;
|
|
4485
|
+
const backgroundRunDone = isBackgroundWorker
|
|
4486
|
+
? new Promise<void>((resolve) => {
|
|
4487
|
+
resolveBackgroundRunDone = resolve;
|
|
4488
|
+
})
|
|
4489
|
+
: null;
|
|
4490
|
+
|
|
4491
|
+
const baseHandleRunComplete =
|
|
4280
4492
|
options.onRunComplete || trackedProgressRunId
|
|
4281
4493
|
? async (run: ActiveRun) => {
|
|
4282
4494
|
try {
|
|
@@ -4289,6 +4501,98 @@ export function createProductionAgentHandler(
|
|
|
4289
4501
|
}
|
|
4290
4502
|
: undefined;
|
|
4291
4503
|
|
|
4504
|
+
// Wrap so the background worker is unblocked even when there is no app
|
|
4505
|
+
// onRunComplete / tracked-progress callback configured.
|
|
4506
|
+
const handleRunComplete =
|
|
4507
|
+
isBackgroundWorker || baseHandleRunComplete
|
|
4508
|
+
? async (run: ActiveRun) => {
|
|
4509
|
+
try {
|
|
4510
|
+
// Persist the (partial) assistant turn to thread_data FIRST — the
|
|
4511
|
+
// server-driven continuation below rebuilds from it, so it must be
|
|
4512
|
+
// committed before we re-fire.
|
|
4513
|
+
await baseHandleRunComplete?.(run);
|
|
4514
|
+
|
|
4515
|
+
// Server-driven background→background continuation. If this chunk
|
|
4516
|
+
// hit its soft-timeout still unfinished (ended at an auto_continue
|
|
4517
|
+
// / loop_limit / recoverable boundary), chain the next chunk by
|
|
4518
|
+
// re-firing the `_process-run` self-dispatch with mode "continue"
|
|
4519
|
+
// (carried as internalContinuation + an incremented count),
|
|
4520
|
+
// instead of relying on the client to re-POST. Mirrors the
|
|
4521
|
+
// agent-teams `fireInternalDispatch({ body: { mode: "continue" }})`
|
|
4522
|
+
// chain. Bounded by MAX_BACKGROUND_RUN_CONTINUATIONS. Aborted /
|
|
4523
|
+
// user-stopped runs do NOT chain.
|
|
4524
|
+
if (
|
|
4525
|
+
shouldChainBackgroundContinuation({
|
|
4526
|
+
isBackgroundWorker,
|
|
4527
|
+
run,
|
|
4528
|
+
continuationCount: backgroundContinuationCount,
|
|
4529
|
+
})
|
|
4530
|
+
) {
|
|
4531
|
+
// Mint the next chunk's runId here and sign the dispatch token
|
|
4532
|
+
// over it, so the `_process-run` route's HMAC check and the
|
|
4533
|
+
// worker's run identity agree. Fresh runId (not this chunk's) so
|
|
4534
|
+
// its seq log starts clean; same turnId folds the assistant
|
|
4535
|
+
// message across chunks.
|
|
4536
|
+
const nextRunId = generateRunId();
|
|
4537
|
+
try {
|
|
4538
|
+
await fireInternalDispatch({
|
|
4539
|
+
event,
|
|
4540
|
+
path: AGENT_CHAT_PROCESS_RUN_PATH,
|
|
4541
|
+
taskId: nextRunId,
|
|
4542
|
+
body: {
|
|
4543
|
+
...body,
|
|
4544
|
+
internalContinuation: true,
|
|
4545
|
+
[AGENT_CHAT_BACKGROUND_RUN_FIELD]: {
|
|
4546
|
+
runId: nextRunId,
|
|
4547
|
+
turnId: effectiveTurnId,
|
|
4548
|
+
continuationCount: backgroundContinuationCount + 1,
|
|
4549
|
+
},
|
|
4550
|
+
},
|
|
4551
|
+
});
|
|
4552
|
+
} catch (chainErr) {
|
|
4553
|
+
// Chain dispatch failed — fail loud so the held row goes
|
|
4554
|
+
// terminal instead of spinning. The reaper would also catch
|
|
4555
|
+
// it, but this is immediate and truthful.
|
|
4556
|
+
console.error(
|
|
4557
|
+
"[agent-chat] background continuation dispatch failed:",
|
|
4558
|
+
chainErr instanceof Error ? chainErr.message : chainErr,
|
|
4559
|
+
);
|
|
4560
|
+
await updateRunStatusIfRunning(runId, "errored").catch(
|
|
4561
|
+
() => {},
|
|
4562
|
+
);
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
} finally {
|
|
4566
|
+
resolveBackgroundRunDone?.();
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
: undefined;
|
|
4570
|
+
|
|
4571
|
+
// Background worker: claim the pre-inserted run idempotently before
|
|
4572
|
+
// executing. A duplicate Netlify delivery loses the claim and no-ops here,
|
|
4573
|
+
// so the run can never be double-executed. Bump the heartbeat immediately
|
|
4574
|
+
// on entry so a slow cold-start doesn't leave the row looking stale to the
|
|
4575
|
+
// reaper before startRun's 1.5s heartbeat timer takes over.
|
|
4576
|
+
if (isBackgroundWorker) {
|
|
4577
|
+
// A chained continuation chunk's runId was minted by the prior chunk and
|
|
4578
|
+
// never inserted, so insert its background row now (idempotently — a
|
|
4579
|
+
// duplicate Netlify delivery that already inserted it just PK-collides and
|
|
4580
|
+
// the claim below dedups). The first chunk's row was inserted by the
|
|
4581
|
+
// foreground, so skip the insert there.
|
|
4582
|
+
if (isChainedBackgroundContinuation) {
|
|
4583
|
+
await insertRun(runId, effectiveThreadId, effectiveTurnId, {
|
|
4584
|
+
dispatchMode: "background",
|
|
4585
|
+
}).catch(() => {});
|
|
4586
|
+
}
|
|
4587
|
+
const won = await claimBackgroundRun(runId);
|
|
4588
|
+
if (!won) {
|
|
4589
|
+
// Already claimed by an earlier delivery — return a benign ack so
|
|
4590
|
+
// Netlify doesn't retry a successful handoff.
|
|
4591
|
+
return { ok: true, skipped: "already-claimed" };
|
|
4592
|
+
}
|
|
4593
|
+
await updateRunHeartbeat(runId).catch(() => {});
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4292
4596
|
startRun(
|
|
4293
4597
|
runId,
|
|
4294
4598
|
effectiveThreadId,
|
|
@@ -4677,6 +4981,10 @@ export function createProductionAgentHandler(
|
|
|
4677
4981
|
{
|
|
4678
4982
|
softTimeoutMs: options.runSoftTimeoutMs,
|
|
4679
4983
|
useHostedSoftTimeoutDefault: true,
|
|
4984
|
+
// Inside the Netlify background function there is no ~60s wall, so lift
|
|
4985
|
+
// the soft-timeout clamp to ~13min for THIS run only. Foreground runs
|
|
4986
|
+
// never set this, so their 40s clamp is unchanged.
|
|
4987
|
+
backgroundFunction: isBackgroundWorker,
|
|
4680
4988
|
// Fold continuation runs of one logical turn onto a single durable
|
|
4681
4989
|
// assistant message. Falls back to the runId (turn == run) when the
|
|
4682
4990
|
// client doesn't supply a turnId.
|
|
@@ -4684,6 +4992,15 @@ export function createProductionAgentHandler(
|
|
|
4684
4992
|
},
|
|
4685
4993
|
);
|
|
4686
4994
|
|
|
4995
|
+
// Background worker: await the run to completion so Netlify keeps the
|
|
4996
|
+
// background function alive for the whole turn (the client is streaming the
|
|
4997
|
+
// same events via the foreground POST's cross-isolate SQL subscription).
|
|
4998
|
+
// The onComplete wrapper above resolves `backgroundRunDone`.
|
|
4999
|
+
if (isBackgroundWorker) {
|
|
5000
|
+
if (backgroundRunDone) await backgroundRunDone;
|
|
5001
|
+
return { ok: true, runId };
|
|
5002
|
+
}
|
|
5003
|
+
|
|
4687
5004
|
// Subscribe to the run and stream events to the client
|
|
4688
5005
|
const stream = subscribeToRun(runId, 0);
|
|
4689
5006
|
if (!stream) {
|