@agent-native/core 0.0.0-beta-20260820143516 → 0.0.0-beta-20260820155159

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 (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/dispatch/changelog/2026-08-20-dispatch-chat-loads-openrouter-provider-reliably.md +6 -0
  3. package/corpus/templates/dispatch/package.json +2 -0
  4. package/corpus/templates/macros/netlify.toml +2 -0
  5. package/dist/action.js +20 -0
  6. package/dist/agent/production-agent.js +37 -10
  7. package/dist/agent/run-manager.d.ts +13 -2
  8. package/dist/agent/run-manager.js +61 -23
  9. package/dist/agent/types.d.ts +21 -0
  10. package/dist/catalog.json +1 -0
  11. package/dist/client/integrations/IntegrationsPanel.js +12 -1
  12. package/dist/collab/awareness.d.ts +2 -2
  13. package/dist/collab/struct-routes.d.ts +1 -1
  14. package/dist/framework-tools.d.ts +16 -1
  15. package/dist/framework-tools.js +15 -0
  16. package/dist/jobs/background-automation-runner.js +26 -2
  17. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  18. package/dist/mcp-client/oauth-routes.d.ts +1 -0
  19. package/dist/mcp-client/oauth-routes.js +37 -9
  20. package/dist/mcp-client/remote-store.d.ts +12 -0
  21. package/dist/mcp-client/remote-store.js +71 -0
  22. package/dist/notifications/routes.d.ts +6 -6
  23. package/dist/observability/routes.d.ts +3 -3
  24. package/dist/observability/traces.js +54 -11
  25. package/dist/progress/routes.d.ts +1 -1
  26. package/dist/secrets/routes.d.ts +3 -3
  27. package/dist/server/action-discovery.d.ts +11 -4
  28. package/dist/server/action-discovery.js +11 -16
  29. package/dist/server/agent-chat/mcp-options.js +16 -1
  30. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  31. package/dist/server/realtime-token.d.ts +1 -1
  32. package/docs/content/external-agents-catalog.mdx +2 -0
  33. package/package.json +2 -2
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8484
34
+ - template files: 8485
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-20
4
+ ---
5
+
6
+ Dispatch chat now loads its OpenRouter provider reliably in production.
@@ -19,9 +19,11 @@
19
19
  "@agent-native/toolkit": "workspace:*",
20
20
  "@fontsource-variable/inter": "^5.2.8",
21
21
  "@libsql/client": "^0.15.8",
22
+ "@openrouter/ai-sdk-provider": "^2.9.1",
22
23
  "@radix-ui/react-tooltip": "^1.2.7",
23
24
  "@tabler/icons-react": "catalog:",
24
25
  "@tanstack/react-query": "^5.99.2",
26
+ "ai": "6.0.258",
25
27
  "h3": "catalog:",
26
28
  "isbot": "^5",
27
29
  "next-themes": "^0.4.6",
@@ -12,6 +12,8 @@ NPM_CONFIG_PRODUCTION = "false"
12
12
 
13
13
  [context.production.environment]
14
14
  AGENT_NATIVE_HOSTED_HARNESS = "true"
15
+ AGENT_NATIVE_ENABLE_KEEP_WARM = "1"
16
+ AGENT_NATIVE_DISABLE_KEEP_WARM_BACKGROUND = "1"
15
17
  # Macros owns its schema at release time (`migrate:production` above), so
16
18
  # production request functions must not probe the database for migrations.
17
19
  # Preview functions still run request-path migrations because previews do not
package/dist/action.js CHANGED
@@ -415,6 +415,22 @@ const PROVIDER_SUPPORTED_FORMATS = new Set([
415
415
  "ipv6",
416
416
  "uuid",
417
417
  ]);
418
+ /**
419
+ * Anthropic's function-schema validator rejects regex lookaround -- `(?=`,
420
+ * `(?!`, `(?<=`, `(?<!` -- inside a `pattern`, with "Invalid JSON schema: regex
421
+ * lookaround is not supported", and it rejects the WHOLE request, before a
422
+ * token is generated. Interactive chat surfaces that as an error; a background
423
+ * run just dies with `http_400` in `agent_runs.error_detail` while the UI says
424
+ * nothing, which is how a scout can be down for days looking merely idle.
425
+ *
426
+ * Zod 4's `z.string().email()` compiles to two negative lookaheads, and it is
427
+ * written in ~35 action schemas across core, the templates, and the apps -- so
428
+ * this is answered here rather than by retyping every one of them, the same way
429
+ * typeless schemas and unsupported `format` values are. `pattern` is a hint for
430
+ * the model; the action's own zod schema still validates the value server-side,
431
+ * so dropping it widens nothing that was actually enforced.
432
+ */
433
+ const LOOKAROUND_IN_PATTERN = /\(\?<?[=!]/;
418
434
  /**
419
435
  * Keywords that only ever CONSTRAIN a value and that OpenAI's validator rejects
420
436
  * outright. Removing a constraint can never make a previously-valid document
@@ -440,6 +456,10 @@ export function stripUnsupportedSchemaKeywords(node) {
440
456
  !PROVIDER_SUPPORTED_FORMATS.has(obj.format)) {
441
457
  delete obj.format;
442
458
  }
459
+ if (typeof obj.pattern === "string" &&
460
+ LOOKAROUND_IN_PATTERN.test(obj.pattern)) {
461
+ delete obj.pattern;
462
+ }
443
463
  for (const key of SUBSCHEMA_VALUE_KEYS) {
444
464
  // `items`/`additionalItems` may also be an array of subschemas.
445
465
  const value = obj[key];
@@ -759,6 +759,14 @@ const VISIBLE_RETRY_THRESHOLD_MS = 10_000;
759
759
  * < MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS (90s, above)
760
760
  * < RUN_NO_PROGRESS_HARD_TIMEOUT_MS (150s, run-manager.ts)
761
761
  *
762
+ * Ordering alone is NOT sufficient between the last two, because they do not
763
+ * measure the same events: the bounds above watch engine-stream frames, while
764
+ * the run-manager backstop watches events this loop FORWARDS. Extended
765
+ * thinking produces the first without the second, so the 150s bound sat inside
766
+ * the working distribution and killed live runs. What keeps them consistent is
767
+ * the `model_stream` start/end bracket around the engine call, which suspends
768
+ * the outer backstop while these inner bounds are the ones on duty.
769
+ *
762
770
  * Background-function runs (proven 15-min budget, no ~40s wall) are likewise
763
771
  * unaffected — they keep the full 90s window for every event, first or not.
764
772
  */
@@ -3464,6 +3472,27 @@ export async function runAgentLoop(opts) {
3464
3472
  const activeToolInputs = new Map();
3465
3473
  let zeroByteToolInputRestart;
3466
3474
  let endedForNoProgress = false;
3475
+ // Bracket the engine call for the run manager's no-progress backstop
3476
+ // (`inFlightWorkDelta` in run-manager.ts). That backstop measures the
3477
+ // events this loop FORWARDS; extended thinking forwards none for
3478
+ // minutes while the frames arriving here prove the stream is alive, so
3479
+ // without the bracket a healthy generation reads as a wedged run and is
3480
+ // killed mid-stream. Closing is idempotent and MUST happen in a
3481
+ // `finally`: a leaked open suspends the backstop for the rest of the
3482
+ // run, which is worse than the stall it exists to catch.
3483
+ let modelStreamBracketOpen = false;
3484
+ const openModelStreamBracket = () => {
3485
+ if (modelStreamBracketOpen)
3486
+ return;
3487
+ modelStreamBracketOpen = true;
3488
+ send({ type: "model_stream", status: "start" });
3489
+ };
3490
+ const closeModelStreamBracket = () => {
3491
+ if (!modelStreamBracketOpen)
3492
+ return;
3493
+ modelStreamBracketOpen = false;
3494
+ send({ type: "model_stream", status: "end" });
3495
+ };
3467
3496
  let lastModelStreamProgressAt = Date.now();
3468
3497
  // FIX 2: true once a real (non-heartbeat) engine-stream event has been
3469
3498
  // retrieved for THIS model call — gates
@@ -3540,6 +3569,10 @@ export async function runAgentLoop(opts) {
3540
3569
  const checkpointNoProgress = () => {
3541
3570
  if (endedForNoProgress)
3542
3571
  return;
3572
+ // The stream is over as far as this loop is concerned, so end the
3573
+ // bracket before the boundary event rather than after it in the
3574
+ // `finally` — the checkpoint stays the last event of the chunk.
3575
+ closeModelStreamBracket();
3543
3576
  send({
3544
3577
  type: "auto_continue",
3545
3578
  reason: "no_progress",
@@ -3637,6 +3670,7 @@ export async function runAgentLoop(opts) {
3637
3670
  };
3638
3671
  const eventIterator = eventStream[Symbol.asyncIterator]();
3639
3672
  let eventIteratorDone = false;
3673
+ openModelStreamBracket();
3640
3674
  try {
3641
3675
  while (true) {
3642
3676
  const nextEvent = await nextEngineEventWithNoProgressTimeout(eventIterator);
@@ -3700,11 +3734,7 @@ export async function runAgentLoop(opts) {
3700
3734
  });
3701
3735
  sendToolInputActivity(event.name, key, undefined, true);
3702
3736
  if (noteZeroByteToolInputStart(event.name)) {
3703
- send({
3704
- type: "auto_continue",
3705
- reason: "no_progress",
3706
- });
3707
- endedForNoProgress = true;
3737
+ checkpointNoProgress();
3708
3738
  break;
3709
3739
  }
3710
3740
  }
@@ -3745,11 +3775,7 @@ export async function runAgentLoop(opts) {
3745
3775
  if (startedZeroByteInput &&
3746
3776
  toolName &&
3747
3777
  noteZeroByteToolInputStart(toolName)) {
3748
- send({
3749
- type: "auto_continue",
3750
- reason: "no_progress",
3751
- });
3752
- endedForNoProgress = true;
3778
+ checkpointNoProgress();
3753
3779
  break;
3754
3780
  }
3755
3781
  }
@@ -3814,6 +3840,7 @@ export async function runAgentLoop(opts) {
3814
3840
  }
3815
3841
  }
3816
3842
  finally {
3843
+ closeModelStreamBracket();
3817
3844
  if (!eventIteratorDone) {
3818
3845
  await requestEventIteratorReturn(eventIterator, !eventIteratorReturnRequested);
3819
3846
  }
@@ -103,11 +103,22 @@ export declare const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS: number;
103
103
  * with the client watching keepalives. This backstop covers every segment by
104
104
  * construction: if no REAL progress event (see `shouldBumpProgressForEvent`;
105
105
  * keepalives and zero-byte prep activity don't count) lands for this long —
106
- * and no tool call is in flight (tool execution legitimately emits nothing
107
- * for minutes and has its own 12-min timeout) — the run manager emits
106
+ * and no unit of work is in flight (see `inFlightWorkDelta`: tool calls,
107
+ * cross-app calls, and the model stream all legitimately emit nothing for
108
+ * minutes and each carry a bound of their own) — the run manager emits
108
109
  * `auto_continue { reason: "no_progress" }` and aborts the chunk, exactly
109
110
  * like the soft timeout, so the normal continuation machinery recovers it.
110
111
  *
112
+ * Being numerically larger than the in-loop watchdogs is NOT what keeps this
113
+ * from killing a healthy run, and treating it that way is what made it do so:
114
+ * this clock and the loop's `lastModelStreamProgressAt` measure DIFFERENT
115
+ * events. An extended-thinking phase bumps the inner clock on every engine
116
+ * frame while forwarding nothing, so the inner watchdog correctly stayed quiet
117
+ * and this one saw pure silence — runs whose worst gap crossed 150s died while
118
+ * still streaming, some by a single second. Ordering between two clocks only
119
+ * means something when they watch the same events; suspending on in-flight
120
+ * work is what actually makes the two agree.
121
+ *
111
122
  * This is now only the CEILING, not the value: `resolveRunNoProgressTimeoutMs`
112
123
  * clamps the foreground backstop to a fraction of the chunk's soft timeout
113
124
  * (~30s at a 40s chunk), which is BELOW the 90s in-loop watchdogs rather than
@@ -78,11 +78,22 @@ export const DEFAULT_BACKGROUND_RUN_SOFT_TIMEOUT_MS = BACKGROUND_SOFT_TIMEOUT_CE
78
78
  * with the client watching keepalives. This backstop covers every segment by
79
79
  * construction: if no REAL progress event (see `shouldBumpProgressForEvent`;
80
80
  * keepalives and zero-byte prep activity don't count) lands for this long —
81
- * and no tool call is in flight (tool execution legitimately emits nothing
82
- * for minutes and has its own 12-min timeout) — the run manager emits
81
+ * and no unit of work is in flight (see `inFlightWorkDelta`: tool calls,
82
+ * cross-app calls, and the model stream all legitimately emit nothing for
83
+ * minutes and each carry a bound of their own) — the run manager emits
83
84
  * `auto_continue { reason: "no_progress" }` and aborts the chunk, exactly
84
85
  * like the soft timeout, so the normal continuation machinery recovers it.
85
86
  *
87
+ * Being numerically larger than the in-loop watchdogs is NOT what keeps this
88
+ * from killing a healthy run, and treating it that way is what made it do so:
89
+ * this clock and the loop's `lastModelStreamProgressAt` measure DIFFERENT
90
+ * events. An extended-thinking phase bumps the inner clock on every engine
91
+ * frame while forwarding nothing, so the inner watchdog correctly stayed quiet
92
+ * and this one saw pure silence — runs whose worst gap crossed 150s died while
93
+ * still streaming, some by a single second. Ordering between two clocks only
94
+ * means something when they watch the same events; suspending on in-flight
95
+ * work is what actually makes the two agree.
96
+ *
86
97
  * This is now only the CEILING, not the value: `resolveRunNoProgressTimeoutMs`
87
98
  * clamps the foreground backstop to a fraction of the chunk's soft timeout
88
99
  * (~30s at a 40s chunk), which is BELOW the 90s in-loop watchdogs rather than
@@ -161,6 +172,36 @@ export function resolveRunNoProgressTimeoutMs(params) {
161
172
  return ceiling;
162
173
  return override === 0 ? 0 : Math.min(override, ceiling);
163
174
  }
175
+ /**
176
+ * Work-tracking transition for one event: `1` opens a unit of in-flight work,
177
+ * `-1` closes one, `0` is not a work-tracking event.
178
+ *
179
+ * Every pair listed here suspends the no-progress backstop for as long as it is
180
+ * open, so each one MUST be bounded by a watchdog of its own — tool calls by
181
+ * the per-tool timeout, cross-app calls by the A2A poll timeout, the model
182
+ * stream by `MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS`, which the agent loop races
183
+ * against every wait for the next engine frame. A pair added here without such
184
+ * a bound turns the backstop off for the rest of the run, which is strictly
185
+ * worse than the stall it was meant to catch.
186
+ *
187
+ * Note what that leaves: the model-stream bound covers waiting for the engine,
188
+ * not a hang while the loop processes a frame it already has. Nothing here
189
+ * covers that segment, so a suspension is only ever as tight as the chunk's own
190
+ * soft timeout. Keep the pairs narrow around genuinely blocking work.
191
+ */
192
+ function inFlightWorkDelta(event) {
193
+ switch (event.type) {
194
+ case "tool_start":
195
+ return 1;
196
+ case "tool_done":
197
+ return -1;
198
+ case "agent_call":
199
+ case "model_stream":
200
+ return event.status === "start" ? 1 : -1;
201
+ default:
202
+ return 0;
203
+ }
204
+ }
164
205
  /**
165
206
  * Default SQL retention for completed run event logs (24 hours).
166
207
  *
@@ -829,9 +870,10 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
829
870
  // ── No-progress backstop (see RUN_NO_PROGRESS_HARD_TIMEOUT_MS) ──────────
830
871
  // Timer-driven and independent of the agent loop, so it fires even when the
831
872
  // stall is in a segment the in-loop watchdogs never see (engine-call
832
- // establishment, setup, a wedged transport emitting keepalives). Tool calls
833
- // and sub-agent calls in flight suspend it — tool execution legitimately
834
- // emits nothing for minutes and carries its own 12-min timeout.
873
+ // establishment, setup, a wedged transport emitting keepalives). Tool calls,
874
+ // sub-agent calls, and the model stream in flight suspend it — each
875
+ // legitimately emits nothing for minutes and each carries a bound of its own
876
+ // (see `inFlightWorkDelta`).
835
877
  let lastRealProgressAt = Date.now();
836
878
  let inFlightWorkCount = 0;
837
879
  let inFlightMarkerSince = null;
@@ -854,24 +896,11 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
854
896
  });
855
897
  };
856
898
  const trackInFlightWork = (event) => {
899
+ const delta = inFlightWorkDelta(event);
900
+ if (delta === 0)
901
+ return; // Not a work-tracking event — no transition.
857
902
  const wasIdle = inFlightWorkCount === 0;
858
- if (event.type === "tool_start") {
859
- inFlightWorkCount += 1;
860
- }
861
- else if (event.type === "tool_done") {
862
- inFlightWorkCount = Math.max(0, inFlightWorkCount - 1);
863
- }
864
- else if (event.type === "agent_call") {
865
- if (event.status === "start") {
866
- inFlightWorkCount += 1;
867
- }
868
- else {
869
- inFlightWorkCount = Math.max(0, inFlightWorkCount - 1);
870
- }
871
- }
872
- else {
873
- return; // Not a work-tracking event — no transition possible.
874
- }
903
+ inFlightWorkCount = Math.max(0, inFlightWorkCount + delta);
875
904
  // Mirror the 0<->N transition into SQL so a stale reaper running in a
876
905
  // DIFFERENT isolate (a client's SQL-subscription poll, a sibling
877
906
  // isolate's opportunistic cleanup, a fresh boot's startup sweep) can
@@ -882,6 +911,14 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
882
911
  // keep failures observable and preserve transition order. See
883
912
  // `setRunInFlightMarker` / `IN_FLIGHT_RUN_STALE_GRACE_MS` in run-store.ts
884
913
  // for the full reasoning and bounded-grace derivation.
914
+ //
915
+ // A model stream in flight mirrors the marker too, deliberately: a long
916
+ // thinking phase is exactly when another isolate's reaper would call this
917
+ // run stale, and the stream is as demonstrably alive as a tool call. It
918
+ // cannot latch a corpse — the grace clause additionally requires the
919
+ // liveness basis to be fresh within `IN_FLIGHT_GRACE_MAX_LIVENESS_GAP_MS`,
920
+ // so a marker left set by a dead producer buys nothing. The cost is one
921
+ // extra set/clear pair per model call on the way to each tool call.
885
922
  if (wasIdle && inFlightWorkCount > 0) {
886
923
  mirrorInFlightMarker(true);
887
924
  }
@@ -924,7 +961,8 @@ export function startRun(runId, threadId, runFn, onComplete, options) {
924
961
  return;
925
962
  if (Date.now() - lastRealProgressAt < noProgressTimeoutMs)
926
963
  return;
927
- console.error(`[run-manager] no real progress for ${noProgressTimeoutMs}ms with no tool in flight — ` +
964
+ console.error(`[run-manager] no real progress for ${noProgressTimeoutMs}ms with no tool ` +
965
+ `or model stream in flight — ` +
928
966
  `checkpointing run for continuation`, runId);
929
967
  // Mirror the soft-timeout semantics exactly: the chunk completes (not
930
968
  // aborts) at an auto_continue boundary, so the continuation machinery —
@@ -276,6 +276,27 @@ export type AgentChatEvent = {
276
276
  text: string;
277
277
  } | {
278
278
  type: "stream_keepalive";
279
+ } | {
280
+ /**
281
+ * Lifecycle bracket around ONE engine (model) call, emitted by the agent
282
+ * loop when the stream is established and again — from a `finally` — when
283
+ * it ends, returns, or throws.
284
+ *
285
+ * Exists so the run manager's no-progress backstop can tell "the model is
286
+ * generating" from "nothing is happening". An extended-thinking phase
287
+ * emits frames that keep the loop's own 90s model-stream watchdog fresh
288
+ * without producing any forwarded event, so the backstop's clock saw pure
289
+ * silence and killed demonstrably-alive runs at 150s. `trackInFlightWork`
290
+ * counts this pair exactly like `tool_start`/`tool_done`: an engine call
291
+ * in flight suspends the backstop, bounded by the in-loop watchdog the
292
+ * same way a tool call is bounded by its own timeout.
293
+ *
294
+ * Deliberately NOT a keepalive: a keepalive proves the transport is up,
295
+ * this proves the loop is inside a model call it will be held accountable
296
+ * for by `MODEL_STREAM_NO_PROGRESS_TIMEOUT_MS`.
297
+ */
298
+ type: "model_stream";
299
+ status: "start" | "end";
279
300
  } | {
280
301
  type: "tool_start";
281
302
  tool: string;
package/dist/catalog.json CHANGED
@@ -14,6 +14,7 @@
14
14
  "@tiptap/extension-collaboration": "3.30.1",
15
15
  "@tiptap/extension-collaboration-caret": "3.30.1",
16
16
  "@tiptap/extension-color": "3.30.1",
17
+ "@tiptap/extension-floating-menu": "3.30.1",
17
18
  "@tiptap/extension-horizontal-rule": "3.30.1",
18
19
  "@tiptap/extension-image": "3.30.1",
19
20
  "@tiptap/extension-link": "3.30.1",
@@ -239,6 +239,15 @@ function compareMcpUrl(value) {
239
239
  return value.trim().replace(/\/+$/, "");
240
240
  }
241
241
  }
242
+ function startMcpOAuthReconnect(server) {
243
+ const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
244
+ const params = new URLSearchParams({
245
+ serverId: server.id,
246
+ scope: server.scope,
247
+ return: returnUrl,
248
+ });
249
+ window.location.assign(agentNativePath(`/_agent-native/mcp/servers/oauth/start?${params}`));
250
+ }
242
251
  function McpServerStatus({ server, onReconnect, reconnecting = false, reconnectError, }) {
243
252
  const t = useT();
244
253
  if (server.status.state === "connected") {
@@ -362,7 +371,9 @@ function McpIntegrationsSection({ query }) {
362
371
  return (_jsxs("div", { className: "flex items-start gap-3 py-3.5", children: [_jsx("span", { className: "mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background text-muted-foreground", children: _jsx(IconServer, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium text-foreground", children: server.name }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: server.scope === "user"
363
372
  ? t("mcpIntegrations.personal")
364
373
  : t("mcpIntegrations.sharedWithWorkspace") })] }), _jsx(McpServerStatus, { server: server, onReconnect: server.status.state === "error"
365
- ? () => void reconnect(server)
374
+ ? () => server.authMode === "oauth"
375
+ ? startMcpOAuthReconnect(server)
376
+ : void reconnect(server)
366
377
  : undefined, reconnecting: reconnectingKey === key, reconnectError: reconnectError?.key === key
367
378
  ? reconnectError.message
368
379
  : undefined })] }), canRemove && (_jsx("button", { type: "button", onClick: () => void removeServer(server), disabled: deleteServer.isPending, className: "shrink-0 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50", children: deleteTarget === key ? "Confirm" : "Remove" }))] }, key));
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  states: {
66
67
  clientId: number;
67
68
  state: string;
68
69
  }[];
69
- error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
+ error?: undefined;
80
81
  users: {
81
82
  clientId: number;
82
83
  lastSeen: number;
83
84
  }[];
84
- error?: undefined;
85
85
  }>>;
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- error: string;
17
16
  ok?: undefined;
17
+ error: string;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -14,7 +14,7 @@ import { type DatabaseToolsOption } from "./scripts/db/tool-mode.js";
14
14
  * when it names them in `initialToolNames`. They stay in the searchable registry
15
15
  * either way.
16
16
  */
17
- export declare const FRAMEWORK_TOOL_GROUPS: readonly ["sharing", "review", "history", "featureFlags", "localization", "audit", "contextXray", "userProfile", "automation", "docs", "resources", "web", "workspaceApps", "chat", "email"];
17
+ export declare const FRAMEWORK_TOOL_GROUPS: readonly ["sharing", "review", "history", "featureFlags", "localization", "audit", "contextXray", "userProfile", "automation", "docs", "resources", "web", "workspaceApps", "chat", "email", "emailCatalog", "workspaceUserGroups", "orgServiceTokens"];
18
18
  export type FrameworkToolGroup = (typeof FRAMEWORK_TOOL_GROUPS)[number];
19
19
  /**
20
20
  * Per-group switches for the framework's own agent tools. Every group defaults
@@ -73,6 +73,21 @@ export interface FrameworkToolsOption {
73
73
  chat?: boolean;
74
74
  /** `core-send-email`. */
75
75
  email?: boolean;
76
+ /** The read-only transactional-email catalog (`list-transactional-emails`,
77
+ * `list-email-log`, `list-email-activity`, …). Separate from `email`, which
78
+ * owns the SEND capability: Dispatch asks any app what it sends without that
79
+ * app opting into sending from the agent, so these default to on. */
80
+ emailCatalog?: boolean;
81
+ /** Reusable workspace member lists (`list-workspace-user-groups`,
82
+ * `upsert-workspace-user-group`, `bulk-update-workspace-user-groups`,
83
+ * `delete-workspace-user-group`). Their UI is the Team page and the share
84
+ * dialog; an app with neither has no use for them. */
85
+ workspaceUserGroups?: boolean;
86
+ /** `create-org-service-token`, `list-org-service-tokens`,
87
+ * `revoke-org-service-token`. Minting a credential is a real capability:
88
+ * `mcp.enabled` decides whether the ROUTES exist, this decides whether the
89
+ * model can call them. */
90
+ orgServiceTokens?: boolean;
76
91
  /** `"minimal"` turns every group above off, for voice-first and
77
92
  * single-purpose apps that want the template's own actions and nothing else.
78
93
  * Any explicit group key wins over the preset, so
@@ -30,6 +30,9 @@ export const FRAMEWORK_TOOL_GROUPS = [
30
30
  "workspaceApps",
31
31
  "chat",
32
32
  "email",
33
+ "emailCatalog",
34
+ "workspaceUserGroups",
35
+ "orgServiceTokens",
33
36
  ];
34
37
  function normalizeFrameworkToolsConfig(config) {
35
38
  if (config === "minimal")
@@ -152,6 +155,18 @@ export const CORE_ACTION_GROUPS = {
152
155
  "get-resource-version": "history",
153
156
  "restore-resource-version": "history",
154
157
  "list-resource-history": "history",
158
+ "list-transactional-emails": "emailCatalog",
159
+ "render-transactional-email-preview": "emailCatalog",
160
+ "list-email-log": "emailCatalog",
161
+ "list-email-activity": "emailCatalog",
162
+ "list-email-engagement": "emailCatalog",
163
+ "list-workspace-user-groups": "workspaceUserGroups",
164
+ "upsert-workspace-user-group": "workspaceUserGroups",
165
+ "bulk-update-workspace-user-groups": "workspaceUserGroups",
166
+ "delete-workspace-user-group": "workspaceUserGroups",
167
+ "create-org-service-token": "orgServiceTokens",
168
+ "list-org-service-tokens": "orgServiceTokens",
169
+ "revoke-org-service-token": "orgServiceTokens",
155
170
  "list-review-comments": "review",
156
171
  "create-review-comment": "review",
157
172
  "reply-review-comment": "review",
@@ -10,6 +10,7 @@ import { resolveAutomationExecutionIdentity, } from "../automations/service.js";
10
10
  import { createThread, getThread, updateThreadData, withThreadDataLock, } from "../chat-threads/store.js";
11
11
  import { queryOrgMembers } from "../org/context.js";
12
12
  import { organizationIdFromResourceOwner, organizationResourceOwner, } from "../resources/store.js";
13
+ import { captureError } from "../server/capture-error.js";
13
14
  import { runWithRequestContext, } from "../server/request-context.js";
14
15
  import { attachAutomationRunThread, finishAutomationRun, startAutomationRun, } from "./run-history.js";
15
16
  const BACKGROUND_RUN_STUCK_MS = 10 * 60_000;
@@ -177,11 +178,32 @@ export async function runBackgroundAutomation(options, deps) {
177
178
  }
178
179
  }
179
180
  let result;
181
+ // Populated as soon as the run id exists, so a failure that never returns a
182
+ // result can still be joined to its LLM trace (`aiTraceId` -> $ai_trace_id).
183
+ const runIdRef = { current: null };
180
184
  try {
181
- result = await executeBackgroundAutomation(options, deps, historyId);
185
+ result = await executeBackgroundAutomation(options, deps, historyId, runIdRef);
182
186
  }
183
187
  catch (err) {
184
188
  const message = err instanceof Error ? err.message : String(err);
189
+ // Both callers (recurring-jobs scheduler, trigger dispatcher) record this
190
+ // onto the automation's own metadata and console.error it, and neither
191
+ // reports it. A failure visible only in a resource field and stdout is not
192
+ // a failure anyone sees: the run-level no-progress cutoff killed
193
+ // automations across two releases without ever raising an issue.
194
+ captureError(err, {
195
+ tags: {
196
+ area: "background-automation",
197
+ automation: automation.name,
198
+ scope: options.orgId ? "organization" : "personal",
199
+ },
200
+ extra: {
201
+ automationPath: automation.resource.path,
202
+ appId: deps.appId,
203
+ historyId,
204
+ },
205
+ ...(runIdRef.current ? { aiTraceId: runIdRef.current } : {}),
206
+ });
185
207
  await recordRunOutcome(historyId, "error", `${message}. No delivery was confirmed.`);
186
208
  throw err;
187
209
  }
@@ -289,7 +311,7 @@ async function recordRunOutcome(historyId, status, error) {
289
311
  console.error(`[automations] Could not record run ${historyId} as ${status}:`, err);
290
312
  }
291
313
  }
292
- async function executeBackgroundAutomation(options, deps, historyId) {
314
+ async function executeBackgroundAutomation(options, deps, historyId, runIdRef) {
293
315
  const { automation, ownerEmail, orgId, prompt, threadTitle, usageLabel } = options;
294
316
  return runWithRequestContext({
295
317
  ...options.requestContext,
@@ -335,6 +357,8 @@ async function executeBackgroundAutomation(options, deps, historyId) {
335
357
  orgId: orgId ?? null,
336
358
  });
337
359
  const runId = createRunId(options.runIdPrefix);
360
+ if (runIdRef)
361
+ runIdRef.current = runId;
338
362
  await recordRunThread(historyId, thread.id, runId);
339
363
  // Scheduled work is background work: it has no synchronous serverless
340
364
  // caller waiting on it, so it must not inherit the interactive clamp
@@ -137,13 +137,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
137
137
  inputSchema: {
138
138
  type: string;
139
139
  properties: {
140
- count?: undefined;
141
140
  query?: undefined;
142
141
  minutes?: undefined;
143
142
  limit?: undefined;
144
143
  clientHint?: undefined;
145
144
  timestamp?: undefined;
146
145
  chapterId?: undefined;
146
+ count?: undefined;
147
147
  startAt?: undefined;
148
148
  endAt?: undefined;
149
149
  reason?: undefined;
@@ -159,7 +159,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
159
159
  inputSchema: {
160
160
  type: string;
161
161
  properties: {
162
- count?: undefined;
163
162
  query: {
164
163
  type: string;
165
164
  description: string;
@@ -175,6 +174,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
175
174
  clientHint?: undefined;
176
175
  timestamp?: undefined;
177
176
  chapterId?: undefined;
177
+ count?: undefined;
178
178
  startAt?: undefined;
179
179
  endAt?: undefined;
180
180
  reason?: undefined;
@@ -190,7 +190,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
190
190
  inputSchema: {
191
191
  type: string;
192
192
  properties: {
193
- count?: undefined;
194
193
  minutes: {
195
194
  type: string;
196
195
  description: string;
@@ -200,6 +199,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
200
199
  clientHint?: undefined;
201
200
  timestamp?: undefined;
202
201
  chapterId?: undefined;
202
+ count?: undefined;
203
203
  startAt?: undefined;
204
204
  endAt?: undefined;
205
205
  reason?: undefined;
@@ -216,7 +216,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
216
216
  type: string;
217
217
  required: string[];
218
218
  properties: {
219
- count?: undefined;
220
219
  query: {
221
220
  type: string;
222
221
  description: string;
@@ -235,6 +234,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
235
234
  };
236
235
  timestamp?: undefined;
237
236
  chapterId?: undefined;
237
+ count?: undefined;
238
238
  startAt?: undefined;
239
239
  endAt?: undefined;
240
240
  reason?: undefined;
@@ -250,7 +250,6 @@ export declare function screenMemoryMcpToolDefinitions(): ({
250
250
  type: string;
251
251
  required: string[];
252
252
  properties: {
253
- count?: undefined;
254
253
  query?: undefined;
255
254
  minutes?: undefined;
256
255
  limit?: undefined;
@@ -264,6 +263,7 @@ export declare function screenMemoryMcpToolDefinitions(): ({
264
263
  description: string;
265
264
  };
266
265
  chapterId?: undefined;
266
+ count?: undefined;
267
267
  startAt?: undefined;
268
268
  endAt?: undefined;
269
269
  includeMicrophone?: undefined;
@@ -314,13 +314,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
314
314
  type: string;
315
315
  required: string[];
316
316
  properties: {
317
- count?: undefined;
318
317
  query?: undefined;
319
318
  minutes?: undefined;
320
319
  limit?: undefined;
321
320
  clientHint?: undefined;
322
321
  timestamp?: undefined;
323
322
  chapterId?: undefined;
323
+ count?: undefined;
324
324
  startAt: {
325
325
  type: string;
326
326
  description: string;
@@ -349,13 +349,13 @@ export declare function screenMemoryMcpToolDefinitions(): ({
349
349
  type: string;
350
350
  required: string[];
351
351
  properties: {
352
- count?: undefined;
353
352
  query?: undefined;
354
353
  minutes?: undefined;
355
354
  limit?: undefined;
356
355
  clientHint?: undefined;
357
356
  timestamp?: undefined;
358
357
  chapterId?: undefined;
358
+ count?: undefined;
359
359
  startAt?: undefined;
360
360
  endAt?: undefined;
361
361
  reason?: undefined;
@@ -16,6 +16,7 @@ export interface McpOAuthFlow {
16
16
  clientInformation: StoredOAuthClientInformation;
17
17
  discoveryState?: McpOAuthDiscoveryState;
18
18
  returnUrl?: string;
19
+ replaceServerId?: string;
19
20
  expiresAt: number;
20
21
  }
21
22
  export interface McpOAuthRoutesOptions {
@@ -8,7 +8,7 @@ import { getH3App } from "../server/framework-request-handler.js";
8
8
  import { getAppUrl, resolveOAuthRedirectUri } from "../server/google-oauth.js";
9
9
  import { runWithRequestContext } from "../server/request-context.js";
10
10
  import { finishMcpOAuthAuthorization, startMcpOAuthAuthorization, validateMcpOAuthCallbackIssuer, } from "./oauth-client.js";
11
- import { addOAuthRemoteServer, normalizeServerName, validateRemoteUrl, } from "./remote-store.js";
11
+ import { addOAuthRemoteServer, listRemoteServers, normalizeServerName, replaceOAuthRemoteServer, validateRemoteUrl, } from "./remote-store.js";
12
12
  const FLOW_COOKIE = "an_mcp_oauth_flow";
13
13
  const FLOW_TTL_SECONDS = 10 * 60;
14
14
  const FLOW_COOKIE_CHUNK_SIZE = 2_800;
@@ -69,8 +69,33 @@ async function handleMcpOAuthStart(event) {
69
69
  if (!session?.email)
70
70
  return unauthorized(event);
71
71
  const query = getQuery(event);
72
- const rawUrl = text(query.url);
73
- const rawName = text(query.name);
72
+ const reconnectServerId = text(query.serverId);
73
+ const reconnectScope = query.scope === "org" ? "org" : "user";
74
+ const reconnectOrg = reconnectScope === "org" ? await getOrgContext(event) : null;
75
+ const reconnectScopeId = reconnectScope === "user" ? session.email : (reconnectOrg?.orgId ?? "");
76
+ let reconnectServer;
77
+ if (reconnectServerId) {
78
+ if (reconnectScope === "org" &&
79
+ (!reconnectScopeId || !isOrgAdmin(reconnectOrg?.role))) {
80
+ setResponseStatus(event, reconnectScopeId ? 403 : 400);
81
+ return {
82
+ error: reconnectScopeId
83
+ ? "Only organization owners and admins can reconnect an org MCP server."
84
+ : "Join an organization before reconnecting an org MCP server.",
85
+ };
86
+ }
87
+ reconnectServer = (await listRemoteServers(reconnectScope, reconnectScopeId)).find((server) => server.id === reconnectServerId);
88
+ if (!reconnectServer) {
89
+ setResponseStatus(event, 404);
90
+ return { error: "MCP server was not found." };
91
+ }
92
+ if (!reconnectServer.oauthSecretKey) {
93
+ setResponseStatus(event, 400);
94
+ return { error: "This MCP server does not use OAuth credentials." };
95
+ }
96
+ }
97
+ const rawUrl = reconnectServer?.url ?? text(query.url);
98
+ const rawName = reconnectServer?.name ?? text(query.name);
74
99
  const returnUrl = text(query.return);
75
100
  if (!rawUrl || !rawName) {
76
101
  setResponseStatus(event, 400);
@@ -159,6 +184,7 @@ async function handleMcpOAuthStart(event) {
159
184
  ? { discoveryState: started.discoveryState }
160
185
  : {}),
161
186
  ...(safeReturnUrl ? { returnUrl: safeReturnUrl } : {}),
187
+ ...(reconnectServerId ? { replaceServerId: reconnectServerId } : {}),
162
188
  expiresAt: Date.now() + FLOW_TTL_SECONDS * 1_000,
163
189
  };
164
190
  setMcpOAuthFlowCookie(event, flow, redirectUri.startsWith("https://"));
@@ -245,12 +271,14 @@ async function handleMcpOAuthCallback(event, options) {
245
271
  authorizationCode: code,
246
272
  iss,
247
273
  }));
248
- const result = await addOAuthRemoteServer(flow.scope, flow.scopeId, {
249
- name: flow.name,
250
- url: flow.url,
251
- description: flow.description,
252
- credentials: finished.credentials,
253
- });
274
+ const result = flow.replaceServerId
275
+ ? await replaceOAuthRemoteServer(flow.scope, flow.scopeId, flow.replaceServerId, finished.credentials)
276
+ : await addOAuthRemoteServer(flow.scope, flow.scopeId, {
277
+ name: flow.name,
278
+ url: flow.url,
279
+ description: flow.description,
280
+ credentials: finished.credentials,
281
+ });
254
282
  if (!result.ok) {
255
283
  setResponseStatus(event, 400);
256
284
  return { error: result.error };
@@ -107,6 +107,18 @@ export declare function addOAuthRemoteServer(scope: RemoteMcpScope, scopeId: str
107
107
  ok: false;
108
108
  error: string;
109
109
  }>;
110
+ /**
111
+ * Replace the OAuth grant for an existing server without changing its id or
112
+ * display name. Reconnect must update the saved grant in place; registering a
113
+ * second row makes the original connection impossible to repair.
114
+ */
115
+ export declare function replaceOAuthRemoteServer(scope: RemoteMcpScope, scopeId: string, serverId: string, credentials: McpOAuthCredentialBundle): Promise<{
116
+ ok: true;
117
+ server: StoredRemoteMcpServer;
118
+ } | {
119
+ ok: false;
120
+ error: string;
121
+ }>;
110
122
  export declare function addFirstPartyRemoteServer(orgId: string, input: {
111
123
  appId: string;
112
124
  name: string;
@@ -193,6 +193,77 @@ export async function addOAuthRemoteServer(scope, scopeId, input) {
193
193
  };
194
194
  }
195
195
  }
196
+ /**
197
+ * Replace the OAuth grant for an existing server without changing its id or
198
+ * display name. Reconnect must update the saved grant in place; registering a
199
+ * second row makes the original connection impossible to repair.
200
+ */
201
+ export async function replaceOAuthRemoteServer(scope, scopeId, serverId, credentials) {
202
+ const credentialResource = validateRemoteUrl(credentials.serverUrl);
203
+ if (!credentialResource.ok || !credentialResource.url) {
204
+ return {
205
+ ok: false,
206
+ error: "MCP server URL must match the OAuth credential resource URL",
207
+ };
208
+ }
209
+ const existing = await readList(scope, scopeId);
210
+ const index = existing.findIndex((server) => server.id === serverId);
211
+ const current = index >= 0 ? existing[index] : undefined;
212
+ if (!current)
213
+ return { ok: false, error: "MCP server was not found" };
214
+ if (!current.oauthSecretKey) {
215
+ return {
216
+ ok: false,
217
+ error: "This MCP server does not use OAuth credentials",
218
+ };
219
+ }
220
+ if (current.url !== credentialResource.url.toString()) {
221
+ return {
222
+ ok: false,
223
+ error: "MCP server URL must match the saved OAuth connection",
224
+ };
225
+ }
226
+ const nextSecretKey = `mcp_oauth:${shortId()}`;
227
+ const nextCredentials = {
228
+ ...credentials,
229
+ serverUrl: credentialResource.url.toString(),
230
+ };
231
+ try {
232
+ await saveMcpOAuthCredentials({
233
+ key: nextSecretKey,
234
+ scope,
235
+ scopeId,
236
+ credentials: nextCredentials,
237
+ });
238
+ const server = {
239
+ ...current,
240
+ url: nextCredentials.serverUrl,
241
+ oauthSecretKey: nextSecretKey,
242
+ };
243
+ const next = [...existing];
244
+ next[index] = server;
245
+ await writeList(scope, scopeId, next);
246
+ await revokeMcpOAuthCredentials({
247
+ key: current.oauthSecretKey,
248
+ scope,
249
+ scopeId,
250
+ serverUrl: current.url,
251
+ }).catch(() => undefined);
252
+ return { ok: true, server };
253
+ }
254
+ catch (err) {
255
+ await revokeMcpOAuthCredentials({
256
+ key: nextSecretKey,
257
+ scope,
258
+ scopeId,
259
+ serverUrl: nextCredentials.serverUrl,
260
+ }).catch(() => undefined);
261
+ return {
262
+ ok: false,
263
+ error: `Failed to replace MCP OAuth credentials: ${err?.message ?? err}`,
264
+ };
265
+ }
266
+ }
196
267
  export async function addFirstPartyRemoteServer(orgId, input) {
197
268
  const trust = await isFirstPartyRemoteEndpointTrusted(orgId, input.appId, input.url);
198
269
  if (!trust.ok)
@@ -11,23 +11,23 @@
11
11
  * DELETE /_agent-native/notifications/:id — delete
12
12
  */
13
13
  export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
14
+ error?: undefined;
14
15
  count: number;
15
16
  updated?: undefined;
16
17
  ok?: undefined;
17
- error?: undefined;
18
18
  } | {
19
- count?: undefined;
19
+ error?: undefined;
20
20
  updated: number;
21
21
  ok?: undefined;
22
- error?: undefined;
23
- } | {
24
22
  count?: undefined;
23
+ } | {
25
24
  updated?: undefined;
26
25
  error: string;
27
26
  ok?: undefined;
28
- } | {
29
27
  count?: undefined;
28
+ } | {
29
+ error?: undefined;
30
30
  updated?: undefined;
31
31
  ok: boolean;
32
- error?: undefined;
32
+ count?: undefined;
33
33
  }>>;
@@ -42,22 +42,22 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
42
42
  avgEvalScore: number;
43
43
  } | {
44
44
  error?: undefined;
45
- ok?: undefined;
46
45
  summary: import("./types.js").TraceSummary;
47
46
  spans: import("./types.js").TraceSpan[];
48
47
  id?: undefined;
48
+ ok?: undefined;
49
49
  } | {
50
50
  error?: undefined;
51
- ok?: undefined;
52
51
  summary?: undefined;
53
52
  spans?: undefined;
54
53
  id: string;
55
- } | {
56
54
  ok?: undefined;
55
+ } | {
57
56
  summary?: undefined;
58
57
  spans?: undefined;
59
58
  id?: undefined;
60
59
  error: any;
60
+ ok?: undefined;
61
61
  } | {
62
62
  error?: undefined;
63
63
  summary?: undefined;
@@ -24,6 +24,17 @@ function costUsdFromCenticents(value) {
24
24
  return Math.round((value / 10_000) * 1_000_000) / 1_000_000;
25
25
  }
26
26
  const MAX_TRACKED_GENERATION_TOOL_CALLS = 50;
27
+ /**
28
+ * `auto_continue` reasons the server PLANNED, which must not read as failures.
29
+ *
30
+ * A hosted foreground chunk ends at `run_timeout` roughly every 40s by design —
31
+ * counting those as errors would bury the boundaries that mean something under
32
+ * the ones that mean "working as intended". Every other reason is a boundary
33
+ * something forced on the run: recoverable, but not normal, and it stays
34
+ * visible as an error carrying its reason as the terminal code. Moving a reason
35
+ * across this line changes what the error rate means, so move it deliberately.
36
+ */
37
+ const EXPECTED_CONTINUATION_REASONS = new Set(["run_timeout", "auto_continue"]);
27
38
  const MAX_TOOL_ERROR_MESSAGE_LENGTH = 500;
28
39
  const STANDALONE_API_KEY_PATTERN = /\b(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|AIza[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,})\b/g;
29
40
  function truncateToolErrorMessage(value) {
@@ -333,6 +344,11 @@ export async function instrumentAgentLoop(opts) {
333
344
  let errorMessage = null;
334
345
  let runMetadata = opts.metadata ?? null;
335
346
  let terminalOutcome;
347
+ // The `auto_continue` boundary this run ended at, if any. A cut-off never
348
+ // reaches the loop's outcome classification (`runAgentLoop` returns early at
349
+ // the checkpoint), so without this the run reports no terminal state at all
350
+ // and the reason is recoverable only from `agent_run_events` in Postgres.
351
+ let cutOffReason = null;
336
352
  const instrumentedOutcome = (outcome) => {
337
353
  terminalOutcome = outcome;
338
354
  if (outcome.state === "completed") {
@@ -377,6 +393,15 @@ export async function instrumentAgentLoop(opts) {
377
393
  if (event.type === "clear" || event.type === "done") {
378
394
  runStatus = "success";
379
395
  errorMessage = null;
396
+ cutOffReason = null;
397
+ }
398
+ else if (event.type === "auto_continue") {
399
+ const reason = event.reason || "auto_continue";
400
+ cutOffReason = reason;
401
+ if (!EXPECTED_CONTINUATION_REASONS.has(reason)) {
402
+ runStatus = "error";
403
+ errorMessage = `Agent run was cut off before finishing (${reason}).`;
404
+ }
380
405
  }
381
406
  else if (event.type === "error") {
382
407
  runStatus = "error";
@@ -635,6 +660,20 @@ export async function instrumentAgentLoop(opts) {
635
660
  }
636
661
  }
637
662
  catch { }
663
+ // A cut-off run never reaches the loop's outcome classification, so stand in
664
+ // for it here rather than reporting no terminal state at all. `failed` +
665
+ // `retryable` is the honest encoding available in `AgentLoopOutcome`: the
666
+ // turn did not finish, and the continuation machinery is expected to
667
+ // recover it. A real reported outcome always wins.
668
+ const effectiveTerminalOutcome = terminalOutcome ??
669
+ (cutOffReason && !EXPECTED_CONTINUATION_REASONS.has(cutOffReason)
670
+ ? {
671
+ state: "failed",
672
+ code: cutOffReason,
673
+ retryable: true,
674
+ message: `Agent run was cut off before finishing (${cutOffReason}).`,
675
+ }
676
+ : undefined);
638
677
  let llmCallCount = 0;
639
678
  if (usage || runStatus === "error") {
640
679
  llmCallCount =
@@ -716,7 +755,7 @@ export async function instrumentAgentLoop(opts) {
716
755
  .sort(([a], [b]) => a - b)
717
756
  .map(([, detail]) => detail),
718
757
  toolsTruncated: toolInvocationCounter > MAX_TRACKED_GENERATION_TOOL_CALLS,
719
- terminalOutcome,
758
+ terminalOutcome: effectiveTerminalOutcome,
720
759
  delegation: opts.delegation,
721
760
  createdAt: runStart,
722
761
  experimentAssignments: opts.experimentAssignments,
@@ -752,13 +791,13 @@ export async function instrumentAgentLoop(opts) {
752
791
  try {
753
792
  const aiError = runStatus === "error"
754
793
  ? toAiErrorDetail(errorMessage, {
755
- state: terminalOutcome?.state,
756
- code: terminalOutcome?.state === "failed" ||
757
- terminalOutcome?.state === "input_required"
758
- ? terminalOutcome.code
794
+ state: effectiveTerminalOutcome?.state,
795
+ code: effectiveTerminalOutcome?.state === "failed" ||
796
+ effectiveTerminalOutcome?.state === "input_required"
797
+ ? effectiveTerminalOutcome.code
759
798
  : undefined,
760
- retryable: terminalOutcome?.state === "failed"
761
- ? terminalOutcome.retryable
799
+ retryable: effectiveTerminalOutcome?.state === "failed"
800
+ ? effectiveTerminalOutcome.retryable
762
801
  : undefined,
763
802
  })
764
803
  : undefined;
@@ -792,6 +831,10 @@ export async function instrumentAgentLoop(opts) {
792
831
  source: "agent_observability",
793
832
  run_id: runId,
794
833
  thread_id: threadId,
834
+ // Present for planned boundaries too, which are not errors: the ratio
835
+ // of run_timeout to no_progress is the signal, and it is unreadable
836
+ // if only one side of it is recorded.
837
+ ...(cutOffReason ? { terminal_reason: cutOffReason } : {}),
795
838
  // A truncated run must not read as a complete one.
796
839
  ...(droppedToolSpans > 0
797
840
  ? {
@@ -897,10 +940,10 @@ export async function instrumentAgentLoop(opts) {
897
940
  "agent.input_tokens": usage?.inputTokens ?? 0,
898
941
  "agent.output_tokens": usage?.outputTokens ?? 0,
899
942
  "agent.cost_cents_x100": costCentsX100,
900
- "agent.terminal_state": terminalOutcome?.state,
901
- "agent.terminal_code": terminalOutcome?.state === "failed" ||
902
- terminalOutcome?.state === "input_required"
903
- ? terminalOutcome.code
943
+ "agent.terminal_state": effectiveTerminalOutcome?.state,
944
+ "agent.terminal_code": effectiveTerminalOutcome?.state === "failed" ||
945
+ effectiveTerminalOutcome?.state === "input_required"
946
+ ? effectiveTerminalOutcome.code
904
947
  : undefined,
905
948
  },
906
949
  });
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- error?: undefined;
19
18
  ok: boolean;
19
+ error?: undefined;
20
20
  }>>;
@@ -34,16 +34,16 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
34
34
  /** POST /_agent-native/secrets/:key — write a secret. */
35
35
  export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
36
36
  error: string;
37
- status?: undefined;
38
37
  ok?: undefined;
38
+ status?: undefined;
39
39
  } | {
40
40
  ok: boolean;
41
41
  status: string;
42
42
  error?: undefined;
43
43
  } | {
44
+ ok?: undefined;
44
45
  error: string;
45
46
  removed?: undefined;
46
- ok?: undefined;
47
47
  } | {
48
48
  ok: boolean;
49
49
  removed: boolean;
@@ -54,9 +54,9 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
54
54
  * or the current stored value without changing anything.
55
55
  */
56
56
  export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
+ ok?: undefined;
57
58
  error: string;
58
59
  note?: undefined;
59
- ok?: undefined;
60
60
  } | {
61
61
  ok: boolean;
62
62
  note?: undefined;
@@ -86,10 +86,17 @@ export { CORE_ACTION_GROUPS };
86
86
  * Core actions with no `frameworkTools` switch, and why:
87
87
  *
88
88
  * - `upload-image` is load-bearing for ordinary work.
89
- * - The email catalog is mounted everywhere on purpose so Dispatch can ask any
90
- * app what it sends without that app opting in (see its comment below). It is
91
- * a read surface, not the `email` group's send capability.
92
- * - MCP tools and org service tokens are already governed by `mcp.enabled`.
89
+ * - MCP tools and the hosted-harness routes never enter the model's action
90
+ * surface at all (`agentTool: false`), so a switch would gate nothing.
91
+ *
92
+ * Membership is expensive in a way that is easy to miss: an untagged action is
93
+ * also in every app's DEFAULT first-request tool list (see
94
+ * `resolveInitialToolNames`), so each entry here is a schema every app pays for
95
+ * on turn one whether or not it has the surface. The email catalog, the
96
+ * workspace user groups, and the org service tokens each sat here for that
97
+ * reason alone and now answer to `emailCatalog`, `workspaceUserGroups`, and
98
+ * `orgServiceTokens` — all defaulting to on, so availability is unchanged, but
99
+ * reachable through `tool-search` instead of riding along in every request.
93
100
  */
94
101
  export declare const ALWAYS_ON_CORE_ACTIONS: ReadonlySet<string>;
95
102
  export declare function mergeCoreSharingActions(registry: Record<string, ActionEntry>): Promise<void>;
@@ -508,25 +508,20 @@ export { CORE_ACTION_GROUPS };
508
508
  * Core actions with no `frameworkTools` switch, and why:
509
509
  *
510
510
  * - `upload-image` is load-bearing for ordinary work.
511
- * - The email catalog is mounted everywhere on purpose so Dispatch can ask any
512
- * app what it sends without that app opting in (see its comment below). It is
513
- * a read surface, not the `email` group's send capability.
514
- * - MCP tools and org service tokens are already governed by `mcp.enabled`.
511
+ * - MCP tools and the hosted-harness routes never enter the model's action
512
+ * surface at all (`agentTool: false`), so a switch would gate nothing.
513
+ *
514
+ * Membership is expensive in a way that is easy to miss: an untagged action is
515
+ * also in every app's DEFAULT first-request tool list (see
516
+ * `resolveInitialToolNames`), so each entry here is a schema every app pays for
517
+ * on turn one whether or not it has the surface. The email catalog, the
518
+ * workspace user groups, and the org service tokens each sat here for that
519
+ * reason alone and now answer to `emailCatalog`, `workspaceUserGroups`, and
520
+ * `orgServiceTokens` — all defaulting to on, so availability is unchanged, but
521
+ * reachable through `tool-search` instead of riding along in every request.
515
522
  */
516
523
  export const ALWAYS_ON_CORE_ACTIONS = new Set([
517
524
  "upload-image",
518
- "list-workspace-user-groups",
519
- "upsert-workspace-user-group",
520
- "bulk-update-workspace-user-groups",
521
- "delete-workspace-user-group",
522
- "list-transactional-emails",
523
- "render-transactional-email-preview",
524
- "list-email-log",
525
- "list-email-activity",
526
- "list-email-engagement",
527
- "create-org-service-token",
528
- "list-org-service-tokens",
529
- "revoke-org-service-token",
530
525
  "list-mcp-tools",
531
526
  "call-mcp-tool",
532
527
  // Hosted harness capability/policy routes are UI-facing and deliberately
@@ -36,10 +36,25 @@ export function resolveAgentChatMcpOptions(input) {
36
36
  // otherwise `disableMcp: true` + `enabled: false` would read as a conflict.
37
37
  const legacyEnabled = input?.disableMcp === undefined ? undefined : !input.disableMcp;
38
38
  const enabled = pick("enabled", "disableMcp", legacyEnabled, mcp.enabled);
39
+ const connectorCatalog = pick("connectorCatalog", "connectorCatalog", input?.connectorCatalog, mcp.connectorCatalog);
40
+ // `catalogMode: "app"` short-circuits `connectorCatalogActive` in
41
+ // `build-server.ts`, so declaring both served the app's FULL registry while
42
+ // the allow-list sat in the config looking authoritative. That is the same
43
+ // unexplainable-report shape `pick` throws on, minus the legacy/nested pair:
44
+ // an allow-list is written to keep something off the external surface, and
45
+ // silently ignoring it hands every action schema to every connector client.
46
+ if (mcp.catalog === "app" &&
47
+ connectorCatalog &&
48
+ connectorCatalog.length > 0) {
49
+ throw new Error(`[agent-native] Conflicting agent-chat options: \`mcp.catalog: "app"\` serves this ` +
50
+ `app's entire action registry flat, which makes \`mcp.connectorCatalog\` ` +
51
+ `(${connectorCatalog.length} name(s)) inert. Keep \`connectorCatalog\` for a curated ` +
52
+ `external surface, or drop it and keep \`catalog: "app"\` — not both.`);
53
+ }
39
54
  return {
40
55
  enabled: enabled ?? true,
41
56
  catalog: mcp.catalog,
42
- connectorCatalog: pick("connectorCatalog", "connectorCatalog", input?.connectorCatalog, mcp.connectorCatalog),
57
+ connectorCatalog,
43
58
  externalAgents: pick("externalAgents", "externalAgents", input?.externalAgents, mcp.externalAgents),
44
59
  builtinCrossAppTools: mcp.builtinCrossAppTools,
45
60
  title: pick("title", "mcpServerInfo.title", legacyInfo?.title, mcp.title),
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
27
27
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
28
28
  error: any;
29
29
  } | {
30
+ error?: undefined;
30
31
  ok: boolean;
31
32
  key: string;
32
33
  baseUrlKey?: string;
33
34
  scope: AgentEngineApiKeyScope;
34
- error?: undefined;
35
35
  }>>;
36
36
  export {};
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
26
26
  expiresAt?: undefined;
27
27
  ttlSeconds?: undefined;
28
28
  } | {
29
- error?: undefined;
30
29
  token: string;
31
30
  expiresAt: string;
32
31
  ttlSeconds: number;
32
+ error?: undefined;
33
33
  }>>;
@@ -201,6 +201,8 @@ export default createAgentChatPlugin({
201
201
 
202
202
  The builtin cross-app tools (`list_apps`, `open_app`, `ask_app`, `create_embed_session`, `create_workspace_app`, `list_templates`) are always included regardless of the declared list.
203
203
 
204
+ `mcp.catalog: "app"` is the opposite choice: it serves this app's entire action registry flat, with no compact or connector trimming. The two are mutually exclusive — declaring both throws at plugin init rather than silently ignoring the allow-list, since an allow-list exists to keep something off the external surface.
205
+
204
206
  ## Tool exposure once connected {#tool-exposure}
205
207
 
206
208
  Once an agent is connected, it sees the [compact catalog](#catalog-tiers) by default: template-declared app actions plus the builtin cross-app verbs (`list_apps`, `open_app`, `ask_app`, and the app-only embed helper). Use `ask_app` to route a natural-language task through an app agent (the same cross-app entry point [A2A](/docs/a2a-protocol) uses).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.0.0-beta-20260820143516",
3
+ "version": "0.0.0-beta-20260820155159",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -428,7 +428,7 @@
428
428
  "yjs": "^13.6.32",
429
429
  "zod": "^4.3.6",
430
430
  "@agent-native/recap-cli": "0.5.5",
431
- "@agent-native/toolkit": "^0.16.7"
431
+ "@agent-native/toolkit": "^0.16.8"
432
432
  },
433
433
  "devDependencies": {
434
434
  "@ai-sdk/anthropic": "^3.0.71",