@agent-native/core 0.145.0 → 0.145.2

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 (72) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/brain/server/lib/capture-sanitization.ts +6 -2
  3. package/corpus/templates/clips/changelog/2026-08-07-full-screen-recordings-on-a-multi-monitor-mac-now-let-you-pi.md +6 -0
  4. package/corpus/templates/clips/chrome-extension/src/styles.css +13 -4
  5. package/corpus/templates/clips/desktop/src/app.tsx +38 -1
  6. package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +123 -104
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +165 -2
  8. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  9. package/corpus/templates/clips/desktop/src/overlays/monitor-picker.tsx +70 -0
  10. package/corpus/templates/clips/desktop/src/styles.css +70 -11
  11. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
  12. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +163 -1
  13. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +6 -1
  14. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +141 -22
  15. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +27 -1
  16. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +12 -1
  17. package/dist/agent/engine/index.d.ts +1 -1
  18. package/dist/agent/engine/index.js +1 -1
  19. package/dist/agent/engine/registry.d.ts +15 -0
  20. package/dist/agent/engine/registry.js +39 -10
  21. package/dist/agent/production-agent.d.ts +40 -1
  22. package/dist/agent/production-agent.js +81 -40
  23. package/dist/agent/run-manager.d.ts +44 -1
  24. package/dist/agent/run-manager.js +70 -8
  25. package/dist/chat-threads/store.js +6 -4
  26. package/dist/client/use-db-sync.js +9 -1
  27. package/dist/db/ddl-guard.d.ts +8 -0
  28. package/dist/db/ddl-guard.js +148 -0
  29. package/dist/deploy/build.d.ts +36 -0
  30. package/dist/deploy/build.js +78 -5
  31. package/dist/integrations/webhook-handler.d.ts +3 -2
  32. package/dist/integrations/webhook-handler.js +8 -38
  33. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  34. package/dist/notifications/routes.d.ts +3 -3
  35. package/dist/observability/routes.d.ts +5 -5
  36. package/dist/org/accept-pending.js +2 -2
  37. package/dist/org/auth-policy.js +4 -0
  38. package/dist/org/auto-join-domain.d.ts +9 -0
  39. package/dist/org/auto-join-domain.js +53 -4
  40. package/dist/org/context.js +12 -3
  41. package/dist/org/handlers.js +17 -5
  42. package/dist/org/request-org-cache.d.ts +24 -2
  43. package/dist/org/request-org-cache.js +57 -2
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  45. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  46. package/dist/resources/store.d.ts +1 -1
  47. package/dist/resources/store.js +96 -7
  48. package/dist/secrets/routes.d.ts +6 -6
  49. package/dist/server/agent-chat-plugin.js +83 -37
  50. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  51. package/dist/server/auth.d.ts +3 -0
  52. package/dist/server/auth.js +22 -1
  53. package/dist/server/collab-plugin.js +10 -4
  54. package/dist/server/complete-text.js +14 -7
  55. package/dist/server/core-routes-plugin.js +3 -0
  56. package/dist/server/index.d.ts +1 -1
  57. package/dist/server/index.js +1 -1
  58. package/dist/server/request-context.d.ts +7 -1
  59. package/dist/server/session-email-cache.d.ts +14 -0
  60. package/dist/server/session-email-cache.js +48 -0
  61. package/dist/server/sweep-runtime.d.ts +22 -0
  62. package/dist/server/sweep-runtime.js +28 -0
  63. package/dist/server/transcribe-voice.d.ts +1 -1
  64. package/dist/settings/store.js +63 -7
  65. package/dist/shared/ttl-cache.d.ts +59 -0
  66. package/dist/shared/ttl-cache.js +43 -0
  67. package/dist/sharing/actions/list-resource-shares.d.ts +3 -3
  68. package/dist/sharing/actions/list-resource-shares.js +4 -1
  69. package/dist/triggers/condition-evaluator.js +11 -27
  70. package/docs/content/deployment.mdx +48 -0
  71. package/docs/content/environment-variables.mdx +11 -0
  72. package/package.json +1 -1
@@ -197,16 +197,69 @@ export const SQL_SUBSCRIPTION_IDLE_POLL_MS = 500;
197
197
  export const SQL_SUBSCRIPTION_ACTIVE_GRACE_MS = 2_000;
198
198
  /** Keep terminal/status probes at the historical cadence to bound DB work. */
199
199
  export const SQL_SUBSCRIPTION_STATUS_POLL_MS = 500;
200
+ /**
201
+ * Consecutive empty polls before the IDLE cadence starts decaying toward
202
+ * `SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS`.
203
+ *
204
+ * The active grace already covers a streaming producer, so decay only ever
205
+ * applies to a subscriber watching a run that is producing nothing: a long tool
206
+ * call, a slow first token, or a wedged producer. Those cost one poll per
207
+ * 500ms each, forever, per subscriber — the single largest source of idle
208
+ * `agent_run_events` reads.
209
+ */
210
+ export const SQL_SUBSCRIPTION_IDLE_DECAY_AFTER_POLLS = 4;
211
+ /**
212
+ * Ceiling for the decayed idle cadence. Bounds the WORST-CASE added latency to
213
+ * the next token a quiet run eventually produces; a row already waiting when the
214
+ * timer fires is delivered immediately, so this is not added to a streaming run.
215
+ */
216
+ export const SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS = 2_000;
217
+ /**
218
+ * Cadence for the opportunistic `reapIfStale` probe inside the SSE poll loop,
219
+ * kept SEPARATE from (and much slower than) the status probe next to it.
220
+ *
221
+ * A reap can only ever act on a run whose liveness basis is older than the
222
+ * tightest staleness window any sweep enforces — `RUN_STALE_MS`, 15s — so
223
+ * running it on the 500ms status cadence issued ~30 rounds of
224
+ * `reconcileTerminalRunFromEvents` + reap-eligibility queries (2-4 round trips
225
+ * each) before the first one could possibly match a row. This cadence still
226
+ * detects a stale producer well inside the client's own idle timeout while
227
+ * cutting that probe traffic ~10x. `getRunById` deliberately stays on the fast
228
+ * status cadence: it is one indexed read and it is what closes the stream on a
229
+ * NORMAL finish, which must stay prompt.
230
+ */
231
+ export const SQL_SUBSCRIPTION_REAP_POLL_MS = 5_000;
200
232
  /** Initial retry delay after a transient cross-isolate SQL polling failure. */
201
233
  export const SQL_SUBSCRIPTION_RETRY_BASE_MS = 250;
202
234
  /** Bound consecutive SQL polling failures so a dead subscription fails loud. */
203
235
  export const SQL_SUBSCRIPTION_MAX_CONSECUTIVE_FAILURES = 4;
204
236
  /** Cap retry delay even if the failure bound changes independently. */
205
237
  export const SQL_SUBSCRIPTION_RETRY_MAX_MS = 2_000;
206
- export function resolveSqlSubscriptionPollMs(now, activePollUntil) {
207
- return now < activePollUntil
208
- ? SQL_SUBSCRIPTION_ACTIVE_POLL_MS
209
- : SQL_SUBSCRIPTION_IDLE_POLL_MS;
238
+ export function resolveSqlSubscriptionPollMs(now, activePollUntil, consecutiveEmptyPolls = 0) {
239
+ if (now < activePollUntil)
240
+ return SQL_SUBSCRIPTION_ACTIVE_POLL_MS;
241
+ // Clamped before exponentiation: an unbounded `2 ** steps` reaches Infinity
242
+ // and would make the cap the only thing keeping this finite.
243
+ const steps = Math.min(16, Math.max(0, Math.floor(consecutiveEmptyPolls) -
244
+ SQL_SUBSCRIPTION_IDLE_DECAY_AFTER_POLLS));
245
+ return Math.min(SQL_SUBSCRIPTION_IDLE_MAX_POLL_MS, SQL_SUBSCRIPTION_IDLE_POLL_MS * 2 ** steps);
246
+ }
247
+ /**
248
+ * Advance the empty-poll counter that drives `resolveSqlSubscriptionPollMs`'s
249
+ * decay.
250
+ *
251
+ * Only IDLE polls count. Counting the ~16 fast polls inside the active grace
252
+ * window would push the ladder to its cap the instant the grace expired, so a
253
+ * producer that pauses a few seconds between tokens would resume at the 2s cap
254
+ * instead of 500ms — a visible mid-stream stutter rather than the intended
255
+ * "this run has gone quiet" backoff.
256
+ */
257
+ export function nextSqlSubscriptionEmptyPolls(current, hadEvents, now, activePollUntil) {
258
+ if (hadEvents)
259
+ return 0;
260
+ if (now < activePollUntil)
261
+ return current;
262
+ return current + 1;
210
263
  }
211
264
  export function resolveSqlSubscriptionRetryMs(consecutiveFailures) {
212
265
  const retryIndex = Math.max(0, Math.floor(consecutiveFailures) - 1);
@@ -1323,7 +1376,9 @@ function subscribeFromSQL(runId, fromSeq) {
1323
1376
  let lastSeq = fromSeq;
1324
1377
  let activePollUntil = 0;
1325
1378
  let lastStatusCheckAt = 0;
1379
+ let lastReapCheckAt = 0;
1326
1380
  let consecutivePollFailures = 0;
1381
+ let consecutiveEmptyPolls = 0;
1327
1382
  const ping = () => {
1328
1383
  try {
1329
1384
  controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
@@ -1382,6 +1437,7 @@ function subscribeFromSQL(runId, fromSeq) {
1382
1437
  try {
1383
1438
  // Read new events from SQL
1384
1439
  const events = await getRunEventsSince(runId, lastSeq);
1440
+ consecutiveEmptyPolls = nextSqlSubscriptionEmptyPolls(consecutiveEmptyPolls, events.length > 0, Date.now(), activePollUntil);
1385
1441
  if (events.length > 0) {
1386
1442
  activePollUntil = Date.now() + SQL_SUBSCRIPTION_ACTIVE_GRACE_MS;
1387
1443
  }
@@ -1419,7 +1475,7 @@ function subscribeFromSQL(runId, fromSeq) {
1419
1475
  if (now - lastStatusCheckAt < SQL_SUBSCRIPTION_STATUS_POLL_MS) {
1420
1476
  if (!cancelled) {
1421
1477
  consecutivePollFailures = 0;
1422
- const pollMs = resolveSqlSubscriptionPollMs(now, activePollUntil);
1478
+ const pollMs = resolveSqlSubscriptionPollMs(now, activePollUntil, consecutiveEmptyPolls);
1423
1479
  pollTimer = setTimeout(poll, pollMs);
1424
1480
  }
1425
1481
  return;
@@ -1427,8 +1483,14 @@ function subscribeFromSQL(runId, fromSeq) {
1427
1483
  lastStatusCheckAt = now;
1428
1484
  // Opportunistically reap a stale producer before trusting SQL's
1429
1485
  // "running" status — otherwise a crashed server leaves us polling
1430
- // forever.
1431
- await reapIfStale(runId).catch(() => { });
1486
+ // forever. Throttled independently of the status probe below: a reap
1487
+ // cannot match a row younger than RUN_STALE_MS, so running it at the
1488
+ // status cadence was ~30 rounds of wasted round trips per run before
1489
+ // the first one could do anything. See SQL_SUBSCRIPTION_REAP_POLL_MS.
1490
+ if (now - lastReapCheckAt >= SQL_SUBSCRIPTION_REAP_POLL_MS) {
1491
+ lastReapCheckAt = now;
1492
+ await reapIfStale(runId).catch(() => { });
1493
+ }
1432
1494
  const run = await getRunById(runId);
1433
1495
  if (!run || run.status !== "running") {
1434
1496
  // Run ended — do one final event read, then close
@@ -1543,7 +1605,7 @@ function subscribeFromSQL(runId, fromSeq) {
1543
1605
  // Schedule next poll
1544
1606
  if (!cancelled) {
1545
1607
  consecutivePollFailures = 0;
1546
- const pollMs = resolveSqlSubscriptionPollMs(Date.now(), activePollUntil);
1608
+ const pollMs = resolveSqlSubscriptionPollMs(Date.now(), activePollUntil, consecutiveEmptyPolls);
1547
1609
  pollTimer = setTimeout(poll, pollMs);
1548
1610
  }
1549
1611
  }
@@ -429,10 +429,12 @@ export async function ensureChatThreadTables() {
429
429
  export async function resolveThreadAccess(userEmail, threadId, minRole = "viewer", ctx = {}) {
430
430
  if (!userEmail || !threadId)
431
431
  return null;
432
- const access = await resolveAccess("chat_thread", threadId, {
433
- userEmail,
434
- orgId: ctx.orgId,
435
- });
432
+ // `skipResourceBody` matters more here than anywhere else: without it the
433
+ // access load is an unprojected `select()` that pulls `thread_data` — the
434
+ // whole conversation JSON — and then this function discards the row and reads
435
+ // it again through `getThread`. Two full-blob reads of the same row per call,
436
+ // on the agent-chat hot path.
437
+ const access = await resolveAccess("chat_thread", threadId, { userEmail, orgId: ctx.orgId }, { skipResourceBody: true });
436
438
  if (!access || !roleSatisfies(access.role, minRole))
437
439
  return null;
438
440
  return await getThread(threadId);
@@ -1216,7 +1216,15 @@ export function useDbSync(options = {}) {
1216
1216
  // the independent framework prefixes, while pure action batches
1217
1217
  // retain their narrow storm-resistant invalidation.
1218
1218
  if (!hasActionEvent) {
1219
- invalidateWithoutCancel({ queryKey: ["action"] });
1219
+ // Honour the app's predicate here too. Without it the contract was
1220
+ // silently conditional: a batch carrying an `action` event
1221
+ // respected the predicate (above), while a batch carrying only
1222
+ // `db`/`collab`/`settings`/`screen-refresh` blew away every
1223
+ // ["action"] query regardless of what the app opted out of — and
1224
+ // an app cannot work around it, because both the prefix and this
1225
+ // call are framework-owned.
1226
+ const predicate = actionInvalidatePredicateRef.current;
1227
+ invalidateWithoutCancel(predicate ? { predicate } : { queryKey: ["action"] });
1220
1228
  }
1221
1229
  if (!hasActionEvent || hasFrameworkPrefixEvent) {
1222
1230
  invalidateWithoutCancel({ queryKey: ["extension"] });
@@ -29,6 +29,14 @@
29
29
  * behaviour there.
30
30
  */
31
31
  import { type DbExec } from "./client.js";
32
+ /**
33
+ * Drop the cached picture after any successful DDL, so a freshly created object
34
+ * is visible to the next probe rather than reported absent for the life of the
35
+ * process.
36
+ */
37
+ export declare function invalidateSchemaSnapshot(injectedClient?: DbExec): void;
38
+ /** Test seam — the snapshots are module state, so suites must clear them. */
39
+ export declare function __resetSchemaSnapshotForTests(): void;
32
40
  /**
33
41
  * True when running against Postgres AND the given table already exists in the
34
42
  * `public` schema. Returns `false` on SQLite (callers gate their own behaviour
@@ -30,6 +30,135 @@
30
30
  */
31
31
  import { isPostgres, getDbExec } from "./client.js";
32
32
  const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+ /**
34
+ * Skip on-demand schema DDL entirely. For deployments that run real migrations,
35
+ * where the probe → DDL machinery below is pure cold-start latency.
36
+ *
37
+ * Implemented at the PROBE layer, not per store: every `ensureTable()` in the
38
+ * codebase reaches the database through these three helpers, and there are ~390
39
+ * call sites across ~57 stores. Reporting "present" here makes
40
+ * `ensureSchemaObject` a no-op without touching any of them.
41
+ *
42
+ * This does not fail closed, and cannot: the only honest signal that schema is
43
+ * missing is the real query failing, which it will, loudly, at the first read.
44
+ * Silently reporting "absent" instead would be worse — it would send every store
45
+ * down the DDL path this flag exists to avoid.
46
+ */
47
+ function schemaEnsureDisabled() {
48
+ const raw = process.env.AGENT_NATIVE_SKIP_ENSURE_TABLES?.trim();
49
+ return !!raw && ["1", "true", "yes", "on"].includes(raw.toLowerCase());
50
+ }
51
+ const injectedSnapshots = new WeakMap();
52
+ let globalSnapshot;
53
+ /**
54
+ * Drop the cached picture after any successful DDL, so a freshly created object
55
+ * is visible to the next probe rather than reported absent for the life of the
56
+ * process.
57
+ */
58
+ export function invalidateSchemaSnapshot(injectedClient) {
59
+ // The global snapshot always goes: a store may create an object through an
60
+ // injected client and later probe through the global one (or the reverse), so
61
+ // after DDL neither picture is trustworthy and both are cheap to rebuild.
62
+ globalSnapshot = undefined;
63
+ if (injectedClient)
64
+ injectedSnapshots.delete(injectedClient);
65
+ }
66
+ /** Test seam — the snapshots are module state, so suites must clear them. */
67
+ export function __resetSchemaSnapshotForTests() {
68
+ globalSnapshot = undefined;
69
+ }
70
+ async function loadSchemaSnapshot(client) {
71
+ try {
72
+ const [columnRows, indexRows] = await Promise.all([
73
+ client.execute(`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public'`),
74
+ client.execute(`SELECT indexname FROM pg_indexes WHERE schemaname = 'public'`),
75
+ ]);
76
+ // An EMPTY result is a valid snapshot (a fresh database really has nothing
77
+ // in `public`). A result whose rows lack the columns we asked for is not —
78
+ // it means something other than Postgres answered, and treating it as "these
79
+ // are the objects that exist" would report every table absent and send every
80
+ // store down the DDL path. Fall back to per-object probes instead.
81
+ const columnData = columnRows.rows;
82
+ const indexData = indexRows.rows;
83
+ if (columnData.length > 0 && !("table_name" in columnData[0]))
84
+ return null;
85
+ if (indexData.length > 0 && !("indexname" in indexData[0]))
86
+ return null;
87
+ const tables = new Set();
88
+ const columns = new Set();
89
+ for (const row of columnData) {
90
+ const table = String(row.table_name ?? "").toLowerCase();
91
+ const column = String(row.column_name ?? "").toLowerCase();
92
+ if (!table)
93
+ continue;
94
+ tables.add(table);
95
+ if (column)
96
+ columns.add(`${table}.${column}`);
97
+ }
98
+ const indexes = new Set();
99
+ for (const row of indexData) {
100
+ const name = String(row.indexname ?? "").toLowerCase();
101
+ if (name)
102
+ indexes.add(name);
103
+ }
104
+ return { tables, columns, indexes };
105
+ }
106
+ catch (err) {
107
+ // `information_schema` unreadable (permissions / non-standard backend).
108
+ // `null` is a typed "no snapshot" the caller tells apart from an empty one:
109
+ // each probe falls back to its original per-object query. It does NOT mean
110
+ // "the schema is empty", which would make every store issue DDL it does not
111
+ // need. Say so once — the fallback is correct but costs a few hundred round
112
+ // trips per cold start, which is invisible otherwise.
113
+ warnIntrospectionUnavailableOnce(err);
114
+ return null;
115
+ }
116
+ }
117
+ let _warnedIntrospectionUnavailable = false;
118
+ function warnIntrospectionUnavailableOnce(err) {
119
+ if (_warnedIntrospectionUnavailable)
120
+ return;
121
+ _warnedIntrospectionUnavailable = true;
122
+ console.warn("[db] batched schema introspection unavailable; falling back to one probe " +
123
+ "per table/column/index (slower cold starts, same behaviour): " +
124
+ (err?.message ?? err));
125
+ }
126
+ // `loadSchemaSnapshot` resolves to `null` on failure rather than rejecting, so
127
+ // these need no `.catch()` — adding one would be dead code hiding that contract.
128
+ function schemaSnapshot(client, injected) {
129
+ if (injected) {
130
+ let pending = injectedSnapshots.get(client);
131
+ if (!pending) {
132
+ pending = loadSchemaSnapshot(client);
133
+ injectedSnapshots.set(client, pending);
134
+ }
135
+ return pending;
136
+ }
137
+ if (!globalSnapshot) {
138
+ globalSnapshot = loadSchemaSnapshot(client);
139
+ }
140
+ return globalSnapshot;
141
+ }
142
+ /**
143
+ * `true`/`false` from the cached picture, or `undefined` when there is no usable
144
+ * snapshot and the caller must fall back to its own single-object probe.
145
+ *
146
+ * A snapshot MISS is authoritative: the snapshot lists every object in `public`,
147
+ * so "not in the set" means the object really is absent and the caller should
148
+ * proceed to DDL. That is what makes this a replacement for the per-object
149
+ * probe rather than a layer in front of it.
150
+ */
151
+ async function snapshotHas(kind, key, client, injected) {
152
+ const snapshot = await schemaSnapshot(client, injected);
153
+ if (!snapshot)
154
+ return undefined;
155
+ const set = kind === "table"
156
+ ? snapshot.tables
157
+ : kind === "column"
158
+ ? snapshot.columns
159
+ : snapshot.indexes;
160
+ return set.has(key.toLowerCase());
161
+ }
33
162
  /**
34
163
  * True when running against Postgres AND the given table already exists in the
35
164
  * `public` schema. Returns `false` on SQLite (callers gate their own behaviour
@@ -47,7 +176,12 @@ export async function pgTableExists(table, injectedClient, dialectIsPostgres) {
47
176
  if (!(dialectIsPostgres ?? isPostgres()) || !PLAIN_IDENTIFIER.test(table)) {
48
177
  return false;
49
178
  }
179
+ if (schemaEnsureDisabled())
180
+ return true;
50
181
  const client = injectedClient ?? getDbExec();
182
+ const cached = await snapshotHas("table", table, client, !!injectedClient);
183
+ if (cached !== undefined)
184
+ return cached;
51
185
  try {
52
186
  const { rows } = await client.execute({
53
187
  sql: `SELECT 1 FROM information_schema.tables
@@ -77,7 +211,12 @@ export async function pgColumnExists(table, column, injectedClient) {
77
211
  if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {
78
212
  return false;
79
213
  }
214
+ if (schemaEnsureDisabled())
215
+ return true;
80
216
  const client = injectedClient ?? getDbExec();
217
+ const cached = await snapshotHas("column", `${table}.${column}`, client, !!injectedClient);
218
+ if (cached !== undefined)
219
+ return cached;
81
220
  try {
82
221
  const { rows } = await client.execute({
83
222
  sql: `SELECT 1 FROM information_schema.columns
@@ -108,7 +247,12 @@ export async function pgIndexExists(indexName, injectedClient, dialectIsPostgres
108
247
  !PLAIN_IDENTIFIER.test(indexName)) {
109
248
  return false;
110
249
  }
250
+ if (schemaEnsureDisabled())
251
+ return true;
111
252
  const client = injectedClient ?? getDbExec();
253
+ const cached = await snapshotHas("index", indexName, client, !!injectedClient);
254
+ if (cached !== undefined)
255
+ return cached;
112
256
  try {
113
257
  const { rows } = await client.execute({
114
258
  sql: `SELECT 1 FROM pg_indexes
@@ -162,6 +306,10 @@ export async function ensureSchemaObject(options) {
162
306
  injectedClient,
163
307
  dialectIsPostgres,
164
308
  });
309
+ // The cached picture predates this object. Drop it before anyone probes again,
310
+ // or a sibling `ensureTable` in the same boot is told the object it just
311
+ // created does not exist.
312
+ invalidateSchemaSnapshot(injectedClient);
165
313
  if (ran)
166
314
  return true;
167
315
  // The DDL was swallowed by a lock-timeout. The object is virtually always
@@ -94,11 +94,47 @@ export declare const NETLIFY_INTEGRATION_RECOVERY_FUNCTION_NAME = "server-integr
94
94
  export declare function isIntegrationDurableDispatchDeployEnabled(): boolean;
95
95
  export declare const NETLIFY_RECURRING_JOBS_FUNCTION_NAME = "agent-native-recurring-jobs";
96
96
  export declare function isRecurringJobsDeployEnabled(): boolean;
97
+ /**
98
+ * Keep-warm is ON by default, and on a provisioned database that is the right
99
+ * default. It is the wrong default on a metered scale-to-zero tier: a
100
+ * once-a-minute wake means autosuspend NEVER fires, so the endpoint bills (and
101
+ * quota-limits) as if it were awake 100% of the time with zero users online. On
102
+ * a free-tier Neon that exhausts the compute quota and the database then
103
+ * hard-blocks every read mid-session — the symptom is a dead app, not a slow one.
104
+ */
105
+ export declare function isKeepWarmDeployEnabled(): boolean;
106
+ /**
107
+ * The background warm is a separate, much more expensive knob than the server
108
+ * warm and gets its own switch. Warming `server` is one health request; warming
109
+ * the `-background` Lambda is a *fresh container*, so every ping pays the full
110
+ * on-demand `ensureTable()` schema-probe fan-out (hundreds of
111
+ * `information_schema` round trips) before it does anything. At the default
112
+ * cadence that is ~1,440 manufactured cold starts a day that no user asked for.
113
+ * Turn this off to keep dispatch-latency protection for the server function
114
+ * while dropping the probe storm.
115
+ */
116
+ export declare function isKeepWarmBackgroundDeployEnabled(): boolean;
117
+ /**
118
+ * Cadence for the keep-warm schedule, overridable with
119
+ * `AGENT_NATIVE_KEEP_WARM_SCHEDULE` (standard 5-field cron).
120
+ *
121
+ * An unparseable value THROWS rather than falling back to the default. Falling
122
+ * back would leave an operator who set this specifically to stop burning
123
+ * database quota still burning it at the original once-a-minute cadence, with a
124
+ * successful build and nothing in the log to say the value was ignored.
125
+ */
126
+ export declare function resolveKeepWarmSchedule(): string;
97
127
  /**
98
128
  * Emit a site-local Netlify Scheduled Function that wakes the public server
99
129
  * function and its database every minute. GitHub Actions schedules can be
100
130
  * delayed by tens of minutes, which is longer than a scale-to-zero database's
101
131
  * autosuspend window and leaves the next visitor to pay the cold-start cost.
132
+ *
133
+ * Both halves are opt-out and the cadence is configurable — see
134
+ * `isKeepWarmDeployEnabled`, `isKeepWarmBackgroundDeployEnabled`, and
135
+ * `resolveKeepWarmSchedule`. The tradeoff this function encodes is next-visitor
136
+ * latency against database awake-time, and only the deployment knows which of
137
+ * those it is paying for.
102
138
  */
103
139
  export declare function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd: string): void;
104
140
  /**
@@ -20,6 +20,7 @@ import path from "path";
20
20
  import { fileURLToPath } from "url";
21
21
  import { AGENT_BACKGROUND_FUNCTION_NAME, AGENT_BACKGROUND_FUNCTION_URL_PATH, AGENT_BACKGROUND_PROCESSOR_A2A, AGENT_BACKGROUND_PROCESSOR_FIELD, AGENT_BACKGROUND_PROCESSOR_INTEGRATION, AGENT_BACKGROUND_PROCESSOR_ROUTE, AGENT_BACKGROUND_PROCESSOR_ROUTE_FIELD, AGENT_CHAT_PROCESS_RUN_PATH, isDurableBackgroundFlagExplicitlyDisabled, } from "../agent/durable-background.js";
22
22
  import { INTEGRATION_RECOVERY_RUNTIME_MARKER, INTEGRATION_RETRY_SWEEP_PATH, INTEGRATION_RETRY_SWEEP_TOKEN_SUBJECT, isIntegrationDurableDispatchConfigured, } from "../integrations/integration-durable-dispatch-config.js";
23
+ import { isValidCron } from "../jobs/cron.js";
23
24
  import { RECURRING_JOBS_SWEEP_PATH, RECURRING_JOBS_SWEEP_TOKEN_SUBJECT, } from "../jobs/scheduler-dispatch.js";
24
25
  import { normalizeAppBasePath } from "../server/app-base-path.js";
25
26
  import { DEFAULT_SPECULATION_RULES_PATH, resolveSsrCacheHeaders, } from "../shared/cache-control.js";
@@ -2196,17 +2197,85 @@ export function isIntegrationDurableDispatchDeployEnabled() {
2196
2197
  }
2197
2198
  const NETLIFY_KEEP_WARM_FUNCTION_NAME = "agent-native-keep-warm";
2198
2199
  export const NETLIFY_RECURRING_JOBS_FUNCTION_NAME = "agent-native-recurring-jobs";
2200
+ /** Shared shape for the `AGENT_NATIVE_DISABLE_*` build kill switches. */
2201
+ function isDisabledByEnv(name) {
2202
+ const value = process.env[name]?.trim();
2203
+ return !!value && ["1", "true", "yes", "on"].includes(value.toLowerCase());
2204
+ }
2199
2205
  export function isRecurringJobsDeployEnabled() {
2200
- const value = process.env.AGENT_NATIVE_DISABLE_RECURRING_JOBS?.trim();
2201
- return !value || !["1", "true", "yes", "on"].includes(value.toLowerCase());
2206
+ return !isDisabledByEnv("AGENT_NATIVE_DISABLE_RECURRING_JOBS");
2207
+ }
2208
+ /**
2209
+ * Keep-warm is ON by default, and on a provisioned database that is the right
2210
+ * default. It is the wrong default on a metered scale-to-zero tier: a
2211
+ * once-a-minute wake means autosuspend NEVER fires, so the endpoint bills (and
2212
+ * quota-limits) as if it were awake 100% of the time with zero users online. On
2213
+ * a free-tier Neon that exhausts the compute quota and the database then
2214
+ * hard-blocks every read mid-session — the symptom is a dead app, not a slow one.
2215
+ */
2216
+ export function isKeepWarmDeployEnabled() {
2217
+ return !isDisabledByEnv("AGENT_NATIVE_DISABLE_KEEP_WARM");
2218
+ }
2219
+ /**
2220
+ * The background warm is a separate, much more expensive knob than the server
2221
+ * warm and gets its own switch. Warming `server` is one health request; warming
2222
+ * the `-background` Lambda is a *fresh container*, so every ping pays the full
2223
+ * on-demand `ensureTable()` schema-probe fan-out (hundreds of
2224
+ * `information_schema` round trips) before it does anything. At the default
2225
+ * cadence that is ~1,440 manufactured cold starts a day that no user asked for.
2226
+ * Turn this off to keep dispatch-latency protection for the server function
2227
+ * while dropping the probe storm.
2228
+ */
2229
+ export function isKeepWarmBackgroundDeployEnabled() {
2230
+ return !isDisabledByEnv("AGENT_NATIVE_DISABLE_KEEP_WARM_BACKGROUND");
2231
+ }
2232
+ const DEFAULT_KEEP_WARM_SCHEDULE = "* * * * *";
2233
+ /**
2234
+ * Cadence for the keep-warm schedule, overridable with
2235
+ * `AGENT_NATIVE_KEEP_WARM_SCHEDULE` (standard 5-field cron).
2236
+ *
2237
+ * An unparseable value THROWS rather than falling back to the default. Falling
2238
+ * back would leave an operator who set this specifically to stop burning
2239
+ * database quota still burning it at the original once-a-minute cadence, with a
2240
+ * successful build and nothing in the log to say the value was ignored.
2241
+ */
2242
+ export function resolveKeepWarmSchedule() {
2243
+ const raw = process.env.AGENT_NATIVE_KEEP_WARM_SCHEDULE?.trim();
2244
+ if (!raw)
2245
+ return DEFAULT_KEEP_WARM_SCHEDULE;
2246
+ const fields = raw.split(/\s+/);
2247
+ // The field count is checked separately from the field values because
2248
+ // `isValidCron` also accepts 6-field (seconds) and `@daily` forms that
2249
+ // Netlify's scheduler does not; "5 fields" is the narrower contract.
2250
+ if (fields.length !== 5 || !isValidCron(raw)) {
2251
+ throw new Error(`AGENT_NATIVE_KEEP_WARM_SCHEDULE must be a 5-field cron expression ` +
2252
+ `(minute hour day month weekday); got "${raw}" (${fields.length} field(s)). ` +
2253
+ `Example: "*/5 * * * *" for every five minutes.`);
2254
+ }
2255
+ return raw;
2202
2256
  }
2203
2257
  /**
2204
2258
  * Emit a site-local Netlify Scheduled Function that wakes the public server
2205
2259
  * function and its database every minute. GitHub Actions schedules can be
2206
2260
  * delayed by tens of minutes, which is longer than a scale-to-zero database's
2207
2261
  * autosuspend window and leaves the next visitor to pay the cold-start cost.
2262
+ *
2263
+ * Both halves are opt-out and the cadence is configurable — see
2264
+ * `isKeepWarmDeployEnabled`, `isKeepWarmBackgroundDeployEnabled`, and
2265
+ * `resolveKeepWarmSchedule`. The tradeoff this function encodes is next-visitor
2266
+ * latency against database awake-time, and only the deployment knows which of
2267
+ * those it is paying for.
2208
2268
  */
2209
2269
  export function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd) {
2270
+ if (!isKeepWarmDeployEnabled()) {
2271
+ console.log("[build] Keep-warm emit skipped: AGENT_NATIVE_DISABLE_KEEP_WARM is set. " +
2272
+ "The database is free to autosuspend; the next visitor after an idle " +
2273
+ "period pays its cold start.");
2274
+ return;
2275
+ }
2276
+ // Resolved before anything is removed or written, so a bad cron fails the
2277
+ // build rather than leaving a wiped/half-emitted function directory behind.
2278
+ const keepWarmSchedule = resolveKeepWarmSchedule();
2210
2279
  const internalDir = path.join(projectCwd, ".netlify", "functions-internal");
2211
2280
  const serverBundle = path.join(internalDir, "server", "main.mjs");
2212
2281
  if (!fs.existsSync(serverBundle)) {
@@ -2223,7 +2292,9 @@ export function emitSingleTemplateNetlifyKeepWarmFunction(projectCwd) {
2223
2292
  // rejected by the `_process-run` route before any DB work, so this only keeps
2224
2293
  // the container alive.
2225
2294
  const backgroundEntryPath = path.join(internalDir, AGENT_BACKGROUND_FUNCTION_NAME, `${AGENT_BACKGROUND_FUNCTION_NAME}.mjs`);
2226
- const backgroundWarmPath = isDurableBackgroundEmitRequired() && fs.existsSync(backgroundEntryPath)
2295
+ const backgroundWarmPath = isKeepWarmBackgroundDeployEnabled() &&
2296
+ isDurableBackgroundEmitRequired() &&
2297
+ fs.existsSync(backgroundEntryPath)
2227
2298
  ? JSON.stringify(AGENT_BACKGROUND_FUNCTION_URL_PATH)
2228
2299
  : "null";
2229
2300
  const entry = `const HEALTH_PATH = "/_agent-native/health";
@@ -2288,12 +2359,14 @@ export default async function handler(request) {
2288
2359
  export const config = {
2289
2360
  name: "agent-native server keep warm",
2290
2361
  generator: "agent-native build",
2291
- schedule: "* * * * *",
2362
+ schedule: ${JSON.stringify(keepWarmSchedule)},
2292
2363
  nodeBundler: "none",
2293
2364
  };
2294
2365
  `;
2295
2366
  fs.writeFileSync(path.join(dest, `${NETLIFY_KEEP_WARM_FUNCTION_NAME}.mjs`), entry);
2296
- console.log(`[build] Emitted Netlify scheduled keep-warm function "${NETLIFY_KEEP_WARM_FUNCTION_NAME}".`);
2367
+ console.log(`[build] Emitted Netlify scheduled keep-warm function ` +
2368
+ `"${NETLIFY_KEEP_WARM_FUNCTION_NAME}" (schedule "${keepWarmSchedule}", ` +
2369
+ `background warm ${backgroundWarmPath === "null" ? "off" : "on"}).`);
2297
2370
  }
2298
2371
  /**
2299
2372
  * Emit the durable recurring-job trigger. Netlify's scheduled function only
@@ -1,7 +1,7 @@
1
1
  import type { H3Event } from "h3";
2
2
  import { type A2AArtifactIdentity } from "../a2a/artifact-response.js";
3
3
  import type { AgentEngine } from "../agent/engine/types.js";
4
- import { type ActionEntry } from "../agent/production-agent.js";
4
+ import { type ActionEntry, type ResolvedOwnerApiKey } from "../agent/production-agent.js";
5
5
  import { type PendingTask } from "./pending-tasks-store.js";
6
6
  import type { PlatformAdapter, IncomingMessage, OutgoingMessage, PlatformDeliveryReceipt } from "./types.js";
7
7
  export type IntegrationResponseDeliveryTaskPayload = {
@@ -84,7 +84,8 @@ export interface WebhookHandlerOptions {
84
84
  handled: false;
85
85
  }>;
86
86
  }
87
- export declare function resolveIntegrationApiKey(engineOption: WebhookHandlerOptions["engine"], ownerEmail: string, fallbackApiKey: string): Promise<string | undefined>;
87
+ export type ResolvedIntegrationApiKey = ResolvedOwnerApiKey;
88
+ export declare function resolveIntegrationApiKey(engineOption: WebhookHandlerOptions["engine"], ownerEmail: string, fallbackApiKey: string): Promise<ResolvedIntegrationApiKey>;
88
89
  /**
89
90
  * Process an incoming webhook from a messaging platform.
90
91
  *
@@ -4,8 +4,7 @@ import { isInBackgroundFunctionRuntime } from "../agent/durable-background.js";
4
4
  import { formatLlmCredentialErrorMessage, isLlmCredentialError, } from "../agent/engine/credential-errors.js";
5
5
  import { getConfiguredEngineNameForRequest, getStoredModelForEngine, normalizeModelForEngine, resolveEngine, } from "../agent/engine/index.js";
6
6
  import { resolveMainChatMaxOutputTokens } from "../agent/engine/output-tokens.js";
7
- import { PROVIDER_TO_ENV } from "../agent/engine/provider-env-vars.js";
8
- import { actionsToEngineTools, filterInitialEngineTools, getOwnerActiveApiKey, getOwnerApiKey, engineToProvider, } from "../agent/production-agent.js";
7
+ import { actionsToEngineTools, filterInitialEngineTools, resolveOwnerEngineApiKey, } from "../agent/production-agent.js";
9
8
  import { appendDurableContinuationContext, runAgentLoopDirectWithSoftTimeout, } from "../agent/run-loop-with-resume.js";
10
9
  import { startRun, } from "../agent/run-manager.js";
11
10
  import { buildCurrentTimeUserContext, buildRuntimeContextPrompt, } from "../agent/runtime-context.js";
@@ -16,7 +15,6 @@ import { updateThreadData } from "../chat-threads/store.js";
16
15
  import { isLocalDatabase } from "../db/client.js";
17
16
  import { getOrgA2ASecret, resolveOrgIdForEmail } from "../org/context.js";
18
17
  import { withConfiguredAppBasePath } from "../server/app-base-path.js";
19
- import { canUseDeployCredentialFallbackForRequest, readDeployCredentialEnv, } from "../server/credential-provider.js";
20
18
  import { runWithRequestContext } from "../server/request-context.js";
21
19
  import { normalizeReasoningEffortForRequest } from "../shared/reasoning-effort.js";
22
20
  import { A2A_CONTINUATION_QUEUED_MARKER } from "./a2a-continuation-marker.js";
@@ -138,18 +136,6 @@ function buildDeliveryHistoryMessageIds(incoming) {
138
136
  assistantMessageId: `integration-${eventKey}-assistant`,
139
137
  };
140
138
  }
141
- function explicitEngineName(engineOption) {
142
- if (!engineOption)
143
- return undefined;
144
- if (typeof engineOption === "string")
145
- return engineOption;
146
- if (typeof engineOption === "object" &&
147
- !("stream" in engineOption) &&
148
- typeof engineOption.name === "string") {
149
- return engineOption.name;
150
- }
151
- return undefined;
152
- }
153
139
  async function resolveIntegrationEngineOption(engineOption, appId) {
154
140
  // A custom engine instance/config is an intentional per-plugin override and
155
141
  // must remain authoritative. A string option is the normal integration
@@ -179,28 +165,11 @@ function collectCompletedMutationToolResultSummaries(completedRun) {
179
165
  .map((event) => ({ tool: event.tool, result: event.result }));
180
166
  }
181
167
  export async function resolveIntegrationApiKey(engineOption, ownerEmail, fallbackApiKey) {
182
- const engineName = explicitEngineName(engineOption);
183
- if (engineName) {
184
- const provider = engineToProvider(engineName);
185
- const userApiKey = await getOwnerApiKey(provider, ownerEmail);
186
- if (userApiKey)
187
- return userApiKey;
188
- const envVar = PROVIDER_TO_ENV[provider];
189
- const providerEnvKey = envVar && canUseDeployCredentialFallbackForRequest(envVar)
190
- ? readDeployCredentialEnv(envVar)
191
- : undefined;
192
- return (providerEnvKey ||
193
- (canUseDeployCredentialFallbackForRequest("ANTHROPIC_API_KEY")
194
- ? fallbackApiKey.trim()
195
- : "") ||
196
- undefined);
197
- }
198
- const userApiKey = await getOwnerActiveApiKey(ownerEmail);
199
- if (userApiKey)
200
- return userApiKey;
201
- return canUseDeployCredentialFallbackForRequest("ANTHROPIC_API_KEY")
202
- ? fallbackApiKey.trim() || undefined
203
- : undefined;
168
+ return resolveOwnerEngineApiKey({
169
+ engineOption,
170
+ ownerEmail,
171
+ anthropicFallback: fallbackApiKey,
172
+ });
204
173
  }
205
174
  /**
206
175
  * Process an incoming webhook from a messaging platform.
@@ -915,7 +884,8 @@ async function processIncomingMessage(incoming, options, opts = {}) {
915
884
  const effectiveApiKey = await resolveIntegrationApiKey(effectiveEngineOption, ownerEmail, apiKey);
916
885
  const engine = await resolveEngine({
917
886
  engineOption: effectiveEngineOption,
918
- apiKey: effectiveApiKey,
887
+ apiKey: effectiveApiKey.apiKey,
888
+ apiKeyEnvVar: effectiveApiKey.apiKeyEnvVar,
919
889
  model,
920
890
  appId: options.appId,
921
891
  });