@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -93,6 +93,10 @@ import {
93
93
  handleInstructionCall,
94
94
  handleStartCall,
95
95
  } from './routes/call-routes.js';
96
+ import {
97
+ startCanonicalGuardianExpirySweep,
98
+ stopCanonicalGuardianExpirySweep,
99
+ } from './routes/canonical-guardian-expiry-sweep.js';
96
100
  import { canonicalChannelAssistantId } from './routes/channel-route-shared.js';
97
101
  import {
98
102
  handleChannelDeliveryAck,
@@ -341,6 +345,9 @@ export class RuntimeHttpServer {
341
345
  startGuardianActionSweep(getGatewayInternalBaseUrl(), this.bearerToken, this.guardianActionCopyGenerator);
342
346
  log.info('Guardian action expiry sweep started');
343
347
 
348
+ startCanonicalGuardianExpirySweep();
349
+ log.info('Canonical guardian request expiry sweep started');
350
+
344
351
  log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
345
352
  if (!isLoopbackHost(this.hostname)) {
346
353
  log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
@@ -361,6 +368,7 @@ export class RuntimeHttpServer {
361
368
  this.pairingStore.stop();
362
369
  stopGuardianExpirySweep();
363
370
  stopGuardianActionSweep();
371
+ stopCanonicalGuardianExpirySweep();
364
372
  if (this.retrySweepTimer) {
365
373
  clearInterval(this.retrySweepTimer);
366
374
  this.retrySweepTimer = null;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Canonical guardian request expiry sweep.
3
+ *
4
+ * Periodically scans the `canonical_guardian_requests` table for pending
5
+ * requests whose `expiresAt` timestamp has passed and transitions them to
6
+ * the `expired` status. This ensures that stale requests are cleaned up
7
+ * even when no follow-up traffic arrives from either the guardian or the
8
+ * requester.
9
+ *
10
+ * Complements the existing sweeps:
11
+ * - `calls/guardian-action-sweep.ts` — voice call guardian action expiry
12
+ * - `runtime/routes/guardian-expiry-sweep.ts` — channel guardian approval expiry
13
+ *
14
+ * Unlike those sweeps, this one operates on the unified canonical domain
15
+ * (`canonical_guardian_requests`) and does not need to auto-deny pending
16
+ * interactions or deliver channel notices — the canonical request status
17
+ * transition is the single source of truth, and consumers (resolvers,
18
+ * clients polling prompts) observe the expired status directly.
19
+ */
20
+
21
+ import {
22
+ listCanonicalGuardianRequests,
23
+ resolveCanonicalGuardianRequest,
24
+ } from '../../memory/canonical-guardian-store.js';
25
+ import { getLogger } from '../../util/logger.js';
26
+
27
+ const log = getLogger('canonical-guardian-expiry-sweep');
28
+
29
+ /** Interval at which the expiry sweep runs (60 seconds). */
30
+ const SWEEP_INTERVAL_MS = 60_000;
31
+
32
+ /** Timer handle for the sweep so it can be stopped in tests and shutdown. */
33
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
34
+
35
+ /** Guard against overlapping sweeps. */
36
+ let sweepInProgress = false;
37
+
38
+ /**
39
+ * Sweep all pending canonical guardian requests that have expired.
40
+ *
41
+ * Uses CAS resolution (`resolveCanonicalGuardianRequest`) so that a
42
+ * concurrent decision that wins the race is never overwritten by the
43
+ * sweep. Returns the count of requests transitioned to expired.
44
+ */
45
+ export function sweepExpiredCanonicalGuardianRequests(): number {
46
+ const pending = listCanonicalGuardianRequests({ status: 'pending' });
47
+ const now = Date.now();
48
+ let expiredCount = 0;
49
+
50
+ for (const request of pending) {
51
+ if (!request.expiresAt) continue;
52
+
53
+ const expiresAtMs = new Date(request.expiresAt).getTime();
54
+ if (expiresAtMs >= now) continue;
55
+
56
+ // CAS resolve: only transition from 'pending' to 'expired'.
57
+ // If someone resolved it between our read and this write, the CAS
58
+ // fails harmlessly (returns null) and we skip the request.
59
+ const resolved = resolveCanonicalGuardianRequest(request.id, 'pending', {
60
+ status: 'expired',
61
+ });
62
+
63
+ if (resolved) {
64
+ expiredCount++;
65
+ log.info(
66
+ {
67
+ event: 'canonical_request_expired',
68
+ requestId: request.id,
69
+ kind: request.kind,
70
+ expiresAt: request.expiresAt,
71
+ },
72
+ 'Expired canonical guardian request via sweep',
73
+ );
74
+ }
75
+ }
76
+
77
+ if (expiredCount > 0) {
78
+ log.info(
79
+ { event: 'canonical_expiry_sweep_complete', expiredCount },
80
+ `Canonical guardian expiry sweep: expired ${expiredCount} request(s)`,
81
+ );
82
+ }
83
+
84
+ return expiredCount;
85
+ }
86
+
87
+ /**
88
+ * Start the periodic canonical guardian expiry sweep. Idempotent — calling
89
+ * it multiple times reuses the same timer.
90
+ */
91
+ export function startCanonicalGuardianExpirySweep(): void {
92
+ if (sweepTimer) return;
93
+ sweepTimer = setInterval(() => {
94
+ if (sweepInProgress) return;
95
+ sweepInProgress = true;
96
+ try {
97
+ sweepExpiredCanonicalGuardianRequests();
98
+ } catch (err) {
99
+ log.error({ err }, 'Canonical guardian expiry sweep failed');
100
+ } finally {
101
+ sweepInProgress = false;
102
+ }
103
+ }, SWEEP_INTERVAL_MS);
104
+ }
105
+
106
+ /**
107
+ * Stop the periodic canonical guardian expiry sweep. Used in tests and
108
+ * shutdown.
109
+ */
110
+ export function stopCanonicalGuardianExpirySweep(): void {
111
+ if (sweepTimer) {
112
+ clearInterval(sweepTimer);
113
+ sweepTimer = null;
114
+ }
115
+ sweepInProgress = false;
116
+ }
@@ -8,6 +8,10 @@ import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '..
8
8
  import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
9
9
  import type { ServerMessage } from '../../daemon/ipc-protocol.js';
10
10
  import * as attachmentsStore from '../../memory/attachments-store.js';
11
+ import {
12
+ createCanonicalGuardianRequest,
13
+ generateCanonicalRequestCode,
14
+ } from '../../memory/canonical-guardian-store.js';
11
15
  import {
12
16
  getConversationByKey,
13
17
  getOrCreateConversation,
@@ -171,6 +175,20 @@ function makeHubPublisher(
171
175
  persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
172
176
  },
173
177
  });
178
+
179
+ // Create a canonical guardian request so IPC/HTTP handlers can find it
180
+ // via applyCanonicalGuardianDecision.
181
+ createCanonicalGuardianRequest({
182
+ id: msg.requestId,
183
+ kind: 'tool_approval',
184
+ sourceType: 'desktop',
185
+ sourceChannel: 'vellum',
186
+ conversationId,
187
+ toolName: msg.toolName,
188
+ status: 'pending',
189
+ requestCode: generateCanonicalRequestCode(),
190
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
191
+ });
174
192
  } else if (msg.type === 'secret_request') {
175
193
  pendingInteractions.register(msg.requestId, {
176
194
  session,
@@ -4,18 +4,18 @@
4
4
  * These endpoints let desktop clients fetch pending guardian prompts and
5
5
  * submit button decisions without relying on text parsing.
6
6
  */
7
- import { applyGuardianDecision } from '../../approvals/guardian-decision-primitive.js';
8
7
  import {
9
- getPendingApprovalForRequest,
10
- listPendingApprovalRequests,
11
- } from '../../memory/channel-guardian-store.js';
8
+ applyCanonicalGuardianDecision,
9
+ } from '../../approvals/guardian-decision-primitive.js';
10
+ import {
11
+ type CanonicalGuardianRequest,
12
+ getCanonicalGuardianRequest,
13
+ listCanonicalGuardianRequests,
14
+ } from '../../memory/canonical-guardian-store.js';
12
15
  import type { ApprovalAction } from '../channel-approval-types.js';
13
- import { handleChannelDecision } from '../channel-approvals.js';
14
16
  import type { GuardianDecisionPrompt } from '../guardian-decision-types.js';
15
17
  import { buildDecisionActions } from '../guardian-decision-types.js';
16
18
  import { httpError } from '../http-errors.js';
17
- import * as pendingInteractions from '../pending-interactions.js';
18
- import { handleAccessRequestDecision } from './access-request-decision.js';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // GET /v1/guardian-actions/pending?conversationId=...
@@ -47,8 +47,9 @@ export function handleGuardianActionsPending(req: Request): Response {
47
47
  /**
48
48
  * Submit a guardian action decision.
49
49
  *
50
- * Looks up the guardian approval by requestId and applies the decision
51
- * through the unified guardian decision primitive.
50
+ * Routes all decisions through the unified canonical guardian decision
51
+ * primitive which handles CAS resolution, resolver dispatch, and grant
52
+ * minting.
52
53
  */
53
54
  export async function handleGuardianActionDecision(req: Request): Promise<Response> {
54
55
  const body = await req.json() as {
@@ -72,65 +73,53 @@ export async function handleGuardianActionDecision(req: Request): Promise<Respon
72
73
  return httpError('BAD_REQUEST', `Invalid action: ${action}. Must be one of: approve_once, approve_always, reject`, 400);
73
74
  }
74
75
 
75
- // Try the channel guardian approval store first (tool approval prompts)
76
- const approval = getPendingApprovalForRequest(requestId);
77
- if (approval) {
78
- // Enforce conversationId scoping: reject decisions that target the wrong conversation.
79
- if (conversationId && conversationId !== approval.conversationId) {
80
- return httpError('BAD_REQUEST', 'conversationId does not match the approval', 400);
76
+ // Verify conversationId scoping before applying the canonical decision.
77
+ // A caller must not be able to cross-resolve requests from a different conversation.
78
+ if (conversationId) {
79
+ const canonicalRequest = getCanonicalGuardianRequest(requestId);
80
+ if (canonicalRequest && canonicalRequest.conversationId && canonicalRequest.conversationId !== conversationId) {
81
+ return httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404);
81
82
  }
83
+ }
82
84
 
83
- // Access request approvals need a separate decision path — they don't have
84
- // pending interactions and use verification sessions instead.
85
- if (approval.toolName === 'ingress_access_request') {
86
- const mappedAction = action === 'reject' ? 'deny' as const : 'approve' as const;
87
- // Use 'desktop' as the actor identity because this endpoint is
88
- // unauthenticated — we cannot verify the caller is the assigned
89
- // guardian, so we record a generic desktop origin instead of
90
- // falsely attributing the decision to guardianExternalUserId.
91
- const decisionResult = handleAccessRequestDecision(
92
- approval,
93
- mappedAction,
94
- 'desktop',
95
- );
85
+ const canonicalResult = await applyCanonicalGuardianDecision({
86
+ requestId,
87
+ action: action as ApprovalAction,
88
+ actorContext: {
89
+ externalUserId: undefined,
90
+ channel: 'vellum',
91
+ isTrusted: true,
92
+ },
93
+ userText: undefined,
94
+ });
95
+
96
+ if (canonicalResult.applied) {
97
+ // When the CAS committed but the resolver failed, the side effect
98
+ // (e.g. minting a verification session) did not happen. From the
99
+ // caller's perspective the decision was not truly applied.
100
+ if (canonicalResult.resolverFailed) {
96
101
  return Response.json({
97
- applied: decisionResult.type !== 'stale',
98
- requestId,
99
- reason: decisionResult.type === 'stale' ? 'stale' : undefined,
100
- accessRequestResult: decisionResult,
102
+ applied: false,
103
+ reason: 'resolver_failed',
104
+ resolverFailureReason: canonicalResult.resolverFailureReason,
105
+ requestId: canonicalResult.requestId,
101
106
  });
102
107
  }
103
108
 
104
- // Note: actorExternalUserId is left undefined because the desktop endpoint
105
- // does not authenticate caller identity. This means scoped grant minting is
106
- // skipped for button-based decisions — an acceptable trade-off to avoid
107
- // falsifying audit records with an unverified guardian identity.
108
- const result = applyGuardianDecision({
109
- approval,
110
- decision: { action: action as 'approve_once' | 'approve_always' | 'reject', source: 'plain_text', requestId },
111
- actorExternalUserId: undefined,
112
- actorChannel: 'vellum',
109
+ return Response.json({
110
+ applied: true,
111
+ requestId: canonicalResult.requestId,
113
112
  });
114
- return Response.json({ ...result, requestId: result.requestId ?? requestId });
115
113
  }
116
114
 
117
- // Fall back to the pending interactions tracker (direct confirmation requests).
118
- // Route through handleChannelDecision so approve_always properly persists trust rules.
119
- const interaction = pendingInteractions.get(requestId);
120
- if (interaction) {
121
- // Enforce conversationId scoping for interactions too.
122
- if (conversationId && conversationId !== interaction.conversationId) {
123
- return httpError('BAD_REQUEST', 'conversationId does not match the interaction', 400);
124
- }
125
-
126
- const result = handleChannelDecision(
127
- interaction.conversationId,
128
- { action: action as ApprovalAction, source: 'plain_text', requestId },
129
- );
130
- return Response.json({ ...result, requestId: result.requestId ?? requestId });
131
- }
132
-
133
- return httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404);
115
+ // Return the reason for failure (stale, expired, not_found, etc.)
116
+ return canonicalResult.reason === 'not_found'
117
+ ? httpError('NOT_FOUND', 'No pending guardian action found for this requestId', 404)
118
+ : Response.json({
119
+ applied: false,
120
+ reason: canonicalResult.reason,
121
+ requestId,
122
+ });
134
123
  }
135
124
 
136
125
  // ---------------------------------------------------------------------------
@@ -140,10 +129,9 @@ export async function handleGuardianActionDecision(req: Request): Promise<Respon
140
129
  /**
141
130
  * Build a list of GuardianDecisionPrompt objects for the given conversation.
142
131
  *
143
- * Aggregates pending guardian approval requests from the channel guardian
144
- * store and pending confirmation interactions from the pending-interactions
145
- * tracker, exposing them in a uniform shape that clients can render as
146
- * structured button UIs.
132
+ * Reads exclusively from the canonical guardian requests store. All request
133
+ * kinds (tool_approval, pending_question, access_request, etc.) that have
134
+ * been created as canonical requests will appear here.
147
135
  */
148
136
  export function listGuardianDecisionPrompts(params: {
149
137
  conversationId: string;
@@ -151,56 +139,59 @@ export function listGuardianDecisionPrompts(params: {
151
139
  const { conversationId } = params;
152
140
  const prompts: GuardianDecisionPrompt[] = [];
153
141
 
154
- // 1. Channel guardian approval requests (tool approvals routed to guardians)
155
- const approvalRequests = listPendingApprovalRequests({
142
+ const canonicalRequests = listCanonicalGuardianRequests({
156
143
  conversationId,
157
144
  status: 'pending',
158
- }).filter(a => a.expiresAt > Date.now() && a.requestId != null);
159
-
160
- for (const approval of approvalRequests) {
161
- const reqId = approval.requestId!;
162
- prompts.push({
163
- requestId: reqId,
164
- requestCode: reqId.slice(0, 6).toUpperCase(),
165
- state: 'pending',
166
- questionText: approval.reason ?? `Approve tool: ${approval.toolName ?? 'unknown'}`,
167
- toolName: approval.toolName ?? null,
168
- actions: buildDecisionActions({ forGuardianOnBehalf: true }),
169
- expiresAt: approval.expiresAt,
170
- conversationId: approval.conversationId,
171
- callSessionId: null,
172
- });
173
- }
145
+ });
174
146
 
175
- // 2. Guardian action requests (voice call guardian questions) are intentionally
176
- // excluded here resolving them requires the answerCall + resolveGuardianActionRequest
177
- // flow which is handled by the conversational session-process path, not by the
178
- // deterministic button decision endpoint.
179
- // TODO: Surface voice guardian-action requests as read-only informational prompts
180
- // so desktop clients can see them even though they can't be resolved via buttons.
181
-
182
- // 3. Pending confirmation interactions (direct tool approval prompts)
183
- const interactions = pendingInteractions.getByConversation(conversationId);
184
- for (const interaction of interactions) {
185
- if (interaction.kind !== 'confirmation' || !interaction.confirmationDetails) continue;
186
- // Skip if already covered by a channel guardian approval above
187
- if (prompts.some(p => p.requestId === interaction.requestId)) continue;
188
-
189
- const details = interaction.confirmationDetails;
190
- prompts.push({
191
- requestId: interaction.requestId,
192
- requestCode: interaction.requestId.slice(0, 6).toUpperCase(),
193
- state: 'pending',
194
- questionText: `Approve tool: ${details.toolName}`,
195
- toolName: details.toolName,
196
- actions: buildDecisionActions({
197
- persistentDecisionsAllowed: details.persistentDecisionsAllowed,
198
- }),
199
- expiresAt: Date.now() + 300_000,
200
- conversationId,
201
- callSessionId: null,
202
- });
147
+ for (const req of canonicalRequests) {
148
+ // Skip expired canonical requests
149
+ if (req.expiresAt && new Date(req.expiresAt).getTime() < Date.now()) continue;
150
+
151
+ const prompt = mapCanonicalRequestToPrompt(req, conversationId);
152
+ prompts.push(prompt);
203
153
  }
204
154
 
205
155
  return prompts;
206
156
  }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Canonical request -> prompt mapping
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Map a canonical guardian request to the client-facing prompt format.
164
+ *
165
+ * Generates an appropriate questionText based on the request kind, and
166
+ * determines which actions are available. Pending questions surface as
167
+ * informational prompts since they may require text input rather than
168
+ * simple approve/reject buttons.
169
+ */
170
+ function mapCanonicalRequestToPrompt(
171
+ req: CanonicalGuardianRequest,
172
+ conversationId: string,
173
+ ): GuardianDecisionPrompt {
174
+ const questionText = req.questionText
175
+ ?? (req.toolName ? `Approve tool: ${req.toolName}` : `Guardian request: ${req.kind}`);
176
+
177
+ // pending_question requests are typically voice-originated and need
178
+ // approve/reject only (no approve_always — guardian-on-behalf invariant).
179
+ const actions = buildDecisionActions({ forGuardianOnBehalf: true });
180
+
181
+ const expiresAt = req.expiresAt
182
+ ? new Date(req.expiresAt).getTime()
183
+ : Date.now() + 300_000;
184
+
185
+ return {
186
+ requestId: req.id,
187
+ requestCode: req.requestCode ?? req.id.slice(0, 6).toUpperCase(),
188
+ state: 'pending',
189
+ questionText,
190
+ toolName: req.toolName ?? null,
191
+ actions,
192
+ expiresAt,
193
+ conversationId: req.conversationId ?? conversationId,
194
+ callSessionId: req.callSessionId ?? null,
195
+ kind: req.kind,
196
+ };
197
+ }