@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.
- package/ARCHITECTURE.md +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared guardian reply router for inbound channel messages.
|
|
3
|
+
*
|
|
4
|
+
* Provides a single entry point (`routeGuardianReply`) for all inbound
|
|
5
|
+
* guardian reply processing across Telegram, SMS, and WhatsApp. Routes
|
|
6
|
+
* through a priority-ordered pipeline:
|
|
7
|
+
*
|
|
8
|
+
* 1. Deterministic callback/ref parsing (button presses with `apr:<requestId>:<action>`)
|
|
9
|
+
* 2. Request code parsing (6-char alphanumeric prefix matching)
|
|
10
|
+
* 3. NL classification via the conversational approval engine
|
|
11
|
+
*
|
|
12
|
+
* All decisions flow through `applyCanonicalGuardianDecision` from M2,
|
|
13
|
+
* which handles identity validation, expiry checks, CAS resolution,
|
|
14
|
+
* kind-specific resolver dispatch, and grant minting.
|
|
15
|
+
*
|
|
16
|
+
* The router is intentionally kept separate from the inbound message handler
|
|
17
|
+
* to allow for incremental migration and independent testability.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
applyCanonicalGuardianDecision,
|
|
22
|
+
type CanonicalDecisionResult,
|
|
23
|
+
} from '../approvals/guardian-decision-primitive.js';
|
|
24
|
+
import type { ActorContext, ChannelDeliveryContext } from '../approvals/guardian-request-resolvers.js';
|
|
25
|
+
import {
|
|
26
|
+
type CanonicalGuardianRequest,
|
|
27
|
+
getCanonicalGuardianRequest,
|
|
28
|
+
getCanonicalGuardianRequestByCode,
|
|
29
|
+
listCanonicalGuardianRequests,
|
|
30
|
+
} from '../memory/canonical-guardian-store.js';
|
|
31
|
+
import { getLogger } from '../util/logger.js';
|
|
32
|
+
import { runApprovalConversationTurn } from './approval-conversation-turn.js';
|
|
33
|
+
import type { ApprovalAction } from './channel-approval-types.js';
|
|
34
|
+
import type {
|
|
35
|
+
ApprovalConversationContext,
|
|
36
|
+
ApprovalConversationGenerator,
|
|
37
|
+
} from './http-types.js';
|
|
38
|
+
|
|
39
|
+
const log = getLogger('guardian-reply-router');
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Context for an inbound message that may be a guardian reply. */
|
|
46
|
+
export interface GuardianReplyContext {
|
|
47
|
+
/** The raw message text (trimmed). */
|
|
48
|
+
messageText: string;
|
|
49
|
+
/** Source channel (telegram, sms, whatsapp, etc.). */
|
|
50
|
+
channel: string;
|
|
51
|
+
/** Actor identity context for the sender. */
|
|
52
|
+
actor: ActorContext;
|
|
53
|
+
/** Conversation ID for this message (may be the guardian's conversation). */
|
|
54
|
+
conversationId: string;
|
|
55
|
+
/** Callback data from button presses (e.g. `apr:<requestId>:<action>`). */
|
|
56
|
+
callbackData?: string;
|
|
57
|
+
/** IDs of known pending canonical requests for this guardian. */
|
|
58
|
+
pendingRequestIds?: string[];
|
|
59
|
+
/** Conversation generator for NL classification (injected by daemon). */
|
|
60
|
+
approvalConversationGenerator?: ApprovalConversationGenerator;
|
|
61
|
+
/** Optional channel delivery context for resolver-driven side effects. */
|
|
62
|
+
channelDeliveryContext?: ChannelDeliveryContext;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type GuardianReplyResultType =
|
|
66
|
+
| 'canonical_decision_applied'
|
|
67
|
+
| 'canonical_decision_stale'
|
|
68
|
+
| 'canonical_resolver_failed'
|
|
69
|
+
| 'code_only_clarification'
|
|
70
|
+
| 'disambiguation_needed'
|
|
71
|
+
| 'nl_keep_pending'
|
|
72
|
+
| 'not_consumed';
|
|
73
|
+
|
|
74
|
+
/** Result from the guardian reply router. */
|
|
75
|
+
export interface GuardianReplyResult {
|
|
76
|
+
/** Whether a decision was applied to a canonical request. */
|
|
77
|
+
decisionApplied: boolean;
|
|
78
|
+
/** Reply text to send back to the guardian (if any). */
|
|
79
|
+
replyText?: string;
|
|
80
|
+
/** Whether the message was consumed and should not enter the agent pipeline. */
|
|
81
|
+
consumed: boolean;
|
|
82
|
+
/** The type of outcome for diagnostics. */
|
|
83
|
+
type: GuardianReplyResultType;
|
|
84
|
+
/** The canonical request ID that was targeted (if any). */
|
|
85
|
+
requestId?: string;
|
|
86
|
+
/** Detailed result from the canonical decision primitive (when a decision was attempted). */
|
|
87
|
+
canonicalResult?: CanonicalDecisionResult;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Callback data parser — format: "apr:<requestId>:<action>"
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const VALID_ACTIONS: ReadonlySet<string> = new Set([
|
|
95
|
+
'approve_once',
|
|
96
|
+
'approve_always',
|
|
97
|
+
'reject',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
interface ParsedCallback {
|
|
101
|
+
requestId: string;
|
|
102
|
+
action: ApprovalAction;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseCallbackAction(data: string): ParsedCallback | null {
|
|
106
|
+
const parts = data.split(':');
|
|
107
|
+
if (parts.length < 3 || parts[0] !== 'apr') return null;
|
|
108
|
+
const requestId = parts[1];
|
|
109
|
+
const action = parts.slice(2).join(':');
|
|
110
|
+
if (!requestId || !VALID_ACTIONS.has(action)) return null;
|
|
111
|
+
return { requestId, action: action as ApprovalAction };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Request code parser
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 6-char alphanumeric request code at the start of a message.
|
|
120
|
+
* Returns the matching canonical request and the remaining text after
|
|
121
|
+
* the code prefix.
|
|
122
|
+
*
|
|
123
|
+
* When `scopeConversationId` is provided, the matched request must belong
|
|
124
|
+
* to that conversation — otherwise the code is treated as unmatched so
|
|
125
|
+
* that requests from other sessions are never accidentally consumed.
|
|
126
|
+
*/
|
|
127
|
+
interface CodeParseResult {
|
|
128
|
+
request: CanonicalGuardianRequest;
|
|
129
|
+
remainingText: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseRequestCode(text: string, scopeConversationId?: string): CodeParseResult | null {
|
|
133
|
+
// Request codes are 6 hex chars (A-F, 0-9), uppercase
|
|
134
|
+
const upper = text.toUpperCase();
|
|
135
|
+
const match = upper.match(/^([A-F0-9]{6})(?:\s|$)/);
|
|
136
|
+
if (!match) return null;
|
|
137
|
+
|
|
138
|
+
const code = match[1];
|
|
139
|
+
const request = getCanonicalGuardianRequestByCode(code);
|
|
140
|
+
if (!request) return null;
|
|
141
|
+
|
|
142
|
+
// Scope to the current conversation when requested, so a code belonging
|
|
143
|
+
// to a different session/conversation is not consumed here. Requests with
|
|
144
|
+
// null conversationId are global/unscoped and match any conversation.
|
|
145
|
+
if (scopeConversationId && request.conversationId && request.conversationId !== scopeConversationId) {
|
|
146
|
+
log.info(
|
|
147
|
+
{ event: 'router_code_conversation_mismatch', code, requestId: request.id, expected: scopeConversationId, actual: request.conversationId },
|
|
148
|
+
'Request code matched a canonical request from a different conversation — ignoring',
|
|
149
|
+
);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const remainingText = text.slice(code.length).trim();
|
|
154
|
+
return { request, remainingText };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Helpers
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** Find all pending canonical requests for a guardian actor. */
|
|
162
|
+
function findPendingCanonicalRequests(
|
|
163
|
+
actor: ActorContext,
|
|
164
|
+
pendingRequestIds?: string[],
|
|
165
|
+
conversationId?: string,
|
|
166
|
+
): CanonicalGuardianRequest[] {
|
|
167
|
+
// When explicit IDs are provided, look them up directly
|
|
168
|
+
if (pendingRequestIds) {
|
|
169
|
+
if (pendingRequestIds.length === 0) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return pendingRequestIds
|
|
173
|
+
.map(getCanonicalGuardianRequest)
|
|
174
|
+
.filter((r): r is CanonicalGuardianRequest => r?.status === 'pending');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Query by guardian identity when available
|
|
178
|
+
if (actor.externalUserId) {
|
|
179
|
+
return listCanonicalGuardianRequests({
|
|
180
|
+
status: 'pending',
|
|
181
|
+
guardianExternalUserId: actor.externalUserId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For desktop/trusted actors without an externalUserId, query by
|
|
186
|
+
// conversationId so the NL path can discover pending requests.
|
|
187
|
+
if (conversationId) {
|
|
188
|
+
return listCanonicalGuardianRequests({
|
|
189
|
+
status: 'pending',
|
|
190
|
+
conversationId,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Trusted actors without a conversationId: return all pending requests
|
|
195
|
+
// so desktop sessions can always discover pending guardian work.
|
|
196
|
+
if (actor.isTrusted) {
|
|
197
|
+
return listCanonicalGuardianRequests({ status: 'pending' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Map an approval action string to the NL engine's allowed actions for guardians. */
|
|
204
|
+
function guardianAllowedActions(): ApprovalAction[] {
|
|
205
|
+
return ['approve_once', 'reject'];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function notConsumed(): GuardianReplyResult {
|
|
209
|
+
return { decisionApplied: false, consumed: false, type: 'not_consumed' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Core router
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Route an inbound guardian reply through the canonical decision pipeline.
|
|
218
|
+
*
|
|
219
|
+
* This is the single entry point for all inbound guardian reply processing.
|
|
220
|
+
* It handles messages from any channel (Telegram, SMS, WhatsApp) and
|
|
221
|
+
* routes through priority-ordered matching:
|
|
222
|
+
*
|
|
223
|
+
* 1. Deterministic callback parsing (button presses)
|
|
224
|
+
* 2. Request code parsing (6-char alphanumeric prefix)
|
|
225
|
+
* 3. NL classification via the conversational approval engine
|
|
226
|
+
*
|
|
227
|
+
* All decisions flow through `applyCanonicalGuardianDecision`.
|
|
228
|
+
*/
|
|
229
|
+
export async function routeGuardianReply(
|
|
230
|
+
ctx: GuardianReplyContext,
|
|
231
|
+
): Promise<GuardianReplyResult> {
|
|
232
|
+
const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
|
|
233
|
+
const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
|
|
234
|
+
|
|
235
|
+
// ── 1. Deterministic callback parsing (button presses) ──
|
|
236
|
+
// No conversationId scoping here — the guardian's reply comes from a
|
|
237
|
+
// different conversation than the requester's. Identity validation in
|
|
238
|
+
// applyCanonicalGuardianDecision is sufficient to prevent unauthorized
|
|
239
|
+
// cross-user decisions.
|
|
240
|
+
if (callbackData) {
|
|
241
|
+
const parsed = parseCallbackAction(callbackData);
|
|
242
|
+
if (parsed) {
|
|
243
|
+
return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── 2. Request code parsing (6-char alphanumeric prefix) ──
|
|
248
|
+
// No conversationId scoping — same rationale as the callback path above.
|
|
249
|
+
// The guardian's conversation differs from the requester's.
|
|
250
|
+
if (messageText.length > 0) {
|
|
251
|
+
const codeResult = parseRequestCode(messageText);
|
|
252
|
+
if (codeResult) {
|
|
253
|
+
const { request } = codeResult;
|
|
254
|
+
|
|
255
|
+
if (request.status !== 'pending') {
|
|
256
|
+
log.info(
|
|
257
|
+
{ event: 'router_code_already_resolved', requestId: request.id, status: request.status },
|
|
258
|
+
'Request code matched a non-pending canonical request',
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
decisionApplied: false,
|
|
262
|
+
consumed: true,
|
|
263
|
+
type: 'canonical_decision_stale',
|
|
264
|
+
requestId: request.id,
|
|
265
|
+
replyText: failureReplyText('already_resolved', request.requestCode),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Code-only messages (no decision text after the code) are treated as
|
|
270
|
+
// clarification inquiries — the guardian may be asking "what is this?"
|
|
271
|
+
// rather than intending to approve. Return helpful context instead of
|
|
272
|
+
// silently defaulting to approve_once.
|
|
273
|
+
if (!codeResult.remainingText || codeResult.remainingText.trim().length === 0) {
|
|
274
|
+
// Identity check: only expose request details to the assigned guardian
|
|
275
|
+
// or trusted (desktop) actors. Mirrors the identity check in
|
|
276
|
+
// applyCanonicalGuardianDecision to prevent leaking request details
|
|
277
|
+
// (toolName, questionText) to unauthorized senders.
|
|
278
|
+
if (
|
|
279
|
+
request.guardianExternalUserId &&
|
|
280
|
+
!actor.isTrusted &&
|
|
281
|
+
actor.externalUserId !== request.guardianExternalUserId
|
|
282
|
+
) {
|
|
283
|
+
log.warn(
|
|
284
|
+
{
|
|
285
|
+
event: 'router_code_only_identity_mismatch',
|
|
286
|
+
requestId: request.id,
|
|
287
|
+
expectedGuardian: request.guardianExternalUserId,
|
|
288
|
+
actualActor: actor.externalUserId,
|
|
289
|
+
},
|
|
290
|
+
'Code-only clarification blocked: actor identity does not match expected guardian',
|
|
291
|
+
);
|
|
292
|
+
return {
|
|
293
|
+
decisionApplied: false,
|
|
294
|
+
consumed: true,
|
|
295
|
+
type: 'code_only_clarification',
|
|
296
|
+
requestId: request.id,
|
|
297
|
+
replyText: 'Request not found.',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
log.info(
|
|
302
|
+
{ event: 'router_code_only_clarification', requestId: request.id, code: request.requestCode },
|
|
303
|
+
'Code-only message treated as clarification inquiry',
|
|
304
|
+
);
|
|
305
|
+
return {
|
|
306
|
+
decisionApplied: false,
|
|
307
|
+
consumed: true,
|
|
308
|
+
type: 'code_only_clarification',
|
|
309
|
+
requestId: request.id,
|
|
310
|
+
replyText: composeCodeOnlyClarification(request),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Remaining text present — infer the decision action from it.
|
|
315
|
+
// If the text indicates rejection, use reject; otherwise approve_once.
|
|
316
|
+
const action = inferActionFromText(codeResult.remainingText);
|
|
317
|
+
|
|
318
|
+
return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── 2.5. Deterministic plain-text decisions for known pending targets ──
|
|
323
|
+
// Desktop sessions intentionally do not enable NL classification; when the
|
|
324
|
+
// caller has exactly one known pending request and sends an explicit
|
|
325
|
+
// approve/reject phrase ("approve", "yes", "reject", "no"), apply the
|
|
326
|
+
// decision directly instead of falling through to legacy handlers.
|
|
327
|
+
if (messageText.length > 0 && pendingRequests.length > 0) {
|
|
328
|
+
const inferredAction = inferDecisionActionFromFreeText(messageText);
|
|
329
|
+
if (inferredAction) {
|
|
330
|
+
if (pendingRequests.length === 1) {
|
|
331
|
+
return applyDecision(
|
|
332
|
+
pendingRequests[0].id,
|
|
333
|
+
inferredAction,
|
|
334
|
+
actor,
|
|
335
|
+
messageText,
|
|
336
|
+
channelDeliveryContext,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const disambiguationReply = composeDisambiguationReply(pendingRequests);
|
|
341
|
+
return {
|
|
342
|
+
decisionApplied: false,
|
|
343
|
+
consumed: true,
|
|
344
|
+
type: 'disambiguation_needed',
|
|
345
|
+
replyText: disambiguationReply,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── 3. NL classification via the conversational approval engine ──
|
|
351
|
+
if (messageText.length > 0 && approvalConversationGenerator) {
|
|
352
|
+
if (pendingRequests.length === 0) {
|
|
353
|
+
return notConsumed();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Use all pending requests for the guardian without conversation scoping.
|
|
357
|
+
// Guardian requests for channel/voice flows are created on the requester's
|
|
358
|
+
// conversation, not the guardian's reply thread, so filtering by
|
|
359
|
+
// conversationId would incorrectly drop valid pending requests. Identity-
|
|
360
|
+
// based filtering in findPendingCanonicalRequests already constrains
|
|
361
|
+
// results to the correct guardian.
|
|
362
|
+
const pendingRequestsForClassification = pendingRequests;
|
|
363
|
+
|
|
364
|
+
// Build the conversation context for the NL engine
|
|
365
|
+
const engineContext: ApprovalConversationContext = {
|
|
366
|
+
toolName: pendingRequestsForClassification[0].toolName ?? 'unknown',
|
|
367
|
+
allowedActions: guardianAllowedActions(),
|
|
368
|
+
role: 'guardian',
|
|
369
|
+
pendingApprovals: pendingRequestsForClassification.map(r => ({
|
|
370
|
+
requestId: r.id,
|
|
371
|
+
toolName: r.toolName ?? 'unknown',
|
|
372
|
+
})),
|
|
373
|
+
userMessage: messageText,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const engineResult = await runApprovalConversationTurn(
|
|
377
|
+
engineContext,
|
|
378
|
+
approvalConversationGenerator,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (engineResult.disposition === 'keep_pending') {
|
|
382
|
+
// When the engine returns keep_pending with multiple pending requests,
|
|
383
|
+
// this likely means the NL classification understood a decision intent
|
|
384
|
+
// but runApprovalConversationTurn fail-closed because no targetRequestId
|
|
385
|
+
// was provided. In this case, produce a disambiguation reply instead of
|
|
386
|
+
// a generic "I couldn't process that" message.
|
|
387
|
+
if (pendingRequestsForClassification.length > 1) {
|
|
388
|
+
log.info(
|
|
389
|
+
{ event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
|
|
390
|
+
'Engine returned keep_pending with multiple pending requests — producing disambiguation',
|
|
391
|
+
);
|
|
392
|
+
const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, undefined);
|
|
393
|
+
return {
|
|
394
|
+
decisionApplied: false,
|
|
395
|
+
consumed: true,
|
|
396
|
+
type: 'disambiguation_needed',
|
|
397
|
+
replyText: disambiguationReply,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
decisionApplied: false,
|
|
402
|
+
replyText: engineResult.replyText,
|
|
403
|
+
consumed: true,
|
|
404
|
+
type: 'nl_keep_pending',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Decision-bearing disposition from the engine
|
|
409
|
+
let decisionAction = engineResult.disposition as ApprovalAction;
|
|
410
|
+
|
|
411
|
+
// Guardians cannot approve_always — the canonical primitive enforces
|
|
412
|
+
// this too, but enforce it here for clarity.
|
|
413
|
+
if (decisionAction === 'approve_always') {
|
|
414
|
+
decisionAction = 'approve_once';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Resolve the target request
|
|
418
|
+
const targetId = engineResult.targetRequestId
|
|
419
|
+
?? (pendingRequestsForClassification.length === 1 ? pendingRequestsForClassification[0].id : undefined);
|
|
420
|
+
|
|
421
|
+
if (!targetId) {
|
|
422
|
+
// Multi-pending and engine didn't pick a target — need disambiguation.
|
|
423
|
+
// Fail-closed: never auto-resolve when the target is ambiguous.
|
|
424
|
+
log.info(
|
|
425
|
+
{ event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
|
|
426
|
+
'NL engine returned a decision but no target for multi-pending requests',
|
|
427
|
+
);
|
|
428
|
+
const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, engineResult.replyText);
|
|
429
|
+
return {
|
|
430
|
+
decisionApplied: false,
|
|
431
|
+
consumed: true,
|
|
432
|
+
type: 'disambiguation_needed',
|
|
433
|
+
replyText: disambiguationReply,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext);
|
|
438
|
+
|
|
439
|
+
// Attach the engine's reply text for stale/expired/identity-mismatch cases,
|
|
440
|
+
// but preserve the explicit failure text when the resolver failed — the engine
|
|
441
|
+
// reply is typically an affirmative confirmation that would be misleading.
|
|
442
|
+
if (engineResult.replyText && result.type !== 'canonical_resolver_failed') {
|
|
443
|
+
result.replyText = engineResult.replyText;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// No matching strategy and no engine — not consumed
|
|
450
|
+
return notConsumed();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Decision application
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Apply a decision to a canonical request through the unified primitive.
|
|
459
|
+
*/
|
|
460
|
+
async function applyDecision(
|
|
461
|
+
requestId: string,
|
|
462
|
+
action: ApprovalAction,
|
|
463
|
+
actor: ActorContext,
|
|
464
|
+
userText?: string,
|
|
465
|
+
channelDeliveryContext?: ChannelDeliveryContext,
|
|
466
|
+
): Promise<GuardianReplyResult> {
|
|
467
|
+
const canonicalResult = await applyCanonicalGuardianDecision({
|
|
468
|
+
requestId,
|
|
469
|
+
action,
|
|
470
|
+
actorContext: actor,
|
|
471
|
+
userText,
|
|
472
|
+
channelDeliveryContext,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (canonicalResult.applied) {
|
|
476
|
+
if (canonicalResult.resolverFailed) {
|
|
477
|
+
log.warn(
|
|
478
|
+
{
|
|
479
|
+
event: 'router_resolver_failed',
|
|
480
|
+
requestId,
|
|
481
|
+
action,
|
|
482
|
+
reason: canonicalResult.resolverFailureReason,
|
|
483
|
+
},
|
|
484
|
+
'Guardian reply router: resolver failed to execute side effects',
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
decisionApplied: false,
|
|
489
|
+
consumed: true,
|
|
490
|
+
type: 'canonical_resolver_failed',
|
|
491
|
+
replyText: `Decision recorded but could not be completed: ${canonicalResult.resolverFailureReason ?? 'unknown error'}. Please try again.`,
|
|
492
|
+
requestId,
|
|
493
|
+
canonicalResult,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
log.info(
|
|
498
|
+
{
|
|
499
|
+
event: 'router_decision_applied',
|
|
500
|
+
requestId,
|
|
501
|
+
action,
|
|
502
|
+
grantMinted: canonicalResult.grantMinted,
|
|
503
|
+
},
|
|
504
|
+
'Guardian reply router applied canonical decision',
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
decisionApplied: true,
|
|
509
|
+
consumed: true,
|
|
510
|
+
type: 'canonical_decision_applied',
|
|
511
|
+
requestId,
|
|
512
|
+
canonicalResult,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
log.info(
|
|
517
|
+
{
|
|
518
|
+
event: 'router_decision_not_applied',
|
|
519
|
+
requestId,
|
|
520
|
+
action,
|
|
521
|
+
reason: canonicalResult.reason,
|
|
522
|
+
},
|
|
523
|
+
`Guardian reply router: canonical decision not applied (${canonicalResult.reason})`,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// When the canonical request doesn't exist, allow the message to fall
|
|
527
|
+
// through so the legacy handleApprovalInterception handler can process it.
|
|
528
|
+
if (canonicalResult.reason === 'not_found') {
|
|
529
|
+
return notConsumed();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
decisionApplied: false,
|
|
534
|
+
consumed: true,
|
|
535
|
+
type: 'canonical_decision_stale',
|
|
536
|
+
requestId,
|
|
537
|
+
canonicalResult,
|
|
538
|
+
replyText: failureReplyText(canonicalResult.reason),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Text-to-action inference
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
const CODE_REJECT_PATTERNS = /^(no|deny|reject|decline|cancel|block)\b/i;
|
|
547
|
+
const EXPLICIT_APPROVE_PHRASES: ReadonlySet<string> = new Set([
|
|
548
|
+
'approve',
|
|
549
|
+
'approved',
|
|
550
|
+
'approve once',
|
|
551
|
+
'yes',
|
|
552
|
+
'y',
|
|
553
|
+
'allow',
|
|
554
|
+
'go ahead',
|
|
555
|
+
'proceed',
|
|
556
|
+
'do it',
|
|
557
|
+
]);
|
|
558
|
+
const EXPLICIT_REJECT_PHRASES: ReadonlySet<string> = new Set([
|
|
559
|
+
'reject',
|
|
560
|
+
'deny',
|
|
561
|
+
'decline',
|
|
562
|
+
'no',
|
|
563
|
+
'n',
|
|
564
|
+
'block',
|
|
565
|
+
'cancel',
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
function normalizeDecisionPhrase(text: string): string {
|
|
569
|
+
return text
|
|
570
|
+
.trim()
|
|
571
|
+
.toLowerCase()
|
|
572
|
+
.replace(/[.!?]+$/g, '')
|
|
573
|
+
.replace(/\s+/g, ' ');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Strict free-text decision parser used when no request code is present.
|
|
578
|
+
* Returns null unless the message starts with an explicit approve/reject cue.
|
|
579
|
+
*/
|
|
580
|
+
function inferDecisionActionFromFreeText(text: string): ApprovalAction | null {
|
|
581
|
+
const normalized = normalizeDecisionPhrase(text);
|
|
582
|
+
if (!normalized) return null;
|
|
583
|
+
if (EXPLICIT_REJECT_PHRASES.has(normalized)) return 'reject';
|
|
584
|
+
if (EXPLICIT_APPROVE_PHRASES.has(normalized)) return 'approve_once';
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Infer a guardian decision action from free-text after a request code.
|
|
590
|
+
* Defaults to approve_once unless clear rejection language is detected.
|
|
591
|
+
*/
|
|
592
|
+
function inferActionFromText(text: string): ApprovalAction {
|
|
593
|
+
if (!text || text.trim().length === 0) {
|
|
594
|
+
return 'approve_once';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (CODE_REJECT_PATTERNS.test(text.trim())) {
|
|
598
|
+
return 'reject';
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return 'approve_once';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Failure reason reply text
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
type CanonicalFailureReason = 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired';
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Map a canonical decision failure reason to a distinct, actionable reply
|
|
612
|
+
* so the guardian understands exactly what happened and what to do next.
|
|
613
|
+
*/
|
|
614
|
+
function failureReplyText(reason: CanonicalFailureReason, requestCode?: string | null): string {
|
|
615
|
+
switch (reason) {
|
|
616
|
+
case 'already_resolved':
|
|
617
|
+
return 'This request has already been resolved.';
|
|
618
|
+
case 'expired':
|
|
619
|
+
return 'This request has expired.';
|
|
620
|
+
case 'identity_mismatch':
|
|
621
|
+
return "You don't have permission to decide on this request.";
|
|
622
|
+
case 'invalid_action':
|
|
623
|
+
return requestCode
|
|
624
|
+
? `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`
|
|
625
|
+
: "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").";
|
|
626
|
+
default:
|
|
627
|
+
return "I couldn't process that request. Please try again.";
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Code-only clarification
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Compose a clarification response when a guardian sends only a request
|
|
637
|
+
* code without any decision text. Provides context about the request and
|
|
638
|
+
* tells the guardian how to approve or reject it.
|
|
639
|
+
*/
|
|
640
|
+
function composeCodeOnlyClarification(request: CanonicalGuardianRequest): string {
|
|
641
|
+
const code = request.requestCode ?? 'unknown';
|
|
642
|
+
const toolLabel = request.toolName ?? 'an action';
|
|
643
|
+
const lines: string[] = [
|
|
644
|
+
`I found request ${code} for ${toolLabel}.`,
|
|
645
|
+
];
|
|
646
|
+
if (request.questionText) {
|
|
647
|
+
lines.push(`Details: ${request.questionText}`);
|
|
648
|
+
}
|
|
649
|
+
lines.push(`Reply "${code} approve" to approve or "${code} reject" to reject.`);
|
|
650
|
+
return lines.join('\n');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// Disambiguation reply
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Compose a disambiguation reply that includes concrete decision examples
|
|
659
|
+
* using actual request codes from the pending requests. Always includes
|
|
660
|
+
* explicit instructions so the guardian knows exactly how to proceed.
|
|
661
|
+
*/
|
|
662
|
+
function composeDisambiguationReply(
|
|
663
|
+
pendingRequests: CanonicalGuardianRequest[],
|
|
664
|
+
engineReplyText?: string,
|
|
665
|
+
): string {
|
|
666
|
+
const lines: string[] = [];
|
|
667
|
+
|
|
668
|
+
if (engineReplyText) {
|
|
669
|
+
lines.push(engineReplyText);
|
|
670
|
+
lines.push('');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
lines.push(`You have ${pendingRequests.length} pending requests. Please specify which one:`);
|
|
674
|
+
|
|
675
|
+
for (const req of pendingRequests) {
|
|
676
|
+
const toolLabel = req.toolName ?? 'action';
|
|
677
|
+
const code = req.requestCode ?? req.id.slice(0, 6).toUpperCase();
|
|
678
|
+
lines.push(` - ${code}: ${toolLabel}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Include a concrete example using the first request's code
|
|
682
|
+
const exampleCode = pendingRequests[0].requestCode ?? pendingRequests[0].id.slice(0, 6).toUpperCase();
|
|
683
|
+
lines.push('');
|
|
684
|
+
lines.push(`Reply "${exampleCode} approve" to approve a specific request.`);
|
|
685
|
+
|
|
686
|
+
return lines.join('\n');
|
|
687
|
+
}
|