@agent-native/core 0.70.3 → 0.72.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.
Files changed (221) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +51 -0
  3. package/corpus/core/docs/content/actions.md +23 -0
  4. package/corpus/core/docs/content/audit-log.md +111 -0
  5. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  6. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  7. package/corpus/core/package.json +2 -1
  8. package/corpus/core/src/action.ts +80 -1
  9. package/corpus/core/src/agent/durable-background.ts +192 -0
  10. package/corpus/core/src/agent/production-agent.ts +357 -13
  11. package/corpus/core/src/agent/run-manager.ts +66 -3
  12. package/corpus/core/src/agent/run-store.ts +143 -23
  13. package/corpus/core/src/agent/types.ts +20 -0
  14. package/corpus/core/src/application-state/store.ts +5 -0
  15. package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
  16. package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
  17. package/corpus/core/src/audit/cleanup-job.ts +100 -0
  18. package/corpus/core/src/audit/config.ts +91 -0
  19. package/corpus/core/src/audit/index.ts +43 -0
  20. package/corpus/core/src/audit/record.ts +143 -0
  21. package/corpus/core/src/audit/redact.ts +109 -0
  22. package/corpus/core/src/audit/store.ts +244 -0
  23. package/corpus/core/src/audit/types.ts +125 -0
  24. package/corpus/core/src/chat-threads/store.ts +10 -0
  25. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  26. package/corpus/core/src/client/AssistantChat.tsx +6 -0
  27. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  28. package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
  29. package/corpus/core/src/db/client.ts +4 -0
  30. package/corpus/core/src/db/widen-columns.ts +75 -0
  31. package/corpus/core/src/deploy/build.ts +96 -0
  32. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  33. package/corpus/core/src/mcp/build-server.ts +23 -13
  34. package/corpus/core/src/oauth-tokens/store.ts +6 -0
  35. package/corpus/core/src/provider-api/custom-registry.ts +8 -0
  36. package/corpus/core/src/resources/store.ts +10 -0
  37. package/corpus/core/src/scripts/runner.ts +6 -3
  38. package/corpus/core/src/server/action-discovery.ts +6 -0
  39. package/corpus/core/src/server/action-routes.ts +1 -0
  40. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  41. package/corpus/core/src/server/auth.ts +5 -0
  42. package/corpus/core/src/server/core-routes-plugin.ts +14 -0
  43. package/corpus/core/src/server/security-headers.ts +9 -6
  44. package/corpus/core/src/settings/store.ts +6 -0
  45. package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  46. package/corpus/core/src/usage/store.ts +6 -0
  47. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  48. package/corpus/templates/analytics/AGENTS.md +8 -0
  49. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  50. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  51. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  52. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
  53. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
  54. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
  55. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
  56. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
  57. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  58. package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
  59. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
  60. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
  61. package/corpus/templates/clips/chrome-extension/src/background.ts +163 -32
  62. package/corpus/templates/clips/chrome-extension/src/content-script.ts +301 -24
  63. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +245 -67
  64. package/corpus/templates/clips/chrome-extension/src/overlay.css +77 -43
  65. package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
  66. package/corpus/templates/clips/chrome-extension/src/overlay.ts +102 -38
  67. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  68. package/corpus/templates/clips/chrome-extension/src/popup.ts +275 -4
  69. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  70. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  71. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  72. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  73. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  74. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  75. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  76. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  77. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  78. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  79. package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
  80. package/corpus/templates/clips/shared/recording-audio.ts +62 -0
  81. package/corpus/templates/clips/shared/recording-core.ts +94 -0
  82. package/dist/action.d.ts +31 -0
  83. package/dist/action.d.ts.map +1 -1
  84. package/dist/action.js +45 -1
  85. package/dist/action.js.map +1 -1
  86. package/dist/agent/context-xray/schema.d.ts +1 -1
  87. package/dist/agent/durable-background.d.ts +60 -0
  88. package/dist/agent/durable-background.d.ts.map +1 -0
  89. package/dist/agent/durable-background.js +144 -0
  90. package/dist/agent/durable-background.js.map +1 -0
  91. package/dist/agent/observational-memory/schema.d.ts +1 -1
  92. package/dist/agent/production-agent.d.ts +20 -0
  93. package/dist/agent/production-agent.d.ts.map +1 -1
  94. package/dist/agent/production-agent.js +320 -14
  95. package/dist/agent/production-agent.js.map +1 -1
  96. package/dist/agent/run-manager.d.ts +48 -0
  97. package/dist/agent/run-manager.d.ts.map +1 -1
  98. package/dist/agent/run-manager.js +45 -3
  99. package/dist/agent/run-manager.js.map +1 -1
  100. package/dist/agent/run-store.d.ts +30 -1
  101. package/dist/agent/run-store.d.ts.map +1 -1
  102. package/dist/agent/run-store.js +138 -24
  103. package/dist/agent/run-store.js.map +1 -1
  104. package/dist/agent/types.d.ts +20 -0
  105. package/dist/agent/types.d.ts.map +1 -1
  106. package/dist/agent/types.js.map +1 -1
  107. package/dist/application-state/store.d.ts.map +1 -1
  108. package/dist/application-state/store.js +5 -0
  109. package/dist/application-state/store.js.map +1 -1
  110. package/dist/audit/actions/get-audit-event.d.ts +11 -0
  111. package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
  112. package/dist/audit/actions/get-audit-event.js +22 -0
  113. package/dist/audit/actions/get-audit-event.js.map +1 -0
  114. package/dist/audit/actions/list-audit-events.d.ts +22 -0
  115. package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
  116. package/dist/audit/actions/list-audit-events.js +61 -0
  117. package/dist/audit/actions/list-audit-events.js.map +1 -0
  118. package/dist/audit/cleanup-job.d.ts +12 -0
  119. package/dist/audit/cleanup-job.d.ts.map +1 -0
  120. package/dist/audit/cleanup-job.js +93 -0
  121. package/dist/audit/cleanup-job.js.map +1 -0
  122. package/dist/audit/config.d.ts +30 -0
  123. package/dist/audit/config.d.ts.map +1 -0
  124. package/dist/audit/config.js +65 -0
  125. package/dist/audit/config.js.map +1 -0
  126. package/dist/audit/index.d.ts +13 -0
  127. package/dist/audit/index.d.ts.map +1 -0
  128. package/dist/audit/index.js +6 -0
  129. package/dist/audit/index.js.map +1 -0
  130. package/dist/audit/record.d.ts +24 -0
  131. package/dist/audit/record.d.ts.map +1 -0
  132. package/dist/audit/record.js +106 -0
  133. package/dist/audit/record.js.map +1 -0
  134. package/dist/audit/redact.d.ts +28 -0
  135. package/dist/audit/redact.d.ts.map +1 -0
  136. package/dist/audit/redact.js +109 -0
  137. package/dist/audit/redact.js.map +1 -0
  138. package/dist/audit/store.d.ts +14 -0
  139. package/dist/audit/store.d.ts.map +1 -0
  140. package/dist/audit/store.js +219 -0
  141. package/dist/audit/store.js.map +1 -0
  142. package/dist/audit/types.d.ts +114 -0
  143. package/dist/audit/types.d.ts.map +1 -0
  144. package/dist/audit/types.js +15 -0
  145. package/dist/audit/types.js.map +1 -0
  146. package/dist/chat-threads/store.d.ts.map +1 -1
  147. package/dist/chat-threads/store.js +10 -0
  148. package/dist/chat-threads/store.js.map +1 -1
  149. package/dist/client/AgentPanel.d.ts.map +1 -1
  150. package/dist/client/AgentPanel.js +2 -2
  151. package/dist/client/AgentPanel.js.map +1 -1
  152. package/dist/client/AssistantChat.d.ts.map +1 -1
  153. package/dist/client/AssistantChat.js +6 -1
  154. package/dist/client/AssistantChat.js.map +1 -1
  155. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  156. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  157. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  158. package/dist/client/chat/repo-helpers.d.ts +16 -0
  159. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  160. package/dist/client/chat/repo-helpers.js +40 -0
  161. package/dist/client/chat/repo-helpers.js.map +1 -1
  162. package/dist/db/client.d.ts.map +1 -1
  163. package/dist/db/client.js +3 -0
  164. package/dist/db/client.js.map +1 -1
  165. package/dist/db/widen-columns.d.ts +39 -0
  166. package/dist/db/widen-columns.d.ts.map +1 -0
  167. package/dist/db/widen-columns.js +73 -0
  168. package/dist/db/widen-columns.js.map +1 -0
  169. package/dist/deploy/build.d.ts +29 -0
  170. package/dist/deploy/build.d.ts.map +1 -1
  171. package/dist/deploy/build.js +85 -0
  172. package/dist/deploy/build.js.map +1 -1
  173. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  174. package/dist/deploy/workspace-deploy.js +108 -0
  175. package/dist/deploy/workspace-deploy.js.map +1 -1
  176. package/dist/mcp/build-server.d.ts.map +1 -1
  177. package/dist/mcp/build-server.js +24 -9
  178. package/dist/mcp/build-server.js.map +1 -1
  179. package/dist/oauth-tokens/store.d.ts.map +1 -1
  180. package/dist/oauth-tokens/store.js +6 -0
  181. package/dist/oauth-tokens/store.js.map +1 -1
  182. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  183. package/dist/provider-api/custom-registry.js +8 -0
  184. package/dist/provider-api/custom-registry.js.map +1 -1
  185. package/dist/resources/store.d.ts.map +1 -1
  186. package/dist/resources/store.js +9 -0
  187. package/dist/resources/store.js.map +1 -1
  188. package/dist/scripts/runner.js +4 -3
  189. package/dist/scripts/runner.js.map +1 -1
  190. package/dist/server/action-discovery.d.ts.map +1 -1
  191. package/dist/server/action-discovery.js +6 -0
  192. package/dist/server/action-discovery.js.map +1 -1
  193. package/dist/server/action-routes.d.ts.map +1 -1
  194. package/dist/server/action-routes.js +1 -0
  195. package/dist/server/action-routes.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +68 -13
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/auth.d.ts.map +1 -1
  200. package/dist/server/auth.js +5 -0
  201. package/dist/server/auth.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  203. package/dist/server/core-routes-plugin.js +13 -0
  204. package/dist/server/core-routes-plugin.js.map +1 -1
  205. package/dist/server/security-headers.d.ts +8 -5
  206. package/dist/server/security-headers.d.ts.map +1 -1
  207. package/dist/server/security-headers.js +9 -6
  208. package/dist/server/security-headers.js.map +1 -1
  209. package/dist/settings/store.d.ts.map +1 -1
  210. package/dist/settings/store.js +6 -0
  211. package/dist/settings/store.js.map +1 -1
  212. package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  213. package/dist/usage/store.d.ts.map +1 -1
  214. package/dist/usage/store.js +5 -0
  215. package/dist/usage/store.js.map +1 -1
  216. package/docs/content/actions.md +23 -0
  217. package/docs/content/audit-log.md +111 -0
  218. package/docs/content/multi-app-workspace.md +2 -2
  219. package/docs/design/durable-agent-runs.md +458 -4
  220. package/package.json +2 -1
  221. package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
@@ -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,
@@ -2948,6 +2958,28 @@ export async function runAgentLoop(opts: {
2948
2958
  approvalKey,
2949
2959
  ...(toolCall.id ? { toolCallId: toolCall.id } : {}),
2950
2960
  });
2961
+ // Audit the blocked attempt: the action did NOT run, but "the agent
2962
+ // tried to do X and was gated" is itself worth recording. Best-effort,
2963
+ // but AWAITED (not fire-and-forget) so the row isn't lost to a
2964
+ // serverless freeze / request teardown when the turn pauses.
2965
+ try {
2966
+ const { recordActionAudit } = await import("../audit/record.js");
2967
+ await recordActionAudit({
2968
+ config: undefined,
2969
+ args: toolCall.input,
2970
+ ctx: {
2971
+ actionName: toolCall.name,
2972
+ caller: "tool",
2973
+ userEmail: getRequestUserEmail(),
2974
+ orgId: getRequestOrgId() ?? null,
2975
+ ...(opts.threadId ? { threadId: opts.threadId } : {}),
2976
+ ...(opts.turnId ? { turnId: opts.turnId } : {}),
2977
+ },
2978
+ status: "denied",
2979
+ });
2980
+ } catch {
2981
+ // Best-effort — auditing must never break the approval pause.
2982
+ }
2951
2983
  const result =
2952
2984
  `Awaiting human approval to run "${toolCall.name}". This action did ` +
2953
2985
  `NOT execute — a human must approve this specific call before it ` +
@@ -3217,6 +3249,11 @@ export async function runAgentLoop(opts: {
3217
3249
  caller: "tool",
3218
3250
  attachments: opts.attachments,
3219
3251
  signal,
3252
+ // Audit attribution: the action name + the agent thread/turn that
3253
+ // triggered this call, so a mutation can be traced to its run.
3254
+ actionName: toolCall.name,
3255
+ ...(opts.threadId ? { threadId: opts.threadId } : {}),
3256
+ ...(opts.turnId ? { turnId: opts.turnId } : {}),
3220
3257
  }),
3221
3258
  );
3222
3259
 
@@ -3559,6 +3596,35 @@ function endsAtInternalContinuationBoundary(run: ActiveRun): boolean {
3559
3596
  return last.type === "error" && isRecoverableContinuationError(last);
3560
3597
  }
3561
3598
 
3599
+ /**
3600
+ * Hard cap on server-driven background→background continuation chunks for a
3601
+ * single logical turn. A `backgroundFunction` run gets a ~13-min soft timeout,
3602
+ * so reaching this boundary at all is the rare exception (most turns finish in
3603
+ * one chunk). The cap bounds a pathological turn that would otherwise chain
3604
+ * background invocations forever, mirroring `MAX_AGENT_TEAM_CONTINUATIONS`.
3605
+ */
3606
+ export const MAX_BACKGROUND_RUN_CONTINUATIONS = 20;
3607
+
3608
+ /**
3609
+ * Whether the background worker should self-fire the next server-driven
3610
+ * continuation chunk. True only when this is a background worker run that ended
3611
+ * at a recoverable soft-timeout boundary (not aborted/stopped) and the chain is
3612
+ * still under its budget. Extracted so the decision is unit testable without
3613
+ * booting the whole handler.
3614
+ */
3615
+ export function shouldChainBackgroundContinuation(opts: {
3616
+ isBackgroundWorker: boolean;
3617
+ run: ActiveRun;
3618
+ continuationCount: number;
3619
+ }): boolean {
3620
+ return (
3621
+ opts.isBackgroundWorker &&
3622
+ opts.run.status !== "aborted" &&
3623
+ endsAtInternalContinuationBoundary(opts.run) &&
3624
+ opts.continuationCount < MAX_BACKGROUND_RUN_CONTINUATIONS
3625
+ );
3626
+ }
3627
+
3562
3628
  function progressStepFromAgentChatEvent(event: AgentChatEvent): string | null {
3563
3629
  switch (event.type) {
3564
3630
  case "activity":
@@ -3622,11 +3688,21 @@ export function createProductionAgentHandler(
3622
3688
  }
3623
3689
 
3624
3690
  let body: AgentChatRequest;
3625
- try {
3626
- body = await readBody(event);
3627
- } catch {
3628
- setResponseStatus(event, 400);
3629
- return { error: "Invalid request body" };
3691
+ // The durable-background `_process-run` route already consumed and verified
3692
+ // the request body (h3 v2's web Request body stream is single-use, so a
3693
+ // second readBody would fail). It stashes the verified+augmented body here
3694
+ // so this re-entered handler reads it instead of the spent stream.
3695
+ const preInjectedBody = (event as any)?.context
3696
+ ?.__agentChatBackgroundBody as AgentChatRequest | undefined;
3697
+ if (preInjectedBody && typeof preInjectedBody === "object") {
3698
+ body = preInjectedBody;
3699
+ } else {
3700
+ try {
3701
+ body = await readBody(event);
3702
+ } catch {
3703
+ setResponseStatus(event, 400);
3704
+ return { error: "Invalid request body" };
3705
+ }
3630
3706
  }
3631
3707
 
3632
3708
  const {
@@ -3646,6 +3722,31 @@ export function createProductionAgentHandler(
3646
3722
  scope,
3647
3723
  trackInRunsTray,
3648
3724
  } = body;
3725
+
3726
+ // Durable-background marker. Present ONLY when this handler was re-entered
3727
+ // as the Netlify background worker via the `_process-run` self-dispatch
3728
+ // (the route HMAC-verifies the dispatch before invoking us). When set, we
3729
+ // run the loop inline with the background soft-timeout, reusing the
3730
+ // pre-claimed runId/turnId — we must NOT re-claim the slot or re-dispatch.
3731
+ const backgroundRunMarker =
3732
+ body[AGENT_CHAT_BACKGROUND_RUN_FIELD] &&
3733
+ typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD] === "object" &&
3734
+ typeof body[AGENT_CHAT_BACKGROUND_RUN_FIELD]!.runId === "string"
3735
+ ? body[AGENT_CHAT_BACKGROUND_RUN_FIELD]!
3736
+ : null;
3737
+ const isBackgroundWorker = backgroundRunMarker !== null;
3738
+ // How many server-driven background continuations have already chained into
3739
+ // this logical turn (0 on the first chunk). Used to bound the chain.
3740
+ const backgroundContinuationCount =
3741
+ isBackgroundWorker &&
3742
+ typeof backgroundRunMarker?.continuationCount === "number" &&
3743
+ Number.isFinite(backgroundRunMarker.continuationCount)
3744
+ ? Math.max(0, Math.floor(backgroundRunMarker.continuationCount))
3745
+ : 0;
3746
+ // The foreground POST decides whether to dispatch into a background
3747
+ // function. The background worker itself never re-dispatches.
3748
+ const dispatchToBackground =
3749
+ !isBackgroundWorker && isAgentChatDurableBackgroundEnabled();
3649
3750
  const requestBrowserTabId = normalizeBrowserTabId(browserTabId);
3650
3751
  const requestChatScope = normalizeChatScope(scope);
3651
3752
  const requestRunCtx = ensureRequestRunContext();
@@ -4169,7 +4270,11 @@ export function createProductionAgentHandler(
4169
4270
  // different serverless isolates both see the correct state — a plain
4170
4271
  // read-then-act check races on multi-isolate deployments because both
4171
4272
  // reads see no running row before either insert commits.
4172
- if (threadId) {
4273
+ //
4274
+ // The background worker SKIPS this: the foreground POST already claimed the
4275
+ // slot and inserted the run row before dispatching, so re-claiming here
4276
+ // would falsely 409 against the row the foreground holds.
4277
+ if (threadId && !isBackgroundWorker) {
4173
4278
  const slot = await tryClaimRunSlot(threadId);
4174
4279
  if (!slot.claimed) {
4175
4280
  setResponseStatus(event, 409);
@@ -4180,19 +4285,67 @@ export function createProductionAgentHandler(
4180
4285
  }
4181
4286
  }
4182
4287
 
4183
- // Start agent loop in background via run-manager
4184
- const runId = generateRunId();
4288
+ // Start agent loop in background via run-manager. The background worker
4289
+ // reuses the runId carried in the marker (signed into the dispatch token):
4290
+ // - First background chunk (count 0): the foreground generated + INSERTED
4291
+ // this runId, so the event stream the client is already subscribed to is
4292
+ // the one we write to.
4293
+ // - Chained continuation chunk (count > 0): the prior chunk minted a FRESH
4294
+ // runId for this one (a reused runId would restart `startRun`'s in-memory
4295
+ // seq log at 0 and collide with the prior chunk's persisted seqs, which
4296
+ // insertRunEvent's ON CONFLICT would drop — making the continuation
4297
+ // invisible). A fresh runId on the SAME thread + SAME turnId folds onto
4298
+ // one assistant message and is surfaced by the existing
4299
+ // `/runs/active?threadId` reconnect path. The continuation worker inserts
4300
+ // its own background row below (the foreground only inserted chunk-0's).
4301
+ const isChainedBackgroundContinuation =
4302
+ isBackgroundWorker && backgroundContinuationCount > 0;
4303
+ const runId = backgroundRunMarker?.runId ?? generateRunId();
4185
4304
  const effectiveThreadId = threadId ?? runId;
4186
4305
  const effectiveTurnId =
4187
- typeof requestTurnId === "string" && requestTurnId.trim()
4188
- ? requestTurnId.trim()
4189
- : runId;
4306
+ typeof backgroundRunMarker?.turnId === "string" &&
4307
+ backgroundRunMarker.turnId.trim()
4308
+ ? backgroundRunMarker.turnId.trim()
4309
+ : typeof requestTurnId === "string" && requestTurnId.trim()
4310
+ ? requestTurnId.trim()
4311
+ : runId;
4190
4312
  const messageToPersist =
4191
4313
  typeof requestDisplayMessage === "string" &&
4192
4314
  requestDisplayMessage.trim().length > 0
4193
4315
  ? requestDisplayMessage
4194
4316
  : requestMessage;
4195
- if (options.onRunPrepared && !internalContinuation) {
4317
+
4318
+ // Server-driven background continuation: when the background worker re-fired
4319
+ // itself at a soft-timeout boundary (a chained continuation chunk), rebuild
4320
+ // the conversation from the persisted thread_data so the next chunk resumes
4321
+ // from committed progress instead of restarting from the original user
4322
+ // message (which would re-do work — the re-hydration thrash the design doc
4323
+ // calls out). Mirrors the agent-teams continuation, which seeds from
4324
+ // thread_data + appends a continuation nudge. Falls back to the body-derived
4325
+ // `messages` if thread_data is empty/unreadable — a continuation that
4326
+ // restarts is worse than one that resumes, but both are correct.
4327
+ if (isChainedBackgroundContinuation && effectiveThreadId) {
4328
+ try {
4329
+ const { getThread } = await import("../chat-threads/store.js");
4330
+ const { threadDataToEngineMessages } =
4331
+ await import("./thread-data-builder.js");
4332
+ const priorThreadData = (await getThread(effectiveThreadId))
4333
+ ?.threadData;
4334
+ const resumed = threadDataToEngineMessages(priorThreadData);
4335
+ if (resumed.length > 0) {
4336
+ appendAgentLoopContinuation(resumed, "run_timeout");
4337
+ messages.length = 0;
4338
+ messages.push(...resumed);
4339
+ }
4340
+ } catch {
4341
+ // Keep the body-derived messages — never drop the run.
4342
+ }
4343
+ }
4344
+
4345
+ // Persist the user's turn exactly once. The foreground POST does this
4346
+ // before dispatching; the background worker must NOT repeat it (it re-enters
4347
+ // with the same body, which would double-persist the user message).
4348
+ if (options.onRunPrepared && !internalContinuation && !isBackgroundWorker) {
4196
4349
  await options.onRunPrepared({
4197
4350
  runId,
4198
4351
  threadId,
@@ -4201,6 +4354,80 @@ export function createProductionAgentHandler(
4201
4354
  });
4202
4355
  }
4203
4356
 
4357
+ // ─── Durable-background dispatch decision ──────────────────────────────
4358
+ // Flag active (hosted + A2A_SECRET + AGENT_CHAT_DURABLE_BACKGROUND) and we
4359
+ // are the FOREGROUND POST: insert the run row (marked background), fire an
4360
+ // HMAC-signed self-dispatch into the Netlify background function (15-min
4361
+ // budget), and return the SSE subscription immediately. The client streams
4362
+ // the same events via the cross-isolate SQL-poll path with no client
4363
+ // change. With the flag OFF this whole branch is skipped and the inline
4364
+ // `startRun` path below runs exactly as before (byte-for-byte).
4365
+ if (dispatchToBackground) {
4366
+ try {
4367
+ // Insert the run row up front so /runs/active sees it immediately and
4368
+ // the slot stays held while the background function cold-starts. Mark
4369
+ // it background-dispatched so the stale reaper uses the wider window.
4370
+ await insertRun(runId, effectiveThreadId, effectiveTurnId, {
4371
+ dispatchMode: "background",
4372
+ });
4373
+ } catch (err) {
4374
+ // A duplicate-PK collision means the row already exists (ret­ried POST);
4375
+ // any other failure means we can't safely hand off — fall back to the
4376
+ // inline path rather than dropping the turn.
4377
+ console.error(
4378
+ "[agent-chat] background insertRun failed; falling back to inline:",
4379
+ err instanceof Error ? err.message : err,
4380
+ );
4381
+ }
4382
+
4383
+ let dispatched = false;
4384
+ try {
4385
+ await fireInternalDispatch({
4386
+ event,
4387
+ path: AGENT_CHAT_PROCESS_RUN_PATH,
4388
+ taskId: runId,
4389
+ body: {
4390
+ ...body,
4391
+ // Carry the pre-claimed identity so the worker reuses this run.
4392
+ [AGENT_CHAT_BACKGROUND_RUN_FIELD]: {
4393
+ runId,
4394
+ turnId: effectiveTurnId,
4395
+ },
4396
+ },
4397
+ });
4398
+ dispatched = true;
4399
+ } catch (err) {
4400
+ console.error(
4401
+ "[agent-chat] background dispatch failed; falling back to inline:",
4402
+ err instanceof Error ? err.message : err,
4403
+ );
4404
+ }
4405
+
4406
+ if (dispatched) {
4407
+ const stream = subscribeToRun(runId, 0);
4408
+ if (stream) {
4409
+ setResponseHeader(event, "Content-Type", "text/event-stream");
4410
+ setResponseHeader(event, "Cache-Control", "no-cache");
4411
+ setResponseHeader(event, "Connection", "keep-alive");
4412
+ setResponseHeader(event, "X-Run-Id", runId);
4413
+ return stream;
4414
+ }
4415
+ // Subscription failed even though dispatch landed — surface an error
4416
+ // rather than silently running inline (the background worker is already
4417
+ // processing this runId, so an inline second run would double-execute).
4418
+ setResponseStatus(event, 500);
4419
+ return { error: "Failed to subscribe to background run" };
4420
+ }
4421
+ // Dispatch failed before any worker could claim the run. Fail loud: flip
4422
+ // the row terminal so the held slot is released (and any reconnect sees a
4423
+ // terminal status instead of spinning), then 500 so the client can retry.
4424
+ // We do NOT silently fall through to inline here — that risks a later,
4425
+ // delayed background delivery double-executing the same runId.
4426
+ await updateRunStatusIfRunning(runId, "errored").catch(() => {});
4427
+ setResponseStatus(event, 500);
4428
+ return { error: "Failed to dispatch background run" };
4429
+ }
4430
+
4204
4431
  const trackedProgressOwner =
4205
4432
  trackInRunsTray === true && ownerEmail ? ownerEmail : null;
4206
4433
  const trackedProgressRunId = trackedProgressOwner
@@ -4276,7 +4503,19 @@ export function createProductionAgentHandler(
4276
4503
  }).catch(() => {});
4277
4504
  }
4278
4505
 
4279
- const handleRunComplete =
4506
+ // The background worker must AWAIT the run to completion before returning,
4507
+ // or Netlify freezes/kills the function the instant the handler returns and
4508
+ // the detached run dies mid-turn (mirrors the agent-teams processor, which
4509
+ // wraps startRun in `await new Promise(resolve => startRun(..., onComplete:
4510
+ // () => resolve()))`). We resolve this when the run's onComplete fires.
4511
+ let resolveBackgroundRunDone: (() => void) | null = null;
4512
+ const backgroundRunDone = isBackgroundWorker
4513
+ ? new Promise<void>((resolve) => {
4514
+ resolveBackgroundRunDone = resolve;
4515
+ })
4516
+ : null;
4517
+
4518
+ const baseHandleRunComplete =
4280
4519
  options.onRunComplete || trackedProgressRunId
4281
4520
  ? async (run: ActiveRun) => {
4282
4521
  try {
@@ -4289,6 +4528,98 @@ export function createProductionAgentHandler(
4289
4528
  }
4290
4529
  : undefined;
4291
4530
 
4531
+ // Wrap so the background worker is unblocked even when there is no app
4532
+ // onRunComplete / tracked-progress callback configured.
4533
+ const handleRunComplete =
4534
+ isBackgroundWorker || baseHandleRunComplete
4535
+ ? async (run: ActiveRun) => {
4536
+ try {
4537
+ // Persist the (partial) assistant turn to thread_data FIRST — the
4538
+ // server-driven continuation below rebuilds from it, so it must be
4539
+ // committed before we re-fire.
4540
+ await baseHandleRunComplete?.(run);
4541
+
4542
+ // Server-driven background→background continuation. If this chunk
4543
+ // hit its soft-timeout still unfinished (ended at an auto_continue
4544
+ // / loop_limit / recoverable boundary), chain the next chunk by
4545
+ // re-firing the `_process-run` self-dispatch with mode "continue"
4546
+ // (carried as internalContinuation + an incremented count),
4547
+ // instead of relying on the client to re-POST. Mirrors the
4548
+ // agent-teams `fireInternalDispatch({ body: { mode: "continue" }})`
4549
+ // chain. Bounded by MAX_BACKGROUND_RUN_CONTINUATIONS. Aborted /
4550
+ // user-stopped runs do NOT chain.
4551
+ if (
4552
+ shouldChainBackgroundContinuation({
4553
+ isBackgroundWorker,
4554
+ run,
4555
+ continuationCount: backgroundContinuationCount,
4556
+ })
4557
+ ) {
4558
+ // Mint the next chunk's runId here and sign the dispatch token
4559
+ // over it, so the `_process-run` route's HMAC check and the
4560
+ // worker's run identity agree. Fresh runId (not this chunk's) so
4561
+ // its seq log starts clean; same turnId folds the assistant
4562
+ // message across chunks.
4563
+ const nextRunId = generateRunId();
4564
+ try {
4565
+ await fireInternalDispatch({
4566
+ event,
4567
+ path: AGENT_CHAT_PROCESS_RUN_PATH,
4568
+ taskId: nextRunId,
4569
+ body: {
4570
+ ...body,
4571
+ internalContinuation: true,
4572
+ [AGENT_CHAT_BACKGROUND_RUN_FIELD]: {
4573
+ runId: nextRunId,
4574
+ turnId: effectiveTurnId,
4575
+ continuationCount: backgroundContinuationCount + 1,
4576
+ },
4577
+ },
4578
+ });
4579
+ } catch (chainErr) {
4580
+ // Chain dispatch failed — fail loud so the held row goes
4581
+ // terminal instead of spinning. The reaper would also catch
4582
+ // it, but this is immediate and truthful.
4583
+ console.error(
4584
+ "[agent-chat] background continuation dispatch failed:",
4585
+ chainErr instanceof Error ? chainErr.message : chainErr,
4586
+ );
4587
+ await updateRunStatusIfRunning(runId, "errored").catch(
4588
+ () => {},
4589
+ );
4590
+ }
4591
+ }
4592
+ } finally {
4593
+ resolveBackgroundRunDone?.();
4594
+ }
4595
+ }
4596
+ : undefined;
4597
+
4598
+ // Background worker: claim the pre-inserted run idempotently before
4599
+ // executing. A duplicate Netlify delivery loses the claim and no-ops here,
4600
+ // so the run can never be double-executed. Bump the heartbeat immediately
4601
+ // on entry so a slow cold-start doesn't leave the row looking stale to the
4602
+ // reaper before startRun's 1.5s heartbeat timer takes over.
4603
+ if (isBackgroundWorker) {
4604
+ // A chained continuation chunk's runId was minted by the prior chunk and
4605
+ // never inserted, so insert its background row now (idempotently — a
4606
+ // duplicate Netlify delivery that already inserted it just PK-collides and
4607
+ // the claim below dedups). The first chunk's row was inserted by the
4608
+ // foreground, so skip the insert there.
4609
+ if (isChainedBackgroundContinuation) {
4610
+ await insertRun(runId, effectiveThreadId, effectiveTurnId, {
4611
+ dispatchMode: "background",
4612
+ }).catch(() => {});
4613
+ }
4614
+ const won = await claimBackgroundRun(runId);
4615
+ if (!won) {
4616
+ // Already claimed by an earlier delivery — return a benign ack so
4617
+ // Netlify doesn't retry a successful handoff.
4618
+ return { ok: true, skipped: "already-claimed" };
4619
+ }
4620
+ await updateRunHeartbeat(runId).catch(() => {});
4621
+ }
4622
+
4292
4623
  startRun(
4293
4624
  runId,
4294
4625
  effectiveThreadId,
@@ -4677,6 +5008,10 @@ export function createProductionAgentHandler(
4677
5008
  {
4678
5009
  softTimeoutMs: options.runSoftTimeoutMs,
4679
5010
  useHostedSoftTimeoutDefault: true,
5011
+ // Inside the Netlify background function there is no ~60s wall, so lift
5012
+ // the soft-timeout clamp to ~13min for THIS run only. Foreground runs
5013
+ // never set this, so their 40s clamp is unchanged.
5014
+ backgroundFunction: isBackgroundWorker,
4680
5015
  // Fold continuation runs of one logical turn onto a single durable
4681
5016
  // assistant message. Falls back to the runId (turn == run) when the
4682
5017
  // client doesn't supply a turnId.
@@ -4684,6 +5019,15 @@ export function createProductionAgentHandler(
4684
5019
  },
4685
5020
  );
4686
5021
 
5022
+ // Background worker: await the run to completion so Netlify keeps the
5023
+ // background function alive for the whole turn (the client is streaming the
5024
+ // same events via the foreground POST's cross-isolate SQL subscription).
5025
+ // The onComplete wrapper above resolves `backgroundRunDone`.
5026
+ if (isBackgroundWorker) {
5027
+ if (backgroundRunDone) await backgroundRunDone;
5028
+ return { ok: true, runId };
5029
+ }
5030
+
4687
5031
  // Subscribe to the run and stream events to the client
4688
5032
  const stream = subscribeToRun(runId, 0);
4689
5033
  if (!stream) {