@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -16,6 +16,7 @@ import { getConfig } from '../config/loader.js';
|
|
|
16
16
|
import { createTimeout, extractToolUse, getConfiguredProvider, userMessage } from '../providers/provider-send-message.js';
|
|
17
17
|
import type { ModelIntent } from '../providers/types.js';
|
|
18
18
|
import { getLogger } from '../util/logger.js';
|
|
19
|
+
import { composeFallbackCopy } from './copy-composer.js';
|
|
19
20
|
import { createDecision } from './decisions-store.js';
|
|
20
21
|
import { getPreferenceSummary } from './preference-summary.js';
|
|
21
22
|
import type { NotificationSignal, RoutingIntent } from './signal.js';
|
|
@@ -251,17 +252,7 @@ function buildFallbackDecision(
|
|
|
251
252
|
};
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
const copy
|
|
255
|
-
for (const ch of selectedChannels) {
|
|
256
|
-
const fallbackBody = isHighUrgencyAction
|
|
257
|
-
? `Action required: ${signal.sourceEventName}`
|
|
258
|
-
: signal.sourceEventName;
|
|
259
|
-
copy[ch] = {
|
|
260
|
-
title: signal.sourceEventName,
|
|
261
|
-
body: fallbackBody,
|
|
262
|
-
...(ch === 'telegram' ? { deliveryText: fallbackBody } : {}),
|
|
263
|
-
};
|
|
264
|
-
}
|
|
255
|
+
const copy = composeFallbackCopy(signal, selectedChannels);
|
|
265
256
|
|
|
266
257
|
return {
|
|
267
258
|
shouldNotify: true,
|
|
@@ -452,7 +443,8 @@ export async function evaluateSignal(
|
|
|
452
443
|
const provider = getConfiguredProvider();
|
|
453
444
|
if (!provider) {
|
|
454
445
|
log.warn('Configured provider unavailable for notification decision, using fallback');
|
|
455
|
-
|
|
446
|
+
let decision = buildFallbackDecision(signal, availableChannels);
|
|
447
|
+
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
456
448
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
457
449
|
return decision;
|
|
458
450
|
}
|
|
@@ -466,6 +458,7 @@ export async function evaluateSignal(
|
|
|
466
458
|
decision = buildFallbackDecision(signal, availableChannels);
|
|
467
459
|
}
|
|
468
460
|
|
|
461
|
+
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
469
462
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
470
463
|
|
|
471
464
|
return decision;
|
|
@@ -600,6 +593,50 @@ export function enforceRoutingIntent(
|
|
|
600
593
|
return decision;
|
|
601
594
|
}
|
|
602
595
|
|
|
596
|
+
// ── Conversation affinity enforcement ───────────────────────────────────
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Enforce conversation affinity on a decision.
|
|
600
|
+
*
|
|
601
|
+
* When the signal carries a conversationAffinityHint (per-channel map of
|
|
602
|
+
* conversationId), override the decision's threadActions for those channels
|
|
603
|
+
* to `reuse_existing` with the hinted conversationId. This is a
|
|
604
|
+
* deterministic post-decision guard that prevents the LLM from routing
|
|
605
|
+
* guardian questions for the same call session to different conversations.
|
|
606
|
+
*/
|
|
607
|
+
export function enforceConversationAffinity(
|
|
608
|
+
decision: NotificationDecision,
|
|
609
|
+
affinityHint: Partial<Record<string, string>> | undefined,
|
|
610
|
+
): NotificationDecision {
|
|
611
|
+
if (!affinityHint) return decision;
|
|
612
|
+
|
|
613
|
+
const entries = Object.entries(affinityHint).filter(
|
|
614
|
+
([, conversationId]) => typeof conversationId === 'string' && conversationId.length > 0,
|
|
615
|
+
);
|
|
616
|
+
if (entries.length === 0) return decision;
|
|
617
|
+
|
|
618
|
+
const enforced = { ...decision };
|
|
619
|
+
const threadActions: Partial<Record<NotificationChannel, ThreadAction>> = {
|
|
620
|
+
...(decision.threadActions ?? {}),
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
for (const [channel, conversationId] of entries) {
|
|
624
|
+
threadActions[channel as NotificationChannel] = {
|
|
625
|
+
action: 'reuse_existing',
|
|
626
|
+
conversationId: conversationId!,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
enforced.threadActions = threadActions;
|
|
631
|
+
|
|
632
|
+
log.info(
|
|
633
|
+
{ affinityHint },
|
|
634
|
+
'Conversation affinity enforcement: overrode threadActions for hinted channels',
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
return enforced;
|
|
638
|
+
}
|
|
639
|
+
|
|
603
640
|
// ── Persistence ────────────────────────────────────────────────────────
|
|
604
641
|
|
|
605
642
|
function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
|
|
@@ -133,6 +133,12 @@ export interface EmitSignalParams {
|
|
|
133
133
|
routingIntent?: RoutingIntent;
|
|
134
134
|
/** Free-form hints from the source for the decision engine. */
|
|
135
135
|
routingHints?: Record<string, unknown>;
|
|
136
|
+
/**
|
|
137
|
+
* Per-channel conversation affinity hint. Forces the decision engine to
|
|
138
|
+
* reuse the specified conversation for the given channel(s), bypassing
|
|
139
|
+
* LLM thread-routing judgment. Keyed by channel name, value is conversationId.
|
|
140
|
+
*/
|
|
141
|
+
conversationAffinityHint?: Partial<Record<string, string>>;
|
|
136
142
|
/** Optional deduplication key. */
|
|
137
143
|
dedupeKey?: string;
|
|
138
144
|
/**
|
|
@@ -177,6 +183,7 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
|
|
|
177
183
|
attentionHints: params.attentionHints,
|
|
178
184
|
routingIntent: params.routingIntent,
|
|
179
185
|
routingHints: params.routingHints,
|
|
186
|
+
conversationAffinityHint: params.conversationAffinityHint,
|
|
180
187
|
};
|
|
181
188
|
|
|
182
189
|
try {
|
|
@@ -27,4 +27,11 @@ export interface NotificationSignal {
|
|
|
27
27
|
routingIntent?: RoutingIntent;
|
|
28
28
|
/** Free-form hints from the source for the decision engine (e.g. preferred channels). */
|
|
29
29
|
routingHints?: Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Per-channel conversation affinity hint. When set, the decision engine
|
|
32
|
+
* must force thread reuse to the specified conversation for that channel,
|
|
33
|
+
* bypassing LLM judgment. Used to enforce deterministic guardian thread
|
|
34
|
+
* affinity within a call session.
|
|
35
|
+
*/
|
|
36
|
+
conversationAffinityHint?: Partial<Record<string, string>>;
|
|
30
37
|
}
|
|
@@ -140,7 +140,8 @@ export function composeThreadSeed(
|
|
|
140
140
|
const parts: string[] = [];
|
|
141
141
|
if (copy.title && copy.title !== 'Notification') parts.push(copy.title);
|
|
142
142
|
if (copy.body) parts.push(copy.body);
|
|
143
|
-
|
|
143
|
+
const alreadyMentionsAction = parts.some((part) => /\baction required\b/i.test(part));
|
|
144
|
+
if (signal.attentionHints.requiresAction && parts.length > 0 && !alreadyMentionsAction) {
|
|
144
145
|
parts.push('Action required.');
|
|
145
146
|
}
|
|
146
147
|
if (parts.length > 0) {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* same approval flow can be reused across transports.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { GuardianDecisionAction } from './guardian-decision-types.js';
|
|
11
|
+
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Approval actions
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
@@ -20,12 +22,20 @@ export interface ApprovalActionOption {
|
|
|
20
22
|
label: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
|
|
27
|
+
* prompt payloads can be derived from the unified decision action set.
|
|
28
|
+
* The `action` field from GuardianDecisionAction maps to the `id` field
|
|
29
|
+
* on ApprovalActionOption (both are canonical action identifiers).
|
|
30
|
+
*/
|
|
31
|
+
export function toApprovalActionOptions(
|
|
32
|
+
actions: GuardianDecisionAction[],
|
|
33
|
+
): ApprovalActionOption[] {
|
|
34
|
+
return actions.map(a => ({
|
|
35
|
+
id: a.action as ApprovalAction,
|
|
36
|
+
label: a.label,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
29
39
|
|
|
30
40
|
// ---------------------------------------------------------------------------
|
|
31
41
|
// Approval prompt
|
|
@@ -17,7 +17,8 @@ import type {
|
|
|
17
17
|
ApprovalUIMetadata,
|
|
18
18
|
ChannelApprovalPrompt,
|
|
19
19
|
} from './channel-approval-types.js';
|
|
20
|
-
import {
|
|
20
|
+
import { toApprovalActionOptions } from './channel-approval-types.js';
|
|
21
|
+
import { buildDecisionActions, buildPlainTextFallback } from './guardian-decision-types.js';
|
|
21
22
|
import * as pendingInteractions from './pending-interactions.js';
|
|
22
23
|
|
|
23
24
|
/** Summary of a pending interaction, used by channel approval flows. */
|
|
@@ -69,6 +70,11 @@ export function getApprovalInfoByConversation(conversationId: string): PendingAp
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Internal helper: turn a PendingApprovalInfo into a ChannelApprovalPrompt.
|
|
73
|
+
*
|
|
74
|
+
* Derives actions from the shared `buildDecisionActions` builder defined in
|
|
75
|
+
* guardian-decision-types.ts, then maps them to the channel-facing
|
|
76
|
+
* `ApprovalActionOption` shape. This ensures channel button sets are always
|
|
77
|
+
* consistent with the unified `GuardianDecisionPrompt` type.
|
|
72
78
|
*/
|
|
73
79
|
function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApprovalPrompt {
|
|
74
80
|
const promptText = composeApprovalMessage({
|
|
@@ -76,15 +82,11 @@ function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApproval
|
|
|
76
82
|
toolName: info.toolName,
|
|
77
83
|
});
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
|
|
85
|
-
const plainTextFallback = info.persistentDecisionsAllowed === false
|
|
86
|
-
? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
|
|
87
|
-
: `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
|
|
85
|
+
const decisionActions = buildDecisionActions({
|
|
86
|
+
persistentDecisionsAllowed: info.persistentDecisionsAllowed,
|
|
87
|
+
});
|
|
88
|
+
const actions = toApprovalActionOptions(decisionActions);
|
|
89
|
+
const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
|
|
88
90
|
|
|
89
91
|
return { promptText, actions, plainTextFallback };
|
|
90
92
|
}
|
|
@@ -199,6 +201,10 @@ export function handleChannelDecision(
|
|
|
199
201
|
* Build an approval prompt that includes context about which non-guardian
|
|
200
202
|
* user is requesting the action. Sent to the guardian's chat so they
|
|
201
203
|
* can approve or deny on behalf of the requester.
|
|
204
|
+
*
|
|
205
|
+
* Uses the shared `buildDecisionActions` builder with `forGuardianOnBehalf`
|
|
206
|
+
* set to true, which excludes `approve_always` since guardians cannot
|
|
207
|
+
* permanently allowlist tools on behalf of requesters.
|
|
202
208
|
*/
|
|
203
209
|
export function buildGuardianApprovalPrompt(
|
|
204
210
|
info: PendingApprovalInfo,
|
|
@@ -210,11 +216,9 @@ export function buildGuardianApprovalPrompt(
|
|
|
210
216
|
requesterIdentifier,
|
|
211
217
|
});
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const plainTextFallback = `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
|
|
219
|
+
const decisionActions = buildDecisionActions({ forGuardianOnBehalf: true });
|
|
220
|
+
const actions = toApprovalActionOptions(decisionActions);
|
|
221
|
+
const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
|
|
218
222
|
|
|
219
223
|
return { promptText, actions, plainTextFallback };
|
|
220
224
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel invite transport abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Defines a transport interface for building shareable invite links and
|
|
5
|
+
* extracting inbound invite tokens from channel-specific payloads. Each
|
|
6
|
+
* channel (Telegram, SMS, Slack, etc.) registers an adapter that knows
|
|
7
|
+
* how to construct deep links and parse incoming tokens for that channel.
|
|
8
|
+
*
|
|
9
|
+
* The transport layer is intentionally thin: it handles URL construction
|
|
10
|
+
* and token extraction only. Redemption logic lives in
|
|
11
|
+
* `invite-redemption-service.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ChannelId } from '../channels/types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface InviteSharePayload {
|
|
21
|
+
/** The full URL the recipient can open to redeem the invite. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Human-readable text suitable for display alongside the link. */
|
|
24
|
+
displayText: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChannelInviteTransport {
|
|
28
|
+
/** The channel this transport handles. */
|
|
29
|
+
channel: ChannelId;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a shareable invite payload (URL + display text) from a raw token.
|
|
33
|
+
*
|
|
34
|
+
* The raw token is the base64url-encoded secret returned by
|
|
35
|
+
* `ingress-invite-store.createInvite`. The transport wraps it in a
|
|
36
|
+
* channel-specific deep link so the recipient can redeem the invite
|
|
37
|
+
* by clicking/tapping the link.
|
|
38
|
+
*/
|
|
39
|
+
buildShareableInvite(params: {
|
|
40
|
+
rawToken: string;
|
|
41
|
+
sourceChannel: ChannelId;
|
|
42
|
+
}): InviteSharePayload;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract an invite token from an inbound channel message.
|
|
46
|
+
*
|
|
47
|
+
* Returns the raw token string (without the `iv_` prefix) if the
|
|
48
|
+
* message contains a valid invite token, or `undefined` otherwise.
|
|
49
|
+
*/
|
|
50
|
+
extractInboundToken(params: {
|
|
51
|
+
commandIntent?: Record<string, unknown>;
|
|
52
|
+
content: string;
|
|
53
|
+
sourceMetadata?: Record<string, unknown>;
|
|
54
|
+
}): string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Registry
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const registry = new Map<ChannelId, ChannelInviteTransport>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register a channel invite transport. Overwrites any previously registered
|
|
65
|
+
* transport for the same channel.
|
|
66
|
+
*/
|
|
67
|
+
export function registerTransport(transport: ChannelInviteTransport): void {
|
|
68
|
+
registry.set(transport.channel, transport);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up the registered transport for a channel. Returns `undefined` when
|
|
73
|
+
* no transport has been registered for the given channel.
|
|
74
|
+
*/
|
|
75
|
+
export function getTransport(channel: ChannelId): ChannelInviteTransport | undefined {
|
|
76
|
+
return registry.get(channel);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reset the registry. Intended for tests only.
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export function _resetRegistry(): void {
|
|
84
|
+
registry.clear();
|
|
85
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel invite transport adapter.
|
|
3
|
+
*
|
|
4
|
+
* Builds `https://t.me/<botUsername>?start=iv_<token>` deep links and
|
|
5
|
+
* extracts invite tokens from `/start iv_<token>` command payloads.
|
|
6
|
+
*
|
|
7
|
+
* The `iv_` prefix distinguishes invite tokens from `gv_` (guardian
|
|
8
|
+
* verification) tokens that use the same `/start` deep-link mechanism.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ChannelId } from '../../channels/types.js';
|
|
12
|
+
import { getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
|
|
13
|
+
import {
|
|
14
|
+
type ChannelInviteTransport,
|
|
15
|
+
type InviteSharePayload,
|
|
16
|
+
registerTransport,
|
|
17
|
+
} from '../channel-invite-transport.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Bot username resolution
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the Telegram bot username from credential metadata, falling back
|
|
25
|
+
* to the TELEGRAM_BOT_USERNAME environment variable. Mirrors the resolution
|
|
26
|
+
* strategy used in `guardian-outbound-actions.ts`.
|
|
27
|
+
*/
|
|
28
|
+
function getTelegramBotUsername(): string | undefined {
|
|
29
|
+
const meta = getCredentialMetadata('telegram', 'bot_token');
|
|
30
|
+
if (meta?.accountInfo && typeof meta.accountInfo === 'string' && meta.accountInfo.trim().length > 0) {
|
|
31
|
+
return meta.accountInfo.trim();
|
|
32
|
+
}
|
|
33
|
+
return process.env.TELEGRAM_BOT_USERNAME || undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Token prefix
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const INVITE_TOKEN_PREFIX = 'iv_';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Transport implementation
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export const telegramInviteTransport: ChannelInviteTransport = {
|
|
47
|
+
channel: 'telegram' as ChannelId,
|
|
48
|
+
|
|
49
|
+
buildShareableInvite(params: {
|
|
50
|
+
rawToken: string;
|
|
51
|
+
sourceChannel: ChannelId;
|
|
52
|
+
}): InviteSharePayload {
|
|
53
|
+
const botUsername = getTelegramBotUsername();
|
|
54
|
+
if (!botUsername) {
|
|
55
|
+
throw new Error('Telegram bot username is not configured. Set up the Telegram integration first.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const url = `https://t.me/${botUsername}?start=${INVITE_TOKEN_PREFIX}${params.rawToken}`;
|
|
59
|
+
return {
|
|
60
|
+
url,
|
|
61
|
+
displayText: `Open in Telegram: ${url}`,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
extractInboundToken(params: {
|
|
66
|
+
commandIntent?: Record<string, unknown>;
|
|
67
|
+
content: string;
|
|
68
|
+
sourceMetadata?: Record<string, unknown>;
|
|
69
|
+
}): string | undefined {
|
|
70
|
+
// Primary path: structured command intent from the gateway.
|
|
71
|
+
// The gateway normalizes `/start <payload>` into
|
|
72
|
+
// `{ type: 'start', payload: '<payload>' }`.
|
|
73
|
+
if (
|
|
74
|
+
params.commandIntent &&
|
|
75
|
+
params.commandIntent.type === 'start' &&
|
|
76
|
+
typeof params.commandIntent.payload === 'string'
|
|
77
|
+
) {
|
|
78
|
+
const payload = params.commandIntent.payload;
|
|
79
|
+
if (payload.startsWith(INVITE_TOKEN_PREFIX)) {
|
|
80
|
+
const token = payload.slice(INVITE_TOKEN_PREFIX.length);
|
|
81
|
+
// Reject empty or whitespace-only tokens
|
|
82
|
+
if (token.length > 0 && token.trim().length > 0) {
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fallback: raw content parsing for `/start iv_<token>` messages.
|
|
90
|
+
// This handles cases where the gateway forwards the raw command text
|
|
91
|
+
// without a structured commandIntent.
|
|
92
|
+
const match = params.content.match(/^\/start\s+iv_(\S+)/);
|
|
93
|
+
if (match && match[1] && match[1].length > 0) {
|
|
94
|
+
return match[1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return undefined;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Auto-register on import
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
registerTransport(telegramInviteTransport);
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* consistently regardless of which channel the guardian answers on.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { mintGrantFromDecision } from '../approvals/approval-primitive.js';
|
|
10
11
|
import type { GuardianActionRequest } from '../memory/guardian-action-store.js';
|
|
11
|
-
import { createScopedApprovalGrant } from '../memory/scoped-approval-grants.js';
|
|
12
12
|
import { getLogger } from '../util/logger.js';
|
|
13
|
+
import { runApprovalConversationTurn } from './approval-conversation-turn.js';
|
|
13
14
|
import { parseApprovalDecision } from './channel-approval-parser.js';
|
|
15
|
+
import type { ApprovalConversationGenerator } from './http-types.js';
|
|
14
16
|
|
|
15
17
|
const log = getLogger('guardian-action-grant-minter');
|
|
16
18
|
|
|
@@ -21,76 +23,131 @@ export const GUARDIAN_ACTION_GRANT_TTL_MS = 5 * 60 * 1000;
|
|
|
21
23
|
* Mint a `tool_signature` scoped grant when a guardian-action request is
|
|
22
24
|
* resolved and the request carries tool metadata (toolName + inputDigest).
|
|
23
25
|
*
|
|
26
|
+
* Uses two-tier classification:
|
|
27
|
+
* 1. Deterministic fast path via parseApprovalDecision (exact keyword match).
|
|
28
|
+
* 2. LLM fallback via runApprovalConversationTurn when the deterministic
|
|
29
|
+
* parser returns null and an approvalConversationGenerator is provided.
|
|
30
|
+
*
|
|
24
31
|
* Skips silently when:
|
|
25
32
|
* - The resolved request has no toolName/inputDigest (informational consult).
|
|
26
|
-
* - The guardian's answer is not
|
|
33
|
+
* - The guardian's answer is not classified as approval by either tier (fail-closed).
|
|
27
34
|
*
|
|
28
35
|
* Fails silently on error -- grant minting is best-effort and must never
|
|
29
36
|
* block the guardian-action answer flow.
|
|
30
37
|
*/
|
|
31
|
-
export function tryMintGuardianActionGrant(params: {
|
|
32
|
-
|
|
38
|
+
export async function tryMintGuardianActionGrant(params: {
|
|
39
|
+
request: GuardianActionRequest;
|
|
33
40
|
answerText: string;
|
|
34
41
|
decisionChannel: string;
|
|
35
42
|
guardianExternalUserId?: string;
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
approvalConversationGenerator?: ApprovalConversationGenerator;
|
|
44
|
+
}): Promise<void> {
|
|
45
|
+
const { request, answerText, decisionChannel, guardianExternalUserId, approvalConversationGenerator } = params;
|
|
38
46
|
|
|
39
47
|
// Only mint for requests that carry tool metadata -- informational
|
|
40
48
|
// ASK_GUARDIAN consults without tool context do not produce grants.
|
|
41
|
-
if (!
|
|
49
|
+
if (!request.toolName || !request.inputDigest) {
|
|
42
50
|
return;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
53
|
+
// Tier 1: Deterministic fast path -- try exact keyword matching first.
|
|
54
|
+
// Guardian-action invariant: grants are always one-time `tool_signature`
|
|
55
|
+
// scoped. We treat `approve_always` from the deterministic parser the
|
|
56
|
+
// same as `approve_once` -- the grant is still single-use. This keeps
|
|
57
|
+
// the guardian-action path aligned with the primary approval interception
|
|
58
|
+
// flow where guardians are limited to approve_once / reject.
|
|
50
59
|
const decision = parseApprovalDecision(answerText);
|
|
51
|
-
|
|
60
|
+
let isApproval = decision?.action === 'approve_once' || decision?.action === 'approve_always';
|
|
61
|
+
|
|
62
|
+
// Tier 2: LLM fallback -- when the deterministic parser found no match
|
|
63
|
+
// and a generator is available, delegate to the conversational engine.
|
|
64
|
+
// Only allow approve_once (not approve_always) to keep guardian-action
|
|
65
|
+
// grants strictly one-time and consistent with guardian policy.
|
|
66
|
+
if (!isApproval && !decision && approvalConversationGenerator) {
|
|
67
|
+
try {
|
|
68
|
+
const llmResult = await runApprovalConversationTurn(
|
|
69
|
+
{
|
|
70
|
+
toolName: request.toolName,
|
|
71
|
+
allowedActions: ['approve_once', 'reject'],
|
|
72
|
+
role: 'guardian',
|
|
73
|
+
pendingApprovals: [{ requestId: request.id, toolName: request.toolName }],
|
|
74
|
+
userMessage: answerText,
|
|
75
|
+
},
|
|
76
|
+
approvalConversationGenerator,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
isApproval = llmResult.disposition === 'approve_once';
|
|
80
|
+
|
|
81
|
+
log.info(
|
|
82
|
+
{
|
|
83
|
+
event: 'guardian_action_grant_llm_fallback',
|
|
84
|
+
toolName: request.toolName,
|
|
85
|
+
requestId: request.id,
|
|
86
|
+
answerText,
|
|
87
|
+
llmDisposition: llmResult.disposition,
|
|
88
|
+
matched: isApproval,
|
|
89
|
+
decisionChannel,
|
|
90
|
+
},
|
|
91
|
+
`LLM fallback classifier returned disposition: ${llmResult.disposition}`,
|
|
92
|
+
);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Fail-closed: generator errors must not produce grants.
|
|
95
|
+
log.warn(
|
|
96
|
+
{
|
|
97
|
+
event: 'guardian_action_grant_llm_fallback_error',
|
|
98
|
+
toolName: request.toolName,
|
|
99
|
+
requestId: request.id,
|
|
100
|
+
err,
|
|
101
|
+
decisionChannel,
|
|
102
|
+
},
|
|
103
|
+
'LLM fallback classifier threw an error; treating as non-approval (fail-closed)',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!isApproval) {
|
|
52
109
|
log.info(
|
|
53
110
|
{
|
|
54
111
|
event: 'guardian_action_grant_skipped_no_approval',
|
|
55
|
-
toolName:
|
|
56
|
-
requestId:
|
|
112
|
+
toolName: request.toolName,
|
|
113
|
+
requestId: request.id,
|
|
57
114
|
answerText,
|
|
58
115
|
parsedAction: decision?.action ?? null,
|
|
59
116
|
decisionChannel,
|
|
60
117
|
},
|
|
61
|
-
'Skipped grant minting: guardian answer not classified as
|
|
118
|
+
'Skipped grant minting: guardian answer not classified as approval',
|
|
62
119
|
);
|
|
63
120
|
return;
|
|
64
121
|
}
|
|
65
122
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
123
|
+
const result = mintGrantFromDecision({
|
|
124
|
+
assistantId: request.assistantId,
|
|
125
|
+
scopeMode: 'tool_signature',
|
|
126
|
+
toolName: request.toolName,
|
|
127
|
+
inputDigest: request.inputDigest,
|
|
128
|
+
requestChannel: request.sourceChannel,
|
|
129
|
+
decisionChannel,
|
|
130
|
+
executionChannel: null,
|
|
131
|
+
conversationId: request.sourceConversationId,
|
|
132
|
+
callSessionId: request.callSessionId,
|
|
133
|
+
guardianExternalUserId: guardianExternalUserId ?? null,
|
|
134
|
+
expiresAt: new Date(Date.now() + GUARDIAN_ACTION_GRANT_TTL_MS).toISOString(),
|
|
135
|
+
});
|
|
80
136
|
|
|
137
|
+
if (result.ok) {
|
|
81
138
|
log.info(
|
|
82
139
|
{
|
|
83
140
|
event: 'guardian_action_grant_minted',
|
|
84
|
-
toolName:
|
|
85
|
-
requestId:
|
|
86
|
-
callSessionId:
|
|
141
|
+
toolName: request.toolName,
|
|
142
|
+
requestId: request.id,
|
|
143
|
+
callSessionId: request.callSessionId,
|
|
87
144
|
decisionChannel,
|
|
88
145
|
},
|
|
89
146
|
'Minted scoped approval grant for guardian-action answer resolution',
|
|
90
147
|
);
|
|
91
|
-
}
|
|
148
|
+
} else {
|
|
92
149
|
log.error(
|
|
93
|
-
{
|
|
150
|
+
{ reason: result.reason, toolName: request.toolName, requestId: request.id },
|
|
94
151
|
'Failed to mint scoped approval grant for guardian-action (non-fatal)',
|
|
95
152
|
);
|
|
96
153
|
}
|