@agent-native/core 0.204.0 → 0.204.1-nightly-20261008034100

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.
Files changed (106) hide show
  1. package/dist/action.js +5 -6
  2. package/dist/agent/auto-continue.d.ts +35 -0
  3. package/dist/agent/auto-continue.js +38 -0
  4. package/dist/agent/connection-required-note.d.ts +52 -0
  5. package/dist/agent/connection-required-note.js +187 -0
  6. package/dist/agent/jev-tool-prefetch.d.ts +5 -1
  7. package/dist/agent/jev-tool-prefetch.js +19 -6
  8. package/dist/agent/previous-turn-note.d.ts +9 -0
  9. package/dist/agent/previous-turn-note.js +48 -0
  10. package/dist/agent/production-agent.d.ts +17 -13
  11. package/dist/agent/production-agent.js +185 -43
  12. package/dist/agent/reaped-run-thread.d.ts +13 -0
  13. package/dist/agent/reaped-run-thread.js +45 -0
  14. package/dist/agent/run-store.d.ts +29 -5
  15. package/dist/agent/run-store.js +144 -30
  16. package/dist/agent/thread-data-builder.d.ts +16 -0
  17. package/dist/agent/thread-data-builder.js +30 -8
  18. package/dist/agent/thread-message-projection.js +5 -3
  19. package/dist/agent/tool-search.js +5 -1
  20. package/dist/agent/types.d.ts +3 -0
  21. package/dist/audit/config.d.ts +6 -0
  22. package/dist/audit/config.js +12 -1
  23. package/dist/audit/index.d.ts +1 -1
  24. package/dist/audit/index.js +1 -1
  25. package/dist/audit/store.js +39 -4
  26. package/dist/cli/skills.d.ts +1 -0
  27. package/dist/cli/skills.js +1 -1
  28. package/dist/client/chat/agentkit-agent-native.js +83 -7
  29. package/dist/client/chat/agentkit-protocol.js +140 -67
  30. package/dist/client/chat/runtime.js +5 -3
  31. package/dist/client/use-run-stuck-detection.d.ts +18 -1
  32. package/dist/client/use-run-stuck-detection.js +115 -75
  33. package/dist/db/request-telemetry.d.ts +7 -1
  34. package/dist/db/request-telemetry.js +20 -1
  35. package/dist/deploy/build.d.ts +25 -1
  36. package/dist/deploy/build.js +176 -3
  37. package/dist/deploy/workspace-deploy.js +8 -3
  38. package/dist/extensions/fetch-tool.js +50 -14
  39. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  40. package/dist/integrations/adapters/email.js +11 -3
  41. package/dist/integrations/adapters/telegram.js +4 -2
  42. package/dist/jobs/actions/automation-no-op.d.ts +12 -0
  43. package/dist/jobs/actions/automation-no-op.js +20 -0
  44. package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
  45. package/dist/jobs/background-automation-runner.d.ts +8 -1
  46. package/dist/jobs/background-automation-runner.js +102 -19
  47. package/dist/jobs/run-history.d.ts +1 -1
  48. package/dist/jobs/run-history.js +8 -4
  49. package/dist/jobs/scheduler.js +25 -12
  50. package/dist/localization/automation-outcome-messages.d.ts +22 -0
  51. package/dist/localization/automation-outcome-messages.js +90 -0
  52. package/dist/localization/default-messages.d.ts +2 -0
  53. package/dist/localization/default-messages.js +4 -2
  54. package/dist/notifications/routes.d.ts +1 -1
  55. package/dist/observability/metrics.d.ts +3 -0
  56. package/dist/observability/metrics.js +10 -0
  57. package/dist/observability/migrations.js +22 -0
  58. package/dist/observability/routes.d.ts +13 -8
  59. package/dist/observability/routes.js +88 -14
  60. package/dist/observability/sentiment.d.ts +5 -0
  61. package/dist/observability/sentiment.js +75 -25
  62. package/dist/observability/store.d.ts +28 -0
  63. package/dist/observability/store.js +58 -0
  64. package/dist/observability/traces.js +37 -5
  65. package/dist/org/index.d.ts +2 -2
  66. package/dist/progress/actions.js +1 -0
  67. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  68. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  69. package/dist/provider-api/actions/provider-api.js +6 -2
  70. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  71. package/dist/provider-api/index.d.ts +1 -0
  72. package/dist/provider-api/index.js +18 -0
  73. package/dist/provider-api/result-outcome.d.ts +1 -0
  74. package/dist/provider-api/result-outcome.js +20 -0
  75. package/dist/provider-api/staging.js +10 -9
  76. package/dist/server/agent-chat/prompt-resources.d.ts +8 -2
  77. package/dist/server/agent-chat/prompt-resources.js +30 -7
  78. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +19 -4
  79. package/dist/server/agent-chat/recurring-jobs-runtime.js +52 -16
  80. package/dist/server/agent-chat/sweep-trigger-auth.d.ts +18 -0
  81. package/dist/server/agent-chat/sweep-trigger-auth.js +36 -0
  82. package/dist/server/agent-chat-plugin.js +20 -13
  83. package/dist/server/agent-engine-default-model-route.d.ts +2 -2
  84. package/dist/server/auth.js +4 -4
  85. package/dist/server/framework-request-handler.js +19 -0
  86. package/dist/server/http-response-telemetry.js +4 -1
  87. package/dist/server/realtime-token.d.ts +1 -1
  88. package/dist/server/request-context.d.ts +6 -0
  89. package/dist/shared/context-status.d.ts +21 -0
  90. package/dist/shared/context-status.js +54 -0
  91. package/dist/templates/chat/app/components/chat/ChatRouteContent.tsx +8 -1
  92. package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +8 -1
  93. package/dist/templates/workspace-core/.agents/skills/recurring-jobs/SKILL.md +3 -1
  94. package/dist/triggers/actions/manage-automation.d.ts +7 -7
  95. package/dist/triggers/dispatcher.js +10 -7
  96. package/docs/content/audit-log.mdx +3 -3
  97. package/docs/content/cloudflare.mdx +27 -0
  98. package/docs/content/deployment-environment-variables.mdx +2 -0
  99. package/docs/content/durable-resume.mdx +3 -1
  100. package/docs/content/environment-variables.mdx +2 -0
  101. package/docs/content/recurring-jobs.mdx +29 -9
  102. package/docs/content/vercel.mdx +30 -0
  103. package/package.json +3 -3
  104. package/src/templates/chat/app/components/chat/ChatRouteContent.tsx +8 -1
  105. package/src/templates/workspace-core/.agents/skills/automations/SKILL.md +8 -1
  106. package/src/templates/workspace-core/.agents/skills/recurring-jobs/SKILL.md +3 -1
@@ -9,10 +9,15 @@ const DEFAULT_POLL_INTERVAL_MS = 5_000;
9
9
  const IDLE_BACKOFF_INTERVAL_MS = 15_000;
10
10
  const MAX_POLL_ERROR_BACKOFF_MS = 30_000;
11
11
  const FRESH_BACKGROUND_HEARTBEAT_MS = 30_000;
12
+ const SETTLED_CONFIRMATIONS = 2;
13
+ const UNREADABLE_AFTER_FAILURES = 2;
12
14
  const POLL_ABORT_MIN_MS = 10_000;
13
15
  function getPollAbortMs(interval) {
14
16
  return Math.max(POLL_ABORT_MIN_MS, interval * 4);
15
17
  }
18
+ function isActiveRunResponse(body) {
19
+ return typeof body?.active === "boolean";
20
+ }
16
21
  const EMPTY_STATE = {
17
22
  isStuck: false,
18
23
  runId: null,
@@ -24,9 +29,27 @@ const EMPTY_STATE = {
24
29
  heartbeatSinceMs: null,
25
30
  dispatchMode: null,
26
31
  hasInFlightWork: null,
32
+ serverSettled: false,
33
+ statusUnreadable: false,
27
34
  };
28
- export function useRunStuckDetection({ threadId, enabled = true, stuckThresholdMs = DEFAULT_STUCK_THRESHOLD_MS, backgroundStuckThresholdMs = DEFAULT_BACKGROUND_STUCK_THRESHOLD_MS, liveBackgroundStuckThresholdMs = DEFAULT_LIVE_BACKGROUND_STUCK_THRESHOLD_MS, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, apiUrl, }) {
35
+ export function useRunStuckDetection({ threadId, enabled = true, awaitingResponse, stuckThresholdMs = DEFAULT_STUCK_THRESHOLD_MS, backgroundStuckThresholdMs = DEFAULT_BACKGROUND_STUCK_THRESHOLD_MS, liveBackgroundStuckThresholdMs = DEFAULT_LIVE_BACKGROUND_STUCK_THRESHOLD_MS, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, apiUrl, }) {
29
36
  const [state, setState] = useState(EMPTY_STATE);
37
+ // Reset in render, not in the effect: an effect-time reset still lets the
38
+ // consumer's own effects act once on the previous answer. The epoch also
39
+ // restarts polling, which drops any poll still in flight from before.
40
+ const awaiting = awaitingResponse === true;
41
+ const [scope, setScope] = useState({ threadId, awaiting, epoch: 0 });
42
+ if (scope.threadId !== threadId || scope.awaiting !== awaiting) {
43
+ const waitingStarted = awaiting && !scope.awaiting;
44
+ setScope({
45
+ threadId,
46
+ awaiting,
47
+ epoch: scope.epoch + (waitingStarted ? 1 : 0),
48
+ });
49
+ if (waitingStarted || scope.threadId !== threadId)
50
+ setState(EMPTY_STATE);
51
+ }
52
+ const awaitingEpoch = scope.epoch;
30
53
  useEffect(() => {
31
54
  setState(EMPTY_STATE);
32
55
  if (!threadId || !enabled)
@@ -38,18 +61,24 @@ export function useRunStuckDetection({ threadId, enabled = true, stuckThresholdM
38
61
  let lastObservedLocalProgressAt = null;
39
62
  let lastObservedLocalProgressSeq = null;
40
63
  let consecutivePollFailures = 0;
64
+ let consecutiveIdlePolls = 0;
41
65
  const nextDelayRef = { current: 2_000 };
42
66
  const pollFailureDelay = () => {
43
67
  consecutivePollFailures += 1;
68
+ // A poll that got no answer breaks an idle streak still being confirmed:
69
+ // a run can start and be missed between two idle answers that surround
70
+ // it. A streak that already settled stands, or the verdict would flip
71
+ // off and on again across one outage.
72
+ if (consecutiveIdlePolls < SETTLED_CONFIRMATIONS) {
73
+ consecutiveIdlePolls = 0;
74
+ }
75
+ if (!cancelled && consecutivePollFailures >= UNREADABLE_AFTER_FAILURES) {
76
+ setState((current) => current.statusUnreadable
77
+ ? current
78
+ : { ...current, statusUnreadable: true });
79
+ }
44
80
  return Math.min(Math.max(pollIntervalMs, 1) * 2 ** consecutivePollFailures, MAX_POLL_ERROR_BACKOFF_MS);
45
81
  };
46
- const clearObservedRun = () => {
47
- snapshotVersion += 1;
48
- if (snapshotTransitionTimer)
49
- clearTimeout(snapshotTransitionTimer);
50
- snapshotTransitionTimer = null;
51
- setState(EMPTY_STATE);
52
- };
53
82
  const effectiveThresholdFor = (dispatchMode, heartbeatSinceMs) => {
54
83
  const liveBackgroundWorker = dispatchMode === "background-processing" &&
55
84
  heartbeatSinceMs != null &&
@@ -180,77 +209,87 @@ export function useRunStuckDetection({ threadId, enabled = true, stuckThresholdM
180
209
  const res = await fetch(`${base}/runs/active?threadId=${encodeURIComponent(threadId)}`, { credentials: "same-origin", signal });
181
210
  if (cancelled)
182
211
  return;
183
- if (res.ok) {
184
- consecutivePollFailures = 0;
185
- const data = (await res.json());
186
- const lastProgressAt = data.lastProgressAt ?? null;
187
- const nowMs = data.serverNow ?? Date.now();
188
- const stuckSinceMs = lastProgressAt != null ? nowMs - lastProgressAt : null;
189
- const heartbeatAt = data.heartbeatAt ?? null;
190
- const heartbeatSinceMs = heartbeatAt != null ? nowMs - heartbeatAt : null;
191
- const dispatchMode = typeof data.dispatchMode === "string" ? data.dispatchMode : null;
192
- const runId = data.runId ?? null;
193
- const activeRun = getActiveRun();
194
- const localProgressObservedAt = activeRun?.threadId === threadId && activeRun.runId === runId
195
- ? (activeRun.lastProgressObservedAt ?? null)
196
- : null;
197
- const localProgressSinceMs = localProgressObservedAt == null
198
- ? null
199
- : Math.max(0, Date.now() - localProgressObservedAt);
200
- const effectiveThresholdMs = effectiveThresholdFor(dispatchMode, heartbeatSinceMs);
201
- const isStuck = Boolean(data.active &&
202
- data.status === "running" &&
203
- stuckSinceMs != null &&
204
- stuckSinceMs > effectiveThresholdMs &&
205
- (localProgressSinceMs == null ||
206
- localProgressSinceMs > effectiveThresholdMs));
207
- const observedAtMs = Date.now();
208
- const version = ++snapshotVersion;
209
- scheduleSnapshotTransition({
210
- active: data.active,
211
- runId,
212
- status: data.status ?? null,
213
- lastProgressAt,
214
- stuckSinceMs,
215
- localProgressObservedAt,
216
- lastProgressSeq: activeRun?.threadId === threadId && activeRun.runId === runId
217
- ? (activeRun.lastProgressSeq ?? null)
218
- : null,
219
- heartbeatAt,
220
- heartbeatSinceMs,
221
- dispatchMode,
222
- }, observedAtMs, version);
223
- setState({
224
- isStuck,
225
- runId,
226
- status: data.status ?? null,
227
- lastProgressAt,
228
- stuckSinceMs: localProgressSinceMs != null &&
229
- localProgressSinceMs <= effectiveThresholdMs
230
- ? null
231
- : stuckSinceMs,
232
- lastProgressSeq: activeRun?.threadId === threadId && activeRun.runId === runId
233
- ? (activeRun.lastProgressSeq ?? null)
234
- : null,
235
- heartbeatAt,
236
- heartbeatSinceMs,
237
- dispatchMode,
238
- hasInFlightWork: typeof data.hasInFlightWork === "boolean"
239
- ? data.hasInFlightWork
240
- : null,
241
- });
242
- if (!data.active || data.status !== "running") {
243
- nextDelay = IDLE_BACKOFF_INTERVAL_MS;
244
- }
212
+ // The endpoint answers "no run" with a 200 and a boolean `active`, so
213
+ // no other status or body is an idle answer: a 401/403/404 or a body
214
+ // without `active` leaves the run's state unknown, not gone.
215
+ if (!res.ok) {
216
+ nextDelay = pollFailureDelay();
217
+ return;
245
218
  }
246
- else if (res.status === 429 || res.status >= 500) {
219
+ const data = await res.json();
220
+ if (cancelled)
221
+ return;
222
+ if (!isActiveRunResponse(data)) {
247
223
  nextDelay = pollFailureDelay();
224
+ return;
248
225
  }
249
- else {
250
- consecutivePollFailures = 0;
251
- clearObservedRun();
252
- nextDelay = MAX_POLL_ERROR_BACKOFF_MS;
226
+ consecutiveIdlePolls = data.active ? 0 : consecutiveIdlePolls + 1;
227
+ const serverSettled = consecutiveIdlePolls >= SETTLED_CONFIRMATIONS;
228
+ const lastProgressAt = data.lastProgressAt ?? null;
229
+ const nowMs = data.serverNow ?? Date.now();
230
+ const stuckSinceMs = lastProgressAt != null ? nowMs - lastProgressAt : null;
231
+ const heartbeatAt = data.heartbeatAt ?? null;
232
+ const heartbeatSinceMs = heartbeatAt != null ? nowMs - heartbeatAt : null;
233
+ const dispatchMode = typeof data.dispatchMode === "string" ? data.dispatchMode : null;
234
+ const runId = data.runId ?? null;
235
+ const activeRun = getActiveRun();
236
+ const localProgressObservedAt = activeRun?.threadId === threadId && activeRun.runId === runId
237
+ ? (activeRun.lastProgressObservedAt ?? null)
238
+ : null;
239
+ const localProgressSinceMs = localProgressObservedAt == null
240
+ ? null
241
+ : Math.max(0, Date.now() - localProgressObservedAt);
242
+ const effectiveThresholdMs = effectiveThresholdFor(dispatchMode, heartbeatSinceMs);
243
+ const isStuck = Boolean(data.active &&
244
+ data.status === "running" &&
245
+ stuckSinceMs != null &&
246
+ stuckSinceMs > effectiveThresholdMs &&
247
+ (localProgressSinceMs == null ||
248
+ localProgressSinceMs > effectiveThresholdMs));
249
+ const observedAtMs = Date.now();
250
+ const version = ++snapshotVersion;
251
+ scheduleSnapshotTransition({
252
+ active: data.active,
253
+ runId,
254
+ status: data.status ?? null,
255
+ lastProgressAt,
256
+ stuckSinceMs,
257
+ localProgressObservedAt,
258
+ lastProgressSeq: activeRun?.threadId === threadId && activeRun.runId === runId
259
+ ? (activeRun.lastProgressSeq ?? null)
260
+ : null,
261
+ heartbeatAt,
262
+ heartbeatSinceMs,
263
+ dispatchMode,
264
+ }, observedAtMs, version);
265
+ setState({
266
+ isStuck,
267
+ runId,
268
+ status: data.status ?? null,
269
+ lastProgressAt,
270
+ stuckSinceMs: localProgressSinceMs != null &&
271
+ localProgressSinceMs <= effectiveThresholdMs
272
+ ? null
273
+ : stuckSinceMs,
274
+ lastProgressSeq: activeRun?.threadId === threadId && activeRun.runId === runId
275
+ ? (activeRun.lastProgressSeq ?? null)
276
+ : null,
277
+ heartbeatAt,
278
+ heartbeatSinceMs,
279
+ dispatchMode,
280
+ hasInFlightWork: typeof data.hasInFlightWork === "boolean"
281
+ ? data.hasInFlightWork
282
+ : null,
283
+ serverSettled,
284
+ statusUnreadable: false,
285
+ });
286
+ // The first idle answer is confirmed at the normal cadence, so a
287
+ // run the server has not registered yet is not called settled.
288
+ const confirmingIdle = !data.active && !serverSettled;
289
+ if (!confirmingIdle && (!data.active || data.status !== "running")) {
290
+ nextDelay = IDLE_BACKOFF_INTERVAL_MS;
253
291
  }
292
+ consecutivePollFailures = 0;
254
293
  }
255
294
  catch {
256
295
  nextDelay = pollFailureDelay();
@@ -292,6 +331,7 @@ export function useRunStuckDetection({ threadId, enabled = true, stuckThresholdM
292
331
  liveBackgroundStuckThresholdMs,
293
332
  pollIntervalMs,
294
333
  apiUrl,
334
+ awaitingEpoch,
295
335
  ]);
296
336
  return state;
297
337
  }
@@ -20,7 +20,13 @@ export interface DatabaseRequestTelemetry {
20
20
  export declare function createDatabaseRequestTelemetry(): DatabaseRequestTelemetry;
21
21
  export declare function claimStartupDatabaseTelemetry(): DatabaseRequestTelemetry | undefined;
22
22
  export declare function runWithDatabaseRequestTelemetry<T>(telemetry: DatabaseRequestTelemetry, fn: () => T): T;
23
- export declare function enterDatabaseRequestTelemetry(telemetry: DatabaseRequestTelemetry): void;
23
+ /**
24
+ * Returns false when the runtime cannot scope telemetry to the rest of the
25
+ * request. workerd's AsyncLocalStorage implements `run()` but throws from
26
+ * `enterWith()`, and a throw here escapes the Nitro request hook and skips
27
+ * every hook after it, including the framework route readiness gate.
28
+ */
29
+ export declare function enterDatabaseRequestTelemetry(telemetry: DatabaseRequestTelemetry): boolean;
24
30
  export declare function beginDatabaseOperation(operation: DatabaseOperationKind): (outcome: "success" | "error" | "timeout") => void;
25
31
  export declare function recordDatabaseRetry(): void;
26
32
  export declare function recordDatabaseQueryResult(sql: string, rowsReturned: number): void;
@@ -62,8 +62,27 @@ export function claimStartupDatabaseTelemetry() {
62
62
  export function runWithDatabaseRequestTelemetry(telemetry, fn) {
63
63
  return getStorage().run(telemetry, fn);
64
64
  }
65
+ const storagesWithoutEnterWith = new WeakSet();
66
+ /**
67
+ * Returns false when the runtime cannot scope telemetry to the rest of the
68
+ * request. workerd's AsyncLocalStorage implements `run()` but throws from
69
+ * `enterWith()`, and a throw here escapes the Nitro request hook and skips
70
+ * every hook after it, including the framework route readiness gate.
71
+ */
65
72
  export function enterDatabaseRequestTelemetry(telemetry) {
66
- getStorage().enterWith(telemetry);
73
+ const storage = getStorage();
74
+ if (storage === NOOP_STORAGE || storagesWithoutEnterWith.has(storage)) {
75
+ return false;
76
+ }
77
+ try {
78
+ storage.enterWith(telemetry);
79
+ return true;
80
+ }
81
+ catch (error) {
82
+ storagesWithoutEnterWith.add(storage);
83
+ console.warn("[db] Per-request database telemetry is unavailable: AsyncLocalStorage.enterWith() failed on this runtime.", error);
84
+ return false;
85
+ }
67
86
  }
68
87
  export function beginDatabaseOperation(operation) {
69
88
  const telemetry = currentDatabaseTelemetry();
@@ -15,7 +15,15 @@ export declare const CLOUDFLARE_MODULE_WORKER_ENTRY = "worker.mjs";
15
15
  export declare function configureAwsAmplifyRuntimeOutput(serverDir: string, appDir: string, env?: NodeJS.ProcessEnv): void;
16
16
  export declare function configureAwsLambdaRuntimeOutput(serverDir: string, appDir: string, env?: NodeJS.ProcessEnv): void;
17
17
  export declare function shimCloudflarePagesModuleTimers(code: string): string;
18
- export declare function generateCloudflareModuleWorkerEntry(): string;
18
+ export declare const CLOUDFLARE_SWEEP_CRON = "* * * * *";
19
+ /**
20
+ * The URL path a platform scheduler requests. Under a custom framework route
21
+ * prefix the request boundary 404s `/_agent-native/*` before any route runs,
22
+ * and the server is mounted under the app base path, so the internal sweep
23
+ * path alone never reaches the sweep.
24
+ */
25
+ export declare function publicRecurringJobsSweepPath(env?: NodeJS.ProcessEnv): string;
26
+ export declare function generateCloudflareModuleWorkerEntry(env?: NodeJS.ProcessEnv): string;
19
27
  export declare function patchCloudflareModuleNitroEntry(code: string): string;
20
28
  export declare function configureCloudflareModuleWorkerOutput(serverDir: string): void;
21
29
  export declare const NITRO_RUNTIME_IGNORE_PATTERNS: string[];
@@ -65,6 +73,22 @@ export declare function isRecurringJobsDeployEnabled(): boolean;
65
73
  export declare function isKeepWarmDeployEnabled(): boolean;
66
74
  export declare function isKeepWarmBackgroundDeployEnabled(): boolean;
67
75
  export declare function resolveKeepWarmSchedule(): string;
76
+ export declare function resolveVercelSweepCronSchedule(env?: NodeJS.ProcessEnv): string;
77
+ export declare function vercelSweepCrons(sweepPaths: readonly string[], env?: NodeJS.ProcessEnv): Array<{
78
+ path: string;
79
+ schedule: string;
80
+ }>;
81
+ export declare function addVercelSweepCron(outputDir: string, env?: NodeJS.ProcessEnv): void;
82
+ /**
83
+ * Reads back the sweep cron `addVercelSweepCron` wrote for an app mounted at
84
+ * `basePath`. A workspace deploy must reuse it rather than recompute it: the
85
+ * app's build resolved its framework route prefix from config files the
86
+ * workspace build never reads.
87
+ */
88
+ export declare function readVercelSweepCron(outputDir: string, basePath: string): {
89
+ path: string;
90
+ schedule: string;
91
+ };
68
92
  export declare function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd: string): void;
69
93
  export declare function emitSingleTemplateNetlifyRecurringJobsFunction(projectCwd: string): void;
70
94
  /**
@@ -19,7 +19,7 @@ import { frameworkSessionHintCookieName, resolveAuthCookieNamespace, } from "../
19
19
  import { resolveAgentNativeBuildId } from "../shared/build-id.js";
20
20
  import { DEFAULT_SPECULATION_RULES_PATH, resolveSsrCacheHeaders, resolveSsrCacheKeyHeaders, SSR_QUERY_CACHE_KEY_HEADER, } from "../shared/cache-control.js";
21
21
  import { EMBED_TARGET_QUERY_PARAM, EMBED_TOKEN_QUERY_PARAM, } from "../shared/embed-auth.js";
22
- import { normalizeFrameworkRoutePrefix } from "../shared/framework-route-prefix.js";
22
+ import { FRAMEWORK_INTERNAL_ROUTE_PREFIX, normalizeFrameworkRoutePrefix, toPublicFrameworkPath, } from "../shared/framework-route-prefix.js";
23
23
  import { mcpEmbedStaticAssetRouteRules } from "../shared/mcp-embed-headers.js";
24
24
  import { isTruthyRuntimeValue } from "../shared/runtime-config.js";
25
25
  import { AGENT_NATIVE_SOCIAL_IMAGE_ALT, AGENT_NATIVE_SOCIAL_IMAGE_CACHE_BUSTER, AGENT_NATIVE_SOCIAL_IMAGE_HEIGHT, AGENT_NATIVE_SOCIAL_IMAGE_PATH, AGENT_NATIVE_SOCIAL_IMAGE_TYPE, AGENT_NATIVE_SOCIAL_IMAGE_WIDTH, } from "../shared/social-meta.js";
@@ -294,7 +294,79 @@ export function shimCloudflarePagesModuleTimers(code) {
294
294
  }
295
295
  return code;
296
296
  }
297
- export function generateCloudflareModuleWorkerEntry() {
297
+ export const CLOUDFLARE_SWEEP_CRON = "* * * * *";
298
+ /**
299
+ * The URL path a platform scheduler requests. Under a custom framework route
300
+ * prefix the request boundary 404s `/_agent-native/*` before any route runs,
301
+ * and the server is mounted under the app base path, so the internal sweep
302
+ * path alone never reaches the sweep.
303
+ */
304
+ export function publicRecurringJobsSweepPath(env = process.env) {
305
+ const basePath = normalizeAppBasePath(env.VITE_APP_BASE_PATH || env.APP_BASE_PATH);
306
+ const publicPath = toPublicFrameworkPath(RECURRING_JOBS_SWEEP_PATH, {
307
+ publicPrefix: resolveBuildFrameworkRoutePrefix(env),
308
+ });
309
+ return `${basePath}${publicPath}`;
310
+ }
311
+ // The token format must match verifyInternalToken in
312
+ // integrations/internal-token.ts.
313
+ function cloudflareSweepTriggerScript(sweepPath) {
314
+ return `const SWEEP_CRON = ${JSON.stringify(CLOUDFLARE_SWEEP_CRON)};
315
+ const SWEEP_PATH = ${JSON.stringify(sweepPath)};
316
+ const SWEEP_TOKEN_SUBJECT = ${JSON.stringify(RECURRING_JOBS_SWEEP_TOKEN_SUBJECT)};
317
+
318
+ async function sweepToken(secret) {
319
+ const timestamp = Date.now();
320
+ const encoder = new TextEncoder();
321
+ const key = await crypto.subtle.importKey(
322
+ "raw",
323
+ encoder.encode(secret),
324
+ { name: "HMAC", hash: "SHA-256" },
325
+ false,
326
+ ["sign"],
327
+ );
328
+ const signature = await crypto.subtle.sign(
329
+ "HMAC",
330
+ key,
331
+ encoder.encode(SWEEP_TOKEN_SUBJECT + ":" + timestamp),
332
+ );
333
+ const hex = Array.from(new Uint8Array(signature), (byte) =>
334
+ byte.toString(16).padStart(2, "0"),
335
+ ).join("");
336
+ return timestamp + "." + hex;
337
+ }
338
+
339
+ async function runSweep(h, env, ctx) {
340
+ const secret = env?.A2A_SECRET;
341
+ if (!secret) {
342
+ throw new Error("[recurring-jobs] A2A_SECRET is required for the scheduled sweep");
343
+ }
344
+ const request = new Request(
345
+ new URL(SWEEP_PATH, env.APP_URL || "https://scheduled-sweep.invalid"),
346
+ {
347
+ method: "POST",
348
+ headers: {
349
+ Authorization: "Bearer " + (await sweepToken(secret)),
350
+ "Content-Type": "application/json",
351
+ "user-agent": "agent-native-cloudflare-cron",
352
+ },
353
+ body: "{}",
354
+ },
355
+ );
356
+ if (typeof ctx?.waitUntil === "function") {
357
+ request.waitUntil = ctx.waitUntil.bind(ctx);
358
+ }
359
+ const response = await h.fetch(request, env, ctx);
360
+ const body = await response.text();
361
+ if (!response.ok) {
362
+ throw new Error(
363
+ "[recurring-jobs] Scheduled sweep failed (" + response.status + "): " + body.slice(0, 500),
364
+ );
365
+ }
366
+ console.log("[recurring-jobs] Scheduled sweep finished", body.slice(0, 500));
367
+ }`;
368
+ }
369
+ export function generateCloudflareModuleWorkerEntry(env = process.env) {
298
370
  return `let handler;
299
371
 
300
372
  async function loadHandler() {
@@ -306,6 +378,8 @@ ${cloudflareBindingsInitScript()}
306
378
 
307
379
  ${cloudflareModuleTimerRestoreScript()}
308
380
 
381
+ ${cloudflareSweepTriggerScript(publicRecurringJobsSweepPath(env))}
382
+
309
383
  export default {
310
384
  async fetch(request, env, ctx) {
311
385
  if (typeof ctx?.waitUntil === "function") {
@@ -320,7 +394,10 @@ export default {
320
394
  initializeBindings(env);
321
395
  const h = await loadHandler();
322
396
  __cfRestoreModuleTimers();
323
- return h.scheduled?.(controller, env, ctx);
397
+ await Promise.all([
398
+ h.scheduled?.(controller, env, ctx),
399
+ controller?.cron === SWEEP_CRON ? runSweep(h, env, ctx) : undefined,
400
+ ]);
324
401
  },
325
402
  async email(message, env, ctx) {
326
403
  initializeBindings(env);
@@ -386,6 +463,13 @@ export function configureCloudflareModuleWorkerOutput(serverDir) {
386
463
  }
387
464
  const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
388
465
  config.main = CLOUDFLARE_MODULE_WORKER_ENTRY;
466
+ const crons = Array.isArray(config.triggers?.crons)
467
+ ? config.triggers.crons.filter((cron) => typeof cron === "string")
468
+ : [];
469
+ config.triggers = {
470
+ ...config.triggers,
471
+ crons: [...new Set([...crons, CLOUDFLARE_SWEEP_CRON])],
472
+ };
389
473
  const compatibilityFlags = Array.isArray(config.compatibility_flags)
390
474
  ? config.compatibility_flags.filter((flag) => typeof flag === "string")
391
475
  : [];
@@ -2696,6 +2780,92 @@ export function resolveKeepWarmSchedule() {
2696
2780
  }
2697
2781
  return raw;
2698
2782
  }
2783
+ const DEFAULT_VERCEL_SWEEP_CRON_SCHEDULE = "* * * * *";
2784
+ // Vercel accepts a narrower grammar than cron-parser, and a schedule outside
2785
+ // it fails the deployment rather than the build.
2786
+ function vercelCronScheduleProblem(raw) {
2787
+ const fields = raw.split(/\s+/);
2788
+ if (fields.length !== 5 || !isValidCron(raw)) {
2789
+ return `must be a 5-field cron expression (minute hour day month weekday); got "${raw}" (${fields.length} field(s))`;
2790
+ }
2791
+ if (!fields.every((field) => /^[\d*,/-]+$/.test(field))) {
2792
+ return `must use numbers, not names such as MON or JAN, on Vercel; got "${raw}"`;
2793
+ }
2794
+ const [, , dayOfMonth, , dayOfWeek] = fields;
2795
+ if (dayOfMonth !== "*" && dayOfWeek !== "*") {
2796
+ return `cannot set both a day of the month and a day of the week on Vercel, so one must be "*"; got "${raw}"`;
2797
+ }
2798
+ const weekdays = dayOfWeek
2799
+ .split(",")
2800
+ .flatMap((part) => part.split("/")[0].split("-"));
2801
+ if (weekdays.some((day) => day !== "*" && Number(day) > 6)) {
2802
+ return `must number weekdays 0-6 (Sunday to Saturday) on Vercel; got "${raw}"`;
2803
+ }
2804
+ return null;
2805
+ }
2806
+ // Vercel Hobby rejects a deployment whose cron runs more than once a day, and
2807
+ // the build cannot see the team's plan, so the schedule is the operator's call.
2808
+ export function resolveVercelSweepCronSchedule(env = process.env) {
2809
+ const raw = env.AGENT_NATIVE_VERCEL_CRON_SCHEDULE?.trim();
2810
+ if (!raw)
2811
+ return DEFAULT_VERCEL_SWEEP_CRON_SCHEDULE;
2812
+ const problem = vercelCronScheduleProblem(raw);
2813
+ if (problem) {
2814
+ throw new Error(`AGENT_NATIVE_VERCEL_CRON_SCHEDULE ${problem}. ` +
2815
+ `Example: "0 9 * * *" for once a day on Vercel Hobby.`);
2816
+ }
2817
+ return raw;
2818
+ }
2819
+ export function vercelSweepCrons(sweepPaths, env = process.env) {
2820
+ const schedule = resolveVercelSweepCronSchedule(env);
2821
+ return sweepPaths.map((sweepPath) => ({ path: sweepPath, schedule }));
2822
+ }
2823
+ export function addVercelSweepCron(outputDir, env = process.env) {
2824
+ const configPath = path.join(outputDir, "config.json");
2825
+ if (!fs.existsSync(configPath)) {
2826
+ throw new Error(`[deploy] Nitro did not generate ${configPath} for vercel`);
2827
+ }
2828
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
2829
+ const [sweepCron] = vercelSweepCrons([publicRecurringJobsSweepPath(env)], env);
2830
+ config.crons = [
2831
+ ...(config.crons ?? []).filter((cron) => cron.path !== sweepCron.path),
2832
+ sweepCron,
2833
+ ];
2834
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
2835
+ console.log(`[build] Added Vercel cron "${sweepCron.schedule}" for ${sweepCron.path}. ` +
2836
+ "Vercel sends CRON_SECRET as its bearer, so set CRON_SECRET in the project." +
2837
+ (env.AGENT_NATIVE_VERCEL_CRON_SCHEDULE?.trim()
2838
+ ? ""
2839
+ : " Hobby only allows daily crons: set AGENT_NATIVE_VERCEL_CRON_SCHEDULE " +
2840
+ 'to a daily expression such as "0 9 * * *" there.'));
2841
+ }
2842
+ const SWEEP_PATH_AFTER_PREFIX = RECURRING_JOBS_SWEEP_PATH.slice(FRAMEWORK_INTERNAL_ROUTE_PREFIX.length);
2843
+ /**
2844
+ * Reads back the sweep cron `addVercelSweepCron` wrote for an app mounted at
2845
+ * `basePath`. A workspace deploy must reuse it rather than recompute it: the
2846
+ * app's build resolved its framework route prefix from config files the
2847
+ * workspace build never reads.
2848
+ */
2849
+ export function readVercelSweepCron(outputDir, basePath) {
2850
+ const configPath = path.join(outputDir, "config.json");
2851
+ if (!fs.existsSync(configPath)) {
2852
+ throw new Error(`[deploy] Expected Vercel build config at ${configPath}`);
2853
+ }
2854
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
2855
+ const sweepCrons = (config.crons ?? []).filter((cron) => {
2856
+ if (!cron.path.startsWith(`${basePath}/`) ||
2857
+ !cron.path.endsWith(SWEEP_PATH_AFTER_PREFIX)) {
2858
+ return false;
2859
+ }
2860
+ const prefix = cron.path.slice(basePath.length, -SWEEP_PATH_AFTER_PREFIX.length);
2861
+ return /^\/[^/]+$/.test(prefix);
2862
+ });
2863
+ if (sweepCrons.length !== 1) {
2864
+ throw new Error(`[deploy] Expected one recurring-jobs sweep cron under ${basePath} in ${configPath}, found ${sweepCrons.length}. ` +
2865
+ "Build the app with this version of @agent-native/core so its scheduled work runs on Vercel.");
2866
+ }
2867
+ return sweepCrons[0];
2868
+ }
2699
2869
  export function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd) {
2700
2870
  if (!isKeepWarmDeployEnabled()) {
2701
2871
  const reason = isDisabledByEnv("AGENT_NATIVE_DISABLE_KEEP_WARM")
@@ -4147,6 +4317,9 @@ export default bundle;
4147
4317
  if (isCloudflareModulePreset(preset)) {
4148
4318
  configureCloudflareModuleWorkerOutput(nitro.options.output.serverDir);
4149
4319
  }
4320
+ if (preset === "vercel") {
4321
+ addVercelSweepCron(nitro.options.output.dir);
4322
+ }
4150
4323
  if (preset === "netlify" ||
4151
4324
  preset === "vercel" ||
4152
4325
  isAwsLambdaPreset(preset) ||
@@ -12,7 +12,7 @@ import { DEFAULT_WORKSPACE_APP_AUDIENCE, normalizeWorkspaceAppHomePath, normaliz
12
12
  import { DISPATCH_WORKSPACE_ROOT_REDIRECTS, isValidWorkspaceAppIdFormat, } from "../shared/workspace-app-id.js";
13
13
  import { createAgentNativeConfigContext, loadResolvedAgentNativeConfig, } from "../vite/agent-native-config-loader.js";
14
14
  import { inferWorkspaceAppRootHomePath, readConfiguredWorkspaceAppHomePath, } from "../workspace-app-config.js";
15
- import { assertEmittedBackgroundFunctionOnDisk, isRecurringJobsDeployEnabled, } from "./build.js";
15
+ import { assertEmittedBackgroundFunctionOnDisk, isRecurringJobsDeployEnabled, readVercelSweepCron, } from "./build.js";
16
16
  import { cloneServerBundleForFunction, pruneSsrIslandFromRewritingClone, } from "./function-bundle.js";
17
17
  import { collectImmutableAssetPaths, IMMUTABLE_ASSET_CACHE_HEADERS, } from "./immutable-assets.js";
18
18
  function workspaceFrameworkRoutePrefixEnv() {
@@ -211,9 +211,13 @@ export async function runWorkspaceDeploy(opts = {}) {
211
211
  }
212
212
  console.log(`[workspace-deploy] Building ${apps.length} app(s) for preset=${preset}`);
213
213
  const execFile = opts.execFile ?? execFileSync;
214
+ const sweepCrons = [];
214
215
  for (const app of apps) {
215
216
  buildOneApp(workspaceRoot, appsDir, app, preset, execFile, workspaceApps, workspaceAuthMode);
216
217
  moveAppBuildIntoWorkspaceOutput(workspaceRoot, appsDir, app, preset, distDir, vercelOutputDir, workspaceApps, workspaceAuthMode);
218
+ if (preset === "vercel") {
219
+ sweepCrons.push(readVercelSweepCron(path.join(appsDir, app, VERCEL_OUTPUT_DIR), `/${app}`));
220
+ }
217
221
  }
218
222
  writeWorkspaceAppManifests(workspaceRoot, apps, workspaceApps, preset);
219
223
  if (workspaceRootPage === "directory") {
@@ -224,7 +228,7 @@ export async function runWorkspaceDeploy(opts = {}) {
224
228
  writeNetlifyHeaders(distDir, apps);
225
229
  }
226
230
  else {
227
- writeVercelBuildConfig(vercelOutputDir, apps, workspaceApps, workspaceRootPage);
231
+ writeVercelBuildConfig(vercelOutputDir, apps, workspaceApps, workspaceRootPage, sweepCrons);
228
232
  }
229
233
  if (buildOnly) {
230
234
  const outputDir = preset === "vercel" ? vercelOutputDir : distDir;
@@ -442,7 +446,7 @@ function netlifyHeaderBlock(pathname) {
442
446
  ...Object.entries(IMMUTABLE_ASSET_CACHE_HEADERS).map(([name, value]) => ` ${name}: ${value}`),
443
447
  ].join("\n");
444
448
  }
445
- function writeVercelBuildConfig(outputDir, apps, workspaceApps, rootPage) {
449
+ function writeVercelBuildConfig(outputDir, apps, workspaceApps, rootPage, sweepCrons) {
446
450
  const routes = [
447
451
  ...vercelImmutableAssetHeaderRoutes(outputDir, apps),
448
452
  { handle: "filesystem" },
@@ -496,6 +500,7 @@ function writeVercelBuildConfig(outputDir, apps, workspaceApps, rootPage) {
496
500
  const config = {
497
501
  version: 3,
498
502
  routes,
503
+ crons: sweepCrons,
499
504
  };
500
505
  fs.writeFileSync(path.join(outputDir, "config.json"), JSON.stringify(config, null, 2) + "\n");
501
506
  }