@agent-native/core 0.109.0 → 0.109.1

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 (125) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +24 -2
  5. package/corpus/core/src/a2a/correlation.ts +50 -0
  6. package/corpus/core/src/a2a/handlers.ts +140 -14
  7. package/corpus/core/src/a2a/index.ts +1 -0
  8. package/corpus/core/src/a2a/invoke.ts +10 -1
  9. package/corpus/core/src/a2a/task-store.ts +146 -6
  10. package/corpus/core/src/a2a/types.ts +16 -0
  11. package/corpus/core/src/action.ts +2 -0
  12. package/corpus/core/src/agent/production-agent.ts +23 -0
  13. package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
  14. package/corpus/core/src/db/client.ts +55 -0
  15. package/corpus/core/src/observability/traces.ts +172 -19
  16. package/corpus/core/src/scripts/call-agent.ts +53 -2
  17. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
  18. package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
  19. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  20. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  21. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  22. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  23. package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
  24. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
  25. package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
  26. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
  27. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
  28. package/corpus/templates/clips/actions/finalize-recording.ts +541 -62
  29. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
  30. package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
  31. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
  32. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -1
  33. package/corpus/templates/clips/app/routes/record.tsx +3 -3
  34. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -3
  35. package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
  38. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
  39. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
  40. package/corpus/templates/clips/desktop/src/app.tsx +18 -6
  41. package/corpus/templates/clips/desktop/src/lib/recorder.ts +111 -13
  42. package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +16 -7
  43. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -4
  44. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
  45. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +45 -18
  46. package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
  47. package/corpus/templates/clips/server/lib/builder-media-compression.ts +15 -0
  48. package/corpus/templates/clips/server/lib/media-verification-state.ts +38 -0
  49. package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
  50. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
  51. package/corpus/templates/clips/server/plugins/auth.ts +5 -5
  52. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  53. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
  54. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +6 -1
  55. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
  56. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
  57. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
  58. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
  59. package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
  60. package/corpus/templates/clips/shared/share-meta.ts +1 -1
  61. package/dist/a2a/client.d.ts +11 -2
  62. package/dist/a2a/client.d.ts.map +1 -1
  63. package/dist/a2a/client.js +16 -3
  64. package/dist/a2a/client.js.map +1 -1
  65. package/dist/a2a/correlation.d.ts +10 -0
  66. package/dist/a2a/correlation.d.ts.map +1 -0
  67. package/dist/a2a/correlation.js +40 -0
  68. package/dist/a2a/correlation.js.map +1 -0
  69. package/dist/a2a/handlers.d.ts.map +1 -1
  70. package/dist/a2a/handlers.js +100 -15
  71. package/dist/a2a/handlers.js.map +1 -1
  72. package/dist/a2a/index.d.ts +1 -1
  73. package/dist/a2a/index.d.ts.map +1 -1
  74. package/dist/a2a/index.js.map +1 -1
  75. package/dist/a2a/invoke.d.ts +4 -1
  76. package/dist/a2a/invoke.d.ts.map +1 -1
  77. package/dist/a2a/invoke.js +3 -0
  78. package/dist/a2a/invoke.js.map +1 -1
  79. package/dist/a2a/task-store.d.ts +12 -1
  80. package/dist/a2a/task-store.d.ts.map +1 -1
  81. package/dist/a2a/task-store.js +96 -6
  82. package/dist/a2a/task-store.js.map +1 -1
  83. package/dist/a2a/types.d.ts +15 -0
  84. package/dist/a2a/types.d.ts.map +1 -1
  85. package/dist/a2a/types.js.map +1 -1
  86. package/dist/action.d.ts +2 -0
  87. package/dist/action.d.ts.map +1 -1
  88. package/dist/action.js.map +1 -1
  89. package/dist/agent/production-agent.d.ts +9 -0
  90. package/dist/agent/production-agent.d.ts.map +1 -1
  91. package/dist/agent/production-agent.js +14 -0
  92. package/dist/agent/production-agent.js.map +1 -1
  93. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  94. package/dist/client/use-run-stuck-detection.js +25 -1
  95. package/dist/client/use-run-stuck-detection.js.map +1 -1
  96. package/dist/collab/routes.d.ts +1 -1
  97. package/dist/collab/struct-routes.d.ts +1 -1
  98. package/dist/db/client.d.ts +7 -0
  99. package/dist/db/client.d.ts.map +1 -1
  100. package/dist/db/client.js +33 -0
  101. package/dist/db/client.js.map +1 -1
  102. package/dist/observability/routes.d.ts +5 -5
  103. package/dist/observability/traces.d.ts +9 -0
  104. package/dist/observability/traces.d.ts.map +1 -1
  105. package/dist/observability/traces.js +137 -19
  106. package/dist/observability/traces.js.map +1 -1
  107. package/dist/resources/handlers.d.ts +2 -2
  108. package/dist/scripts/call-agent.d.ts.map +1 -1
  109. package/dist/scripts/call-agent.js +35 -3
  110. package/dist/scripts/call-agent.js.map +1 -1
  111. package/dist/secrets/routes.d.ts +9 -9
  112. package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
  113. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  114. package/dist/server/agent-chat/action-filters-a2a.js +35 -7
  115. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  116. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  117. package/dist/server/agent-chat-plugin.js +93 -6
  118. package/dist/server/agent-chat-plugin.js.map +1 -1
  119. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  120. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  121. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  122. package/package.json +1 -1
  123. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  124. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  125. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
@@ -1,10 +1,17 @@
1
1
  import crypto from "crypto";
2
2
 
3
3
  import { getDbExec, intType, isPostgres } from "../db/client.js";
4
- import { ensureTableExists, ensureColumnExists } from "../db/ddl-guard.js";
4
+ import {
5
+ ensureTableExists,
6
+ ensureColumnExists,
7
+ ensureIndexExists,
8
+ } from "../db/ddl-guard.js";
5
9
  import type { Task, Message, TaskState, Artifact } from "./types.js";
6
10
 
7
11
  let _initPromise: Promise<void> | undefined;
12
+ export const MAX_A2A_IDEMPOTENCY_KEY_CHARS = 128;
13
+ const A2A_IDEMPOTENCY_INDEX = "idx_a2a_tasks_owner_scope_idempotency";
14
+ export const A2A_PERSONAL_OWNER_SCOPE = "__personal__";
8
15
 
9
16
  async function ensureTable(): Promise<void> {
10
17
  if (!_initPromise) {
@@ -20,10 +27,16 @@ async function ensureTable(): Promise<void> {
20
27
  history TEXT NOT NULL DEFAULT '[]',
21
28
  artifacts TEXT NOT NULL DEFAULT '[]',
22
29
  metadata TEXT,
30
+ owner_email TEXT,
31
+ owner_scope TEXT NOT NULL DEFAULT '',
32
+ idempotency_key TEXT,
23
33
  created_at ${intType()} NOT NULL,
24
34
  updated_at ${intType()} NOT NULL
25
35
  )
26
36
  `;
37
+ const createIdempotencyIndexSql =
38
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${A2A_IDEMPOTENCY_INDEX} ` +
39
+ `ON a2a_tasks(owner_email, owner_scope, idempotency_key)`;
27
40
  const createApprovalsSql = `
28
41
  CREATE TABLE IF NOT EXISTS a2a_approvals (
29
42
  id TEXT PRIMARY KEY,
@@ -57,6 +70,20 @@ async function ensureTable(): Promise<void> {
57
70
  "owner_email",
58
71
  `ALTER TABLE a2a_tasks ADD COLUMN IF NOT EXISTS owner_email TEXT`,
59
72
  );
73
+ await ensureColumnExists(
74
+ "a2a_tasks",
75
+ "owner_scope",
76
+ `ALTER TABLE a2a_tasks ADD COLUMN IF NOT EXISTS owner_scope TEXT NOT NULL DEFAULT ''`,
77
+ );
78
+ await ensureColumnExists(
79
+ "a2a_tasks",
80
+ "idempotency_key",
81
+ `ALTER TABLE a2a_tasks ADD COLUMN IF NOT EXISTS idempotency_key TEXT`,
82
+ );
83
+ await ensureIndexExists(
84
+ A2A_IDEMPOTENCY_INDEX,
85
+ createIdempotencyIndexSql,
86
+ );
60
87
  await ensureTableExists("a2a_approvals", createApprovalsSql);
61
88
  return;
62
89
  }
@@ -69,6 +96,13 @@ async function ensureTable(): Promise<void> {
69
96
  // have NULL owner_email and remain accessible to legacy callers via
70
97
  // the legacy-token apiKeyEnv path; new rows are scoped from this point
71
98
  // forward.
99
+ try {
100
+ await client.execute(
101
+ `ALTER TABLE a2a_tasks ADD COLUMN owner_scope TEXT NOT NULL DEFAULT ''`,
102
+ );
103
+ } catch {
104
+ // Column already exists — expected on every restart after first run.
105
+ }
72
106
  try {
73
107
  await client.execute(
74
108
  `ALTER TABLE a2a_tasks ADD COLUMN owner_email TEXT`,
@@ -76,6 +110,14 @@ async function ensureTable(): Promise<void> {
76
110
  } catch {
77
111
  // Column already exists — expected on every restart after first run.
78
112
  }
113
+ try {
114
+ await client.execute(
115
+ `ALTER TABLE a2a_tasks ADD COLUMN idempotency_key TEXT`,
116
+ );
117
+ } catch {
118
+ // Column already exists — expected on every restart after first run.
119
+ }
120
+ await client.execute(createIdempotencyIndexSql);
79
121
  await client.execute(createApprovalsSql);
80
122
  })().catch((err) => {
81
123
  // Retry init on the next call after a failed startup.
@@ -320,7 +362,10 @@ export async function pauseProcessingA2ATask(
320
362
  return task;
321
363
  }
322
364
 
323
- function taskFromRow(row: any): Task & { ownerEmail?: string | null } {
365
+ function taskFromRow(row: any): Task & {
366
+ ownerEmail?: string | null;
367
+ ownerScope?: string | null;
368
+ } {
324
369
  return {
325
370
  id: row.id as string,
326
371
  contextId: (row.context_id as string) || undefined,
@@ -335,6 +380,7 @@ function taskFromRow(row: any): Task & { ownerEmail?: string | null } {
335
380
  artifacts: JSON.parse(row.artifacts as string),
336
381
  metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
337
382
  ownerEmail: (row.owner_email as string | null) ?? null,
383
+ ownerScope: (row.owner_scope as string | null) ?? null,
338
384
  };
339
385
  }
340
386
 
@@ -356,6 +402,7 @@ export async function createTask(
356
402
  contextId?: string,
357
403
  metadata?: Record<string, unknown>,
358
404
  ownerEmail?: string | null,
405
+ ownerScope?: string | null,
359
406
  ): Promise<Task> {
360
407
  await ensureTable();
361
408
  const client = getDbExec();
@@ -373,7 +420,7 @@ export async function createTask(
373
420
  };
374
421
 
375
422
  await client.execute({
376
- sql: `INSERT INTO a2a_tasks (id, context_id, status_state, status_timestamp, history, artifacts, metadata, owner_email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
423
+ sql: `INSERT INTO a2a_tasks (id, context_id, status_state, status_timestamp, history, artifacts, metadata, owner_email, owner_scope, idempotency_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
377
424
  args: [
378
425
  id,
379
426
  contextId ?? null,
@@ -383,6 +430,8 @@ export async function createTask(
383
430
  "[]",
384
431
  metadata ? JSON.stringify(metadata) : null,
385
432
  ownerEmail ?? null,
433
+ ownerScope ?? "",
434
+ null,
386
435
  now,
387
436
  now,
388
437
  ],
@@ -391,6 +440,87 @@ export async function createTask(
391
440
  return task;
392
441
  }
393
442
 
443
+ export async function createOrReuseTask(
444
+ message: Message,
445
+ contextId: string | undefined,
446
+ metadata: Record<string, unknown> | undefined,
447
+ ownerEmail: string | null,
448
+ ownerScope: string | null,
449
+ idempotencyKey: string | undefined,
450
+ ): Promise<{ task: Task; reused: boolean }> {
451
+ if (!ownerEmail || !idempotencyKey) {
452
+ return {
453
+ task: await createTask(
454
+ message,
455
+ contextId,
456
+ metadata,
457
+ ownerEmail,
458
+ ownerScope,
459
+ ),
460
+ reused: false,
461
+ };
462
+ }
463
+ if (idempotencyKey.length > MAX_A2A_IDEMPOTENCY_KEY_CHARS) {
464
+ throw new Error("A2A idempotency key is too long");
465
+ }
466
+
467
+ await ensureTable();
468
+ const client = getDbExec();
469
+ const normalizedOwner = ownerEmail.trim().toLowerCase();
470
+ const normalizedScope =
471
+ ownerScope?.trim().toLowerCase() || A2A_PERSONAL_OWNER_SCOPE;
472
+
473
+ for (let attempt = 0; attempt < 2; attempt++) {
474
+ const id = crypto.randomUUID();
475
+ const now = Date.now();
476
+ const timestamp = new Date().toISOString();
477
+ await client.execute({
478
+ sql: `INSERT INTO a2a_tasks (id, context_id, status_state, status_timestamp, history, artifacts, metadata, owner_email, owner_scope, idempotency_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(owner_email, owner_scope, idempotency_key) DO NOTHING`,
479
+ args: [
480
+ id,
481
+ contextId ?? null,
482
+ "submitted",
483
+ timestamp,
484
+ JSON.stringify([message]),
485
+ "[]",
486
+ metadata ? JSON.stringify(metadata) : null,
487
+ normalizedOwner,
488
+ normalizedScope,
489
+ idempotencyKey,
490
+ now,
491
+ now,
492
+ ],
493
+ });
494
+
495
+ const { rows } = await client.execute({
496
+ sql: `SELECT * FROM a2a_tasks WHERE owner_email = ? AND owner_scope = ? AND idempotency_key = ?`,
497
+ args: [normalizedOwner, normalizedScope, idempotencyKey],
498
+ });
499
+ if (rows.length === 0) {
500
+ throw new Error("A2A idempotent task insert could not be verified");
501
+ }
502
+ const task = taskFromRow(rows[0]);
503
+ const reused = task.id !== id;
504
+ if (
505
+ reused &&
506
+ (task.status.state === "failed" || task.status.state === "canceled")
507
+ ) {
508
+ await client.execute({
509
+ sql: `UPDATE a2a_tasks SET idempotency_key = NULL, updated_at = ? WHERE id = ? AND idempotency_key = ? AND status_state IN ('failed', 'canceled')`,
510
+ args: [Date.now(), task.id, idempotencyKey],
511
+ });
512
+ continue;
513
+ }
514
+ return { task, reused };
515
+ }
516
+ throw new Error("A2A terminal task idempotency key could not be released");
517
+ }
518
+
519
+ export interface A2ATaskOwnership {
520
+ ownerEmail: string | null;
521
+ ownerScope: string | null;
522
+ }
523
+
394
524
  /**
395
525
  * Fetch the verified owner email recorded against a task at creation time.
396
526
  * Returns null when the task has no owner (legacy rows or unauthenticated
@@ -400,15 +530,25 @@ export async function createTask(
400
530
  * verified caller's email must match `owner_email` to read or cancel.
401
531
  */
402
532
  export async function getTaskOwner(id: string): Promise<string | null> {
533
+ return (await getTaskOwnership(id)).ownerEmail;
534
+ }
535
+
536
+ export async function getTaskOwnership(id: string): Promise<A2ATaskOwnership> {
403
537
  await ensureTable();
404
538
  const client = getDbExec();
405
539
  const { rows } = await client.execute({
406
- sql: `SELECT owner_email FROM a2a_tasks WHERE id = ?`,
540
+ sql: `SELECT owner_email, owner_scope FROM a2a_tasks WHERE id = ?`,
407
541
  args: [id],
408
542
  });
409
- if (rows.length === 0) return null;
543
+ if (rows.length === 0) return { ownerEmail: null, ownerScope: null };
410
544
  const ownerEmail = (rows[0] as any).owner_email;
411
- return typeof ownerEmail === "string" && ownerEmail ? ownerEmail : null;
545
+ const ownerScope = (rows[0] as any).owner_scope;
546
+ return {
547
+ ownerEmail:
548
+ typeof ownerEmail === "string" && ownerEmail ? ownerEmail : null,
549
+ ownerScope:
550
+ typeof ownerScope === "string" && ownerScope ? ownerScope : null,
551
+ };
412
552
  }
413
553
 
414
554
  /**
@@ -132,6 +132,19 @@ export interface A2AApprovedAction {
132
132
  input: unknown;
133
133
  }
134
134
 
135
+ /**
136
+ * Telemetry-only cross-app correlation. Receivers must never use these
137
+ * caller-supplied values for identity, ownership, org scoping, access, or
138
+ * approval decisions.
139
+ */
140
+ export interface A2ACorrelationMetadata {
141
+ callerApp?: string;
142
+ callerThreadId?: string;
143
+ parentRunId?: string;
144
+ parentTurnId?: string;
145
+ invocationId?: string;
146
+ }
147
+
135
148
  // --- Framework config ---
136
149
 
137
150
  export interface A2AHandlerContext {
@@ -168,6 +181,7 @@ export interface A2AApprovalExecution {
168
181
  export interface A2AReadOnlyActionInvocation {
169
182
  action: string;
170
183
  input: Record<string, unknown>;
184
+ invocationId: string;
171
185
  }
172
186
 
173
187
  export interface A2AReadOnlyActionResult {
@@ -183,6 +197,8 @@ export type A2AHandler = (
183
197
 
184
198
  export interface A2AConfig {
185
199
  name: string;
200
+ /** Canonical receiver app id used only for telemetry attribution. */
201
+ appId?: string;
186
202
  description: string;
187
203
  version?: string;
188
204
  skills: AgentSkill[];
@@ -97,6 +97,8 @@ export interface ActionRunContext {
97
97
  * every human/programmatic surface.
98
98
  */
99
99
  threadId?: string;
100
+ /** Concrete execution id for this agent-loop attempt. */
101
+ runId?: string;
100
102
  turnId?: string;
101
103
  }
102
104
 
@@ -2481,6 +2481,9 @@ export interface ExecuteAgentToolCallOptions {
2481
2481
  orgId?: string | null;
2482
2482
  /** Audit/action attribution for this externally selected call. */
2483
2483
  caller?: ActionCaller;
2484
+ networkProtocol?: "a2a" | "mcp" | "provider-api";
2485
+ networkId?: string;
2486
+ networkPeer?: string;
2484
2487
  threadId?: string;
2485
2488
  turnId?: string;
2486
2489
  approvedToolCalls?: string[];
@@ -2576,6 +2579,9 @@ export async function executeAgentToolCall(
2576
2579
  ownerEmail: options.ownerEmail,
2577
2580
  orgId: options.orgId,
2578
2581
  actionCaller: options.caller,
2582
+ networkProtocol: options.networkProtocol,
2583
+ networkId: options.networkId,
2584
+ networkPeer: options.networkPeer,
2579
2585
  executionMode: "act",
2580
2586
  maxIterations: 2,
2581
2587
  threadId: options.threadId,
@@ -3157,6 +3163,12 @@ export async function runAgentLoop(opts: {
3157
3163
  orgId?: string | null;
3158
3164
  /** Action invocation attribution. Defaults to the normal agent tool loop. */
3159
3165
  actionCaller?: ActionCaller;
3166
+ /** Concrete execution id used for cross-app trace correlation. */
3167
+ runId?: string;
3168
+ /** Verified/telemetry-only delegated lineage supplied by the transport. */
3169
+ networkProtocol?: "a2a" | "mcp" | "provider-api";
3170
+ networkId?: string;
3171
+ networkPeer?: string;
3160
3172
  /**
3161
3173
  * Attachments submitted with this turn (pasted text, files, images), passed
3162
3174
  * through to each tool's `ActionRunContext.attachments` so actions can
@@ -4215,6 +4227,9 @@ export async function runAgentLoop(opts: {
4215
4227
  userEmail: getRequestUserEmail(),
4216
4228
  orgId: getRequestOrgId() ?? null,
4217
4229
  caller: opts.actionCaller ?? "tool",
4230
+ networkProtocol: opts.networkProtocol,
4231
+ networkId: opts.networkId,
4232
+ networkPeer: opts.networkPeer,
4218
4233
  }),
4219
4234
  )
4220
4235
  : actionEntry.needsApproval === true;
@@ -4249,6 +4264,9 @@ export async function runAgentLoop(opts: {
4249
4264
  ctx: {
4250
4265
  actionName: toolCall.name,
4251
4266
  caller: opts.actionCaller ?? "tool",
4267
+ networkProtocol: opts.networkProtocol,
4268
+ networkId: opts.networkId,
4269
+ networkPeer: opts.networkPeer,
4252
4270
  userEmail: getRequestUserEmail(),
4253
4271
  orgId: getRequestOrgId() ?? null,
4254
4272
  ...(opts.threadId ? { threadId: opts.threadId } : {}),
@@ -4645,12 +4663,16 @@ export async function runAgentLoop(opts: {
4645
4663
  userEmail: actionUserEmail ?? undefined,
4646
4664
  orgId: actionOrgId,
4647
4665
  caller: opts.actionCaller ?? "tool",
4666
+ networkProtocol: opts.networkProtocol,
4667
+ networkId: opts.networkId,
4668
+ networkPeer: opts.networkPeer,
4648
4669
  attachments: opts.attachments,
4649
4670
  signal,
4650
4671
  // Audit attribution: the action name + the agent thread/turn that
4651
4672
  // triggered this call, so a mutation can be traced to its run.
4652
4673
  actionName: toolCall.name,
4653
4674
  ...(opts.threadId ? { threadId: opts.threadId } : {}),
4675
+ ...(opts.runId ? { runId: opts.runId } : {}),
4654
4676
  ...(opts.turnId ? { turnId: opts.turnId } : {}),
4655
4677
  };
4656
4678
  const requestContext = getRequestContext();
@@ -8004,6 +8026,7 @@ export function createProductionAgentHandler(
8004
8026
  const agentLoopOpts = {
8005
8027
  engine,
8006
8028
  model: effectiveModel,
8029
+ runId,
8007
8030
  systemPrompt: requestSystemPrompt,
8008
8031
  tools: requestTools,
8009
8032
  availableTools: availableRequestTools,
@@ -80,6 +80,7 @@ export const DEFAULT_BACKGROUND_STUCK_THRESHOLD_MS = 180_000;
80
80
  export const DEFAULT_LIVE_BACKGROUND_STUCK_THRESHOLD_MS = 13 * 60_000;
81
81
  const DEFAULT_POLL_INTERVAL_MS = 5_000;
82
82
  const IDLE_BACKOFF_INTERVAL_MS = 15_000;
83
+ const MAX_POLL_ERROR_BACKOFF_MS = 30_000;
83
84
  const FRESH_BACKGROUND_HEARTBEAT_MS = 30_000;
84
85
 
85
86
  interface ActiveRunResponse {
@@ -129,6 +130,22 @@ export function useRunStuckDetection({
129
130
  let timer: ReturnType<typeof setTimeout> | null = null;
130
131
  let snapshotTransitionTimer: ReturnType<typeof setTimeout> | null = null;
131
132
  let snapshotVersion = 0;
133
+ let consecutivePollFailures = 0;
134
+
135
+ const pollFailureDelay = () => {
136
+ consecutivePollFailures += 1;
137
+ return Math.min(
138
+ Math.max(pollIntervalMs, 1) * 2 ** consecutivePollFailures,
139
+ MAX_POLL_ERROR_BACKOFF_MS,
140
+ );
141
+ };
142
+
143
+ const clearObservedRun = () => {
144
+ snapshotVersion += 1;
145
+ if (snapshotTransitionTimer) clearTimeout(snapshotTransitionTimer);
146
+ snapshotTransitionTimer = null;
147
+ setState(EMPTY_STATE);
148
+ };
132
149
 
133
150
  type RunHealthSnapshot = {
134
151
  active: boolean;
@@ -269,6 +286,7 @@ export function useRunStuckDetection({
269
286
  );
270
287
  if (cancelled) return;
271
288
  if (res.ok) {
289
+ consecutivePollFailures = 0;
272
290
  const data = (await res.json()) as ActiveRunResponse;
273
291
  const lastProgressAt = data.lastProgressAt ?? null;
274
292
  // Measure elapsed against the SERVER clock (serverNow) rather than the
@@ -336,9 +354,17 @@ export function useRunStuckDetection({
336
354
  if (!data.active || data.status !== "running") {
337
355
  nextDelay = IDLE_BACKOFF_INTERVAL_MS;
338
356
  }
357
+ } else if (res.status === 429 || res.status >= 500) {
358
+ nextDelay = pollFailureDelay();
359
+ } else {
360
+ consecutivePollFailures = 0;
361
+ clearObservedRun();
362
+ nextDelay = MAX_POLL_ERROR_BACKOFF_MS;
339
363
  }
340
364
  } catch {
341
- // Network blip — leave previous state. Next tick will retry.
365
+ // Network/503 blip — leave previous state and ease polling pressure
366
+ // while the server or database recovers.
367
+ nextDelay = pollFailureDelay();
342
368
  }
343
369
  if (!cancelled) {
344
370
  timer = setTimeout(poll, nextDelay);
@@ -669,6 +669,61 @@ export function isConnectionError(err: any): boolean {
669
669
  );
670
670
  }
671
671
 
672
+ /**
673
+ * Classify database failures that should temporarily shed request load.
674
+ * Statement timeouts are not included in isConnectionError() because retrying
675
+ * every timed-out mutation would not be safe, but request handlers can still
676
+ * return a retryable service-unavailable response for them.
677
+ */
678
+ export function isTransientDatabaseError(err: unknown): boolean {
679
+ const error = err as {
680
+ code?: unknown;
681
+ name?: unknown;
682
+ message?: unknown;
683
+ stack?: unknown;
684
+ cause?: {
685
+ code?: unknown;
686
+ name?: unknown;
687
+ message?: unknown;
688
+ stack?: unknown;
689
+ };
690
+ };
691
+ const code = String(error?.code ?? error?.cause?.code ?? "");
692
+ if (
693
+ code === "ECHECKOUTTIMEOUT" ||
694
+ code === "53300" ||
695
+ code === "57014" ||
696
+ /^08/.test(code) ||
697
+ /^57P0[123]$/.test(code)
698
+ ) {
699
+ return true;
700
+ }
701
+
702
+ const message = [error?.message, error?.cause?.message]
703
+ .filter((value): value is string => typeof value === "string")
704
+ .join(" ");
705
+ if (
706
+ /\bstatement timeout\b|\bdb (?:query|connect) timed out\b/i.test(message)
707
+ ) {
708
+ return true;
709
+ }
710
+
711
+ const databaseSurface = [
712
+ error?.name,
713
+ error?.cause?.name,
714
+ error?.stack,
715
+ error?.cause?.stack,
716
+ ]
717
+ .filter((value): value is string => typeof value === "string")
718
+ .join(" ");
719
+ return (
720
+ isConnectionError(error) &&
721
+ /@neondatabase|@libsql|\bpostgres(?:ql)?\b|\bpg-pool\b|drizzle-orm|\/db\/client\.[cm]?[jt]s/i.test(
722
+ databaseSurface,
723
+ )
724
+ );
725
+ }
726
+
672
727
  export async function retryOnConnectionError<T>(
673
728
  fn: () => Promise<T>,
674
729
  maxAttempts = 3,