@agent-native/core 0.109.0 → 0.109.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 (154) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/integrations.mdx +87 -253
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/a2a/client.ts +24 -2
  6. package/corpus/core/src/a2a/correlation.ts +50 -0
  7. package/corpus/core/src/a2a/handlers.ts +140 -14
  8. package/corpus/core/src/a2a/index.ts +1 -0
  9. package/corpus/core/src/a2a/invoke.ts +10 -1
  10. package/corpus/core/src/a2a/task-store.ts +146 -6
  11. package/corpus/core/src/a2a/types.ts +16 -0
  12. package/corpus/core/src/action.ts +2 -0
  13. package/corpus/core/src/agent/production-agent.ts +23 -0
  14. package/corpus/core/src/client/blocks/library/diagram.config.ts +10 -1
  15. package/corpus/core/src/client/blocks/library/diagram.tsx +9 -4
  16. package/corpus/core/src/client/use-run-stuck-detection.ts +27 -1
  17. package/corpus/core/src/db/client.ts +55 -0
  18. package/corpus/core/src/deploy/build.ts +63 -3
  19. package/corpus/core/src/observability/traces.ts +172 -19
  20. package/corpus/core/src/scripts/call-agent.ts +53 -2
  21. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +72 -25
  22. package/corpus/core/src/server/agent-chat-plugin.ts +107 -6
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  24. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  25. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  26. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  27. package/corpus/templates/analytics/changelog/2026-07-17-analytics-now-includes-a-default-alert-for-spikes-in-stuck-agent-chats.md +6 -0
  28. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +23 -14
  29. package/corpus/templates/analytics/docs/schemas/tracked-events.md +5 -1
  30. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +3 -3
  31. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +209 -87
  32. package/corpus/templates/clips/.agents/skills/dictate/SKILL.md +28 -5
  33. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +7 -0
  34. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +23 -0
  35. package/corpus/templates/clips/AGENTS.md +10 -0
  36. package/corpus/templates/clips/actions/create-dictation.ts +29 -4
  37. package/corpus/templates/clips/actions/finalize-recording.ts +542 -65
  38. package/corpus/templates/clips/actions/get-recording-player-data.ts +16 -6
  39. package/corpus/templates/clips/actions/set-mobile-capture-state.ts +37 -0
  40. package/corpus/templates/clips/actions/update-dictation.ts +9 -1
  41. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +17 -2
  42. package/corpus/templates/clips/app/components/player/video-player.tsx +14 -2
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +2 -2
  44. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +14 -3
  45. package/corpus/templates/clips/app/routes/record.tsx +3 -3
  46. package/corpus/templates/clips/app/routes/share.$shareId.tsx +19 -5
  47. package/corpus/templates/clips/changelog/2026-07-17-capture-dictation-background-meeting-audio-and-camera-videos.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-clips-now-retries-storage-verification-in-the-background-ins.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-17-delayed-upload-verification-keeps-local-backups-retryable.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-17-fixed-broken-thumbnail-icons-appearing-over-playable-clip-em.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-17-playback-comments-stay-readable-at-faster-playback-speeds.md +6 -0
  52. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +3 -0
  53. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -3
  54. package/corpus/templates/clips/desktop/src/app.tsx +35 -6
  55. package/corpus/templates/clips/desktop/src/lib/processing-backup-recovery.ts +19 -0
  56. package/corpus/templates/clips/desktop/src/lib/recorder.ts +141 -18
  57. package/corpus/templates/clips/desktop/src/lib/upload-verification.ts +28 -7
  58. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -4
  59. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +32 -15
  60. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +59 -18
  61. package/corpus/templates/clips/server/db/schema.ts +9 -1
  62. package/corpus/templates/clips/server/jobs/media-verification.ts +130 -0
  63. package/corpus/templates/clips/server/lib/builder-media-compression.ts +74 -7
  64. package/corpus/templates/clips/server/lib/media-verification-state.ts +55 -0
  65. package/corpus/templates/clips/server/lib/player-thumbnail-url.ts +31 -0
  66. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +10 -3
  67. package/corpus/templates/clips/server/plugins/auth.ts +5 -5
  68. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  69. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +12 -2
  70. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +13 -1
  71. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +340 -0
  72. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +24 -7
  73. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +91 -7
  74. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +22 -1
  75. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +14 -0
  76. package/corpus/templates/clips/shared/finalize-recovery.ts +61 -2
  77. package/corpus/templates/clips/shared/share-meta.ts +1 -1
  78. package/corpus/templates/plan/changelog/2026-07-17-diagrams-can-opt-into-a-polished-clean-rendering-mode-withou.md +6 -0
  79. package/corpus/templates/plan/shared/plan-content.ts +3 -0
  80. package/dist/a2a/client.d.ts +11 -2
  81. package/dist/a2a/client.d.ts.map +1 -1
  82. package/dist/a2a/client.js +16 -3
  83. package/dist/a2a/client.js.map +1 -1
  84. package/dist/a2a/correlation.d.ts +10 -0
  85. package/dist/a2a/correlation.d.ts.map +1 -0
  86. package/dist/a2a/correlation.js +40 -0
  87. package/dist/a2a/correlation.js.map +1 -0
  88. package/dist/a2a/handlers.d.ts.map +1 -1
  89. package/dist/a2a/handlers.js +100 -15
  90. package/dist/a2a/handlers.js.map +1 -1
  91. package/dist/a2a/index.d.ts +1 -1
  92. package/dist/a2a/index.d.ts.map +1 -1
  93. package/dist/a2a/index.js.map +1 -1
  94. package/dist/a2a/invoke.d.ts +4 -1
  95. package/dist/a2a/invoke.d.ts.map +1 -1
  96. package/dist/a2a/invoke.js +3 -0
  97. package/dist/a2a/invoke.js.map +1 -1
  98. package/dist/a2a/task-store.d.ts +12 -1
  99. package/dist/a2a/task-store.d.ts.map +1 -1
  100. package/dist/a2a/task-store.js +96 -6
  101. package/dist/a2a/task-store.js.map +1 -1
  102. package/dist/a2a/types.d.ts +15 -0
  103. package/dist/a2a/types.d.ts.map +1 -1
  104. package/dist/a2a/types.js.map +1 -1
  105. package/dist/action.d.ts +2 -0
  106. package/dist/action.d.ts.map +1 -1
  107. package/dist/action.js.map +1 -1
  108. package/dist/agent/production-agent.d.ts +9 -0
  109. package/dist/agent/production-agent.d.ts.map +1 -1
  110. package/dist/agent/production-agent.js +14 -0
  111. package/dist/agent/production-agent.js.map +1 -1
  112. package/dist/client/blocks/library/diagram.config.d.ts +2 -0
  113. package/dist/client/blocks/library/diagram.config.d.ts.map +1 -1
  114. package/dist/client/blocks/library/diagram.config.js +8 -1
  115. package/dist/client/blocks/library/diagram.config.js.map +1 -1
  116. package/dist/client/blocks/library/diagram.d.ts.map +1 -1
  117. package/dist/client/blocks/library/diagram.js +9 -4
  118. package/dist/client/blocks/library/diagram.js.map +1 -1
  119. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  120. package/dist/client/use-run-stuck-detection.js +25 -1
  121. package/dist/client/use-run-stuck-detection.js.map +1 -1
  122. package/dist/collab/routes.d.ts +1 -1
  123. package/dist/db/client.d.ts +7 -0
  124. package/dist/db/client.d.ts.map +1 -1
  125. package/dist/db/client.js +33 -0
  126. package/dist/db/client.js.map +1 -1
  127. package/dist/deploy/build.d.ts +3 -0
  128. package/dist/deploy/build.d.ts.map +1 -1
  129. package/dist/deploy/build.js +45 -1
  130. package/dist/deploy/build.js.map +1 -1
  131. package/dist/observability/routes.d.ts +1 -1
  132. package/dist/observability/traces.d.ts +9 -0
  133. package/dist/observability/traces.d.ts.map +1 -1
  134. package/dist/observability/traces.js +137 -19
  135. package/dist/observability/traces.js.map +1 -1
  136. package/dist/progress/routes.d.ts +1 -1
  137. package/dist/scripts/call-agent.d.ts.map +1 -1
  138. package/dist/scripts/call-agent.js +35 -3
  139. package/dist/scripts/call-agent.js.map +1 -1
  140. package/dist/server/agent-chat/action-filters-a2a.d.ts +18 -2
  141. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  142. package/dist/server/agent-chat/action-filters-a2a.js +35 -7
  143. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  144. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  145. package/dist/server/agent-chat-plugin.js +93 -6
  146. package/dist/server/agent-chat-plugin.js.map +1 -1
  147. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  148. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  149. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
  150. package/docs/content/integrations.mdx +87 -253
  151. package/package.json +1 -1
  152. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +21 -0
  153. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +7 -1
  154. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -1
@@ -0,0 +1,50 @@
1
+ import type { A2ACorrelationMetadata } from "./types.js";
2
+
3
+ export const MAX_A2A_CORRELATION_VALUE_CHARS = 200;
4
+
5
+ const APP_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
6
+ const CORRELATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
7
+
8
+ function boundedIdentifier(
9
+ value: unknown,
10
+ pattern: RegExp,
11
+ ): string | undefined {
12
+ if (typeof value !== "string") return undefined;
13
+ const trimmed = value.trim();
14
+ if (
15
+ !trimmed ||
16
+ trimmed.length > MAX_A2A_CORRELATION_VALUE_CHARS ||
17
+ !pattern.test(trimmed)
18
+ ) {
19
+ return undefined;
20
+ }
21
+ return trimmed;
22
+ }
23
+
24
+ export function sanitizeA2ACorrelationId(value: unknown): string | undefined {
25
+ return boundedIdentifier(value, CORRELATION_ID_PATTERN);
26
+ }
27
+
28
+ /**
29
+ * Keep only bounded, opaque ASCII correlation identifiers. These values
30
+ * remain telemetry hints; authentication continues to come exclusively from
31
+ * the verified A2A token/request context.
32
+ */
33
+ export function sanitizeA2ACorrelationMetadata(
34
+ value: unknown,
35
+ ): A2ACorrelationMetadata {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
37
+ const metadata = value as Record<string, unknown>;
38
+ const callerApp = boundedIdentifier(metadata.callerApp, APP_ID_PATTERN);
39
+ const callerThreadId = sanitizeA2ACorrelationId(metadata.callerThreadId);
40
+ const parentRunId = sanitizeA2ACorrelationId(metadata.parentRunId);
41
+ const parentTurnId = sanitizeA2ACorrelationId(metadata.parentTurnId);
42
+ const invocationId = sanitizeA2ACorrelationId(metadata.invocationId);
43
+ return {
44
+ ...(callerApp ? { callerApp } : {}),
45
+ ...(callerThreadId ? { callerThreadId } : {}),
46
+ ...(parentRunId ? { parentRunId } : {}),
47
+ ...(parentTurnId ? { parentTurnId } : {}),
48
+ ...(invocationId ? { invocationId } : {}),
49
+ };
50
+ }
@@ -1,3 +1,5 @@
1
+ import crypto from "node:crypto";
2
+
1
3
  import { setResponseHeader, setResponseStatus } from "h3";
2
4
 
3
5
  import {
@@ -7,18 +9,22 @@ import {
7
9
  isAgentChatDurableBackgroundEnabled,
8
10
  resolveAgentChatProcessRunDispatchPath,
9
11
  } from "../agent/durable-background.js";
12
+ import { trackingIdentityProperties } from "../observability/tracking-identity.js";
10
13
  import { withConfiguredAppBasePath } from "../server/app-base-path.js";
11
14
  import { getOrigin, isConfiguredAppOrigin } from "../server/google-oauth.js";
12
15
  import { fireInternalDispatch } from "../server/self-dispatch.js";
13
16
  import { agentChat } from "../shared/agent-chat.js";
17
+ import { track } from "../tracking/registry.js";
14
18
  import {
15
19
  hasConfiguredA2ASecret,
16
20
  isA2AProductionRuntime,
17
21
  } from "./auth-policy.js";
22
+ import { sanitizeA2ACorrelationMetadata } from "./correlation.js";
18
23
  import {
19
24
  createTask,
25
+ createOrReuseTask,
20
26
  getTask,
21
- getTaskOwner,
27
+ getTaskOwnership,
22
28
  updateTask,
23
29
  claimA2ATaskForProcessing,
24
30
  getA2ATaskDispatchState,
@@ -28,6 +34,8 @@ import {
28
34
  touchQueuedA2ATaskDispatch,
29
35
  touchProcessingA2ATask,
30
36
  pauseProcessingA2ATask,
37
+ MAX_A2A_IDEMPOTENCY_KEY_CHARS,
38
+ A2A_PERSONAL_OWNER_SCOPE,
31
39
  } from "./task-store.js";
32
40
  import type {
33
41
  A2AApprovedAction,
@@ -51,6 +59,7 @@ const A2A_PROCESSING_HEARTBEAT_MS = 30_000;
51
59
  const MAX_A2A_APPROVED_ACTIONS = 10;
52
60
  const MAX_A2A_DIRECT_ACTION_NAME_CHARS = 200;
53
61
  const MAX_A2A_DIRECT_ACTION_INPUT_BYTES = 64 * 1024;
62
+ const A2A_READ_INVOKE_EVENT = "$a2a_read_invoke";
54
63
 
55
64
  function trustedApprovedActions(
56
65
  value: unknown,
@@ -580,6 +589,22 @@ async function runHandlerAndPersist(
580
589
  }
581
590
  }
582
591
 
592
+ function verifiedTaskOwner(event?: any): {
593
+ ownerEmail: string | null;
594
+ ownerScope: string | null;
595
+ } {
596
+ const ownerEmail =
597
+ (event?.context?.__a2aVerifiedEmail as string | undefined) ?? null;
598
+ return {
599
+ ownerEmail,
600
+ ownerScope: ownerEmail
601
+ ? ((event?.context?.__a2aOrgDomain as string | undefined)
602
+ ?.trim()
603
+ .toLowerCase() ?? A2A_PERSONAL_OWNER_SCOPE)
604
+ : null,
605
+ };
606
+ }
607
+
583
608
  async function handleSend(
584
609
  params: Record<string, unknown>,
585
610
  config: A2AConfig,
@@ -606,8 +631,31 @@ async function handleSend(
606
631
  // on every subsequent tasks/get and tasks/cancel call. Caller-supplied
607
632
  // metadata.userEmail is NEVER used for ownership; that would re-introduce
608
633
  // the IDOR class fixed here.
609
- const ownerEmailForTask =
610
- (event?.context?.__a2aVerifiedEmail as string | undefined) ?? null;
634
+ const { ownerEmail: ownerEmailForTask, ownerScope: ownerScopeForTask } =
635
+ verifiedTaskOwner(event);
636
+ let idempotencyKey: string | undefined;
637
+ if (ownerEmailForTask && params.idempotencyKey !== undefined) {
638
+ if (typeof params.idempotencyKey !== "string") {
639
+ return {
640
+ ...jsonRpcError(
641
+ 0,
642
+ -32602,
643
+ "Invalid params: idempotencyKey must be a string",
644
+ ),
645
+ _id: 0,
646
+ };
647
+ }
648
+ idempotencyKey = params.idempotencyKey.trim();
649
+ if (
650
+ !idempotencyKey ||
651
+ idempotencyKey.length > MAX_A2A_IDEMPOTENCY_KEY_CHARS
652
+ ) {
653
+ return {
654
+ ...jsonRpcError(0, -32602, "Invalid params: idempotencyKey is invalid"),
655
+ _id: 0,
656
+ };
657
+ }
658
+ }
611
659
 
612
660
  // Async mode: return the task immediately in `working` state, run the
613
661
  // handler in the background, and let the caller poll `tasks/get`. This is
@@ -620,6 +668,7 @@ async function handleSend(
620
668
  // production — see the additional gate below.
621
669
  const asyncMode =
622
670
  params.async === true || (event && event.context?.__a2aForceAsync === true);
671
+ if (!asyncMode) idempotencyKey = undefined;
623
672
 
624
673
  if (asyncMode) {
625
674
  // Refuse async mode entirely when no auth is configured in production.
@@ -667,12 +716,20 @@ async function handleSend(
667
716
  approvedActions: approvedActions ?? null,
668
717
  },
669
718
  };
670
- const task = await createTask(
719
+ const { task, reused } = await createOrReuseTask(
671
720
  message,
672
721
  contextId,
673
722
  taskMetadata,
674
723
  ownerEmailForTask,
724
+ ownerScopeForTask,
725
+ idempotencyKey,
675
726
  );
727
+ if (reused) {
728
+ return {
729
+ ...jsonRpcResult(0, sanitizeTaskForResponse(task)),
730
+ _id: 0,
731
+ };
732
+ }
676
733
  const working = await updateTask(task.id, { state: "working" });
677
734
 
678
735
  // Awaited, not fire-and-forget: this handler is about to return, and a
@@ -695,12 +752,20 @@ async function handleSend(
695
752
  }
696
753
 
697
754
  return withA2ARequestContext(metadata, event, async () => {
698
- const task = await createTask(
755
+ const { task, reused } = await createOrReuseTask(
699
756
  message,
700
757
  contextId,
701
758
  undefined,
702
759
  ownerEmailForTask,
760
+ ownerScopeForTask,
761
+ idempotencyKey,
703
762
  );
763
+ if (reused) {
764
+ return {
765
+ ...jsonRpcResult(0, sanitizeTaskForResponse(task)),
766
+ _id: 0,
767
+ };
768
+ }
704
769
  await updateTask(task.id, { state: "working" });
705
770
 
706
771
  const ctx = makeHandlerContext(
@@ -790,8 +855,8 @@ async function handleStream(
790
855
  const contextId = params.contextId as string | undefined;
791
856
  const metadata = params.metadata as Record<string, unknown> | undefined;
792
857
  const approvedActions = trustedApprovedActions(params.approvedActions, event);
793
- const ownerEmailForTask =
794
- (event?.context?.__a2aVerifiedEmail as string | undefined) ?? null;
858
+ const { ownerEmail: ownerEmailForTask, ownerScope: ownerScopeForTask } =
859
+ verifiedTaskOwner(event);
795
860
 
796
861
  await withA2ARequestContext(metadata, event, async () => {
797
862
  const task = await createTask(
@@ -799,6 +864,7 @@ async function handleStream(
799
864
  contextId,
800
865
  undefined,
801
866
  ownerEmailForTask,
867
+ ownerScopeForTask,
802
868
  );
803
869
 
804
870
  await updateTask(task.id, { state: "working" });
@@ -879,16 +945,23 @@ const SENSITIVE_METADATA_KEYS = new Set([
879
945
 
880
946
  function sanitizeTaskForResponse(task: any): any {
881
947
  if (!task || typeof task !== "object") return task;
882
- if (!task.metadata || typeof task.metadata !== "object") return task;
948
+ const {
949
+ ownerEmail: _ownerEmail,
950
+ ownerScope: _ownerScope,
951
+ ...publicTask
952
+ } = task;
953
+ if (!publicTask.metadata || typeof publicTask.metadata !== "object") {
954
+ return publicTask;
955
+ }
883
956
 
884
- const meta = task.metadata as Record<string, unknown>;
957
+ const meta = publicTask.metadata as Record<string, unknown>;
885
958
  const publicMeta: Record<string, unknown> = {};
886
959
  for (const [k, v] of Object.entries(meta)) {
887
960
  if (k === "__a2a_processor") continue;
888
961
  if (SENSITIVE_METADATA_KEYS.has(k)) continue;
889
962
  publicMeta[k] = v;
890
963
  }
891
- return { ...task, metadata: publicMeta };
964
+ return { ...publicTask, metadata: publicMeta };
892
965
  }
893
966
 
894
967
  /**
@@ -907,6 +980,7 @@ function sanitizeTaskForResponse(task: any): any {
907
980
  */
908
981
  function authorizeTaskAccess(
909
982
  taskOwnerEmail: string | null,
983
+ taskOwnerScope: string | null,
910
984
  event: any,
911
985
  config: A2AConfig,
912
986
  ): JsonRpcResponse | null {
@@ -928,6 +1002,15 @@ function authorizeTaskAccess(
928
1002
  if (verifiedEmail.toLowerCase() !== taskOwnerEmail.toLowerCase()) {
929
1003
  return jsonRpcError(0, -32001, "Task not found");
930
1004
  }
1005
+ if (taskOwnerScope) {
1006
+ const verifiedScope =
1007
+ (event?.context?.__a2aOrgDomain as string | undefined)
1008
+ ?.trim()
1009
+ .toLowerCase() ?? A2A_PERSONAL_OWNER_SCOPE;
1010
+ if (verifiedScope !== taskOwnerScope.toLowerCase()) {
1011
+ return jsonRpcError(0, -32001, "Task not found");
1012
+ }
1013
+ }
931
1014
  }
932
1015
  // Legacy row (no owner_email recorded). The route-level auth gate is the
933
1016
  // only thing protecting it — fall through and serve.
@@ -943,8 +1026,13 @@ async function handleGet(
943
1026
  if (!id) {
944
1027
  return jsonRpcError(0, -32602, "Invalid params: id required");
945
1028
  }
946
- const ownerEmail = await getTaskOwner(id);
947
- const denied = authorizeTaskAccess(ownerEmail, event, config);
1029
+ const ownership = await getTaskOwnership(id);
1030
+ const denied = authorizeTaskAccess(
1031
+ ownership.ownerEmail,
1032
+ ownership.ownerScope,
1033
+ event,
1034
+ config,
1035
+ );
948
1036
  if (denied) return denied;
949
1037
 
950
1038
  const task = await getTask(id);
@@ -1038,8 +1126,13 @@ async function handleCancel(
1038
1126
  if (!id) {
1039
1127
  return jsonRpcError(0, -32602, "Invalid params: id required");
1040
1128
  }
1041
- const ownerEmail = await getTaskOwner(id);
1042
- const denied = authorizeTaskAccess(ownerEmail, event, config);
1129
+ const ownership = await getTaskOwnership(id);
1130
+ const denied = authorizeTaskAccess(
1131
+ ownership.ownerEmail,
1132
+ ownership.ownerScope,
1133
+ event,
1134
+ config,
1135
+ );
1043
1136
  if (denied) return denied;
1044
1137
 
1045
1138
  const task = await updateTask(id, { state: "canceled" });
@@ -1089,15 +1182,48 @@ async function handleInvokeReadOnlyAction(
1089
1182
  return jsonRpcError(0, -32601, "Direct action invocation not supported");
1090
1183
  }
1091
1184
 
1185
+ const startedAt = Date.now();
1186
+ const correlation = sanitizeA2ACorrelationMetadata(params.metadata);
1187
+ const invocationId = crypto.randomUUID();
1188
+ const verifiedEmail = event.context.__a2aVerifiedEmail as string;
1189
+ const emitTracking = (status: "completed" | "failed") => {
1190
+ const identity = trackingIdentityProperties();
1191
+ track(
1192
+ A2A_READ_INVOKE_EVENT,
1193
+ {
1194
+ ...identity,
1195
+ action,
1196
+ receiver_app: config.appId ?? identity.app ?? config.name,
1197
+ caller_app: correlation.callerApp ?? "unknown",
1198
+ duration_ms: Math.max(0, Date.now() - startedAt),
1199
+ status,
1200
+ invocation_id: invocationId,
1201
+ ...(correlation.parentRunId
1202
+ ? { parent_run_id: correlation.parentRunId }
1203
+ : {}),
1204
+ ...(correlation.parentTurnId
1205
+ ? { parent_turn_id: correlation.parentTurnId }
1206
+ : {}),
1207
+ ...(correlation.invocationId
1208
+ ? { caller_invocation_id: correlation.invocationId }
1209
+ : {}),
1210
+ },
1211
+ { userId: verifiedEmail },
1212
+ );
1213
+ };
1214
+
1092
1215
  try {
1093
1216
  const result = await withA2ARequestContext(undefined, event, () =>
1094
1217
  config.executeReadOnlyAction!({
1095
1218
  action,
1096
1219
  input: input as Record<string, unknown>,
1220
+ invocationId,
1097
1221
  }),
1098
1222
  );
1223
+ emitTracking(result.status);
1099
1224
  return jsonRpcResult(0, { action, ...result });
1100
1225
  } catch (error) {
1226
+ emitTracking("failed");
1101
1227
  console.error(`[a2a] Direct action ${action} failed:`, error);
1102
1228
  return jsonRpcError(0, -32000, "Direct action invocation failed");
1103
1229
  }
@@ -34,6 +34,7 @@ export type {
34
34
  Artifact,
35
35
  JsonRpcRequest,
36
36
  JsonRpcResponse,
37
+ A2ACorrelationMetadata,
37
38
  A2AReadOnlyActionInvocation,
38
39
  A2AReadOnlyActionResult,
39
40
  } from "./types.js";
@@ -7,7 +7,10 @@ import {
7
7
  callAction as defaultCallAction,
8
8
  callAgent as defaultCallAgent,
9
9
  } from "./client.js";
10
- import type { A2AReadOnlyActionResult } from "./types.js";
10
+ import type {
11
+ A2ACorrelationMetadata,
12
+ A2AReadOnlyActionResult,
13
+ } from "./types.js";
11
14
 
12
15
  export type AgentInvocationErrorCode =
13
16
  | "missing-target"
@@ -82,6 +85,8 @@ export interface InvokeAgentOptions extends ResolveAgentInvocationTargetOptions
82
85
  timeoutMs?: number;
83
86
  pollIntervalMs?: number;
84
87
  includeInvocationHint?: boolean;
88
+ correlation?: A2ACorrelationMetadata;
89
+ idempotencyKey?: string;
85
90
  runtime?: Partial<AgentInvocationRuntime>;
86
91
  }
87
92
 
@@ -94,6 +99,7 @@ export interface InvokeAgentActionOptions extends ResolveAgentInvocationTargetOp
94
99
  orgDomain?: string;
95
100
  orgSecret?: string;
96
101
  requestTimeoutMs?: number;
102
+ correlation?: A2ACorrelationMetadata;
97
103
  runtime?: Partial<AgentInvocationRuntime>;
98
104
  }
99
105
 
@@ -197,6 +203,8 @@ export async function invokeAgent(
197
203
  async: options.async,
198
204
  timeoutMs: options.timeoutMs,
199
205
  pollIntervalMs: options.pollIntervalMs,
206
+ correlation: options.correlation,
207
+ idempotencyKey: options.idempotencyKey,
200
208
  });
201
209
 
202
210
  return {
@@ -243,6 +251,7 @@ export async function invokeAgentAction(
243
251
  orgDomain: options.orgDomain,
244
252
  orgSecret: options.orgSecret,
245
253
  requestTimeoutMs: options.requestTimeoutMs,
254
+ correlation: options.correlation,
246
255
  });
247
256
 
248
257
  return { target, action, result };
@@ -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