@vellumai/assistant 0.3.18 → 0.3.20
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 +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -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 +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -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 +779 -0
- 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 +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- 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 +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -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 +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- 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__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- 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 +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- 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 +18 -0
- 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/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- 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 +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -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/config-channels.ts +18 -0
- 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/handlers/skills.ts +45 -2
- 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/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- 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 +260 -422
- 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/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- 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 +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- 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/permissions/checker.ts +27 -0
- 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 +154 -0
- 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 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- 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 +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- 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/network/script-proxy/session-manager.ts +1 -5
- 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 +6 -0
- 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
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD and atomic consume for scoped approval grants.
|
|
3
|
+
*
|
|
4
|
+
* Grants authorise exactly one tool execution. Two scope modes exist:
|
|
5
|
+
* - `request_id` — grant is bound to a specific pending request
|
|
6
|
+
* - `tool_signature` — grant is bound to a tool name + input digest
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - At most one successful consume per grant (CAS: active -> consumed).
|
|
10
|
+
* - Matching requires all non-null scope fields to match exactly.
|
|
11
|
+
* - Expired and revoked grants cannot be consumed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
15
|
+
import { v4 as uuid } from 'uuid';
|
|
16
|
+
|
|
17
|
+
import { getLogger } from '../util/logger.js';
|
|
18
|
+
import { getDb, rawChanges } from './db.js';
|
|
19
|
+
import { scopedApprovalGrants } from './schema.js';
|
|
20
|
+
|
|
21
|
+
const log = getLogger('scoped-approval-grants');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export type ScopeMode = 'request_id' | 'tool_signature';
|
|
28
|
+
export type GrantStatus = 'active' | 'consumed' | 'expired' | 'revoked';
|
|
29
|
+
|
|
30
|
+
export interface ScopedApprovalGrant {
|
|
31
|
+
id: string;
|
|
32
|
+
assistantId: string;
|
|
33
|
+
scopeMode: ScopeMode;
|
|
34
|
+
requestId: string | null;
|
|
35
|
+
toolName: string | null;
|
|
36
|
+
inputDigest: string | null;
|
|
37
|
+
requestChannel: string;
|
|
38
|
+
decisionChannel: string;
|
|
39
|
+
executionChannel: string | null;
|
|
40
|
+
conversationId: string | null;
|
|
41
|
+
callSessionId: string | null;
|
|
42
|
+
requesterExternalUserId: string | null;
|
|
43
|
+
guardianExternalUserId: string | null;
|
|
44
|
+
status: GrantStatus;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
consumedAt: string | null;
|
|
47
|
+
consumedByRequestId: string | null;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Constants
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Max CAS retry attempts when a concurrent consumer steals the selected candidate. */
|
|
57
|
+
const MAX_CAS_RETRIES = 3;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function rowToGrant(row: typeof scopedApprovalGrants.$inferSelect): ScopedApprovalGrant {
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
assistantId: row.assistantId,
|
|
67
|
+
scopeMode: row.scopeMode as ScopeMode,
|
|
68
|
+
requestId: row.requestId,
|
|
69
|
+
toolName: row.toolName,
|
|
70
|
+
inputDigest: row.inputDigest,
|
|
71
|
+
requestChannel: row.requestChannel,
|
|
72
|
+
decisionChannel: row.decisionChannel,
|
|
73
|
+
executionChannel: row.executionChannel,
|
|
74
|
+
conversationId: row.conversationId,
|
|
75
|
+
callSessionId: row.callSessionId,
|
|
76
|
+
requesterExternalUserId: row.requesterExternalUserId,
|
|
77
|
+
guardianExternalUserId: row.guardianExternalUserId,
|
|
78
|
+
status: row.status as GrantStatus,
|
|
79
|
+
expiresAt: row.expiresAt,
|
|
80
|
+
consumedAt: row.consumedAt,
|
|
81
|
+
consumedByRequestId: row.consumedByRequestId,
|
|
82
|
+
createdAt: row.createdAt,
|
|
83
|
+
updatedAt: row.updatedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Create
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export interface CreateScopedApprovalGrantParams {
|
|
92
|
+
assistantId: string;
|
|
93
|
+
scopeMode: ScopeMode;
|
|
94
|
+
requestId?: string | null;
|
|
95
|
+
toolName?: string | null;
|
|
96
|
+
inputDigest?: string | null;
|
|
97
|
+
requestChannel: string;
|
|
98
|
+
decisionChannel: string;
|
|
99
|
+
executionChannel?: string | null;
|
|
100
|
+
conversationId?: string | null;
|
|
101
|
+
callSessionId?: string | null;
|
|
102
|
+
requesterExternalUserId?: string | null;
|
|
103
|
+
guardianExternalUserId?: string | null;
|
|
104
|
+
expiresAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createScopedApprovalGrant(params: CreateScopedApprovalGrantParams): ScopedApprovalGrant {
|
|
108
|
+
const db = getDb();
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
const id = uuid();
|
|
111
|
+
|
|
112
|
+
const row = {
|
|
113
|
+
id,
|
|
114
|
+
assistantId: params.assistantId,
|
|
115
|
+
scopeMode: params.scopeMode,
|
|
116
|
+
requestId: params.requestId ?? null,
|
|
117
|
+
toolName: params.toolName ?? null,
|
|
118
|
+
inputDigest: params.inputDigest ?? null,
|
|
119
|
+
requestChannel: params.requestChannel,
|
|
120
|
+
decisionChannel: params.decisionChannel,
|
|
121
|
+
executionChannel: params.executionChannel ?? null,
|
|
122
|
+
conversationId: params.conversationId ?? null,
|
|
123
|
+
callSessionId: params.callSessionId ?? null,
|
|
124
|
+
requesterExternalUserId: params.requesterExternalUserId ?? null,
|
|
125
|
+
guardianExternalUserId: params.guardianExternalUserId ?? null,
|
|
126
|
+
status: 'active' as const,
|
|
127
|
+
expiresAt: params.expiresAt,
|
|
128
|
+
consumedAt: null,
|
|
129
|
+
consumedByRequestId: null,
|
|
130
|
+
createdAt: now,
|
|
131
|
+
updatedAt: now,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
db.insert(scopedApprovalGrants).values(row).run();
|
|
135
|
+
|
|
136
|
+
log.info(
|
|
137
|
+
{
|
|
138
|
+
event: 'scoped_grant_created',
|
|
139
|
+
grantId: id,
|
|
140
|
+
scopeMode: params.scopeMode,
|
|
141
|
+
toolName: params.toolName ?? null,
|
|
142
|
+
assistantId: params.assistantId,
|
|
143
|
+
requestChannel: params.requestChannel,
|
|
144
|
+
decisionChannel: params.decisionChannel,
|
|
145
|
+
executionChannel: params.executionChannel ?? null,
|
|
146
|
+
expiresAt: params.expiresAt,
|
|
147
|
+
},
|
|
148
|
+
'Scoped approval grant created',
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return rowToGrant(row);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Consume by request ID (CAS: active -> consumed)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export interface ConsumeByRequestIdResult {
|
|
159
|
+
ok: boolean;
|
|
160
|
+
grant: ScopedApprovalGrant | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Atomically consume a grant by request ID.
|
|
165
|
+
*
|
|
166
|
+
* Only succeeds when exactly one active, non-expired grant matches the
|
|
167
|
+
* given `requestId` and `assistantId`. Uses compare-and-swap on the
|
|
168
|
+
* `status` column so concurrent consumers race safely — at most one wins.
|
|
169
|
+
*/
|
|
170
|
+
function consumeScopedApprovalGrantByRequestId(
|
|
171
|
+
requestId: string,
|
|
172
|
+
consumingRequestId: string,
|
|
173
|
+
assistantId: string,
|
|
174
|
+
now?: string,
|
|
175
|
+
): ConsumeByRequestIdResult {
|
|
176
|
+
const db = getDb();
|
|
177
|
+
const currentTime = now ?? new Date().toISOString();
|
|
178
|
+
|
|
179
|
+
// Two-step select-then-update with LIMIT 1 to consume exactly one grant
|
|
180
|
+
// even if duplicate rows exist (the index on request_id is non-unique).
|
|
181
|
+
for (let attempt = 0; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
182
|
+
const candidate = db
|
|
183
|
+
.select({ id: scopedApprovalGrants.id })
|
|
184
|
+
.from(scopedApprovalGrants)
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
eq(scopedApprovalGrants.requestId, requestId),
|
|
188
|
+
eq(scopedApprovalGrants.assistantId, assistantId),
|
|
189
|
+
eq(scopedApprovalGrants.scopeMode, 'request_id'),
|
|
190
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
191
|
+
sql`${scopedApprovalGrants.expiresAt} > ${currentTime}`,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
.limit(1)
|
|
195
|
+
.get();
|
|
196
|
+
|
|
197
|
+
if (!candidate) {
|
|
198
|
+
log.info(
|
|
199
|
+
{ event: 'scoped_grant_consume_miss', requestId, consumingRequestId, assistantId, scopeMode: 'request_id', attempt },
|
|
200
|
+
'No matching active grant found for request ID',
|
|
201
|
+
);
|
|
202
|
+
return { ok: false, grant: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
db.update(scopedApprovalGrants)
|
|
206
|
+
.set({
|
|
207
|
+
status: 'consumed',
|
|
208
|
+
consumedAt: currentTime,
|
|
209
|
+
consumedByRequestId: consumingRequestId,
|
|
210
|
+
updatedAt: currentTime,
|
|
211
|
+
})
|
|
212
|
+
.where(
|
|
213
|
+
and(
|
|
214
|
+
eq(scopedApprovalGrants.id, candidate.id),
|
|
215
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
.run();
|
|
219
|
+
|
|
220
|
+
if (rawChanges() === 0) {
|
|
221
|
+
// CAS failed — another consumer raced and won this candidate; retry with next match
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fetch the consumed grant to return to the caller
|
|
226
|
+
const row = db
|
|
227
|
+
.select()
|
|
228
|
+
.from(scopedApprovalGrants)
|
|
229
|
+
.where(eq(scopedApprovalGrants.id, candidate.id))
|
|
230
|
+
.get();
|
|
231
|
+
|
|
232
|
+
const grant = row ? rowToGrant(row) : null;
|
|
233
|
+
log.info(
|
|
234
|
+
{ event: 'scoped_grant_consume_success', grantId: grant?.id, requestId, consumingRequestId, assistantId, scopeMode: 'request_id' },
|
|
235
|
+
'Scoped approval grant consumed by request ID',
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return { ok: true, grant };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// All retry attempts exhausted — every candidate was stolen by concurrent consumers
|
|
242
|
+
log.info(
|
|
243
|
+
{ event: 'scoped_grant_consume_miss', requestId, consumingRequestId, assistantId, scopeMode: 'request_id', reason: 'cas_exhausted' },
|
|
244
|
+
'All CAS retry attempts exhausted for request ID consume',
|
|
245
|
+
);
|
|
246
|
+
return { ok: false, grant: null };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Consume by tool signature (CAS: active -> consumed)
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
export interface ConsumeByToolSignatureParams {
|
|
254
|
+
toolName: string;
|
|
255
|
+
inputDigest: string;
|
|
256
|
+
consumingRequestId: string;
|
|
257
|
+
/** Optional context constraints — only matched when the grant has a non-null value */
|
|
258
|
+
assistantId?: string;
|
|
259
|
+
executionChannel?: string;
|
|
260
|
+
conversationId?: string;
|
|
261
|
+
callSessionId?: string;
|
|
262
|
+
requesterExternalUserId?: string;
|
|
263
|
+
now?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface ConsumeByToolSignatureResult {
|
|
267
|
+
ok: boolean;
|
|
268
|
+
grant: ScopedApprovalGrant | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Atomically consume a grant by tool name + input digest.
|
|
273
|
+
*
|
|
274
|
+
* All non-null scope fields on the grant must match the provided context.
|
|
275
|
+
* This is enforced via SQL conditions that check: either the grant field is
|
|
276
|
+
* NULL (wildcard), or it equals the provided value.
|
|
277
|
+
*
|
|
278
|
+
* If a CAS contention miss occurs (another consumer races and wins the
|
|
279
|
+
* selected candidate), re-selects and retries up to {@link MAX_CAS_RETRIES}
|
|
280
|
+
* times before giving up. This prevents false denials when multiple matching
|
|
281
|
+
* grants exist but a concurrent consumer steals the first pick.
|
|
282
|
+
*/
|
|
283
|
+
function consumeScopedApprovalGrantByToolSignature(
|
|
284
|
+
params: ConsumeByToolSignatureParams,
|
|
285
|
+
): ConsumeByToolSignatureResult {
|
|
286
|
+
const db = getDb();
|
|
287
|
+
const currentTime = params.now ?? new Date().toISOString();
|
|
288
|
+
|
|
289
|
+
const conditions = [
|
|
290
|
+
eq(scopedApprovalGrants.toolName, params.toolName),
|
|
291
|
+
eq(scopedApprovalGrants.inputDigest, params.inputDigest),
|
|
292
|
+
eq(scopedApprovalGrants.scopeMode, 'tool_signature'),
|
|
293
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
294
|
+
sql`${scopedApprovalGrants.expiresAt} > ${currentTime}`,
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
// assistantId is always set on grants — scope consumption to the current
|
|
298
|
+
// assistant so grants minted for one assistant cannot be consumed by another.
|
|
299
|
+
if (params.assistantId !== undefined) {
|
|
300
|
+
conditions.push(eq(scopedApprovalGrants.assistantId, params.assistantId));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Context constraints: grant field must be NULL (any) or match exactly
|
|
304
|
+
if (params.executionChannel !== undefined) {
|
|
305
|
+
conditions.push(
|
|
306
|
+
sql`(${scopedApprovalGrants.executionChannel} IS NULL OR ${scopedApprovalGrants.executionChannel} = ${params.executionChannel})`,
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
// If caller provides no execution channel, only match grants with NULL (any)
|
|
310
|
+
conditions.push(sql`${scopedApprovalGrants.executionChannel} IS NULL`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (params.conversationId !== undefined) {
|
|
314
|
+
conditions.push(
|
|
315
|
+
sql`(${scopedApprovalGrants.conversationId} IS NULL OR ${scopedApprovalGrants.conversationId} = ${params.conversationId})`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
conditions.push(sql`${scopedApprovalGrants.conversationId} IS NULL`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (params.callSessionId !== undefined) {
|
|
322
|
+
conditions.push(
|
|
323
|
+
sql`(${scopedApprovalGrants.callSessionId} IS NULL OR ${scopedApprovalGrants.callSessionId} = ${params.callSessionId})`,
|
|
324
|
+
);
|
|
325
|
+
} else {
|
|
326
|
+
conditions.push(sql`${scopedApprovalGrants.callSessionId} IS NULL`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (params.requesterExternalUserId !== undefined) {
|
|
330
|
+
conditions.push(
|
|
331
|
+
sql`(${scopedApprovalGrants.requesterExternalUserId} IS NULL OR ${scopedApprovalGrants.requesterExternalUserId} = ${params.requesterExternalUserId})`,
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
conditions.push(sql`${scopedApprovalGrants.requesterExternalUserId} IS NULL`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const specificityOrder = sql`(CASE WHEN ${scopedApprovalGrants.executionChannel} IS NOT NULL THEN 1 ELSE 0 END
|
|
338
|
+
+ CASE WHEN ${scopedApprovalGrants.conversationId} IS NOT NULL THEN 1 ELSE 0 END
|
|
339
|
+
+ CASE WHEN ${scopedApprovalGrants.callSessionId} IS NOT NULL THEN 1 ELSE 0 END
|
|
340
|
+
+ CASE WHEN ${scopedApprovalGrants.requesterExternalUserId} IS NOT NULL THEN 1 ELSE 0 END) DESC`;
|
|
341
|
+
|
|
342
|
+
// Retry loop: if CAS fails because another consumer stole our candidate,
|
|
343
|
+
// re-select and try again — another matching active grant may still exist.
|
|
344
|
+
for (let attempt = 0; attempt <= MAX_CAS_RETRIES; attempt++) {
|
|
345
|
+
// Select a single matching grant to consume (prefer most specific: fewest NULL scope fields).
|
|
346
|
+
// This avoids burning multiple grants when a wildcard grant and a specific grant both match.
|
|
347
|
+
const candidate = db
|
|
348
|
+
.select({ id: scopedApprovalGrants.id })
|
|
349
|
+
.from(scopedApprovalGrants)
|
|
350
|
+
.where(and(...conditions))
|
|
351
|
+
.orderBy(specificityOrder)
|
|
352
|
+
.limit(1)
|
|
353
|
+
.get();
|
|
354
|
+
|
|
355
|
+
if (!candidate) {
|
|
356
|
+
log.info(
|
|
357
|
+
{ event: 'scoped_grant_consume_miss', toolName: params.toolName, scopeMode: 'tool_signature', attempt },
|
|
358
|
+
'No matching active grant found for tool signature',
|
|
359
|
+
);
|
|
360
|
+
return { ok: false, grant: null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
db.update(scopedApprovalGrants)
|
|
364
|
+
.set({
|
|
365
|
+
status: 'consumed',
|
|
366
|
+
consumedAt: currentTime,
|
|
367
|
+
consumedByRequestId: params.consumingRequestId,
|
|
368
|
+
updatedAt: currentTime,
|
|
369
|
+
})
|
|
370
|
+
.where(
|
|
371
|
+
and(
|
|
372
|
+
eq(scopedApprovalGrants.id, candidate.id),
|
|
373
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
.run();
|
|
377
|
+
|
|
378
|
+
if (rawChanges() === 0) {
|
|
379
|
+
// CAS failed — another consumer raced and won this candidate; retry with next match
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Fetch the consumed grant
|
|
384
|
+
const row = db
|
|
385
|
+
.select()
|
|
386
|
+
.from(scopedApprovalGrants)
|
|
387
|
+
.where(eq(scopedApprovalGrants.id, candidate.id))
|
|
388
|
+
.get();
|
|
389
|
+
|
|
390
|
+
const grant = row ? rowToGrant(row) : null;
|
|
391
|
+
log.info(
|
|
392
|
+
{ event: 'scoped_grant_consume_success', grantId: grant?.id, toolName: params.toolName, consumingRequestId: params.consumingRequestId, scopeMode: 'tool_signature' },
|
|
393
|
+
'Scoped approval grant consumed by tool signature',
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
return { ok: true, grant };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// All retry attempts exhausted — every candidate was stolen by concurrent consumers
|
|
400
|
+
log.info(
|
|
401
|
+
{ event: 'scoped_grant_consume_miss', toolName: params.toolName, scopeMode: 'tool_signature', reason: 'cas_exhausted' },
|
|
402
|
+
'All CAS retry attempts exhausted for tool signature consume',
|
|
403
|
+
);
|
|
404
|
+
return { ok: false, grant: null };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Expire grants past their TTL
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Bulk-expire all active grants whose `expiresAt` is at or before `now`.
|
|
413
|
+
* Returns the number of grants expired.
|
|
414
|
+
*/
|
|
415
|
+
export function expireScopedApprovalGrants(now?: string): number {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
const currentTime = now ?? new Date().toISOString();
|
|
418
|
+
|
|
419
|
+
db.update(scopedApprovalGrants)
|
|
420
|
+
.set({
|
|
421
|
+
status: 'expired',
|
|
422
|
+
updatedAt: currentTime,
|
|
423
|
+
})
|
|
424
|
+
.where(
|
|
425
|
+
and(
|
|
426
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
427
|
+
sql`${scopedApprovalGrants.expiresAt} <= ${currentTime}`,
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
.run();
|
|
431
|
+
|
|
432
|
+
const count = rawChanges();
|
|
433
|
+
if (count > 0) {
|
|
434
|
+
log.info(
|
|
435
|
+
{ event: 'scoped_grant_expired', count },
|
|
436
|
+
`Expired ${count} scoped approval grant(s)`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return count;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Revoke active grants for a context
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
export interface RevokeContextParams {
|
|
448
|
+
assistantId?: string;
|
|
449
|
+
conversationId?: string;
|
|
450
|
+
callSessionId?: string;
|
|
451
|
+
requestChannel?: string;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Revoke all active grants matching the given context filters.
|
|
456
|
+
* At least one filter must be provided. Returns the number of
|
|
457
|
+
* grants revoked.
|
|
458
|
+
*
|
|
459
|
+
* Typical use: revoke all grants for a call session when the call ends.
|
|
460
|
+
*/
|
|
461
|
+
export function revokeScopedApprovalGrantsForContext(params: RevokeContextParams, now?: string): number {
|
|
462
|
+
const db = getDb();
|
|
463
|
+
const currentTime = now ?? new Date().toISOString();
|
|
464
|
+
|
|
465
|
+
const conditions = [eq(scopedApprovalGrants.status, 'active')];
|
|
466
|
+
|
|
467
|
+
if (params.assistantId !== undefined) {
|
|
468
|
+
conditions.push(eq(scopedApprovalGrants.assistantId, params.assistantId));
|
|
469
|
+
}
|
|
470
|
+
if (params.conversationId !== undefined) {
|
|
471
|
+
conditions.push(eq(scopedApprovalGrants.conversationId, params.conversationId));
|
|
472
|
+
}
|
|
473
|
+
if (params.callSessionId !== undefined) {
|
|
474
|
+
conditions.push(eq(scopedApprovalGrants.callSessionId, params.callSessionId));
|
|
475
|
+
}
|
|
476
|
+
if (params.requestChannel !== undefined) {
|
|
477
|
+
conditions.push(eq(scopedApprovalGrants.requestChannel, params.requestChannel));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Guard: at least one context filter must be provided to avoid revoking ALL active grants
|
|
481
|
+
if (conditions.length === 1) {
|
|
482
|
+
throw new Error('revokeScopedApprovalGrantsForContext requires at least one context filter');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
db.update(scopedApprovalGrants)
|
|
486
|
+
.set({
|
|
487
|
+
status: 'revoked',
|
|
488
|
+
updatedAt: currentTime,
|
|
489
|
+
})
|
|
490
|
+
.where(and(...conditions))
|
|
491
|
+
.run();
|
|
492
|
+
|
|
493
|
+
const count = rawChanges();
|
|
494
|
+
if (count > 0) {
|
|
495
|
+
log.info(
|
|
496
|
+
{
|
|
497
|
+
event: 'scoped_grant_revoked',
|
|
498
|
+
count,
|
|
499
|
+
assistantId: params.assistantId,
|
|
500
|
+
conversationId: params.conversationId,
|
|
501
|
+
callSessionId: params.callSessionId,
|
|
502
|
+
requestChannel: params.requestChannel,
|
|
503
|
+
},
|
|
504
|
+
`Revoked ${count} scoped approval grant(s) for context`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return count;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// @internal — exposed for tests and the approval-primitive wrapper only.
|
|
512
|
+
// Do not import these from production code outside this package; use the
|
|
513
|
+
// approval-primitive API instead.
|
|
514
|
+
export const _internal = {
|
|
515
|
+
createScopedApprovalGrant,
|
|
516
|
+
consumeScopedApprovalGrantByRequestId,
|
|
517
|
+
consumeScopedApprovalGrantByToolSignature,
|
|
518
|
+
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type {
|
|
9
9
|
SlackApiResponse,
|
|
10
10
|
SlackAuthTestResponse,
|
|
11
|
+
SlackChatDeleteResponse,
|
|
11
12
|
SlackConversationHistoryResponse,
|
|
12
13
|
SlackConversationLeaveResponse,
|
|
13
14
|
SlackConversationMarkResponse,
|
|
@@ -188,6 +189,17 @@ export async function addReaction(
|
|
|
188
189
|
});
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
export async function deleteMessage(
|
|
193
|
+
token: string,
|
|
194
|
+
channel: string,
|
|
195
|
+
ts: string,
|
|
196
|
+
): Promise<SlackChatDeleteResponse> {
|
|
197
|
+
return request<SlackChatDeleteResponse>(token, 'chat.delete', undefined, {
|
|
198
|
+
channel,
|
|
199
|
+
ts,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
191
203
|
export async function leaveConversation(
|
|
192
204
|
token: string,
|
|
193
205
|
channel: string,
|
|
@@ -116,4 +116,9 @@ export type SlackReactionAddResponse = SlackApiResponse;
|
|
|
116
116
|
|
|
117
117
|
export type SlackConversationLeaveResponse = SlackApiResponse;
|
|
118
118
|
|
|
119
|
+
export interface SlackChatDeleteResponse extends SlackApiResponse {
|
|
120
|
+
channel: string;
|
|
121
|
+
ts: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
119
124
|
export type SlackConversationMarkResponse = SlackApiResponse;
|
|
@@ -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) {
|