@vellumai/assistant 0.4.3 → 0.4.4

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -7,6 +7,7 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
7
7
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
8
8
  import { getLogger } from '../util/logger.js';
9
9
  import { deliverReplyViaCallback } from './channel-reply-delivery.js';
10
+ import { resolveRoutingStateFromRuntime } from './guardian-context-resolver.js';
10
11
  import type { MessageProcessor } from './http-types.js';
11
12
 
12
13
  const log = getLogger('runtime-http');
@@ -129,7 +130,9 @@ export async function sweepFailedEvents(
129
130
  },
130
131
  assistantId,
131
132
  guardianContext,
132
- isInteractive: guardianContext?.trustClass === 'guardian',
133
+ isInteractive: guardianContext
134
+ ? resolveRoutingStateFromRuntime(guardianContext).promptWaitingAllowed
135
+ : false,
133
136
  },
134
137
  sourceChannel,
135
138
  sourceInterface,
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Bridge trusted-contact confirmation_request events to guardian.question notifications.
3
+ *
4
+ * When a trusted-contact channel session creates a confirmation_request (tool approval),
5
+ * this helper emits a guardian.question notification signal and persists canonical
6
+ * delivery rows to guardian destinations (Telegram/SMS/Vellum), enabling the guardian
7
+ * to approve via callback/request-code path.
8
+ *
9
+ * Modeled after the tool-grant-request-helper pattern. Designed to be called from
10
+ * both the daemon event registrar (server.ts) and the HTTP hub publisher
11
+ * (conversation-routes.ts) — the two paths that create confirmation_request
12
+ * canonical records.
13
+ */
14
+
15
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
16
+ import {
17
+ type CanonicalGuardianRequest,
18
+ createCanonicalGuardianDelivery,
19
+ } from '../memory/canonical-guardian-store.js';
20
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
21
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
22
+ import { getLogger } from '../util/logger.js';
23
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
24
+ import { getGuardianBinding } from './channel-guardian-service.js';
25
+
26
+ const log = getLogger('confirmation-request-guardian-bridge');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface BridgeConfirmationRequestParams {
33
+ /** The canonical guardian request already persisted for this confirmation_request. */
34
+ canonicalRequest: CanonicalGuardianRequest;
35
+ /** Guardian runtime context from the session. */
36
+ guardianContext: GuardianRuntimeContext;
37
+ /** Conversation ID where the confirmation_request was emitted. */
38
+ conversationId: string;
39
+ /** Tool name from the confirmation_request. */
40
+ toolName: string;
41
+ /** Logical assistant ID (defaults to 'self'). */
42
+ assistantId?: string;
43
+ }
44
+
45
+ export type BridgeConfirmationRequestResult =
46
+ | { bridged: true; signalId: string }
47
+ | { skipped: true; reason: 'not_trusted_contact' | 'no_guardian_binding' | 'missing_guardian_identity' | 'binding_identity_mismatch' };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Helper
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Bridge a trusted-contact confirmation_request to a guardian.question notification.
55
+ *
56
+ * Only emits when the session belongs to a trusted-contact actor with a
57
+ * resolvable guardian binding. Guardian and unknown actors are skipped — guardians
58
+ * self-approve, and unknown actors are already fail-closed by the routing layer.
59
+ *
60
+ * Fire-and-forget safe: notification emission errors are logged but not propagated.
61
+ */
62
+ export function bridgeConfirmationRequestToGuardian(
63
+ params: BridgeConfirmationRequestParams,
64
+ ): BridgeConfirmationRequestResult {
65
+ const {
66
+ canonicalRequest,
67
+ guardianContext,
68
+ conversationId,
69
+ toolName,
70
+ assistantId = DAEMON_INTERNAL_ASSISTANT_ID,
71
+ } = params;
72
+
73
+ // Only bridge for trusted-contact sessions. Guardians self-approve and
74
+ // unknown actors are fail-closed by the routing layer.
75
+ if (guardianContext.trustClass !== 'trusted_contact') {
76
+ return { skipped: true, reason: 'not_trusted_contact' };
77
+ }
78
+
79
+ if (!guardianContext.guardianExternalUserId) {
80
+ log.debug(
81
+ { conversationId, sourceChannel: guardianContext.sourceChannel },
82
+ 'Skipping guardian bridge: no guardian identity on trusted-contact context',
83
+ );
84
+ return { skipped: true, reason: 'missing_guardian_identity' };
85
+ }
86
+
87
+ const sourceChannel = guardianContext.sourceChannel;
88
+ const binding = getGuardianBinding(assistantId, sourceChannel);
89
+ if (!binding) {
90
+ log.debug(
91
+ { sourceChannel, assistantId },
92
+ 'No guardian binding for confirmation request bridge',
93
+ );
94
+ return { skipped: true, reason: 'no_guardian_binding' };
95
+ }
96
+
97
+ // Validate that the binding's guardian identity matches the canonical request's
98
+ // guardian identity. A mismatch can occur if a guardian rebind happens between
99
+ // message ingress and confirmation emission — sending the notification to the
100
+ // new binding would leak requester/tool metadata to the wrong recipient.
101
+ //
102
+ // Both sides are canonicalized before comparison because the canonical request
103
+ // value was normalized by resolveGuardianContext() while the binding stores the
104
+ // raw identity. On phone channels the same guardian can have format variance
105
+ // (e.g. "+1 555-123-4567" vs "+15551234567") that would cause a false mismatch.
106
+ const canonicalBindingId = canonicalizeInboundIdentity(sourceChannel, binding.guardianExternalUserId);
107
+ const canonicalRequestId = canonicalRequest.guardianExternalUserId
108
+ ? canonicalizeInboundIdentity(sourceChannel, canonicalRequest.guardianExternalUserId)
109
+ : null;
110
+ if (
111
+ canonicalRequestId &&
112
+ canonicalBindingId !== canonicalRequestId
113
+ ) {
114
+ log.warn(
115
+ {
116
+ sourceChannel,
117
+ assistantId,
118
+ bindingGuardianId: binding.guardianExternalUserId,
119
+ expectedGuardianId: canonicalRequest.guardianExternalUserId,
120
+ requestId: canonicalRequest.id,
121
+ },
122
+ 'Guardian binding identity does not match canonical request guardian — skipping notification to prevent misrouting',
123
+ );
124
+ return { skipped: true, reason: 'binding_identity_mismatch' };
125
+ }
126
+
127
+ const senderLabel = guardianContext.requesterIdentifier
128
+ || guardianContext.requesterExternalUserId
129
+ || 'unknown';
130
+
131
+ const questionText = `Tool approval request: ${toolName}`;
132
+
133
+ // Emit guardian.question notification so the guardian is alerted.
134
+ const signalPromise = emitNotificationSignal({
135
+ sourceEventName: 'guardian.question',
136
+ sourceChannel,
137
+ sourceSessionId: conversationId,
138
+ assistantId,
139
+ attentionHints: {
140
+ requiresAction: true,
141
+ urgency: 'high',
142
+ isAsyncBackground: false,
143
+ visibleInSourceNow: false,
144
+ },
145
+ contextPayload: {
146
+ requestKind: 'tool_approval' as const,
147
+ requestId: canonicalRequest.id,
148
+ requestCode: canonicalRequest.requestCode ?? canonicalRequest.id.slice(0, 6).toUpperCase(),
149
+ sourceChannel,
150
+ requesterExternalUserId: guardianContext.requesterExternalUserId,
151
+ requesterChatId: guardianContext.requesterChatId ?? null,
152
+ requesterIdentifier: senderLabel,
153
+ toolName,
154
+ questionText,
155
+ },
156
+ dedupeKey: `tc-confirmation-request:${canonicalRequest.id}`,
157
+ onThreadCreated: (info) => {
158
+ createCanonicalGuardianDelivery({
159
+ requestId: canonicalRequest.id,
160
+ destinationChannel: 'vellum',
161
+ destinationConversationId: info.conversationId,
162
+ });
163
+ },
164
+ });
165
+
166
+ // Record channel deliveries from the notification pipeline (fire-and-forget).
167
+ void signalPromise.then((signalResult) => {
168
+ for (const result of signalResult.deliveryResults) {
169
+ if (result.channel === 'vellum') continue; // handled in onThreadCreated
170
+ if (result.channel !== 'telegram' && result.channel !== 'sms') continue;
171
+ createCanonicalGuardianDelivery({
172
+ requestId: canonicalRequest.id,
173
+ destinationChannel: result.channel,
174
+ destinationChatId: result.destination.length > 0 ? result.destination : undefined,
175
+ });
176
+ }
177
+ }).catch((err) => {
178
+ log.warn({ err, requestId: canonicalRequest.id }, 'Failed to record channel deliveries for guardian bridge');
179
+ });
180
+
181
+ log.info(
182
+ {
183
+ sourceChannel,
184
+ requesterExternalUserId: guardianContext.requesterExternalUserId,
185
+ toolName,
186
+ requestId: canonicalRequest.id,
187
+ requestCode: canonicalRequest.requestCode,
188
+ },
189
+ 'Guardian notified of trusted-contact confirmation request',
190
+ );
191
+
192
+ // Return the signal ID synchronously from the promise-producing call.
193
+ // The actual signal ID is not available until the promise resolves, but
194
+ // callers only need to know it was bridged — the ID is for diagnostics.
195
+ // We use the canonical request ID as a stable correlation key.
196
+ return { bridged: true, signalId: canonicalRequest.id };
197
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Guardian action follow-up executor.
3
3
  *
4
- * After the conversation engine (M5) classifies the guardian's reply as
4
+ * After the conversation engine classifies the guardian's reply as
5
5
  * `call_back` or `message_back` and transitions the follow-up state to
6
6
  * `dispatching`, this module executes the actual action:
7
7
  *
@@ -62,6 +62,88 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
62
62
  };
63
63
  }
64
64
 
65
+ // ---------------------------------------------------------------------------
66
+ // Routing-state helper
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Routing state for a channel actor turn.
71
+ *
72
+ * Determines whether a turn should be treated as interactive (the caller
73
+ * can be kept waiting for a guardian to respond to an approval prompt) by
74
+ * combining trust class with guardian route resolvability.
75
+ *
76
+ * A guardian route is "resolvable" when a verified guardian binding exists
77
+ * for the channel — meaning there is a concrete destination to deliver
78
+ * approval notifications to. Without a resolvable guardian route, entering
79
+ * an interactive wait (up to 300s) is a dead-end: no guardian will ever
80
+ * see the prompt.
81
+ */
82
+ export interface RoutingState {
83
+ /** Whether the actor's trust class alone permits interactive waits. */
84
+ canBeInteractive: boolean;
85
+ /** Whether a verified guardian destination exists for this channel. */
86
+ guardianRouteResolvable: boolean;
87
+ /**
88
+ * Whether the turn should actually enter an interactive prompt wait.
89
+ * True only when the actor can be interactive AND a guardian route is
90
+ * resolvable. This is the canonical decision used by processMessage.
91
+ */
92
+ promptWaitingAllowed: boolean;
93
+ }
94
+
95
+ /**
96
+ * Compute the routing state for a channel actor turn.
97
+ *
98
+ * Guardian actors are always interactive (they self-approve).
99
+ * Trusted contacts are only interactive when a guardian binding exists
100
+ * to receive approval notifications. Unknown actors are never interactive.
101
+ */
102
+ export function resolveRoutingState(ctx: GuardianContext): RoutingState {
103
+ const isGuardian = ctx.trustClass === 'guardian';
104
+ const isTrustedContact = ctx.trustClass === 'trusted_contact';
105
+
106
+ // Guardians self-approve — they are always interactive and route-resolvable.
107
+ if (isGuardian) {
108
+ return {
109
+ canBeInteractive: true,
110
+ guardianRouteResolvable: true,
111
+ promptWaitingAllowed: true,
112
+ };
113
+ }
114
+
115
+ // Trusted contacts can be interactive only if a guardian destination
116
+ // exists. The guardian binding populates guardianExternalUserId during
117
+ // trust resolution; its presence means there is a verified guardian
118
+ // to route approval notifications to.
119
+ const guardianRouteResolvable = !!ctx.guardianExternalUserId;
120
+ if (isTrustedContact) {
121
+ return {
122
+ canBeInteractive: true,
123
+ guardianRouteResolvable,
124
+ promptWaitingAllowed: guardianRouteResolvable,
125
+ };
126
+ }
127
+
128
+ // Unknown actors are never interactive.
129
+ return {
130
+ canBeInteractive: false,
131
+ guardianRouteResolvable: !!ctx.guardianExternalUserId,
132
+ promptWaitingAllowed: false,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Convenience: compute routing state from a GuardianRuntimeContext
138
+ * (the shape persisted in stored payloads and used by the retry sweep).
139
+ */
140
+ export function resolveRoutingStateFromRuntime(ctx: GuardianRuntimeContext): RoutingState {
141
+ return resolveRoutingState({
142
+ trustClass: ctx.trustClass,
143
+ guardianExternalUserId: ctx.guardianExternalUserId,
144
+ });
145
+ }
146
+
65
147
  export function toGuardianRuntimeContext(sourceChannel: ChannelId, ctx: GuardianContext): GuardianRuntimeContext {
66
148
  return {
67
149
  sourceChannel,
@@ -94,7 +94,6 @@ function getTelegramBotUsername(): string | undefined {
94
94
 
95
95
  export interface StartOutboundParams {
96
96
  channel: ChannelId;
97
- assistantId?: string;
98
97
  destination?: string;
99
98
  rebind?: boolean;
100
99
  /** Origin conversation ID so completion/failure pointers can route back. */
@@ -103,14 +102,12 @@ export interface StartOutboundParams {
103
102
 
104
103
  export interface ResendOutboundParams {
105
104
  channel: ChannelId;
106
- assistantId?: string;
107
105
  /** Origin conversation ID so completion/failure pointers can route back on resend. */
108
106
  originConversationId?: string;
109
107
  }
110
108
 
111
109
  export interface CancelOutboundParams {
112
110
  channel: ChannelId;
113
- assistantId?: string;
114
111
  }
115
112
 
116
113
  /**
@@ -21,13 +21,20 @@ import {
21
21
  applyCanonicalGuardianDecision,
22
22
  type CanonicalDecisionResult,
23
23
  } from '../approvals/guardian-decision-primitive.js';
24
- import type { ActorContext, ChannelDeliveryContext } from '../approvals/guardian-request-resolvers.js';
24
+ import type { ActorContext, ChannelDeliveryContext, ResolverEmissionContext } from '../approvals/guardian-request-resolvers.js';
25
25
  import {
26
26
  type CanonicalGuardianRequest,
27
27
  getCanonicalGuardianRequest,
28
28
  getCanonicalGuardianRequestByCode,
29
29
  listCanonicalGuardianRequests,
30
30
  } from '../memory/canonical-guardian-store.js';
31
+ import {
32
+ buildGuardianCodeOnlyClarification,
33
+ buildGuardianDisambiguationExample,
34
+ buildGuardianDisambiguationLabel,
35
+ buildGuardianInvalidActionReply,
36
+ resolveGuardianInstructionModeForRequest,
37
+ } from '../notifications/guardian-question-mode.js';
31
38
  import { getLogger } from '../util/logger.js';
32
39
  import { runApprovalConversationTurn } from './approval-conversation-turn.js';
33
40
  import type { ApprovalAction } from './channel-approval-types.js';
@@ -60,6 +67,8 @@ export interface GuardianReplyContext {
60
67
  approvalConversationGenerator?: ApprovalConversationGenerator;
61
68
  /** Optional channel delivery context for resolver-driven side effects. */
62
69
  channelDeliveryContext?: ChannelDeliveryContext;
70
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
71
+ emissionContext?: ResolverEmissionContext;
63
72
  }
64
73
 
65
74
  export type GuardianReplyResultType =
@@ -235,9 +244,11 @@ function notConsumed(): GuardianReplyResult {
235
244
  export async function routeGuardianReply(
236
245
  ctx: GuardianReplyContext,
237
246
  ): Promise<GuardianReplyResult> {
238
- const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
247
+ const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext, emissionContext } = ctx;
239
248
  const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
240
- const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
249
+ const scopedPendingRequestIds = ctx.pendingRequestIds && ctx.pendingRequestIds.length > 0
250
+ ? new Set(ctx.pendingRequestIds)
251
+ : null;
241
252
 
242
253
  // ── 1. Deterministic callback parsing (button presses) ──
243
254
  // No conversationId scoping here — the guardian's reply comes from a
@@ -247,7 +258,7 @@ export async function routeGuardianReply(
247
258
  if (callbackData) {
248
259
  const parsed = parseCallbackAction(callbackData);
249
260
  if (parsed) {
250
- return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext);
261
+ return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext, emissionContext);
251
262
  }
252
263
  }
253
264
 
@@ -280,7 +291,7 @@ export async function routeGuardianReply(
280
291
  consumed: true,
281
292
  type: 'canonical_decision_stale',
282
293
  requestId: request.id,
283
- replyText: failureReplyText('already_resolved', request.requestCode),
294
+ replyText: failureReplyText('already_resolved', request.requestCode, request),
284
295
  };
285
296
  }
286
297
 
@@ -333,7 +344,7 @@ export async function routeGuardianReply(
333
344
  // If the text indicates rejection, use reject; otherwise approve_once.
334
345
  const action = inferActionFromText(codeResult.remainingText);
335
346
 
336
- return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext);
347
+ return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext, emissionContext);
337
348
  }
338
349
  }
339
350
 
@@ -375,6 +386,7 @@ export async function routeGuardianReply(
375
386
  actor,
376
387
  messageText,
377
388
  channelDeliveryContext,
389
+ emissionContext,
378
390
  );
379
391
  }
380
392
 
@@ -475,12 +487,13 @@ export async function routeGuardianReply(
475
487
  };
476
488
  }
477
489
 
478
- const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext);
490
+ const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext, emissionContext);
479
491
 
480
492
  // Attach the engine's reply text for stale/expired/identity-mismatch cases,
481
- // but preserve the explicit failure text when the resolver failed — the engine
482
- // reply is typically an affirmative confirmation that would be misleading.
483
- if (engineResult.replyText && result.type !== 'canonical_resolver_failed') {
493
+ // but preserve resolver-authored replies (for example verification codes)
494
+ // and explicit resolver-failure text.
495
+ const hasResolverReplyText = Boolean(result.canonicalResult?.applied && result.canonicalResult.resolverReplyText);
496
+ if (engineResult.replyText && result.type !== 'canonical_resolver_failed' && !hasResolverReplyText) {
484
497
  result.replyText = engineResult.replyText;
485
498
  }
486
499
 
@@ -504,6 +517,7 @@ async function applyDecision(
504
517
  actor: ActorContext,
505
518
  userText?: string,
506
519
  channelDeliveryContext?: ChannelDeliveryContext,
520
+ emissionContext?: ResolverEmissionContext,
507
521
  ): Promise<GuardianReplyResult> {
508
522
  const canonicalResult = await applyCanonicalGuardianDecision({
509
523
  requestId,
@@ -511,6 +525,7 @@ async function applyDecision(
511
525
  actorContext: actor,
512
526
  userText,
513
527
  channelDeliveryContext,
528
+ emissionContext,
514
529
  });
515
530
 
516
531
  if (canonicalResult.applied) {
@@ -549,6 +564,7 @@ async function applyDecision(
549
564
  decisionApplied: true,
550
565
  consumed: true,
551
566
  type: 'canonical_decision_applied',
567
+ ...(canonicalResult.resolverReplyText ? { replyText: canonicalResult.resolverReplyText } : {}),
552
568
  requestId,
553
569
  canonicalResult,
554
570
  };
@@ -570,13 +586,15 @@ async function applyDecision(
570
586
  return notConsumed();
571
587
  }
572
588
 
589
+ const request = getCanonicalGuardianRequest(requestId);
590
+
573
591
  return {
574
592
  decisionApplied: false,
575
593
  consumed: true,
576
594
  type: 'canonical_decision_stale',
577
595
  requestId,
578
596
  canonicalResult,
579
- replyText: failureReplyText(canonicalResult.reason),
597
+ replyText: failureReplyText(canonicalResult.reason, request?.requestCode, request ?? undefined),
580
598
  };
581
599
  }
582
600
 
@@ -643,6 +661,12 @@ function inferActionFromText(text: string): ApprovalAction {
643
661
  return 'approve_once';
644
662
  }
645
663
 
664
+ function resolveRequestInstructionMode(
665
+ request?: Pick<CanonicalGuardianRequest, 'kind' | 'toolName'> | null,
666
+ ): 'approval' | 'answer' {
667
+ return resolveGuardianInstructionModeForRequest(request);
668
+ }
669
+
646
670
  // ---------------------------------------------------------------------------
647
671
  // Failure reason reply text
648
672
  // ---------------------------------------------------------------------------
@@ -653,7 +677,11 @@ type CanonicalFailureReason = 'already_resolved' | 'identity_mismatch' | 'invali
653
677
  * Map a canonical decision failure reason to a distinct, actionable reply
654
678
  * so the guardian understands exactly what happened and what to do next.
655
679
  */
656
- function failureReplyText(reason: CanonicalFailureReason, requestCode?: string | null): string {
680
+ function failureReplyText(
681
+ reason: CanonicalFailureReason,
682
+ requestCode?: string | null,
683
+ request?: CanonicalGuardianRequest,
684
+ ): string {
657
685
  switch (reason) {
658
686
  case 'already_resolved':
659
687
  return 'This request has already been resolved.';
@@ -662,9 +690,7 @@ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string |
662
690
  case 'identity_mismatch':
663
691
  return "You don't have permission to decide on this request.";
664
692
  case 'invalid_action':
665
- return requestCode
666
- ? `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`
667
- : "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").";
693
+ return buildGuardianInvalidActionReply(resolveRequestInstructionMode(request), requestCode ?? undefined);
668
694
  default:
669
695
  return "I couldn't process that request. Please try again.";
670
696
  }
@@ -681,15 +707,12 @@ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string |
681
707
  */
682
708
  function composeCodeOnlyClarification(request: CanonicalGuardianRequest): string {
683
709
  const code = request.requestCode ?? 'unknown';
684
- const toolLabel = request.toolName ?? 'an action';
685
- const lines: string[] = [
686
- `I found request ${code} for ${toolLabel}.`,
687
- ];
688
- if (request.questionText) {
689
- lines.push(`Details: ${request.questionText}`);
690
- }
691
- lines.push(`Reply "${code} approve" to approve or "${code} reject" to reject.`);
692
- return lines.join('\n');
710
+ const mode = resolveRequestInstructionMode(request);
711
+ return buildGuardianCodeOnlyClarification(mode, {
712
+ requestCode: code,
713
+ questionText: request.questionText,
714
+ toolName: request.toolName,
715
+ });
693
716
  }
694
717
 
695
718
  // ---------------------------------------------------------------------------
@@ -706,6 +729,10 @@ function composeDisambiguationReply(
706
729
  engineReplyText?: string,
707
730
  ): string {
708
731
  const lines: string[] = [];
732
+ const requestsWithMode = pendingRequests.map((request) => ({
733
+ request,
734
+ mode: resolveRequestInstructionMode(request),
735
+ }));
709
736
 
710
737
  if (engineReplyText) {
711
738
  lines.push(engineReplyText);
@@ -714,16 +741,26 @@ function composeDisambiguationReply(
714
741
 
715
742
  lines.push(`You have ${pendingRequests.length} pending requests. Please specify which one:`);
716
743
 
717
- for (const req of pendingRequests) {
718
- const toolLabel = req.toolName ?? 'action';
719
- const code = req.requestCode ?? req.id.slice(0, 6).toUpperCase();
744
+ for (const { request, mode } of requestsWithMode) {
745
+ const toolLabel = buildGuardianDisambiguationLabel(mode, {
746
+ questionText: request.questionText,
747
+ toolName: request.toolName,
748
+ });
749
+ const code = request.requestCode ?? request.id.slice(0, 6).toUpperCase();
720
750
  lines.push(` - ${code}: ${toolLabel}`);
721
751
  }
722
752
 
723
- // Include a concrete example using the first request's code
724
- const exampleCode = pendingRequests[0].requestCode ?? pendingRequests[0].id.slice(0, 6).toUpperCase();
753
+ const questionRequest = requestsWithMode.find(({ mode }) => mode === 'answer');
754
+ const decisionRequest = requestsWithMode.find(({ mode }) => mode === 'approval');
725
755
  lines.push('');
726
- lines.push(`Reply "${exampleCode} approve" to approve a specific request.`);
756
+ if (questionRequest) {
757
+ const exampleCode = questionRequest.request.requestCode ?? questionRequest.request.id.slice(0, 6).toUpperCase();
758
+ lines.push(buildGuardianDisambiguationExample(questionRequest.mode, exampleCode));
759
+ }
760
+ if (decisionRequest) {
761
+ const exampleCode = decisionRequest.request.requestCode ?? decisionRequest.request.id.slice(0, 6).toUpperCase();
762
+ lines.push(buildGuardianDisambiguationExample(decisionRequest.mode, exampleCode));
763
+ }
727
764
 
728
765
  return lines.join('\n');
729
766
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Startup migration: backfill channel='vellum' guardian binding.
3
+ *
4
+ * On runtime start, ensures that a guardian binding exists for the
5
+ * 'vellum' channel with a guardianPrincipalId. This is required for
6
+ * the identity-bound hatch bootstrap flow.
7
+ *
8
+ * - If a vellum binding already exists, no-op.
9
+ * - If no vellum binding exists, creates one with a fresh principal.
10
+ * - Preserves existing guardian bindings for other channels unchanged.
11
+ */
12
+
13
+ import { v4 as uuid } from 'uuid';
14
+
15
+ import {
16
+ createBinding,
17
+ getActiveBinding,
18
+ } from '../memory/guardian-bindings.js';
19
+ import { getLogger } from '../util/logger.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
+
22
+ const log = getLogger('guardian-vellum-migration');
23
+
24
+ /**
25
+ * Ensure a vellum guardian binding exists for the given assistant.
26
+ * Called during daemon startup to backfill existing installations.
27
+ *
28
+ * Returns the guardianPrincipalId (existing or newly created).
29
+ */
30
+ export function ensureVellumGuardianBinding(assistantId: string = DAEMON_INTERNAL_ASSISTANT_ID): string {
31
+ const existing = getActiveBinding(assistantId, 'vellum');
32
+ if (existing) {
33
+ log.debug(
34
+ { assistantId, guardianPrincipalId: existing.guardianExternalUserId },
35
+ 'Vellum guardian binding already exists',
36
+ );
37
+ return existing.guardianExternalUserId;
38
+ }
39
+
40
+ const guardianPrincipalId = `vellum-principal-${uuid()}`;
41
+
42
+ createBinding({
43
+ assistantId,
44
+ channel: 'vellum',
45
+ guardianExternalUserId: guardianPrincipalId,
46
+ guardianDeliveryChatId: 'local',
47
+ verifiedVia: 'startup-migration',
48
+ metadataJson: JSON.stringify({ migratedAt: Date.now() }),
49
+ });
50
+
51
+ log.info(
52
+ { assistantId, guardianPrincipalId },
53
+ 'Backfilled vellum guardian binding on startup',
54
+ );
55
+
56
+ return guardianPrincipalId;
57
+ }