@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.
Files changed (93) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -0
  3. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  4. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +21 -10
  7. package/corpus/core/src/agent/durable-background.ts +192 -0
  8. package/corpus/core/src/agent/production-agent.ts +330 -13
  9. package/corpus/core/src/agent/run-manager.ts +66 -3
  10. package/corpus/core/src/agent/run-store.ts +129 -23
  11. package/corpus/core/src/agent/types.ts +20 -0
  12. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  13. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  14. package/corpus/core/src/deploy/build.ts +96 -0
  15. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  16. package/corpus/core/src/mcp/build-server.ts +22 -13
  17. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  18. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  19. package/corpus/templates/analytics/AGENTS.md +8 -0
  20. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  21. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  22. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  24. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  25. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  26. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  29. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  30. package/corpus/templates/clips/chrome-extension/src/background.ts +164 -32
  31. package/corpus/templates/clips/chrome-extension/src/content-script.ts +163 -13
  32. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +198 -35
  33. package/corpus/templates/clips/chrome-extension/src/overlay.css +73 -23
  34. package/corpus/templates/clips/chrome-extension/src/overlay.ts +82 -13
  35. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  36. package/corpus/templates/clips/chrome-extension/src/popup.ts +290 -1
  37. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  38. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  39. package/corpus/templates/clips/desktop/src/lib/recorder.ts +17 -20
  40. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  41. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  42. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  43. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  44. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  45. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  46. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  47. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  48. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  49. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  50. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  51. package/dist/action.js +21 -10
  52. package/dist/action.js.map +1 -1
  53. package/dist/agent/durable-background.d.ts +60 -0
  54. package/dist/agent/durable-background.d.ts.map +1 -0
  55. package/dist/agent/durable-background.js +144 -0
  56. package/dist/agent/durable-background.js.map +1 -0
  57. package/dist/agent/production-agent.d.ts +20 -0
  58. package/dist/agent/production-agent.d.ts.map +1 -1
  59. package/dist/agent/production-agent.js +292 -14
  60. package/dist/agent/production-agent.js.map +1 -1
  61. package/dist/agent/run-manager.d.ts +48 -0
  62. package/dist/agent/run-manager.d.ts.map +1 -1
  63. package/dist/agent/run-manager.js +45 -3
  64. package/dist/agent/run-manager.js.map +1 -1
  65. package/dist/agent/run-store.d.ts +30 -1
  66. package/dist/agent/run-store.d.ts.map +1 -1
  67. package/dist/agent/run-store.js +124 -24
  68. package/dist/agent/run-store.js.map +1 -1
  69. package/dist/agent/types.d.ts +20 -0
  70. package/dist/agent/types.d.ts.map +1 -1
  71. package/dist/agent/types.js.map +1 -1
  72. package/dist/client/AgentPanel.d.ts.map +1 -1
  73. package/dist/client/AgentPanel.js +2 -2
  74. package/dist/client/AgentPanel.js.map +1 -1
  75. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  76. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  77. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  78. package/dist/deploy/build.d.ts +29 -0
  79. package/dist/deploy/build.d.ts.map +1 -1
  80. package/dist/deploy/build.js +85 -0
  81. package/dist/deploy/build.js.map +1 -1
  82. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  83. package/dist/deploy/workspace-deploy.js +108 -0
  84. package/dist/deploy/workspace-deploy.js.map +1 -1
  85. package/dist/mcp/build-server.d.ts.map +1 -1
  86. package/dist/mcp/build-server.js +23 -9
  87. package/dist/mcp/build-server.js.map +1 -1
  88. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  89. package/dist/server/agent-chat-plugin.js +68 -13
  90. package/dist/server/agent-chat-plugin.js.map +1 -1
  91. package/docs/content/multi-app-workspace.md +2 -2
  92. package/docs/design/durable-agent-runs.md +458 -4
  93. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -27,5 +27,5 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
27
27
 
28
28
  ## Generated Counts
29
29
 
30
- - core files: 1147
31
- - template files: 3993
30
+ - core files: 1148
31
+ - template files: 4000
@@ -1,5 +1,24 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.71.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 38266fc: Add opt-in durable background agent-chat runs (off by default, host-agnostic). Behind `AGENT_CHAT_DURABLE_BACKGROUND` (active only when hosted AND `A2A_SECRET` is set AND the flag is truthy), a long in-app agent-chat turn is routed through a server-driven background worker via the framework's portable self-dispatch instead of completing synchronously under the ~40s interactive soft-timeout: the foreground POST claims the run slot, inserts the run row, fires an HMAC-signed self-dispatch to a new `/_agent-native/agent-chat/_process-run` route, and returns the existing `subscribeToRun` SSE stream so the client streams the same events via the cross-isolate SQL-poll path with no client change. The background worker idempotently claims the run, runs the full multi-step loop to completion under a host-natural soft-timeout (`backgroundFunction` mode lifts the 40s clamp for that invocation only — the foreground/interactive clamp is unchanged), and chains a server-driven continuation if a chunk hits its budget unfinished. A background-aware stale window (`dispatch_mode`) prevents a cold-starting background run from being falsely reaped. With the flag off, the agent-chat run path is byte-for-byte the current synchronous behavior.
8
+
9
+ As a per-host optimization layered on the portable baseline, the Netlify deploy build emits a second function whose name ends in `-background` (re-exporting the same `main.mjs` handler bundle, with a `config.path` of the process-run route) so the `_process-run` POST runs on Netlify's async 15-minute budget and a long turn completes in one invocation; on that invocation the worker's soft-timeout is raised to ~13 min (`backgroundFunction` mode) instead of 40s. This emit is build-time gated on the same `AGENT_CHAT_DURABLE_BACKGROUND` flag for both the single-template (`deploy/build.ts`) and workspace (`deploy/workspace-deploy.ts`) deploy paths: when the flag is unset at build time the emit functions are never invoked, so the deploy output (functions, routing, config) is byte-identical to today and the default single-function deploy is unchanged.
10
+
11
+ ### Patch Changes
12
+
13
+ - 38266fc: Move "Collapse sidebar" to the top of the agent panel's options menu (the `⋯` dropdown in the sidebar header), above All chats / Agent runs / Settings, with a separator below it. Makes the most common dismiss action the first item in the list.
14
+ - 38266fc: Fix inline MCP App embeds being hard-killed on `resources/read`. The inline-embed kill switch was enforced inside the shared `resolveMcpAppResource` resolver, which also backs `resources/read` — so when a host read a `ui://` URI it already held (e.g. a cached descriptor) while embeds were disabled, it got a hard `-32603` instead of the shell. The switch is now enforced only at the advertisement/render sites (`tools/list` descriptor meta, `tools/call` result meta, `resources/list`), so disabled embeds are never advertised while `resources/read` still degrades gracefully to the served shell.
15
+
16
+ ## 0.70.3
17
+
18
+ ### Patch Changes
19
+
20
+ - 3c80603: Document and regression-test that `useActionQuery` / `callAction` GET calls round-trip boolean and number params. Browser query params are serialized through `URLSearchParams`, which stringifies everything — so `useActionQuery("instrument-overview", { includeSeries: true, limit: 5 })` sends `includeSeries: "true"` / `limit: "5"`. Schema-aware coercion (added in 0.70.2) already restores native types before validation, but it was framed and tested only as a model-gateway concern. This adds an end-to-end regression test through the action route for the GET path and broadens the coercion doc comment so it is not narrowed to gateway-only and silently re-break browser GET calls. No runtime behavior change.
21
+
3
22
  ## 0.70.2
4
23
 
5
24
  ### Patch Changes
@@ -117,8 +117,8 @@ The merge happens by file name. If an app provides a local file that also exists
117
117
 
118
118
  ```an-diagram title="Three layers, merged by file name" summary="Each app resolves plugins, skills, actions, and AGENTS.md from app-local first, then the shared package, then the framework default."
119
119
  {
120
- "html": "<div class=\"layer\"><div class=\"diagram-card accent\"><span class=\"diagram-pill accent\">1 &middot; App local</span><small class=\"diagram-muted\"><code>apps/&lt;name&gt;/</code> &mdash; highest priority</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">&darr;</div><div class=\"diagram-card\"><span class=\"diagram-pill\">2 &middot; Workspace shared</span><small class=\"diagram-muted\"><code>packages/shared/</code> &mdash; the mid-layer</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">&darr;</div><div class=\"diagram-card\"><span class=\"diagram-pill\">3 &middot; Framework default</span><small class=\"diagram-muted\"><code>@agent-native/core</code> &mdash; lowest</small></div><div class=\"diagram-arrow diagram-accent\" aria-hidden=\"true\">&rarr;</div><div class=\"diagram-box ok\">first match wins</div></div>",
121
- "css": ".layer{display:flex;flex-direction:column;align-items:flex-start;gap:8px}.layer .diagram-card{display:flex;flex-direction:column;gap:3px;padding:12px 16px;min-width:300px}.layer .diagram-arrow{font-size:20px;align-self:center}.layer .diagram-box{align-self:center;margin-top:4px}"
120
+ "html": "<div class=\"layer\"><div class=\"diagram-card accent\"><span class=\"diagram-pill accent\">1 &middot; App local</span><small class=\"diagram-muted\"><code>apps/&lt;name&gt;/</code> &mdash; highest priority</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">&darr;</div><div class=\"diagram-card\"><span class=\"diagram-pill\">2 &middot; Workspace shared</span><small class=\"diagram-muted\"><code>packages/shared/</code> &mdash; the mid-layer</small></div><div class=\"diagram-arrow diagram-muted\" aria-hidden=\"true\">&darr;</div><div class=\"diagram-card\"><span class=\"diagram-pill\">3 &middot; Framework default</span><small class=\"diagram-muted\"><code>@agent-native/core</code> &mdash; lowest</small></div><div class=\"diagram-arrow diagram-accent\" aria-hidden=\"true\">&darr;</div><div class=\"diagram-box ok\">first match wins</div></div>",
121
+ "css": ".layer{display:flex;flex-direction:column;align-items:center;gap:6px}.layer .diagram-card{display:flex;flex-direction:column;gap:3px;padding:12px 16px;width:320px}.layer .diagram-arrow{font-size:18px;line-height:1}.layer .diagram-box{margin-top:2px}"
122
122
  }
123
123
  ```
124
124
 
@@ -1,10 +1,28 @@
1
1
  # Design: Durable / Checkpointed Agent Runs
2
2
 
3
- Status: proposed
4
- Owner: core / run-manager
3
+ Status: Phase 1 + Phase 2 implemented (flagged, off by default); Phase 0 shipped;
4
+ internal per-step checkpointing (Option A core) still recommended-not-built.
5
+ Owner: core / run-manager + deploy
5
6
  Related code: `packages/core/src/agent/run-manager.ts`,
7
+ `packages/core/src/agent/durable-background.ts`,
8
+ `packages/core/src/agent/production-agent.ts`,
9
+ `packages/core/src/server/agent-chat-plugin.ts`,
10
+ `packages/core/src/deploy/build.ts`,
11
+ `packages/core/src/deploy/workspace-deploy.ts`,
6
12
  `packages/core/src/agent/engine/builder-engine.ts`
7
13
 
14
+ > **Final architecture, in one line.** Durable background runs are a
15
+ > **host-agnostic core** — the foreground turn fires an HMAC-signed _self
16
+ > dispatch_ to a sibling worker route, the worker runs the full multi-step loop
17
+ > and persists every event to SQL, and the browser streams those events through
18
+ > the existing cross-isolate SQL-poll reconnect path — with the **Netlify 15-min
19
+ > `-background` function as a per-host optimization** layered on top. The
20
+ > portable baseline works on any host that can re-invoke itself (it just
21
+ > server-chains continuations when the host budget is short); the Netlify layer
22
+ > simply lets one invocation run ~15 min so a long turn finishes in **one**
23
+ > chunk instead of many re-hydrating ones. Nothing about the baseline is
24
+ > Netlify-specific; see [Final layered architecture](#final-layered-architecture).
25
+
8
26
  ## Problem
9
27
 
10
28
  Hosted agent runs are bounded by a ~40s soft timeout enforced in
@@ -179,22 +197,34 @@ itself checkpoint).
179
197
 
180
198
  ### Phased plan
181
199
 
200
+ > Status: **Phase 0 shipped. Phase 1 + Phase 2 implemented** (the host-agnostic
201
+ > durable-background worker and the Netlify 15-min `-background` optimization),
202
+ > behind `AGENT_CHAT_DURABLE_BACKGROUND`, off by default. Internal per-step
203
+ > checkpointing (the Option A core of Phase 1) is recommended-not-yet-built; the
204
+ > worker today gets its durability from the long single invocation plus
205
+ > server-chained continuations rather than per-step idempotent checkpoints. See
206
+ > [Final layered architecture](#final-layered-architecture).
207
+
182
208
  1. **Phase 0 — Stop hitting the ceiling so often (near-term, cheapest).** Land
183
209
  the mitigations in the Tie-in below (one-call atomic primitives,
184
210
  self-documenting actions, loud termination, proof-of-done verification).
185
211
  These don't fix the ceiling but sharply cut how often multi-step loops are
186
212
  even attempted, and make the failures that remain _loud and truthful_ instead
187
213
  of silent. Capture the agent-facing half as the `reliable-mutations` skill.
214
+ _(Shipped.)_
188
215
  2. **Phase 1 — Checkpointed continuation (Option A).** Add a SQL-backed progress
189
216
  checkpoint for long operations and make their steps idempotent/resumable so
190
217
  each `auto_continue` chunk advances committed progress instead of replanning.
191
218
  Drive terminal state ("N of N", or "M of N + remainder") from the checkpoint.
192
- This is the primary reliability win.
219
+ This is the primary reliability win. _(The durable-background worker +
220
+ host-agnostic self-dispatch/SQL-event/reconnect baseline is implemented;
221
+ per-step idempotent checkpointing is still recommended-not-built.)_
193
222
  3. **Phase 2 — Durable background execution (Option B).** For operations that
194
223
  can exceed any reasonable number of continuation chunks, enqueue them onto the
195
224
  core background infrastructure, have the durable worker run them to completion
196
225
  (checkpointing internally per Phase 1), and stream truthful progress back to
197
- the foreground run and UI.
226
+ the foreground run and UI. _(Implemented: host-agnostic worker baseline +
227
+ Netlify 15-min `-background` per-host optimization, flagged off by default.)_
198
228
 
199
229
  ## Tie-in: cheaper near-term mitigations reduce, but do not replace, the fix
200
230
 
@@ -215,3 +245,427 @@ failures honest. They are valuable and should ship first (Phase 0), but the
215
245
  The agent-facing rules for these live in the `reliable-mutations` skill
216
246
  (`.agents/skills/reliable-mutations/SKILL.md`). They lower the blast radius;
217
247
  checkpointed and durable runs remove the ceiling itself.
248
+
249
+ ---
250
+
251
+ # Final layered architecture
252
+
253
+ Status: implemented (flagged, off by default)
254
+ Owner: core / run-manager + deploy
255
+
256
+ Durable background runs ship as **two layers**. Both are gated behind
257
+ `AGENT_CHAT_DURABLE_BACKGROUND` and default off; when off, the agent-chat run
258
+ path and the deploy output are byte-for-byte the pre-existing synchronous
259
+ behavior.
260
+
261
+ ### Layer 1 (portable baseline) — host-agnostic durable execution
262
+
263
+ This is the actual durability mechanism and it is **not tied to any host**:
264
+
265
+ - **Self-dispatch worker.** The foreground POST claims the run slot, inserts the
266
+ run row, and `fireInternalDispatch`es (`server/self-dispatch.ts`) an
267
+ HMAC-signed request to a sibling worker route,
268
+ `AGENT_CHAT_PROCESS_RUN_PATH = /_agent-native/agent-chat/_process-run`
269
+ (`agent/durable-background.ts`). The worker re-enters the same agent-chat
270
+ handler set as the background worker and runs the full multi-step
271
+ `runAgentLoop` (`agent/production-agent.ts`).
272
+ - **SQL event log as the transport.** The worker persists every event to
273
+ `agent_run_events` (`run-store.ts`, idempotent on `(run_id, seq)`). The
274
+ browser streams those events through the existing `subscribeToRun` →
275
+ cross-isolate SQL-poll path (`run-manager.ts subscribeFromSQL`), so the client
276
+ needs no change and reconnect/leave-and-return already works
277
+ (`GET /runs/active`, `GET /runs/:id/events?after=N`).
278
+ - **Idempotent claim + auth.** The worker claims the run with a conditional
279
+ update (`claimBackgroundRun`) so a duplicate delivery no-ops, and verifies the
280
+ HMAC token (`prepareProcessRunRequest`) exactly like the agent-teams / A2A /
281
+ webhook processors.
282
+ - **Continuation by self-chaining, not by the browser.** If a worker chunk hits
283
+ its soft-timeout unfinished, it emits `auto_continue` and **re-fires another
284
+ background dispatch** (mode `continue`) instead of bouncing back to the client.
285
+ On a host with a short invocation budget this just produces more chunks; the
286
+ run still completes, server-driven. This is what makes the baseline portable:
287
+ it degrades to chained self-dispatch on any host that can re-invoke itself.
288
+
289
+ Nothing in Layer 1 assumes Netlify, a 15-minute budget, or a particular preset.
290
+ It is the host-agnostic Option B worker with a SQL fan-in.
291
+
292
+ ### Layer 2 (per-host optimization) — Netlify 15-min `-background` function
293
+
294
+ On Netlify, Layer 1's worker invocation can be made to run for up to **15
295
+ minutes** in a single shot, eliminating almost all re-hydration (the costly
296
+ part). This is purely an optimization of _where_ Layer 1's worker runs:
297
+
298
+ - **Deploy emit (build-time gated).** When the flag is set at build time, the
299
+ deploy emits a **second** Netlify function whose name ends in `-background`,
300
+ re-exporting the **same** `main.mjs` handler bundle, with a `config.path` of
301
+ the process-run route. Netlify invokes any function whose deployed name ends in
302
+ `-background` asynchronously (202 immediately, up to 15-min budget). Single
303
+ template: `emitSingleTemplateNetlifyBackgroundFunction` (`deploy/build.ts`).
304
+ Workspace: `emitNetlifyBackgroundFunction` (`deploy/workspace-deploy.ts`),
305
+ per app with a base-path-scoped `config.path`. **When the flag is not set at
306
+ build time, neither emit runs and the single-function output is byte-identical
307
+ to today** (see [Build-time gate](#build-time-gate-byte-identical-when-off)).
308
+ - **Raised soft-timeout on that invocation only.** When the worker is running
309
+ inside the background function it calls
310
+ `startRun(..., { backgroundFunction: true })`, which lifts
311
+ `resolveRunSoftTimeoutMs`'s hosted ceiling from 40s to
312
+ `BACKGROUND_SOFT_TIMEOUT_CEILING_MS` (~13 min, ~2 min under Netlify's 15-min
313
+ hard kill) for that invocation. The interactive/foreground 40s clamp is
314
+ unchanged (see Guardrail).
315
+
316
+ If Layer 2 is absent (or its routing resolves to the synchronous function),
317
+ Layer 1 still works — the run just completes via more, shorter, server-chained
318
+ chunks. **No regression, only a missed optimization.**
319
+
320
+ ### Other hosts
321
+
322
+ Any host that can re-invoke itself runs Layer 1 unchanged (chained
323
+ self-dispatch). A host-specific Layer 2 would mirror the Netlify pattern: emit
324
+ or configure a long-lived async worker invocation and pass
325
+ `backgroundFunction: true` to `startRun` on that path. No such layer is built for
326
+ non-Netlify hosts yet; they get the portable baseline.
327
+
328
+ ## Build-time gate (byte-identical when off)
329
+
330
+ The safety-critical claim is that with `AGENT_CHAT_DURABLE_BACKGROUND` **unset at
331
+ build time**, the emitted Netlify deploy output is unchanged. How it is
332
+ guaranteed:
333
+
334
+ - The only code that produces the `-background` artifact is the two emit
335
+ functions above, and each is reached **only** through a call site guarded by
336
+ `isDurableBackgroundDeployEnabled()` (single template: `deploy/build.ts`,
337
+ inside the `preset === "netlify"` block; workspace:
338
+ `deploy/workspace-deploy.ts`, inside `copyNetlifyFunctionIntoWorkspace`).
339
+ `isDurableBackgroundDeployEnabled()` returns `false` for an unset or
340
+ non-truthy flag, so when the flag is absent the emit functions are **never
341
+ invoked** — no second function directory, no extra entry file, no extra
342
+ `config.path` route is written. The deploy walks exactly the same code path it
343
+ does today and emits exactly one function per app.
344
+ - The gate reads the flag at **build time** (in the deploy process env), so a
345
+ build produced without the flag can never contain the artifact regardless of
346
+ runtime env.
347
+ - This is covered by tests that run the real deploy path with the flag unset and
348
+ assert that no `*-agent-background` directory exists, and with the flag set and
349
+ assert that exactly one does (`deploy/workspace-deploy.spec.ts`,
350
+ `deploy/build.spec.ts`).
351
+
352
+ ---
353
+
354
+ # Netlify Layer 2: concrete implementation notes
355
+
356
+ This section details the Netlify-specific Layer 2 optimization (the deploy emit
357
+ and the 15-min budget), reusing the framework's existing background-run
358
+ machinery and Netlify's **background functions** (async, up to **15 minutes**).
359
+ It is deliberately not a from-scratch system: ~90% of the required plumbing
360
+ already ships for Agent Teams, A2A, and integration webhooks — and the
361
+ host-agnostic Layer 1 above is what actually carries the run.
362
+
363
+ ## Why a 15-min background invocation changes the math
364
+
365
+ The whole `auto_continue` / 40s soft-timeout dance exists to stay under the
366
+ **serverless function wall** (~60–65s synchronous on Netlify), not under any
367
+ model limit. Evidence:
368
+
369
+ - `builder-engine.ts:62` `MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS = 180_000`
370
+ (3 min) is allowed locally; the run loop has no inherent reason to stop at 40s.
371
+ - `run-manager.ts:58,68` `DEFAULT_HOSTED_RUN_SOFT_TIMEOUT_MS` /
372
+ `HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000` are pinned just under the function
373
+ wall, and `templates/brain/netlify.toml` sets `[functions."*"] timeout = 75`.
374
+
375
+ Netlify **background functions** (any function whose deployed name ends in
376
+ `-background`) are invoked asynchronously: the HTTP POST returns `202 Accepted`
377
+ immediately and the function runs detached for up to **15 minutes**. Inside that
378
+ function there is no ~60s wall, so:
379
+
380
+ - The agent loop can run for minutes in a single invocation with **few or no
381
+ `auto_continue` continuations**.
382
+ - The per-model-call gateway cap (`MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000`,
383
+ `builder-engine.ts:60`) still applies per call — see
384
+ [Per-model-call gateway cap](#per-model-call-gateway-cap) — but the _run_ is no
385
+ longer chopped into 40s chunks.
386
+
387
+ This is exactly the Layer 1 worker with a concrete long-lived host: Netlify is
388
+ the durable worker, reached through the existing self-dispatch primitive — the
389
+ same primitive that, absent this layer, just chains shorter invocations.
390
+
391
+ ## What already exists and is reused verbatim
392
+
393
+ The Agent Teams background processor is the template. The chat path can reuse
394
+ nearly all of it:
395
+
396
+ | Capability | Existing code | Reuse for chat |
397
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
398
+ | Fire a fresh function invocation with its own budget | `server/self-dispatch.ts:122` `fireInternalDispatch()` (HMAC-signed POST, 250ms settle race) | Point it at the `-background` function path instead of the same-function processor route |
399
+ | Processor route pattern (claim → run full loop → persist → finalize) | `agent-teams.ts` `processAgentTeamRun()` + route at `agent-chat-plugin.ts:6001` (`AGENT_TEAM_PROCESS_RUN_PATH = /_agent-native/agent-teams/_process-run`) | Add a sibling `/_agent-native/agent-chat/_process-run` that runs the **chat** loop |
400
+ | HMAC processor auth | `integrations/internal-token.ts:61,72` `signInternalToken` / `verifyInternalToken` (5-min TTL, task-id-bound, timing-safe); gated by `A2A_SECRET` | Same — sign with `runId` |
401
+ | Atomic single-claim (no double-processing) | `agent-teams-run-queue.ts:177` `claimAgentTeamRun()` (`UPDATE … WHERE status='queued' OR (status='running' AND updated_at < stuckCutoff)`) | Same shape; the chat already has an equivalent with `updateRunStatusIfRunning()` (`run-store.ts:392`, `UPDATE … WHERE id=? AND status='running'`) and `tryClaimRunSlot(threadId)` |
402
+ | The actual multi-step agent loop | `runAgentLoop()` (imported into both `production-agent.ts` and `agent-teams.ts:1790`) | Identical — call it from the background processor |
403
+ | Event persistence (SQL, ordered, idempotent) | `run-store.ts`: `insertRunEvent(runId, seq, json)` with `ON CONFLICT (run_id, seq) DO NOTHING` (`:452,466`); `getRunEventsSince(runId, fromSeq)` (`:471`) | Unchanged — background worker writes the same `agent_run_events` rows |
404
+ | Client reconnect / replay by cursor | `agent-chat-plugin.ts:7103` `GET /runs/:id/events?after=N` → `subscribeToRun(runId, after)`; cross-isolate SQL polling path `run-manager.ts:750` `subscribeFromSQL()` (polls `getRunEventsSince` every 500ms) | **This is the key reuse**: the background worker is a different isolate, so the client already falls through to the SQL-polling subscription. Reconnect already works across isolates. |
405
+ | Discover an in-flight run on reload | `agent-chat-plugin.ts:7130` `GET /runs/active?threadId=X` (returns `runId`, `turnId`, `status`, `heartbeatAt`, `lastProgressAt`, `serverNow`) | Unchanged |
406
+ | Heartbeat + stale reaper | `run-manager.ts:340` (1.5s heartbeat), `run-store.ts:19` `RUN_STALE_MS = 15_000`, `reapIfStale` / `reapAllStaleRuns` | Reused; see [Failure handling](#failure-handling-and-loud-terminal-state) for the 15s-vs-15min tension |
407
+ | Stuck-dispatch re-fire / reconcile | `agent-teams.ts:689` refire, `reconcileAgentTeamRunsForOwner` (`:797`) | Optional reuse for chat reconcile |
408
+
409
+ The single genuinely **new** infrastructure piece is **deploy-time function
410
+ splitting**: today the Nitro `netlify` preset emits exactly one function
411
+ (`.netlify/functions-internal/server/` → `server.mjs` re-exporting `main.mjs`,
412
+ patched in `workspace-deploy.ts:670` `patchNetlifyFunctionEntry`; routed via the
413
+ `config.path` array at `:742`). We must additionally emit a second function whose
414
+ name ends in `-background` that re-exports the **same** `main.mjs` handler.
415
+
416
+ ## The dispatch flow (designed)
417
+
418
+ ```
419
+ ┌── Browser ─────────────────────────────────────────────────────────────┐
420
+ │ 1. POST /_agent-native/agent-chat { message, threadId, turnId } │
421
+ └────────────────────────────────────────────────────────────────────────┘
422
+ │ (foreground function: the normal interactive handler)
423
+
424
+ ┌── agent-chat handler (production-agent.ts) ────────────────────────────┐
425
+ │ 2. tryClaimRunSlot(threadId) → 409 if a run is already active │
426
+ │ 3. runId = generateRunId(); insertRun(runId, threadId, turnId) │
427
+ │ (status='running', heartbeat set) — run-store.ts:233 │
428
+ │ 4. IF durable-background enabled (hosted + flag + A2A_SECRET): │
429
+ │ fireInternalDispatch({ │
430
+ │ path: BACKGROUND_FUNCTION_INVOKE_PATH, // 202, detached │
431
+ │ taskId: runId, body: { threadId, turnId, message, … } }) │
432
+ │ return SSE stream = subscribeToRun(runId, 0) // immediately │
433
+ │ ELSE (local / flag off): │
434
+ │ startRun(runId, …) inline (today's behavior, unchanged) │
435
+ └────────────────────────────────────────────────────────────────────────┘
436
+ │ 202 leaves the box (250ms settle race)
437
+
438
+ ┌── Netlify background function (…-background, up to 15 min) ───────────┐
439
+ │ 5. Route: POST /_agent-native/agent-chat/_process-run │
440
+ │ 6. verifyInternalToken(runId, bearer) // internal-token.ts:72 │
441
+ │ 7. atomically claim runId (updateRunStatusIfRunning-style guard) │
442
+ │ 8. raise gateway cap for this invocation (see §gateway-cap) │
443
+ │ 9. startRun(runId, …, { softTimeoutMs: 0 OR ~13min }) │
444
+ │ → runAgentLoop(...) runs the FULL multi-step turn │
445
+ │ → emitRunEvent(...) persists every event to agent_run_events (SQL) │
446
+ │ 10. on finish: updateRunStatusIfRunning(runId,'completed'|'errored') │
447
+ │ + terminal event ('done'/'error') persisted │
448
+ └────────────────────────────────────────────────────────────────────────┘
449
+ ▲ writes SQL events
450
+ │ reads SQL events (cross-isolate)
451
+ ┌── Browser (same SSE response from step 4, OR reconnect) ──────────────┐
452
+ │ 11. The SSE stream is subscribeToRun(runId,0). Because the producer is │
453
+ │ a *different* isolate, it serves via subscribeFromSQL() — │
454
+ │ run-manager.ts:750 — polling getRunEventsSince every 500ms. │
455
+ │ 12. On disconnect/reload: GET /runs/active?threadId → runId+lastSeq, │
456
+ │ then GET /runs/:id/events?after=lastSeq resumes the same stream. │
457
+ └────────────────────────────────────────────────────────────────────────┘
458
+ ```
459
+
460
+ ### Endpoints / constants to add
461
+
462
+ - `BACKGROUND_FUNCTION_INVOKE_PATH` — the Netlify async path,
463
+ `/.netlify/functions/<app>-agent-background` (Netlify maps `…-background` to
464
+ async/202). The dispatch URL is built with `resolveSelfDispatchBaseUrl(event)`
465
+ (`self-dispatch.ts:59`) + that path. (Alternatively, give the background
466
+ function a `config.path` like `/_agent-native/_bg/*` so dispatch stays a clean
467
+ framework path and Netlify still routes it to the `-background` function and
468
+ invokes it async.)
469
+ - `AGENT_CHAT_PROCESS_RUN_PATH = "/_agent-native/agent-chat/_process-run"` — the
470
+ handler the background function actually runs (sibling to
471
+ `AGENT_TEAM_PROCESS_RUN_PATH`). It is reached _through_ the background
472
+ function, so it inherits the 15-min budget.
473
+ - Feature flag: `AGENT_CHAT_DURABLE_BACKGROUND` (env or per-app config),
474
+ defaulting **off**, gated additionally on `isHostedRuntime()` and
475
+ `hasConfiguredA2ASecret()`. Local dev keeps the inline path so SSE stays a
476
+ single live stream and no second function is needed.
477
+
478
+ ## Where the soft-timeout / auto_continue logic changes
479
+
480
+ Today (`run-manager.ts:344`) every hosted run gets a 40s soft timeout that emits
481
+ `auto_continue` and the client re-POSTs. In the background-function path:
482
+
483
+ - **The background `startRun` gets `softTimeoutMs` ≈ 13 min** (a margin under
484
+ Netlify's 15-min hard kill), not 40s. To allow this, `resolveRunSoftTimeoutMs`
485
+ must learn a **background context**: the `HOSTED_SOFT_TIMEOUT_CEILING_MS = 40_000`
486
+ clamp (`run-manager.ts:170`) currently _defeats_ any larger value on hosted.
487
+ Add a `backgroundFunction: true` option that raises the ceiling to ~`780_000`
488
+ (13 min) for that one invocation. **Do not** change the default hosted ceiling
489
+ — the 40s clamp stays correct for the interactive/foreground path; the
490
+ Guardrail above still holds for non-background runs.
491
+ - **`auto_continue` becomes the rare exception, not the rule.** Most turns finish
492
+ inside 13 min with zero continuations, killing the re-hydration thrash
493
+ described in the Problem section. If a turn _does_ exceed 13 min, the existing
494
+ mechanism still works: emit `auto_continue` and re-fire **another background
495
+ dispatch** (mode `continue`) exactly as `agent-teams.ts:1886` does — i.e. the
496
+ continuation chains background invocations instead of bouncing back to the
497
+ browser. The client never has to drive continuation.
498
+ - **Internally, the worker should still checkpoint (Option A).** 15 min is large
499
+ but not infinite; combining background execution with idempotent/checkpointed
500
+ steps means even a continued run advances monotonically. Option B is strongest
501
+ containing Option A.
502
+
503
+ ## How the client UX changes (SSE → SSE-over-SQL + reconnect)
504
+
505
+ Minimal client change, because the reconnect machinery already exists:
506
+
507
+ - **The response shape is unchanged.** Step 4 still returns an SSE
508
+ `ReadableStream` from `subscribeToRun(runId, 0)`. The client keeps reading SSE
509
+ exactly as today. The only difference is that the events are produced in
510
+ another isolate and arrive via the 500ms SQL-poll path
511
+ (`subscribeFromSQL`, `run-manager.ts:750`) instead of the in-memory fast path.
512
+ Latency goes from ~instant to ≤500ms per event — acceptable for chat.
513
+ - **Reconnect/leave-and-return becomes a first-class, reliable flow.** Because
514
+ the producer is detached on Netlify, closing the tab no longer kills the run.
515
+ On return, the client calls `GET /runs/active?threadId` (`:7130`) → gets
516
+ `runId` + status, then `GET /runs/:id/events?after=<lastSeq>` (`:7103`) to
517
+ replay from the cursor. This already works; we are just making it the primary
518
+ UX. Recommend the client persist `lastSeq` per thread so reconnect resumes
519
+ precisely.
520
+ - **Optional: drop the long-held foreground SSE entirely and poll.** Instead of
521
+ holding step-4's SSE open against the 75s foreground-function `timeout`
522
+ (`netlify.toml`), the foreground POST can return `{ runId, turnId }` (202-style
523
+ JSON) and the client immediately opens `GET /runs/:id/events?after=0`. This
524
+ avoids tying up a foreground function for the run's lifetime. Either works;
525
+ the JSON-then-poll variant is cleaner on Netlify because the interactive
526
+ function returns in well under 75s.
527
+
528
+ ## Per-model-call gateway cap
529
+
530
+ Even in a 15-min function, a _single_ model call is still capped by the Builder
531
+ gateway: `MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000` (`builder-engine.ts:60`),
532
+ resolved by `getBuilderGatewayTimeoutMs()` (`:770`) /
533
+ `resolveMaxBuilderGatewayTimeoutMs()` (`:758`). Locally the cap is already
534
+ `180_000` (`:62`) when the gateway base URL is localhost.
535
+
536
+ Options, in scope-order:
537
+
538
+ 1. **In scope / safe now:** keep the 45s per-call cap. A 15-min function makes
539
+ _many_ 45s calls; the cap bounds one call, not the run. This alone delivers
540
+ the goal (long multi-step runs finish) with **zero gateway changes**.
541
+ 2. **Flag, out of scope until confirmed:** raise the per-call cap toward 180s in
542
+ the background context. `getBuilderGatewayTimeoutMs()` already honors
543
+ `AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS`, but it is hard-clamped to
544
+ `resolveMaxBuilderGatewayTimeoutMs()` = 45s for non-localhost gateways. Raising
545
+ it requires the **hosted Builder gateway to actually allow >45s upstream** —
546
+ unverified, and not the framework's call. Until confirmed, do not raise it.
547
+ 3. **Escape hatch:** a direct Anthropic engine (no Builder gateway) has no 45s
548
+ cap, so a single very long call could use it. Out of scope for this change;
549
+ note it as the lever if a single model call truly needs >45s.
550
+
551
+ **Recommendation:** ship with option 1. The win is removing the _run-level_
552
+ ceiling; the per-call cap is orthogonal and rarely the binding constraint.
553
+
554
+ ## Idempotency / dedup
555
+
556
+ Already strong; make the new claim match:
557
+
558
+ - **Run claim.** The foreground inserts the run row (`insertRun`,
559
+ `run-store.ts:233`) _before_ dispatching. The background processor must claim
560
+ it with a conditional update (mirror `updateRunStatusIfRunning`,
561
+ `run-store.ts:392`, or add a `claimRunForProcessing(runId)` that flips a
562
+ `processing` marker only from the unclaimed state). A duplicate Netlify
563
+ delivery (background functions can in theory be retried) then no-ops on the
564
+ second claim, exactly like `claimAgentTeamRun` returning `null`
565
+ (`agent-teams-run-queue.ts:177`).
566
+ - **Event dedup.** `insertRunEvent` is already idempotent on `(run_id, seq)`
567
+ via `ON CONFLICT … DO NOTHING` (`run-store.ts:452,466`). Re-emitting an event
568
+ with the same seq is a safe no-op, so a retried/overlapping producer cannot
569
+ duplicate the stream.
570
+ - **Dispatch token.** `fireInternalDispatch` signs `runId`
571
+ (`self-dispatch.ts:131`); the processor verifies it (`internal-token.ts:72`,
572
+ 5-min TTL). A stale or forged dispatch is rejected.
573
+
574
+ ## Failure handling and loud terminal state
575
+
576
+ - **15s heartbeat vs 15-min runs — the one real conflict.** The stale reaper
577
+ (`RUN_STALE_MS = 15_000`, `run-store.ts:19`; `reapIfStale`/`reapAllStaleRuns`)
578
+ marks a run `errored` after 15s without a heartbeat. The background worker
579
+ _does_ run `startRun`'s 1.5s heartbeat (`run-manager.ts:340`), so as long as
580
+ the worker is alive the row stays fresh and the reaper leaves it alone — this
581
+ is fine. The risk is a worker that is _slow to start_ (Netlify cold-start of
582
+ the background function) leaving a freshly-inserted `running` row unheartbeaten
583
+ for >15s, which the reaper would falsely kill. Mitigation: the foreground
584
+ insert sets `heartbeat_at = now` (`insertRun`), and the background claim should
585
+ bump the heartbeat immediately on entry; if cold starts can exceed 15s, widen
586
+ `RUN_STALE_MS` for rows known to be background-dispatched (e.g. a
587
+ `dispatch_mode` column or a separate, larger stale window for background runs).
588
+ - **Worker dies mid-run (crash / 15-min kill).** Heartbeat stops, the reaper
589
+ flips the row to `errored` and appends a synthetic terminal event
590
+ (`reapIfStale`), so the client's SQL-poll subscription sees a terminal event
591
+ and stops — no infinite spinner. This is the **loud terminal** contract from
592
+ Phase 0 already wired.
593
+ - **Dispatch never lands (202 lost).** Reuse the Agent-Teams reconcile pattern
594
+ (`reconcileAgentTeamRunsForOwner`, `agent-teams.ts:797`): if a `running` row
595
+ has no heartbeat after a grace window, re-fire the dispatch once, then fail
596
+ loud. Optional for the first slice.
597
+ - **Truthful terminal state.** Combined with the `reliable-mutations` Phase-0
598
+ proof-of-done discipline, a completed background run reports concrete proof;
599
+ a killed one reports `errored` with what was committed — never a false success.
600
+
601
+ ## Phased implementation plan (smallest working slice first)
602
+
603
+ > Status: \*\*Slices 0–1 implemented and the Slice-3 background-aware stale window
604
+ >
605
+ > - background→background continuation chaining are implemented\*\*, all behind
606
+ > `AGENT_CHAT_DURABLE_BACKGROUND` (off by default). The host-agnostic baseline
607
+ > (Layer 1) carries the run on any host; the Netlify `-background` emit (Layer 2)
608
+ > is the deploy-time optimization. Slice 2's richer reconnect-first client UX and
609
+ > the internal per-step checkpointing (Option A) remain follow-ups; Slice 4
610
+ > (raising the per-call gateway cap) is intentionally out of scope (see
611
+ > [Per-model-call gateway cap](#per-model-call-gateway-cap)).
612
+
613
+ **Slice 0 — prove async dispatch on Netlify (no chat yet).**
614
+ Emit one extra `-background` function in the deploy build that re-exports the
615
+ existing `main.mjs` handler (extend `patchNetlifyFunctionEntry`,
616
+ `workspace-deploy.ts:670`, and the single-template Netlify output in
617
+ `deploy/build.ts:1935`). Add `AGENT_CHAT_PROCESS_RUN_PATH` returning a stub that
618
+ just writes a run event. Dispatch to it from a temporary test route via
619
+ `fireInternalDispatch`. **Done when:** a POST returns 202 immediately and the
620
+ stub writes an event to `agent_run_events` from the background function, visible
621
+ via `GET /runs/:id/events`. This de-risks the only genuinely new infra.
622
+
623
+ **Slice 1 — route the real chat loop through the background function (flagged).**
624
+ Behind `AGENT_CHAT_DURABLE_BACKGROUND` (off by default; hosted + `A2A_SECRET`
625
+ only): foreground handler inserts the run row, dispatches, returns the SSE
626
+ stream from `subscribeToRun`. The background `_process-run` claims the run and
627
+ calls the same `startRun` + `runAgentLoop` the inline path uses, with
628
+ `softTimeoutMs ≈ 13min` (new `backgroundFunction` option in
629
+ `resolveRunSoftTimeoutMs`). Per-call gateway cap stays at 45s (option 1).
630
+ **Done when:** a long multi-step turn that thrashes today completes in one
631
+ background invocation with zero `auto_continue`, events streaming to the client
632
+ via the SQL-poll path, terminal `done` persisted.
633
+
634
+ **Slice 2 — reconnect/leave-and-return UX.**
635
+ Make the client persist `lastSeq` per thread and, on load, use
636
+ `/runs/active` + `/runs/:id/events?after=lastSeq` as the primary resume path.
637
+ Optionally switch the foreground POST to return `{ runId, turnId }` JSON instead
638
+ of holding SSE, then poll. **Done when:** closing/reopening the tab mid-run
639
+ resumes the live stream with no lost or duplicated events.
640
+
641
+ **Slice 3 — robustness.**
642
+ Background-aware stale window (cold-start tolerance), reconcile/re-fire for lost
643
+ dispatches, and background→background `auto_continue` chaining for the rare
644
+
645
+ > 13-min turn (mirror `agent-teams.ts:1886`). Internal checkpointing (Option A)
646
+ > for monotonic progress across any continuation.
647
+
648
+ **Slice 4 (optional, separate decision) — raise the per-call gateway cap.**
649
+ Only after confirming the hosted Builder gateway accepts >45s upstream; wire
650
+ `AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS` through with a background-context max.
651
+ Out of scope until that confirmation exists.
652
+
653
+ ## Open risks / unknowns
654
+
655
+ 1. **Netlify background-function invocation contract.** Confirm the exact
656
+ trigger: name-suffix `-background` invoked at `/.netlify/functions/<name>`
657
+ returning 202, and whether a `config.path` route can also mark a function
658
+ background. The deploy currently emits a single function — splitting it is the
659
+ main new work and must not regress the existing `config.path` routing
660
+ (`workspace-deploy.ts:742`).
661
+ 2. **Cold-start vs 15s stale reaper** (detailed above) — the highest-likelihood
662
+ false-failure; needs the background-aware stale window or an on-entry
663
+ heartbeat bump.
664
+ 3. **Hosted gateway >45s** — unknown whether upstream allows it; gates Slice 4.
665
+ 4. **Two functions sharing one bundle** — both re-export the same `main.mjs`, so
666
+ `includedFiles: ["**"]` (`workspace-deploy.ts:747`) must cover the background
667
+ function dir too; verify bundle size and that env (`A2A_SECRET`, DB URL) is
668
+ present in the background function's environment.
669
+ 5. **Cost / concurrency** — background functions are billed and concurrency-
670
+ limited differently; long runs hold a slot for minutes. Out of scope to
671
+ solve, but flag for capacity planning.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.70.2",
3
+ "version": "0.71.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -956,16 +956,27 @@ function coerceStringToSchemaType(raw: string, types: string[]): unknown {
956
956
  }
957
957
 
958
958
  /**
959
- * Defensively coerce gateway-stringified tool arguments to the types the schema
960
- * expects. Some model gateways (notably Builder's Gemini-backed gateway) hand
961
- * back structured tool-call arguments as JSON strings — an array param arrives
962
- * as `"[{...}]"`, a boolean as `"true"`. Standard Schema (zod) `validate` does
963
- * not coerce, so these fail validation and the agent thrashes retrying shapes
964
- * (and can hang). We only touch a string value when the schema expects a
965
- * non-string type and the string parses cleanly to it; anything ambiguous
966
- * (schema also allows string) or unparseable is left as-is. Operates on
967
- * top-level properties only once an array/object param is parsed, its nested
968
- * members are already native and validate normally.
959
+ * Defensively coerce stringified action arguments to the types the schema
960
+ * expects. Two callers depend on this:
961
+ *
962
+ * 1. Model gateways (notably Builder's Gemini-backed gateway) hand back
963
+ * structured tool-call arguments as JSON strings an array param arrives
964
+ * as `"[{...}]"`, a boolean as `"true"`.
965
+ * 2. GET actions called from the browser via `useActionQuery` / `callAction`.
966
+ * Those serialize params into the query string, where `URLSearchParams`
967
+ * stringifies everythingso `includeSeries: true` arrives as the string
968
+ * `"true"` and `limit: 5` as `"5"` (see `action-routes.ts`).
969
+ *
970
+ * In both cases Standard Schema (zod) `validate` does not coerce, so the call
971
+ * fails validation ("expected boolean, received string") — the agent thrashes
972
+ * retrying shapes and the frontend query errors. We only touch a string value
973
+ * when the schema expects a non-string type and the string parses cleanly to
974
+ * it; anything ambiguous (schema also allows string) or unparseable is left
975
+ * as-is. Operates on top-level properties only — once an array/object param is
976
+ * parsed, its nested members are already native and validate normally.
977
+ *
978
+ * Do NOT narrow this to "gateway-only": the GET query-string path relies on it
979
+ * too, and `action-routes.spec.ts` guards that round-trip.
969
980
  */
970
981
  function coerceGatewayStringifiedArgs(
971
982
  args: unknown,