@vellumai/assistant 0.10.3 → 0.10.4-staging.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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -42,6 +42,7 @@ mock.module("../../util/logger.js", () => ({
42
42
  // the a2a.enabled flag. We use the real config system backed by initializeDb's
43
43
  // workspace directory.
44
44
 
45
+ import { seedContactChannel } from "../../__tests__/helpers/seed-contact-channel.js";
45
46
  import {
46
47
  invalidateConfigCache,
47
48
  loadRawConfig,
@@ -80,6 +81,15 @@ const originalFetch = globalThis.fetch;
80
81
  // Helpers
81
82
  // ---------------------------------------------------------------------------
82
83
 
84
+ /** Read a channel's local ACL columns directly to assert the gateway dual-write. */
85
+ function aclColumns(
86
+ channelId: string,
87
+ ): { status: string; policy: string } | null {
88
+ return getSqlite()
89
+ .query("SELECT status, policy FROM contact_channels WHERE id = ?")
90
+ .get(channelId) as { status: string; policy: string } | null;
91
+ }
92
+
83
93
  function resetTables(): void {
84
94
  const sqlite = getSqlite();
85
95
  sqlite.run("DELETE FROM a2a_tasks");
@@ -164,17 +174,16 @@ describe("e2e: trusted contact setup", () => {
164
174
  {
165
175
  type: "a2a",
166
176
  address: "assistant-b",
167
- status: "active",
168
- policy: "allow",
169
177
  },
170
178
  ],
171
179
  });
172
180
 
181
+ // upsertContact persists the a2a channel identity; the gateway owns the ACL
182
+ // status verdict.
173
183
  const contact = findContactByAddress("a2a", "assistant-b");
174
184
  expect(contact).not.toBeNull();
175
185
  expect(contact!.channels.some((ch) => ch.type === "a2a")).toBe(true);
176
186
  const a2aChannel = contact!.channels.find((ch) => ch.type === "a2a");
177
- expect(a2aChannel!.status).toBe("active");
178
187
  expect(a2aChannel!.address).toBe("assistant-b");
179
188
  });
180
189
  });
@@ -355,21 +364,13 @@ describe("e2e: unknown sender blocked (ACL enforcement)", () => {
355
364
  });
356
365
 
357
366
  test("trusted contact exists with active a2a channel — ACL passes", async () => {
358
- const { upsertContact } = await import("../../contacts/contact-store.js");
359
-
360
367
  // Pre-create a trusted contact for the sender
361
- upsertContact({
368
+ seedContactChannel({
369
+ sourceChannel: "a2a",
370
+ externalUserId: "trusted-assistant",
362
371
  displayName: "Trusted Bot",
363
- contactType: "assistant",
364
- role: "contact",
365
- channels: [
366
- {
367
- type: "a2a",
368
- address: "trusted-assistant",
369
- status: "active",
370
- policy: "allow",
371
- },
372
- ],
372
+ status: "active",
373
+ policy: "allow",
373
374
  });
374
375
 
375
376
  // Verify the contact exists (the ACL check the runtime performs)
@@ -378,8 +379,9 @@ describe("e2e: unknown sender blocked (ACL enforcement)", () => {
378
379
 
379
380
  const a2aChannel = contact!.channels.find((ch) => ch.type === "a2a");
380
381
  expect(a2aChannel).toBeTruthy();
381
- expect(a2aChannel!.status).toBe("active");
382
- expect(a2aChannel!.policy).toBe("allow");
382
+ const acl = aclColumns(a2aChannel!.id);
383
+ expect(acl!.status).toBe("active");
384
+ expect(acl!.policy).toBe("allow");
383
385
 
384
386
  // A task from this sender would pass the ACL check
385
387
  const msg = makeRequestMessage();
@@ -391,28 +393,21 @@ describe("e2e: unknown sender blocked (ACL enforcement)", () => {
391
393
  });
392
394
 
393
395
  test("contact exists but channel is blocked — ACL would reject", async () => {
394
- const { upsertContact } = await import("../../contacts/contact-store.js");
395
-
396
- upsertContact({
396
+ seedContactChannel({
397
+ sourceChannel: "a2a",
398
+ externalUserId: "blocked-assistant",
397
399
  displayName: "Blocked Bot",
398
- contactType: "assistant",
399
- role: "contact",
400
- channels: [
401
- {
402
- type: "a2a",
403
- address: "blocked-assistant",
404
- status: "blocked",
405
- policy: "deny",
406
- },
407
- ],
400
+ status: "blocked",
401
+ policy: "deny",
408
402
  });
409
403
 
410
404
  const contact = findContactByAddress("a2a", "blocked-assistant");
411
405
  expect(contact).not.toBeNull();
412
406
 
413
407
  const a2aChannel = contact!.channels.find((ch) => ch.type === "a2a");
414
- expect(a2aChannel!.status).toBe("blocked");
415
- expect(a2aChannel!.policy).toBe("deny");
408
+ const acl = aclColumns(a2aChannel!.id);
409
+ expect(acl!.status).toBe("blocked");
410
+ expect(acl!.policy).toBe("deny");
416
411
  });
417
412
  });
418
413
 
@@ -507,26 +502,19 @@ describe("e2e: full A2A round-trip", () => {
507
502
  setConfigEnabled(true);
508
503
 
509
504
  // Step 1: Create trusted contact for Assistant B (platform-mediated)
510
- upsertContact({
505
+ seedContactChannel({
506
+ sourceChannel: "a2a",
507
+ externalUserId: "assistant-b",
511
508
  displayName: "Assistant B",
512
- contactType: "assistant",
513
- role: "contact",
514
- channels: [
515
- {
516
- type: "a2a",
517
- address: "assistant-b",
518
- status: "active",
519
- policy: "allow",
520
- },
521
- ],
509
+ status: "active",
510
+ policy: "allow",
522
511
  });
523
512
 
524
513
  // Step 2: Verify trusted contact was created
525
514
  const contact = findContactByAddress("a2a", "assistant-b");
526
515
  expect(contact).not.toBeNull();
527
- expect(contact!.channels.find((ch) => ch.type === "a2a")!.status).toBe(
528
- "active",
529
- );
516
+ const a2aChannel = contact!.channels.find((ch) => ch.type === "a2a")!;
517
+ expect(aclColumns(a2aChannel.id)!.status).toBe("active");
530
518
 
531
519
  // Step 3: Simulate inbound A2A message from B (as if B sent us a request)
532
520
  const inboundMsg = makeRequestMessage({
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Verifies the agent loop's exclusive-tool dispatch: when a tool the loop is
3
- * told is exclusive (e.g. the advisor) appears in a multi-call turn, only that
4
- * tool runs and the siblings are deferred un-run with a benign result — so the
5
- * model incorporates the exclusive tool's output before acting on anything
6
- * else. Drives the REAL loop, mocking only the provider boundary.
3
+ * told is exclusive appears in a multi-call turn, only that tool runs and the
4
+ * siblings are deferred un-run with a benign result — so the model incorporates
5
+ * the exclusive tool's output before acting on anything else. Drives the REAL
6
+ * loop, mocking only the provider boundary.
7
7
  */
8
8
  import { describe, expect, test } from "bun:test";
9
9
 
@@ -55,7 +55,7 @@ describe("AgentLoop — exclusive tool deferral", () => {
55
55
  test("runs the exclusive tool alone and defers sibling calls un-run", async () => {
56
56
  const { provider } = createMockProvider([
57
57
  toolUseTurn([
58
- { id: "call-advisor", name: "advisor" },
58
+ { id: "call-exclusive", name: "exclusive_tool" },
59
59
  { id: "call-edit", name: "write_file" },
60
60
  ]),
61
61
  endTurn("done"),
@@ -67,7 +67,11 @@ describe("AgentLoop — exclusive tool deferral", () => {
67
67
  systemPrompt: "sys",
68
68
  conversationId: "excl-1",
69
69
  tools: [
70
- { name: "advisor", description: "", input_schema: { type: "object" } },
70
+ {
71
+ name: "exclusive_tool",
72
+ description: "",
73
+ input_schema: { type: "object" },
74
+ },
71
75
  {
72
76
  name: "write_file",
73
77
  description: "",
@@ -78,7 +82,7 @@ describe("AgentLoop — exclusive tool deferral", () => {
78
82
  executed.push(name);
79
83
  return { content: `ran ${name}`, isError: false };
80
84
  },
81
- isExclusiveTool: (name) => name === "advisor",
85
+ isExclusiveTool: (name) => name === "exclusive_tool",
82
86
  });
83
87
 
84
88
  const { history } = await loop.run({
@@ -87,19 +91,19 @@ describe("AgentLoop — exclusive tool deferral", () => {
87
91
  });
88
92
 
89
93
  // Only the exclusive tool actually executed.
90
- expect(executed).toEqual(["advisor"]);
94
+ expect(executed).toEqual(["exclusive_tool"]);
91
95
 
92
96
  const results = toolResults(history);
93
- const advisorResult = results.find(
94
- (b) => b.tool_use_id === "call-advisor",
97
+ const exclusiveResult = results.find(
98
+ (b) => b.tool_use_id === "call-exclusive",
95
99
  )!;
96
100
  const editResult = results.find((b) => b.tool_use_id === "call-edit")!;
97
101
 
98
- // The advisor ran; the sibling came back un-run (not an error) so the model
99
- // can re-issue it after reading the guidance.
100
- expect(advisorResult.content).toBe("ran advisor");
102
+ // The exclusive tool ran; the sibling came back un-run (not an error) so the
103
+ // model can re-issue it after reading the guidance.
104
+ expect(exclusiveResult.content).toBe("ran exclusive_tool");
101
105
  expect(editResult.content).toContain("not run");
102
- expect(editResult.content).toContain("advisor");
106
+ expect(editResult.content).toContain("exclusive_tool");
103
107
  expect(editResult.is_error).toBe(false);
104
108
  });
105
109
 
@@ -133,7 +137,7 @@ describe("AgentLoop — exclusive tool deferral", () => {
133
137
  executed.push(name);
134
138
  return { content: `ran ${name}`, isError: false };
135
139
  },
136
- isExclusiveTool: (name) => name === "advisor",
140
+ isExclusiveTool: (name) => name === "exclusive_tool",
137
141
  });
138
142
 
139
143
  const { history } = await loop.run({
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Verifies the agent loop's provider-native web-search gate (used by the
3
+ * tool-less advisor consult): when `enableNativeWebSearch` is set, the loop
4
+ * appends a `web_search`-named SERVER tool to the outbound request and forces
5
+ * `tool_choice: auto` — but ONLY when the provider/model the call ACTUALLY
6
+ * routes to reports native-search support. A non-native target gets nothing,
7
+ * and the consult stays tool-less (no client `web_search` tool surfaced).
8
+ *
9
+ * The gate prefers the routing-aware `supportsNativeWebSearchFor(options)` (the
10
+ * routed (provider, model)'s capability) over the construction-time
11
+ * `supportsNativeWebSearch` snapshot. The advisor's `advisorProfile` can route
12
+ * `subagentSpawn` to a provider/model whose native-search support DIFFERS from
13
+ * the default, so the routed capability — not the default's — must drive the
14
+ * decision in both directions. Drives the REAL loop, mocking only the provider
15
+ * boundary.
16
+ */
17
+ import { describe, expect, test } from "bun:test";
18
+
19
+ import { createMockProvider } from "../__tests__/helpers/mock-provider.js";
20
+ import type {
21
+ Provider,
22
+ ProviderResponse,
23
+ SendMessageOptions,
24
+ } from "../providers/types.js";
25
+ import { AgentLoop } from "./loop.js";
26
+
27
+ const endTurn = (text: string): ProviderResponse => ({
28
+ content: [{ type: "text", text }],
29
+ model: "mock-model",
30
+ usage: { inputTokens: 1, outputTokens: 1 },
31
+ stopReason: "end_turn",
32
+ });
33
+
34
+ const baseRun = {
35
+ requestId: "req-web",
36
+ onEvent: () => {},
37
+ callSite: "subagentSpawn" as const,
38
+ trust: { sourceChannel: "vellum" as const, trustClass: "unknown" as const },
39
+ };
40
+
41
+ const userMessages = [
42
+ {
43
+ role: "user" as const,
44
+ content: [{ type: "text" as const, text: "advise" }],
45
+ },
46
+ ];
47
+
48
+ /** Build a tool-less loop (mirrors the advisor consult) with the given flag. */
49
+ function buildAdvisorLoop(
50
+ provider: Provider,
51
+ enableNativeWebSearch: boolean,
52
+ ): AgentLoop {
53
+ return new AgentLoop({
54
+ provider,
55
+ systemPrompt: "advisor system",
56
+ conversationId: "advisor-1",
57
+ // Tool-less for client tools — exactly the advisor role's empty allowlist.
58
+ config: { enableNativeWebSearch },
59
+ });
60
+ }
61
+
62
+ describe("AgentLoop — provider-native web search gate", () => {
63
+ test("attaches the native web_search server tool when the provider supports it", async () => {
64
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
65
+ (
66
+ provider as { supportsNativeWebSearch?: boolean }
67
+ ).supportsNativeWebSearch = true;
68
+
69
+ const loop = buildAdvisorLoop(provider, true);
70
+ await loop.run({ ...baseRun, messages: userMessages });
71
+
72
+ expect(calls).toHaveLength(1);
73
+ const sent = calls[0];
74
+ // The native web_search SERVER tool is the only tool surfaced.
75
+ expect(sent.tools?.map((t) => t.name)).toEqual(["web_search"]);
76
+ // tool_choice is forced to auto so the model may invoke the search.
77
+ expect(sent.options?.config?.tool_choice).toEqual({ type: "auto" });
78
+ });
79
+
80
+ test("attaches nothing on a non-native provider (consult stays tool-less)", async () => {
81
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
82
+ // supportsNativeWebSearch is absent (falsy) — a non-native provider.
83
+
84
+ const loop = buildAdvisorLoop(provider, true);
85
+ await loop.run({ ...baseRun, messages: userMessages });
86
+
87
+ expect(calls).toHaveLength(1);
88
+ const sent = calls[0];
89
+ // No web_search tool surfaced — no client tool the one-shot consult can't run.
90
+ expect(sent.tools).toBeUndefined();
91
+ // No tool_choice forced when nothing is attached.
92
+ expect(sent.options?.config?.tool_choice).toBeUndefined();
93
+ });
94
+
95
+ test("attaches nothing when the flag is off, even on a native provider", async () => {
96
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
97
+ (
98
+ provider as { supportsNativeWebSearch?: boolean }
99
+ ).supportsNativeWebSearch = true;
100
+
101
+ const loop = buildAdvisorLoop(provider, false);
102
+ await loop.run({ ...baseRun, messages: userMessages });
103
+
104
+ expect(calls).toHaveLength(1);
105
+ const sent = calls[0];
106
+ expect(sent.tools).toBeUndefined();
107
+ expect(sent.options?.config?.tool_choice).toBeUndefined();
108
+ });
109
+
110
+ test("does not duplicate web_search when a tool of that name is already present", async () => {
111
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
112
+ (
113
+ provider as { supportsNativeWebSearch?: boolean }
114
+ ).supportsNativeWebSearch = true;
115
+
116
+ // A loop that already exposes a `web_search` client tool (e.g. researcher
117
+ // role). The gate must not append a second `web_search` entry.
118
+ const loop = new AgentLoop({
119
+ provider,
120
+ systemPrompt: "sys",
121
+ conversationId: "advisor-dup",
122
+ config: { enableNativeWebSearch: true },
123
+ tools: [
124
+ {
125
+ name: "web_search",
126
+ description: "",
127
+ input_schema: { type: "object" },
128
+ },
129
+ ],
130
+ });
131
+ await loop.run({ ...baseRun, messages: userMessages });
132
+
133
+ const sent = calls[0];
134
+ expect(sent.tools?.filter((t) => t.name === "web_search")).toHaveLength(1);
135
+ });
136
+
137
+ // ── Routed capability drives the decision (not the static default) ────────
138
+
139
+ test("false positive: static flag is native but the ROUTED target is not — attaches nothing", async () => {
140
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
141
+ // The construction-time default supports native search…
142
+ (
143
+ provider as { supportsNativeWebSearch?: boolean }
144
+ ).supportsNativeWebSearch = true;
145
+ // …but the advisorProfile routes `subagentSpawn` to a provider/model that
146
+ // does NOT. The routing-aware probe wins, so no unexecutable client tool is
147
+ // surfaced to the otherwise tool-less advisor.
148
+ (
149
+ provider as {
150
+ supportsNativeWebSearchFor?: (o?: SendMessageOptions) => boolean;
151
+ }
152
+ ).supportsNativeWebSearchFor = () => false;
153
+
154
+ const loop = buildAdvisorLoop(provider, true);
155
+ await loop.run({ ...baseRun, messages: userMessages });
156
+
157
+ const sent = calls[0];
158
+ expect(sent.tools).toBeUndefined();
159
+ expect(sent.options?.config?.tool_choice).toBeUndefined();
160
+ });
161
+
162
+ test("false negative: static flag is non-native but the ROUTED target is — attaches the tool", async () => {
163
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
164
+ // The construction-time default lacks native search (flag absent/falsy)…
165
+ // …but the advisorProfile routes to a provider/model that has it.
166
+ (
167
+ provider as {
168
+ supportsNativeWebSearchFor?: (o?: SendMessageOptions) => boolean;
169
+ }
170
+ ).supportsNativeWebSearchFor = () => true;
171
+
172
+ const loop = buildAdvisorLoop(provider, true);
173
+ await loop.run({ ...baseRun, messages: userMessages });
174
+
175
+ const sent = calls[0];
176
+ expect(sent.tools?.map((t) => t.name)).toEqual(["web_search"]);
177
+ expect(sent.options?.config?.tool_choice).toEqual({ type: "auto" });
178
+ });
179
+
180
+ test("the routing probe receives the loop's callSite", async () => {
181
+ const { provider, calls } = createMockProvider([endTurn("guidance")]);
182
+ const probeOptions: (SendMessageOptions | undefined)[] = [];
183
+ (
184
+ provider as {
185
+ supportsNativeWebSearchFor?: (o?: SendMessageOptions) => boolean;
186
+ }
187
+ ).supportsNativeWebSearchFor = (o) => {
188
+ probeOptions.push(o);
189
+ return true;
190
+ };
191
+
192
+ const loop = buildAdvisorLoop(provider, true);
193
+ await loop.run({ ...baseRun, messages: userMessages });
194
+
195
+ expect(calls).toHaveLength(1);
196
+ // The probe is resolved against the same callSite the dispatch uses
197
+ // (`subagentSpawn` per `baseRun`), so the routed (provider, model) matches.
198
+ expect(probeOptions[0]?.config?.callSite).toBe("subagentSpawn");
199
+ });
200
+ });
package/src/agent/loop.ts CHANGED
@@ -95,6 +95,74 @@ export interface AgentLoopConfig {
95
95
  minTurnIntervalMs?: number;
96
96
  /** Override the default prompt cache TTL sent to the provider (e.g. "5m" for short-lived subagents). */
97
97
  cacheTtl?: "5m" | "1h";
98
+ /**
99
+ * Give every LLM call provider-native (server-side) web search, gated on the
100
+ * native-search capability of the (provider, model) the call routes to —
101
+ * {@link Provider.supportsNativeWebSearchFor} when the provider exposes it,
102
+ * else the static {@link Provider.supportsNativeWebSearch} flag.
103
+ * When both are true, the loop appends a `web_search`-named tool to the
104
+ * outbound request — which Anthropic/OpenAI substitute for their server-side
105
+ * search tool, running the search inline and returning results without a
106
+ * client tool round-trip — and forces `tool_choice: auto` so the model may
107
+ * call it. Non-native providers get nothing.
108
+ *
109
+ * This is a SERVER tool the provider runs itself, distinct from the client
110
+ * tool list (`tools` / `resolveTools`): it is never executed by
111
+ * {@link AgentLoopConstructorOptions.toolExecutor} and does not require any
112
+ * client-tool allowlist entry. Used by the tool-less advisor consult to
113
+ * ground its guidance with live web access while staying one-shot for client
114
+ * tools. Defaults to false — existing behavior.
115
+ */
116
+ enableNativeWebSearch?: boolean;
117
+ }
118
+
119
+ /**
120
+ * The `web_search`-named tool the loop appends when
121
+ * {@link AgentLoopConfig.enableNativeWebSearch} is set on a native provider.
122
+ * Anthropic/OpenAI intercept a tool with this name and substitute their own
123
+ * server-side web search (run inline, no client execution), so the exact
124
+ * `input_schema` is informational — the provider supplies the real schema.
125
+ */
126
+ const NATIVE_WEB_SEARCH_TOOL: ToolDefinition = {
127
+ name: "web_search",
128
+ description:
129
+ "Search the web for current information to ground your response.",
130
+ input_schema: {
131
+ type: "object",
132
+ properties: {
133
+ query: { type: "string", description: "The search query." },
134
+ },
135
+ required: ["query"],
136
+ },
137
+ };
138
+
139
+ /**
140
+ * Build the minimal `SendMessageOptions` a routing-aware provider needs to
141
+ * report the native web-search capability of the (provider, model) THIS turn
142
+ * routes to. Mirrors the call-site fields the loop plumbs onto the actual send
143
+ * (`callSite` + `overrideProfile`/`forceOverrideProfile` + per-conversation
144
+ * `selectionSeed`) so the capability probe and the dispatch resolve the same
145
+ * arm. Returns `undefined` when there is no `callSite` (the legacy
146
+ * default-provider path); `selectionSeed` is omitted for standalone loops with
147
+ * no conversation id, matching the dispatch path's own guard.
148
+ */
149
+ function buildNativeWebSearchProbeOptions(
150
+ callSite: LLMCallSite | undefined,
151
+ overrideProfile: string | undefined,
152
+ forceOverrideProfile: boolean,
153
+ conversationId: string | undefined,
154
+ ): SendMessageOptions | undefined {
155
+ if (!callSite) return undefined;
156
+ return {
157
+ config: {
158
+ callSite,
159
+ ...(overrideProfile ? { overrideProfile } : {}),
160
+ ...(overrideProfile && forceOverrideProfile
161
+ ? { forceOverrideProfile: true }
162
+ : {}),
163
+ ...(conversationId ? { selectionSeed: conversationId } : {}),
164
+ },
165
+ };
98
166
  }
99
167
 
100
168
  export interface CheckpointInfo {
@@ -1240,10 +1308,44 @@ export class AgentLoop {
1240
1308
 
1241
1309
  // Resolve tools for this turn: use the dynamic resolver if provided,
1242
1310
  // otherwise fall back to the static tool list.
1243
- const currentTools = this.resolveTools
1311
+ const resolvedTools = this.resolveTools
1244
1312
  ? this.resolveTools(history)
1245
1313
  : this.tools;
1246
1314
 
1315
+ // Provider-native web search: append a `web_search`-named tool that the
1316
+ // provider substitutes for its server-side search (run inline, no client
1317
+ // execution), gated STRICTLY on the capability of the provider/model
1318
+ // this call ACTUALLY routes to so a non-native provider never sees an
1319
+ // unexecutable client tool. The advisor consult's `advisorProfile` can
1320
+ // route `subagentSpawn` to a provider/model whose native-search support
1321
+ // differs from the construction-time default, so the gate resolves the
1322
+ // routed target (callSite + overrideProfile) via
1323
+ // `supportsNativeWebSearchFor` rather than the static
1324
+ // `this.provider.supportsNativeWebSearch` snapshot; providers without
1325
+ // the routing-aware probe fall back to the static flag. This is a SERVER
1326
+ // tool — it bypasses the client allowlist and the tool executor — so the
1327
+ // tool-less advisor consult can ground its guidance with live web access
1328
+ // while staying one-shot for client tools. Skip when a `web_search` tool
1329
+ // is already present so we never duplicate the name.
1330
+ const supportsRoutedNativeWebSearch = this.provider
1331
+ .supportsNativeWebSearchFor
1332
+ ? this.provider.supportsNativeWebSearchFor(
1333
+ buildNativeWebSearchProbeOptions(
1334
+ callSite,
1335
+ resolveEffectiveOverrideProfile(),
1336
+ forceOverrideProfile,
1337
+ this.conversationId,
1338
+ ),
1339
+ )
1340
+ : this.provider.supportsNativeWebSearch === true;
1341
+ const attachNativeWebSearch =
1342
+ this.config.enableNativeWebSearch === true &&
1343
+ supportsRoutedNativeWebSearch &&
1344
+ !resolvedTools.some((t) => t.name === NATIVE_WEB_SEARCH_TOOL.name);
1345
+ const currentTools = attachNativeWebSearch
1346
+ ? [...resolvedTools, NATIVE_WEB_SEARCH_TOOL]
1347
+ : resolvedTools;
1348
+
1247
1349
  // Field precedence (highest wins):
1248
1350
  // 1. Per-run explicit (`runModel`)
1249
1351
  // 2. Call-site resolved values (filled by
@@ -1286,6 +1388,11 @@ export class AgentLoop {
1286
1388
 
1287
1389
  if (this.config.toolChoice) {
1288
1390
  providerConfig.tool_choice = this.config.toolChoice;
1391
+ } else if (attachNativeWebSearch) {
1392
+ // The native web-search tool is the only tool on this turn (the
1393
+ // advisor consult is otherwise tool-less). Let the model decide
1394
+ // whether to search rather than forcing it.
1395
+ providerConfig.tool_choice = { type: "auto" };
1289
1396
  }
1290
1397
 
1291
1398
  if (this.config.cacheTtl) {
@@ -283,6 +283,13 @@ const SlackMessageLinkSchema = z.object({
283
283
  webUrl: z.string().optional(),
284
284
  });
285
285
 
286
+ const SlackReactionSchema = z.object({
287
+ emoji: z.string(),
288
+ op: z.enum(["added", "removed"]),
289
+ actorDisplayName: z.string().optional(),
290
+ targetChannelTs: z.string(),
291
+ });
292
+
286
293
  /** Slack provenance for a history row that originated from a Slack channel. */
287
294
  export const ConversationSlackMessageSchema = z.object({
288
295
  channelId: z.string(),
@@ -297,6 +304,8 @@ export const ConversationSlackMessageSchema = z.object({
297
304
  .optional(),
298
305
  messageLink: SlackMessageLinkSchema.optional(),
299
306
  threadLink: SlackMessageLinkSchema.optional(),
307
+ eventKind: z.enum(["message", "reaction"]).optional(),
308
+ reaction: SlackReactionSchema.optional(),
300
309
  });
301
310
  export type ConversationSlackMessage = z.infer<
302
311
  typeof ConversationSlackMessageSchema
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { answerCall } from "../calls/call-domain.js";
15
15
  import { findContactChannel } from "../contacts/contact-store.js";
16
- import { upsertContactChannel } from "../contacts/contacts-write.js";
16
+ import { activateMemberChannel } from "../contacts/member-write-relay.js";
17
17
  import { findConversation } from "../daemon/conversation-registry.js";
18
18
  import {
19
19
  type CanonicalGuardianRequest,
@@ -592,19 +592,31 @@ const accessRequestResolver: GuardianRequestResolver = {
592
592
  // a verification session. The caller is already on the line and the
593
593
  // relay server's in-call wait loop will detect the approved status.
594
594
  if (channel === "phone") {
595
+ let activation: Awaited<ReturnType<typeof activateMemberChannel>>;
595
596
  try {
596
- upsertContactChannel({
597
+ // Gateway-first activation: the gateway owns the ACL verdict, the local
598
+ // mirror persists the caller's contact/channel identity.
599
+ activation = await activateMemberChannel({
597
600
  sourceChannel: "phone",
598
601
  externalUserId: requesterExternalUserId,
599
602
  externalChatId: requesterChatId,
600
- status: "active",
601
- policy: "allow",
602
603
  });
603
604
  } catch (err) {
604
605
  log.error(
605
606
  { err, requesterExternalUserId },
606
607
  "Access request resolver: failed to activate voice caller as trusted contact",
607
608
  );
609
+ return { ok: false, reason: "voice_activation_failed" };
610
+ }
611
+
612
+ // Fail-closed: a refused activation did not land on the gateway source of
613
+ // truth, so the caller is not actually trusted — do not report success.
614
+ if (activation.status === "refused") {
615
+ log.error(
616
+ { requesterExternalUserId },
617
+ "Access request resolver: gateway refused voice caller activation",
618
+ );
619
+ return { ok: false, reason: "voice_activation_refused" };
608
620
  }
609
621
 
610
622
  log.info(