@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for A2A config handler.
3
+ *
4
+ * Uses the real DB (via `initializeDb()`) and the test preload which sets
5
+ * `VELLUM_WORKSPACE_DIR` to a per-file temp directory.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ mock.module("../../../util/logger.js", () => ({
11
+ getLogger: () =>
12
+ new Proxy({} as Record<string, unknown>, {
13
+ get: () => () => {},
14
+ }),
15
+ }));
16
+
17
+ import {
18
+ invalidateConfigCache,
19
+ loadRawConfig,
20
+ saveRawConfig,
21
+ setNestedValue,
22
+ } from "../../../config/loader.js";
23
+ import { getSqlite } from "../../../memory/db-connection.js";
24
+ import { initializeDb } from "../../../memory/db-init.js";
25
+ import { clearA2AConfig, getA2AConfig, setA2AConfig } from "../config-a2a.js";
26
+
27
+ initializeDb();
28
+
29
+ function resetTables(): void {
30
+ const sqlite = getSqlite();
31
+ sqlite.run("DELETE FROM assistant_contact_metadata");
32
+ sqlite.run("DELETE FROM contact_channels");
33
+ sqlite.run("DELETE FROM contacts");
34
+ }
35
+
36
+ function setConfigEnabled(enabled: boolean): void {
37
+ const raw = loadRawConfig();
38
+ setNestedValue(raw, "a2a.enabled", enabled);
39
+ saveRawConfig(raw);
40
+ invalidateConfigCache();
41
+ }
42
+
43
+ describe("getA2AConfig", () => {
44
+ beforeEach(() => {
45
+ resetTables();
46
+ setConfigEnabled(false);
47
+ });
48
+
49
+ test("returns enabled: false when a2a is disabled", () => {
50
+ const result = getA2AConfig();
51
+ expect(result.success).toBe(true);
52
+ expect(result.enabled).toBe(false);
53
+ expect(result.activeConnections).toBe(0);
54
+ });
55
+
56
+ test("returns enabled: true when a2a is enabled", () => {
57
+ setConfigEnabled(true);
58
+ const result = getA2AConfig();
59
+ expect(result.success).toBe(true);
60
+ expect(result.enabled).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("setA2AConfig", () => {
65
+ beforeEach(() => {
66
+ resetTables();
67
+ setConfigEnabled(false);
68
+ });
69
+
70
+ test("enables a2a in config", () => {
71
+ const result = setA2AConfig();
72
+ expect(result.success).toBe(true);
73
+ expect(result.enabled).toBe(true);
74
+ });
75
+
76
+ test("is idempotent", () => {
77
+ setA2AConfig();
78
+ const result = setA2AConfig();
79
+ expect(result.success).toBe(true);
80
+ expect(result.enabled).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe("clearA2AConfig", () => {
85
+ beforeEach(() => {
86
+ resetTables();
87
+ setConfigEnabled(true);
88
+ });
89
+
90
+ test("disables a2a in config", () => {
91
+ const result = clearA2AConfig();
92
+ expect(result.success).toBe(true);
93
+ expect(result.enabled).toBe(false);
94
+ });
95
+ });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * A2A channel configuration handler.
3
+ *
4
+ * - getA2AConfig() — read a2a.enabled, count active a2a contact_channels
5
+ * - setA2AConfig() — set a2a.enabled = true
6
+ * - clearA2AConfig() — set a2a.enabled = false
7
+ * - createA2AInvite() — create a shareable invite token for link-based contact creation
8
+ * - redeemA2AInvite() — receiver-side: create trusted contact from sender identity
9
+ */
10
+
11
+ import {
12
+ getConfig,
13
+ invalidateConfigCache,
14
+ loadRawConfig,
15
+ saveRawConfig,
16
+ setNestedValue,
17
+ } from "../../config/loader.js";
18
+ import {
19
+ findContactByAddress,
20
+ searchContacts,
21
+ upsertContact,
22
+ } from "../../contacts/contact-store.js";
23
+ import type { VellumAssistantMetadata } from "../../contacts/types.js";
24
+ import { getPublicBaseUrl } from "../../inbound/public-ingress-urls.js";
25
+ import { getDb } from "../../memory/db-connection.js";
26
+ import {
27
+ claimA2AInvite,
28
+ createInvite,
29
+ hashToken,
30
+ } from "../../memory/invite-store.js";
31
+ import { assistantContactMetadata } from "../../memory/schema.js";
32
+ import { getAssistantName } from "../identity-helpers.js";
33
+ // ── Result types ────────────────────────────────────────────────────
34
+
35
+ export interface A2AConfigResult {
36
+ success: boolean;
37
+ enabled: boolean;
38
+ activeConnections: number;
39
+ error?: string;
40
+ }
41
+
42
+ export interface CreateA2AInviteResult {
43
+ success: boolean;
44
+ inviteId?: string;
45
+ token?: string;
46
+ expiresAt?: number;
47
+ senderGatewayUrl?: string;
48
+ error?: string;
49
+ }
50
+
51
+ export interface CompleteA2AInviteResult {
52
+ success: boolean;
53
+ sender?: { assistantId: string; displayName: string; gatewayUrl: string };
54
+ error?: string;
55
+ }
56
+
57
+ export interface RedeemA2AInviteResult {
58
+ success: boolean;
59
+ contactId?: string;
60
+ alreadyConnected?: boolean;
61
+ error?: string;
62
+ }
63
+
64
+ // ── Config operations ───────────────────────────────────────────────
65
+
66
+ export function getA2AConfig(): A2AConfigResult {
67
+ const config = getConfig();
68
+ const enabled = config.a2a?.enabled ?? false;
69
+
70
+ const contacts = searchContacts({ channelType: "a2a" });
71
+ const activeConnections = contacts.reduce((count, c) => {
72
+ return (
73
+ count +
74
+ c.channels.filter((ch) => ch.type === "a2a" && ch.status === "active")
75
+ .length
76
+ );
77
+ }, 0);
78
+
79
+ return { success: true, enabled, activeConnections };
80
+ }
81
+
82
+ export function setA2AConfig(): A2AConfigResult {
83
+ const raw = loadRawConfig();
84
+ setNestedValue(raw, "a2a.enabled", true);
85
+ saveRawConfig(raw);
86
+ invalidateConfigCache();
87
+
88
+ const result = getA2AConfig();
89
+ return { ...result, success: true };
90
+ }
91
+
92
+ export function clearA2AConfig(): A2AConfigResult {
93
+ const raw = loadRawConfig();
94
+ setNestedValue(raw, "a2a.enabled", false);
95
+ saveRawConfig(raw);
96
+ invalidateConfigCache();
97
+
98
+ return { success: true, enabled: false, activeConnections: 0 };
99
+ }
100
+
101
+ // ── A2A invite creation ────────────────────────────────────────────
102
+
103
+ export function createA2AInvite(params: {
104
+ expiresInHours?: number;
105
+ }): CreateA2AInviteResult {
106
+ // 1. Ensure A2A channel is enabled (auto-enable on first invite)
107
+ const config = getA2AConfig();
108
+ if (!config.enabled) {
109
+ setA2AConfig();
110
+ }
111
+
112
+ // 2. Resolve public base URL
113
+ let publicBaseUrl: string;
114
+ try {
115
+ publicBaseUrl = getPublicBaseUrl(getConfig());
116
+ } catch {
117
+ return {
118
+ success: false,
119
+ error:
120
+ "No public base URL configured. Set ingress.publicBaseUrl in config.",
121
+ };
122
+ }
123
+
124
+ // 3. Create placeholder contact (no channels — will be bound on acceptance)
125
+ const contact = upsertContact({
126
+ displayName: "Pending A2A invite",
127
+ contactType: "assistant",
128
+ role: "contact",
129
+ });
130
+
131
+ // 4. Create the invite
132
+ const expiresInMs = (params.expiresInHours ?? 72) * 60 * 60 * 1000;
133
+ const { invite, rawToken } = createInvite({
134
+ sourceChannel: "a2a",
135
+ contactId: contact.id,
136
+ maxUses: 1,
137
+ expiresInMs,
138
+ });
139
+
140
+ return {
141
+ success: true,
142
+ inviteId: invite.id,
143
+ token: rawToken,
144
+ expiresAt: invite.expiresAt,
145
+ senderGatewayUrl: publicBaseUrl,
146
+ };
147
+ }
148
+
149
+ // ── A2A invite completion (sender side) ───────────────────────────
150
+
151
+ export function completeA2AInvite(params: {
152
+ token: string;
153
+ senderAssistantId: string;
154
+ acceptor: {
155
+ assistantId: string;
156
+ displayName: string;
157
+ gatewayUrl: string;
158
+ };
159
+ }): CompleteA2AInviteResult {
160
+ // Resolve sender identity before any mutations so we fail cleanly
161
+ const displayName = getAssistantName() ?? "Vellum Assistant";
162
+ let gatewayUrl: string;
163
+ try {
164
+ gatewayUrl = getPublicBaseUrl(getConfig());
165
+ } catch {
166
+ return {
167
+ success: false,
168
+ error:
169
+ "No public base URL configured. Set ingress.publicBaseUrl in config.",
170
+ };
171
+ }
172
+
173
+ const tokenHash = hashToken(params.token);
174
+ const claimResult = claimA2AInvite({
175
+ tokenHash,
176
+ redeemedByExternalUserId: params.acceptor.assistantId,
177
+ });
178
+
179
+ if (!claimResult.claimed || !claimResult.invite) {
180
+ return { success: false, error: claimResult.error };
181
+ }
182
+
183
+ const invite = claimResult.invite;
184
+
185
+ // Promote the placeholder contact with the acceptor's identity
186
+ upsertContact({
187
+ id: invite.contactId,
188
+ displayName: params.acceptor.displayName,
189
+ contactType: "assistant",
190
+ role: "contact",
191
+ channels: [
192
+ {
193
+ type: "a2a",
194
+ address: params.acceptor.assistantId.toLowerCase(),
195
+ externalUserId: params.acceptor.assistantId,
196
+ status: "active",
197
+ policy: "allow",
198
+ },
199
+ ],
200
+ });
201
+
202
+ // Write assistant contact metadata
203
+ const db = getDb();
204
+ const metadataJson = JSON.stringify({
205
+ assistantId: params.acceptor.assistantId,
206
+ gatewayUrl: params.acceptor.gatewayUrl,
207
+ } satisfies VellumAssistantMetadata);
208
+ db.insert(assistantContactMetadata)
209
+ .values({
210
+ contactId: invite.contactId,
211
+ species: "vellum",
212
+ metadata: metadataJson,
213
+ })
214
+ .onConflictDoUpdate({
215
+ target: assistantContactMetadata.contactId,
216
+ set: { species: "vellum", metadata: metadataJson },
217
+ })
218
+ .run();
219
+
220
+ return {
221
+ success: true,
222
+ sender: {
223
+ assistantId: params.senderAssistantId,
224
+ displayName,
225
+ gatewayUrl,
226
+ },
227
+ };
228
+ }
229
+
230
+ // ── A2A invite redemption (receiver side) ─────────────────────────
231
+
232
+ export function redeemA2AInvite(params: {
233
+ sender: {
234
+ assistantId: string;
235
+ displayName: string;
236
+ gatewayUrl: string;
237
+ };
238
+ }): RedeemA2AInviteResult {
239
+ // 1. Ensure A2A channel is enabled (auto-enable if needed)
240
+ const config = getA2AConfig();
241
+ if (!config.enabled) {
242
+ setA2AConfig();
243
+ }
244
+
245
+ // 2. Check for existing active contact with this sender
246
+ const existing = findContactByAddress("a2a", params.sender.assistantId);
247
+ if (
248
+ existing &&
249
+ existing.channels.some((ch) => ch.type === "a2a" && ch.status === "active")
250
+ ) {
251
+ return { success: true, alreadyConnected: true, contactId: existing.id };
252
+ }
253
+
254
+ // 3. Create the sender as a local trusted contact
255
+ const contact = upsertContact({
256
+ displayName: params.sender.displayName,
257
+ contactType: "assistant",
258
+ role: "contact",
259
+ channels: [
260
+ {
261
+ type: "a2a",
262
+ address: params.sender.assistantId.toLowerCase(),
263
+ externalUserId: params.sender.assistantId,
264
+ status: "active",
265
+ policy: "allow",
266
+ },
267
+ ],
268
+ });
269
+
270
+ // 4. Write assistant contact metadata
271
+ const db = getDb();
272
+ const metadataJson = JSON.stringify({
273
+ assistantId: params.sender.assistantId,
274
+ gatewayUrl: params.sender.gatewayUrl,
275
+ } satisfies VellumAssistantMetadata);
276
+ db.insert(assistantContactMetadata)
277
+ .values({
278
+ contactId: contact.id,
279
+ species: "vellum",
280
+ metadata: metadataJson,
281
+ })
282
+ .onConflictDoUpdate({
283
+ target: assistantContactMetadata.contactId,
284
+ set: { species: "vellum", metadata: metadataJson },
285
+ })
286
+ .run();
287
+
288
+ return { success: true, contactId: contact.id };
289
+ }
@@ -141,6 +141,7 @@ export async function regenerateResponse(
141
141
  const conversation = await getOrCreateConversation(conversationId);
142
142
  touchConversation(conversationId);
143
143
  conversation.updateClient(broadcastMessage, false);
144
+ getSubagentManager().updateParentSender(conversationId, broadcastMessage);
144
145
  const requestId = uuid();
145
146
  conversation.traceEmitter.emit("request_received", "Regenerate requested", {
146
147
  requestId,
@@ -22,10 +22,11 @@
22
22
  * `running`; the rollback path on a failed `start` restores from the
23
23
  * current confirmed pointer (not from a per-call snapshot of a sibling
24
24
  * optimistic write), so two overlapping starts that both fail cannot
25
- * leave a phantom lock and a late-arriving `running` for an older
26
- * overlapping start still updates the confirmed pointer so the lock
27
- * survives a subsequent rollback of the newer start. The lock is
28
- * released outright when the owning proxy's `dispose()` fires.
25
+ * leave a phantom lock. Each session carries a monotonic `dispatchedAt`
26
+ * counter so out-of-order `running` responses promote in dispatch order:
27
+ * the latest-dispatched start that the host confirms becomes the
28
+ * confirmed baseline, regardless of which response arrived last. The
29
+ * lock is released outright when the owning proxy's `dispose()` fires.
29
30
  *
30
31
  * `app_control_start` is the only tool that can acquire the lock — the
31
32
  * user's medium-risk approval at start time is the consent boundary. All
@@ -84,8 +85,22 @@ export interface ActiveAppControlSession {
84
85
  * the `app` of subsequent non-start tool calls.
85
86
  */
86
87
  app: string;
88
+ /**
89
+ * Strictly monotonic counter assigned when the session is created (in
90
+ * `request()` for a `start`). Used by {@link promoteStartIfCurrent} to
91
+ * tell which of two confirmations from overlapping starts is newer when
92
+ * host responses arrive out of order. Larger values are newer.
93
+ */
94
+ dispatchedAt: number;
87
95
  }
88
96
 
97
+ /**
98
+ * Monotonic counter that stamps each `start`'s {@link
99
+ * ActiveAppControlSession.dispatchedAt}. Process-lifetime monotonic; the
100
+ * absolute value is meaningless — only ordering matters.
101
+ */
102
+ let nextDispatchedAt = 1;
103
+
89
104
  /**
90
105
  * Currently active session, or `undefined` when no session is held. This
91
106
  * is the optimistic value: it is set the moment a `start` is dispatched
@@ -112,6 +127,13 @@ export function _getActiveAppControlSession():
112
127
  return activeAppControlSession;
113
128
  }
114
129
 
130
+ /** Test-only helper: read the last host-confirmed session. */
131
+ export function _getConfirmedAppControlSession():
132
+ | ActiveAppControlSession
133
+ | undefined {
134
+ return confirmedAppControlSession;
135
+ }
136
+
115
137
  /** Test-only helper: clear both session pointers between test cases. */
116
138
  export function _resetActiveAppControlSession(): void {
117
139
  activeAppControlSession = undefined;
@@ -123,11 +145,18 @@ export function _resetActiveAppControlSession(): void {
123
145
  * round-trip. Useful for tests that exercise non-start tool paths and
124
146
  * don't need to verify the start flow itself.
125
147
  */
126
- export function _setActiveAppControlSession(
127
- session: ActiveAppControlSession,
128
- ): void {
129
- activeAppControlSession = session;
130
- confirmedAppControlSession = session;
148
+ export function _setActiveAppControlSession(session: {
149
+ conversationId: string;
150
+ app: string;
151
+ dispatchedAt?: number;
152
+ }): void {
153
+ const full: ActiveAppControlSession = {
154
+ conversationId: session.conversationId,
155
+ app: session.app,
156
+ dispatchedAt: session.dispatchedAt ?? nextDispatchedAt++,
157
+ };
158
+ activeAppControlSession = full;
159
+ confirmedAppControlSession = full;
131
160
  }
132
161
 
133
162
  /**
@@ -280,6 +309,7 @@ export class HostAppControlProxy extends HostProxyBase<
280
309
  attemptedSession = {
281
310
  conversationId: this.conversationId,
282
311
  app: input.app,
312
+ dispatchedAt: nextDispatchedAt++,
283
313
  };
284
314
  activeAppControlSession = attemptedSession;
285
315
  } else {
@@ -356,15 +386,27 @@ export class HostAppControlProxy extends HostProxyBase<
356
386
  }
357
387
 
358
388
  /**
359
- * Promote this start's optimistic write to the confirmed pointer when
360
- * the host returns `running`. Gated on conversation ownership rather
361
- * than object identity: a newer overlapping start in the same
362
- * conversation may have superseded our optimistic write while we were
363
- * waiting on the host, but the host's `running` response for our
364
- * `attempted` is still ground-truth that the lock should be held.
365
- * The conversation-ownership check ensures we don't resurrect a session
366
- * after `dispose()` cleared the lock or after another conversation
367
- * acquired it.
389
+ * Promote this start's session to the confirmed pointer when the host
390
+ * returns `running`. Two gates:
391
+ *
392
+ * 1. The live optimistic write must still belong to this conversation —
393
+ * if `dispose()` cleared the lock or another conversation acquired
394
+ * it, this confirmation must not resurrect a stale session.
395
+ * 2. The confirming session must be at least as recent as the currently
396
+ * confirmed one, compared via {@link
397
+ * ActiveAppControlSession.dispatchedAt}. The dispatch counter is
398
+ * assigned synchronously in `request()`, so it captures dispatch
399
+ * order even when host responses arrive out of order. The latest
400
+ * dispatched start that confirms wins, which is the right baseline
401
+ * for the rollback path: if a newer start later fails, rollback
402
+ * restores the most recently confirmed session, not an older one.
403
+ *
404
+ * Also advance the active pointer when it is strictly older than the
405
+ * newly-confirmed session. This handles the case where an even newer
406
+ * optimistic write has already failed and rolled active back to the
407
+ * previous confirmed session; without this, observe/actions for the
408
+ * newly-confirmed session would target the older app. A newer
409
+ * in-flight optimistic write (higher `dispatchedAt`) is preserved.
368
410
  */
369
411
  private promoteStartIfCurrent(
370
412
  attempted: ActiveAppControlSession | undefined,
@@ -373,7 +415,16 @@ export class HostAppControlProxy extends HostProxyBase<
373
415
  if (activeAppControlSession?.conversationId !== attempted.conversationId) {
374
416
  return;
375
417
  }
418
+ if (
419
+ confirmedAppControlSession != null &&
420
+ attempted.dispatchedAt <= confirmedAppControlSession.dispatchedAt
421
+ ) {
422
+ return;
423
+ }
376
424
  confirmedAppControlSession = attempted;
425
+ if (activeAppControlSession.dispatchedAt < attempted.dispatchedAt) {
426
+ activeAppControlSession = attempted;
427
+ }
377
428
  }
378
429
 
379
430
  /**
@@ -29,6 +29,9 @@
29
29
  import type { HostProxyCapability, InterfaceId } from "../channels/types.js";
30
30
  import { supportsHostProxy } from "../channels/types.js";
31
31
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
32
+ import { getLogger } from "../util/logger.js";
33
+
34
+ const log = getLogger("host-proxy-preactivation");
32
35
 
33
36
  /**
34
37
  * Subset of Conversation/ProcessConversationContext that
@@ -36,9 +39,29 @@ import { assistantEventHub } from "../runtime/assistant-event-hub.js";
36
39
  * `ProcessConversationContext` satisfy this structurally.
37
40
  */
38
41
  export interface HostProxyPreactivationTarget {
42
+ readonly conversationId: string;
39
43
  addPreactivatedSkillId(id: string): void;
40
44
  }
41
45
 
46
+ /**
47
+ * Why an attachment decision went the way it did. Logged per turn so that
48
+ * silent-gate failures (e.g. ATL-609: computer-use never reaches the LLM
49
+ * surface for a macOS user) can be diagnosed from production logs without
50
+ * extra instrumentation.
51
+ */
52
+ export type HostProxyAttachmentReason =
53
+ | "native_support"
54
+ | "cross_client"
55
+ | "denied_no_interface"
56
+ | "denied_chrome_extension"
57
+ | "denied_no_clients";
58
+
59
+ export interface HostProxyAttachmentDecision {
60
+ shouldAttach: boolean;
61
+ reason: HostProxyAttachmentReason;
62
+ clientCount?: number;
63
+ }
64
+
42
65
  /**
43
66
  * Registry mapping each host-proxy capability to the skill that must be
44
67
  * preactivated when that capability is supported by the source interface.
@@ -62,45 +85,89 @@ export const HOST_PROXY_SKILL_PREACTIVATIONS: ReadonlyArray<{
62
85
  ];
63
86
 
64
87
  /**
65
- * Returns true when a host-proxy for the given capability should be attached
66
- * (instantiated and preactivated) for the current turn. Two cases qualify:
88
+ * Returns the full attachment decision for a host-proxy capability used both
89
+ * to gate proxy instantiation and to feed the structured preactivation log so
90
+ * silent gates can be diagnosed without re-instrumenting after the fact.
67
91
  *
68
- * 1. The source interface natively supports the capability (e.g. macOS host_cu).
69
- * 2. The source interface doesn't support the capability natively but at least
70
- * one connected client does cross-client routing. `chrome-extension` is
71
- * excluded as a security boundary: it is its own executor context and cannot
72
- * broker cross-client routing to a macOS client.
92
+ * 1. No source interface → `denied_no_interface`.
93
+ * 2. Source interface natively supports the capability `native_support`.
94
+ * 3. `chrome-extension` source can never broker cross-client routing to a
95
+ * macOS client (security boundary) `denied_chrome_extension`.
96
+ * 4. At least one connected client advertises the capability
97
+ * `cross_client` with `clientCount`.
98
+ * 5. Otherwise → `denied_no_clients` with `clientCount: 0`.
73
99
  *
74
- * This is the single source of truth for both preactivation and proxy
75
- * instantiation, so the two decisions stay in sync.
100
+ * Single source of truth for preactivation and proxy instantiation.
101
+ */
102
+ export function evaluateHostProxyAttachment(
103
+ capability: HostProxyCapability,
104
+ sourceInterface: InterfaceId | undefined,
105
+ ): HostProxyAttachmentDecision {
106
+ if (!sourceInterface) {
107
+ return { shouldAttach: false, reason: "denied_no_interface" };
108
+ }
109
+ if (supportsHostProxy(sourceInterface, capability)) {
110
+ return { shouldAttach: true, reason: "native_support" };
111
+ }
112
+ if (sourceInterface === "chrome-extension") {
113
+ return { shouldAttach: false, reason: "denied_chrome_extension" };
114
+ }
115
+ const clientCount =
116
+ assistantEventHub.listClientsByCapability(capability).length;
117
+ if (clientCount > 0) {
118
+ return { shouldAttach: true, reason: "cross_client", clientCount };
119
+ }
120
+ return { shouldAttach: false, reason: "denied_no_clients", clientCount: 0 };
121
+ }
122
+
123
+ /**
124
+ * Boolean wrapper retained for the proxy-instantiation call sites that only
125
+ * need the gate result. Prefer `evaluateHostProxyAttachment` when the reason
126
+ * is also useful (e.g. for logging or telemetry).
76
127
  */
77
128
  export function shouldAttachHostProxyForCapability(
78
129
  capability: HostProxyCapability,
79
130
  sourceInterface: InterfaceId | undefined,
80
131
  ): boolean {
81
- if (!sourceInterface) return false;
82
- if (supportsHostProxy(sourceInterface, capability)) return true;
83
- if (sourceInterface === "chrome-extension") return false;
84
- return assistantEventHub.listClientsByCapability(capability).length > 0;
132
+ return evaluateHostProxyAttachment(capability, sourceInterface).shouldAttach;
85
133
  }
86
134
 
87
135
  /**
88
136
  * Preactivate every host-proxy-backed skill that the given source interface
89
- * supports. No-op when `sourceInterface` is undefined.
137
+ * supports, and emit one structured `log.info` line per turn capturing each
138
+ * capability's decision + the final preactivated skill IDs.
139
+ *
140
+ * The log line fires unconditionally — even when `sourceInterface` is
141
+ * undefined — because "preactivation never ran because no interface" is
142
+ * itself the diagnostic signal we want visible in production.
90
143
  *
91
144
  * Callers are responsible for any additional gating (e.g. only preactivating
92
145
  * when the conversation is idle vs. when re-adding after dequeue), since
93
- * those constraints differ across create vs. drain paths. This helper just
94
- * iterates the registry and dispatches.
146
+ * those constraints differ across create vs. drain paths.
95
147
  */
96
148
  export function preactivateHostProxySkills(
97
149
  conversation: HostProxyPreactivationTarget,
98
150
  sourceInterface: InterfaceId | undefined,
99
151
  ): void {
100
- if (!sourceInterface) return;
152
+ const decisions: Record<string, HostProxyAttachmentDecision> = {};
153
+ const preactivatedSkillIds: string[] = [];
154
+
101
155
  for (const { capability, skillId } of HOST_PROXY_SKILL_PREACTIVATIONS) {
102
- if (shouldAttachHostProxyForCapability(capability, sourceInterface)) {
156
+ const decision = evaluateHostProxyAttachment(capability, sourceInterface);
157
+ decisions[capability] = decision;
158
+ if (decision.shouldAttach) {
103
159
  conversation.addPreactivatedSkillId(skillId);
160
+ preactivatedSkillIds.push(skillId);
104
161
  }
105
162
  }
163
+
164
+ log.info(
165
+ {
166
+ conversationId: conversation.conversationId,
167
+ sourceInterface,
168
+ decisions,
169
+ preactivatedSkillIds,
170
+ },
171
+ "host-proxy preactivation decision",
172
+ );
106
173
  }