vellum 0.2.13 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
* GET /v1/calls/:callSessionId — get call status
|
|
6
6
|
* POST /v1/calls/:callSessionId/cancel — cancel a call
|
|
7
7
|
* POST /v1/calls/:callSessionId/answer — answer a pending question
|
|
8
|
+
* POST /v1/calls/:callSessionId/instruction — relay an instruction to an active call
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import { startCall, getCallStatus, cancelCall, answerCall } from '../../calls/call-domain.js';
|
|
11
|
+
import { startCall, getCallStatus, cancelCall, answerCall, relayInstruction } from '../../calls/call-domain.js';
|
|
11
12
|
import { getConfig } from '../../config/loader.js';
|
|
13
|
+
import { VALID_CALLER_IDENTITY_MODES } from '../../config/schema.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* POST /v1/calls/start
|
|
15
17
|
*
|
|
16
|
-
* Body: { phoneNumber: string; task: string; context?: string; conversationId: string }
|
|
18
|
+
* Body: { phoneNumber: string; task: string; context?: string; conversationId: string; callerIdentityMode?: 'assistant_number' | 'user_number' }
|
|
17
19
|
*/
|
|
18
20
|
export async function handleStartCall(req: Request): Promise<Response> {
|
|
19
21
|
if (!getConfig().calls.enabled) {
|
|
@@ -28,6 +30,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
|
|
|
28
30
|
task?: string;
|
|
29
31
|
context?: string;
|
|
30
32
|
conversationId?: string;
|
|
33
|
+
callerIdentityMode?: 'assistant_number' | 'user_number';
|
|
31
34
|
};
|
|
32
35
|
try {
|
|
33
36
|
body = await req.json() as typeof body;
|
|
@@ -35,15 +38,28 @@ export async function handleStartCall(req: Request): Promise<Response> {
|
|
|
35
38
|
return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
42
|
+
return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
if (!body.conversationId) {
|
|
39
46
|
return Response.json({ error: 'conversationId is required' }, { status: 400 });
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
if (body.callerIdentityMode != null &&
|
|
50
|
+
!(VALID_CALLER_IDENTITY_MODES as readonly string[]).includes(body.callerIdentityMode as string)) {
|
|
51
|
+
return Response.json(
|
|
52
|
+
{ error: `Invalid callerIdentityMode: "${body.callerIdentityMode}". Must be one of: ${VALID_CALLER_IDENTITY_MODES.join(', ')}` },
|
|
53
|
+
{ status: 400 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
const result = await startCall({
|
|
43
58
|
phoneNumber: body.phoneNumber ?? '',
|
|
44
59
|
task: body.task ?? '',
|
|
45
60
|
context: body.context,
|
|
46
61
|
conversationId: body.conversationId,
|
|
62
|
+
callerIdentityMode: body.callerIdentityMode,
|
|
47
63
|
});
|
|
48
64
|
|
|
49
65
|
if (!result.ok) {
|
|
@@ -56,6 +72,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
|
|
|
56
72
|
status: result.session.status,
|
|
57
73
|
toNumber: result.session.toNumber,
|
|
58
74
|
fromNumber: result.session.fromNumber,
|
|
75
|
+
callerIdentityMode: result.callerIdentityMode,
|
|
59
76
|
}, { status: 201 });
|
|
60
77
|
}
|
|
61
78
|
|
|
@@ -127,6 +144,10 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
|
|
|
127
144
|
return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
148
|
+
return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
const result = await answerCall({
|
|
131
152
|
callSessionId,
|
|
132
153
|
answer: body.answer ?? '',
|
|
@@ -138,3 +159,32 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
|
|
|
138
159
|
|
|
139
160
|
return Response.json({ ok: true, questionId: result.questionId });
|
|
140
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* POST /v1/calls/:callSessionId/instruction
|
|
165
|
+
*
|
|
166
|
+
* Body: { instruction: string }
|
|
167
|
+
*/
|
|
168
|
+
export async function handleInstructionCall(req: Request, callSessionId: string): Promise<Response> {
|
|
169
|
+
let body: { instruction?: string };
|
|
170
|
+
try {
|
|
171
|
+
body = await req.json() as typeof body;
|
|
172
|
+
} catch {
|
|
173
|
+
return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
177
|
+
return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = await relayInstruction({
|
|
181
|
+
callSessionId,
|
|
182
|
+
instructionText: body.instruction ?? '',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (!result.ok) {
|
|
186
|
+
return Response.json({ error: result.error }, { status: result.status ?? 500 });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Response.json({ ok: true });
|
|
190
|
+
}
|
|
@@ -6,11 +6,22 @@ import { deleteConversationKey } from '../../memory/conversation-key-store.js';
|
|
|
6
6
|
import * as conversationStore from '../../memory/conversation-store.js';
|
|
7
7
|
import * as attachmentsStore from '../../memory/attachments-store.js';
|
|
8
8
|
import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
|
|
9
|
+
import * as externalConversationStore from '../../memory/external-conversation-store.js';
|
|
10
|
+
import { getPendingConfirmationsByConversation } from '../../memory/runs-store.js';
|
|
9
11
|
import { renderHistoryContent } from '../../daemon/handlers.js';
|
|
10
12
|
import { checkIngressForSecrets } from '../../security/secret-ingress.js';
|
|
11
13
|
import { IngressBlockedError } from '../../util/errors.js';
|
|
12
14
|
import { getLogger } from '../../util/logger.js';
|
|
13
|
-
import { deliverChannelReply } from '../gateway-client.js';
|
|
15
|
+
import { deliverChannelReply, deliverApprovalPrompt } from '../gateway-client.js';
|
|
16
|
+
import { parseApprovalDecision } from '../channel-approval-parser.js';
|
|
17
|
+
import {
|
|
18
|
+
getChannelApprovalPrompt,
|
|
19
|
+
buildApprovalUIMetadata,
|
|
20
|
+
handleChannelDecision,
|
|
21
|
+
buildReminderPrompt,
|
|
22
|
+
} from '../channel-approvals.js';
|
|
23
|
+
import type { ApprovalAction, ApprovalDecisionResult } from '../channel-approval-types.js';
|
|
24
|
+
import type { RunOrchestrator } from '../run-orchestrator.js';
|
|
14
25
|
import type {
|
|
15
26
|
MessageProcessor,
|
|
16
27
|
RuntimeAttachmentMetadata,
|
|
@@ -18,6 +29,32 @@ import type {
|
|
|
18
29
|
|
|
19
30
|
const log = getLogger('runtime-http');
|
|
20
31
|
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Feature flag
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export function isChannelApprovalsEnabled(): boolean {
|
|
37
|
+
return process.env.CHANNEL_APPROVALS_ENABLED === 'true';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Callback data parser — format: "apr:<runId>:<action>"
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const VALID_ACTIONS: ReadonlySet<string> = new Set<string>([
|
|
45
|
+
'approve_once',
|
|
46
|
+
'approve_always',
|
|
47
|
+
'reject',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function parseCallbackData(data: string): ApprovalDecisionResult | null {
|
|
51
|
+
const parts = data.split(':');
|
|
52
|
+
if (parts.length < 3 || parts[0] !== 'apr') return null;
|
|
53
|
+
const action = parts.slice(2).join(':');
|
|
54
|
+
if (!VALID_ACTIONS.has(action)) return null;
|
|
55
|
+
return { action: action as ApprovalAction, source: 'telegram_button' };
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
export async function handleDeleteConversation(req: Request): Promise<Response> {
|
|
22
59
|
const body = await req.json() as {
|
|
23
60
|
sourceChannel?: string;
|
|
@@ -35,6 +72,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
|
|
|
35
72
|
|
|
36
73
|
const conversationKey = `${sourceChannel}:${externalChatId}`;
|
|
37
74
|
deleteConversationKey(conversationKey);
|
|
75
|
+
externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
|
|
38
76
|
|
|
39
77
|
return Response.json({ ok: true });
|
|
40
78
|
}
|
|
@@ -43,6 +81,7 @@ export async function handleChannelInbound(
|
|
|
43
81
|
req: Request,
|
|
44
82
|
processMessage?: MessageProcessor,
|
|
45
83
|
bearerToken?: string,
|
|
84
|
+
runOrchestrator?: RunOrchestrator,
|
|
46
85
|
): Promise<Response> {
|
|
47
86
|
const body = await req.json() as {
|
|
48
87
|
sourceChannel?: string;
|
|
@@ -56,6 +95,8 @@ export async function handleChannelInbound(
|
|
|
56
95
|
senderUsername?: string;
|
|
57
96
|
sourceMetadata?: Record<string, unknown>;
|
|
58
97
|
replyCallbackUrl?: string;
|
|
98
|
+
callbackQueryId?: string;
|
|
99
|
+
callbackData?: string;
|
|
59
100
|
};
|
|
60
101
|
|
|
61
102
|
const {
|
|
@@ -179,6 +220,16 @@ export async function handleChannelInbound(
|
|
|
179
220
|
{ sourceMessageId },
|
|
180
221
|
);
|
|
181
222
|
|
|
223
|
+
// Upsert external conversation binding with sender metadata
|
|
224
|
+
externalConversationStore.upsertBinding({
|
|
225
|
+
conversationId: result.conversationId,
|
|
226
|
+
sourceChannel,
|
|
227
|
+
externalChatId,
|
|
228
|
+
externalUserId: body.senderExternalUserId ?? null,
|
|
229
|
+
displayName: body.senderName ?? null,
|
|
230
|
+
username: body.senderUsername ?? null,
|
|
231
|
+
});
|
|
232
|
+
|
|
182
233
|
const metadataHintsRaw = sourceMetadata?.hints;
|
|
183
234
|
const metadataHints = Array.isArray(metadataHintsRaw)
|
|
184
235
|
? metadataHintsRaw.filter((hint): hint is string => typeof hint === 'string' && hint.trim().length > 0)
|
|
@@ -189,6 +240,33 @@ export async function handleChannelInbound(
|
|
|
189
240
|
|
|
190
241
|
const replyCallbackUrl = body.replyCallbackUrl;
|
|
191
242
|
|
|
243
|
+
// ── Approval interception (gated behind feature flag) ──
|
|
244
|
+
if (
|
|
245
|
+
isChannelApprovalsEnabled() &&
|
|
246
|
+
runOrchestrator &&
|
|
247
|
+
replyCallbackUrl &&
|
|
248
|
+
!result.duplicate
|
|
249
|
+
) {
|
|
250
|
+
const approvalResult = await handleApprovalInterception({
|
|
251
|
+
conversationId: result.conversationId,
|
|
252
|
+
callbackData: body.callbackData,
|
|
253
|
+
content: trimmedContent,
|
|
254
|
+
externalChatId,
|
|
255
|
+
replyCallbackUrl,
|
|
256
|
+
bearerToken,
|
|
257
|
+
orchestrator: runOrchestrator,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (approvalResult.handled) {
|
|
261
|
+
return Response.json({
|
|
262
|
+
accepted: true,
|
|
263
|
+
duplicate: false,
|
|
264
|
+
eventId: result.eventId,
|
|
265
|
+
approval: approvalResult.type,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
192
270
|
// For new (non-duplicate) messages, run the secret ingress check
|
|
193
271
|
// synchronously, then fire off the agent loop in the background.
|
|
194
272
|
if (!result.duplicate && processMessage) {
|
|
@@ -217,21 +295,40 @@ export async function handleChannelInbound(
|
|
|
217
295
|
throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
|
|
218
296
|
}
|
|
219
297
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
298
|
+
// When approval flow is enabled and we have an orchestrator, use the
|
|
299
|
+
// orchestrator-backed path which properly intercepts confirmation_request
|
|
300
|
+
// events and sends proactive approval prompts to the channel.
|
|
301
|
+
const useApprovalPath =
|
|
302
|
+
isChannelApprovalsEnabled() && runOrchestrator && replyCallbackUrl;
|
|
303
|
+
|
|
304
|
+
if (useApprovalPath) {
|
|
305
|
+
processChannelMessageWithApprovals({
|
|
306
|
+
orchestrator: runOrchestrator,
|
|
307
|
+
conversationId: result.conversationId,
|
|
308
|
+
eventId: result.eventId,
|
|
309
|
+
content: content ?? '',
|
|
310
|
+
attachmentIds: hasAttachments ? attachmentIds : undefined,
|
|
311
|
+
externalChatId,
|
|
312
|
+
replyCallbackUrl,
|
|
313
|
+
bearerToken,
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
// Fire-and-forget: process the message and deliver the reply in the background.
|
|
317
|
+
// The HTTP response returns immediately so the gateway webhook is not blocked.
|
|
318
|
+
processChannelMessageInBackground({
|
|
319
|
+
processMessage,
|
|
320
|
+
conversationId: result.conversationId,
|
|
321
|
+
eventId: result.eventId,
|
|
322
|
+
content: content ?? '',
|
|
323
|
+
attachmentIds: hasAttachments ? attachmentIds : undefined,
|
|
324
|
+
sourceChannel,
|
|
325
|
+
externalChatId,
|
|
326
|
+
metadataHints,
|
|
327
|
+
metadataUxBrief,
|
|
328
|
+
replyCallbackUrl,
|
|
329
|
+
bearerToken,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
235
332
|
}
|
|
236
333
|
|
|
237
334
|
return Response.json({
|
|
@@ -298,6 +395,189 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
298
395
|
})();
|
|
299
396
|
}
|
|
300
397
|
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Orchestrator-backed channel processing with approval prompt delivery
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
const RUN_POLL_INTERVAL_MS = 500;
|
|
403
|
+
const RUN_POLL_MAX_WAIT_MS = 300_000; // 5 minutes
|
|
404
|
+
|
|
405
|
+
interface ApprovalProcessingParams {
|
|
406
|
+
orchestrator: RunOrchestrator;
|
|
407
|
+
conversationId: string;
|
|
408
|
+
eventId: string;
|
|
409
|
+
content: string;
|
|
410
|
+
attachmentIds?: string[];
|
|
411
|
+
externalChatId: string;
|
|
412
|
+
replyCallbackUrl: string;
|
|
413
|
+
bearerToken?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Process a channel message using the run orchestrator so that
|
|
418
|
+
* `confirmation_request` events are intercepted and written to the
|
|
419
|
+
* runs store. Polls the run until it reaches a terminal state,
|
|
420
|
+
* sending approval prompts when `needs_confirmation` is detected.
|
|
421
|
+
*/
|
|
422
|
+
function processChannelMessageWithApprovals(params: ApprovalProcessingParams): void {
|
|
423
|
+
const {
|
|
424
|
+
orchestrator,
|
|
425
|
+
conversationId,
|
|
426
|
+
eventId,
|
|
427
|
+
content,
|
|
428
|
+
attachmentIds,
|
|
429
|
+
externalChatId,
|
|
430
|
+
replyCallbackUrl,
|
|
431
|
+
bearerToken,
|
|
432
|
+
} = params;
|
|
433
|
+
|
|
434
|
+
(async () => {
|
|
435
|
+
try {
|
|
436
|
+
const run = await orchestrator.startRun(conversationId, content, attachmentIds);
|
|
437
|
+
|
|
438
|
+
// Poll the run until it reaches a terminal state, delivering approval
|
|
439
|
+
// prompts when it transitions to needs_confirmation.
|
|
440
|
+
const startTime = Date.now();
|
|
441
|
+
let lastStatus = run.status;
|
|
442
|
+
|
|
443
|
+
while (Date.now() - startTime < RUN_POLL_MAX_WAIT_MS) {
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
|
|
445
|
+
|
|
446
|
+
const current = orchestrator.getRun(run.id);
|
|
447
|
+
if (!current) break;
|
|
448
|
+
|
|
449
|
+
if (current.status === 'needs_confirmation' && lastStatus !== 'needs_confirmation') {
|
|
450
|
+
// Run just transitioned to needs_confirmation — send approval prompt
|
|
451
|
+
const prompt = getChannelApprovalPrompt(conversationId);
|
|
452
|
+
if (prompt) {
|
|
453
|
+
const pending = getPendingConfirmationsByConversation(conversationId);
|
|
454
|
+
if (pending.length > 0) {
|
|
455
|
+
const uiMetadata = buildApprovalUIMetadata(prompt, pending[0]);
|
|
456
|
+
try {
|
|
457
|
+
await deliverApprovalPrompt(
|
|
458
|
+
replyCallbackUrl,
|
|
459
|
+
externalChatId,
|
|
460
|
+
prompt.promptText,
|
|
461
|
+
uiMetadata,
|
|
462
|
+
bearerToken,
|
|
463
|
+
);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
log.error({ err, runId: run.id }, 'Failed to deliver approval prompt for channel run');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
lastStatus = current.status;
|
|
472
|
+
|
|
473
|
+
if (current.status === 'completed' || current.status === 'failed') {
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
channelDeliveryStore.markProcessed(eventId);
|
|
479
|
+
|
|
480
|
+
// Deliver the final assistant reply
|
|
481
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
log.error({ err, conversationId }, 'Approval-aware channel message processing failed');
|
|
484
|
+
channelDeliveryStore.recordProcessingFailure(eventId, err);
|
|
485
|
+
}
|
|
486
|
+
})();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Approval interception
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
interface ApprovalInterceptionParams {
|
|
494
|
+
conversationId: string;
|
|
495
|
+
callbackData?: string;
|
|
496
|
+
content: string;
|
|
497
|
+
externalChatId: string;
|
|
498
|
+
replyCallbackUrl: string;
|
|
499
|
+
bearerToken?: string;
|
|
500
|
+
orchestrator: RunOrchestrator;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface ApprovalInterceptionResult {
|
|
504
|
+
handled: boolean;
|
|
505
|
+
type?: 'decision_applied' | 'reminder_sent';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Check for pending approvals and handle inbound messages accordingly.
|
|
510
|
+
*
|
|
511
|
+
* Returns `{ handled: true }` when the message was consumed by the approval
|
|
512
|
+
* flow (either as a decision or a reminder), so the caller should NOT proceed
|
|
513
|
+
* to normal message processing.
|
|
514
|
+
*/
|
|
515
|
+
async function handleApprovalInterception(
|
|
516
|
+
params: ApprovalInterceptionParams,
|
|
517
|
+
): Promise<ApprovalInterceptionResult> {
|
|
518
|
+
const {
|
|
519
|
+
conversationId,
|
|
520
|
+
callbackData,
|
|
521
|
+
content,
|
|
522
|
+
externalChatId,
|
|
523
|
+
replyCallbackUrl,
|
|
524
|
+
bearerToken,
|
|
525
|
+
orchestrator,
|
|
526
|
+
} = params;
|
|
527
|
+
|
|
528
|
+
const pendingPrompt = getChannelApprovalPrompt(conversationId);
|
|
529
|
+
if (!pendingPrompt) return { handled: false };
|
|
530
|
+
|
|
531
|
+
// Try to extract a decision from callback data (button press) first,
|
|
532
|
+
// then fall back to plain-text parsing.
|
|
533
|
+
let decision: ApprovalDecisionResult | null = null;
|
|
534
|
+
|
|
535
|
+
if (callbackData) {
|
|
536
|
+
decision = parseCallbackData(callbackData);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!decision && content) {
|
|
540
|
+
decision = parseApprovalDecision(content);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (decision) {
|
|
544
|
+
const result = handleChannelDecision(conversationId, decision, orchestrator);
|
|
545
|
+
|
|
546
|
+
if (result.applied) {
|
|
547
|
+
// Deliver the run's result back to the channel once the decision is applied.
|
|
548
|
+
// The run will resume in the background; deliver whatever assistant reply
|
|
549
|
+
// is available now (there may not be one yet if the run is still processing).
|
|
550
|
+
try {
|
|
551
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
log.error({ err, conversationId }, 'Failed to deliver post-decision reply');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { handled: true, type: 'decision_applied' };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// The message is not a decision — send a reminder with the approval buttons.
|
|
561
|
+
const reminder = buildReminderPrompt(pendingPrompt);
|
|
562
|
+
const pending = getPendingConfirmationsByConversation(conversationId);
|
|
563
|
+
if (pending.length > 0) {
|
|
564
|
+
const uiMetadata = buildApprovalUIMetadata(reminder, pending[0]);
|
|
565
|
+
try {
|
|
566
|
+
await deliverApprovalPrompt(
|
|
567
|
+
replyCallbackUrl,
|
|
568
|
+
externalChatId,
|
|
569
|
+
reminder.promptText,
|
|
570
|
+
uiMetadata,
|
|
571
|
+
bearerToken,
|
|
572
|
+
);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
log.error({ err, conversationId }, 'Failed to deliver approval reminder');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return { handled: true, type: 'reminder_sent' };
|
|
579
|
+
}
|
|
580
|
+
|
|
301
581
|
async function deliverReplyViaCallback(
|
|
302
582
|
conversationId: string,
|
|
303
583
|
externalChatId: string,
|
|
@@ -8,64 +8,133 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getOrCreateConversation } from '../../memory/conversation-key-store.js';
|
|
11
|
-
import { assistantEventHub } from '../assistant-event-hub.js';
|
|
12
|
-
import { formatSseFrame } from '../assistant-event.js';
|
|
11
|
+
import { assistantEventHub, AssistantEventHub } from '../assistant-event-hub.js';
|
|
12
|
+
import { formatSseFrame, formatSseHeartbeat } from '../assistant-event.js';
|
|
13
13
|
import type { AssistantEventSubscription } from '../assistant-event-hub.js';
|
|
14
14
|
|
|
15
|
+
/** Keep-alive comment sent to idle clients every 30 s by default. */
|
|
16
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
19
|
* Stream assistant events as Server-Sent Events for a specific conversation.
|
|
17
20
|
*
|
|
18
21
|
* Query params:
|
|
19
22
|
* conversationKey — required; scopes the stream to one conversation.
|
|
23
|
+
*
|
|
24
|
+
* Options (for testing):
|
|
25
|
+
* hub — override the event hub (defaults to process singleton).
|
|
26
|
+
* heartbeatIntervalMs — how often to emit keep-alive comments (default 30 s).
|
|
20
27
|
*/
|
|
21
28
|
export function handleSubscribeAssistantEvents(
|
|
22
29
|
req: Request,
|
|
23
30
|
url: URL,
|
|
31
|
+
options?: {
|
|
32
|
+
hub?: AssistantEventHub;
|
|
33
|
+
heartbeatIntervalMs?: number;
|
|
34
|
+
},
|
|
24
35
|
): Response {
|
|
25
36
|
const conversationKey = url.searchParams.get('conversationKey');
|
|
26
37
|
if (!conversationKey) {
|
|
27
38
|
return Response.json({ error: 'conversationKey is required' }, { status: 400 });
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
const hub = options?.hub ?? assistantEventHub;
|
|
42
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
43
|
+
|
|
30
44
|
const mapping = getOrCreateConversation(conversationKey);
|
|
31
45
|
const encoder = new TextEncoder();
|
|
32
|
-
|
|
46
|
+
|
|
47
|
+
// ── Eager subscribe ──────────────────────────────────────────────────────
|
|
48
|
+
// Subscribe before creating the ReadableStream so the callback and onEvict
|
|
49
|
+
// closures are in place before events can arrive. `controllerRef` is set
|
|
50
|
+
// synchronously inside ReadableStream's start(), so it is non-null by the
|
|
51
|
+
// time any event or eviction fires.
|
|
52
|
+
// 'self' is the assistantId that RunOrchestrator assigns to all HTTP-run
|
|
53
|
+
// events (see buildAssistantEvent('self', ...) in run-orchestrator.ts).
|
|
54
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
55
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
56
|
+
let sub!: AssistantEventSubscription;
|
|
57
|
+
|
|
58
|
+
function cleanup() {
|
|
59
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
60
|
+
try { controllerRef?.close(); } catch { /* already closed */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
sub = hub.subscribe(
|
|
65
|
+
{ assistantId: 'self', sessionId: mapping.conversationId },
|
|
66
|
+
(event) => {
|
|
67
|
+
const controller = controllerRef;
|
|
68
|
+
if (!controller) return;
|
|
69
|
+
try {
|
|
70
|
+
// Shed stalled consumers: desiredSize <= 0 means the 16-event buffer
|
|
71
|
+
// is full and the client isn't draining it.
|
|
72
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
73
|
+
sub.dispose();
|
|
74
|
+
cleanup();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
controller.enqueue(encoder.encode(formatSseFrame(event)));
|
|
78
|
+
} catch {
|
|
79
|
+
sub.dispose();
|
|
80
|
+
cleanup();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
// Called by the hub when a newer connection evicts this one (capacity
|
|
85
|
+
// management: oldest subscriber out, newest in).
|
|
86
|
+
onEvict: cleanup,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof RangeError) {
|
|
91
|
+
return Response.json({ error: 'Too many concurrent connections' }, { status: 503 });
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
33
95
|
|
|
34
96
|
// Allow up to 16 queued frames before treating the consumer as stalled.
|
|
35
97
|
// This absorbs normal token-stream bursts without prematurely closing the
|
|
36
98
|
// connection, while still shedding genuinely slow clients.
|
|
37
|
-
const stream = new ReadableStream({
|
|
99
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
38
100
|
start(controller) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
101
|
+
controllerRef = controller;
|
|
102
|
+
|
|
103
|
+
// If the client already disconnected before start() ran, clean up
|
|
104
|
+
// immediately — the abort event fires once and won't be re-dispatched.
|
|
105
|
+
if (req.signal.aborted) {
|
|
106
|
+
sub.dispose();
|
|
107
|
+
cleanup();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Send a keep-alive comment on each interval to prevent proxies and
|
|
112
|
+
// load-balancers from treating idle connections as timed out.
|
|
113
|
+
heartbeatTimer = setInterval(() => {
|
|
114
|
+
try {
|
|
115
|
+
// Apply the same slow-consumer guard as the event path: stop
|
|
116
|
+
// feeding heartbeats into a queue the client is not draining.
|
|
117
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
118
|
+
sub.dispose();
|
|
119
|
+
cleanup();
|
|
120
|
+
return;
|
|
58
121
|
}
|
|
59
|
-
|
|
60
|
-
|
|
122
|
+
controller.enqueue(encoder.encode(formatSseHeartbeat()));
|
|
123
|
+
} catch {
|
|
124
|
+
// Controller already closed (e.g. client disconnected).
|
|
125
|
+
sub.dispose();
|
|
126
|
+
cleanup();
|
|
127
|
+
}
|
|
128
|
+
}, heartbeatIntervalMs);
|
|
61
129
|
|
|
62
130
|
req.signal.addEventListener('abort', () => {
|
|
63
|
-
sub
|
|
64
|
-
|
|
131
|
+
sub.dispose();
|
|
132
|
+
cleanup();
|
|
65
133
|
}, { once: true });
|
|
66
134
|
},
|
|
67
135
|
cancel() {
|
|
68
|
-
sub
|
|
136
|
+
sub.dispose();
|
|
137
|
+
cleanup();
|
|
69
138
|
},
|
|
70
139
|
}, new CountQueuingStrategy({ highWaterMark: 16 }));
|
|
71
140
|
|
|
@@ -200,13 +200,8 @@ export async function handleAddTrustRule(
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// would never match and users would keep getting re-prompted.
|
|
206
|
-
addRule(confirmation.toolName, pattern, scope, decision, 100, {
|
|
207
|
-
principalKind: confirmation.principalKind,
|
|
208
|
-
principalId: confirmation.principalId,
|
|
209
|
-
principalVersion: confirmation.principalVersion,
|
|
203
|
+
addRule(confirmation.toolName, pattern, scope, decision, undefined, {
|
|
204
|
+
executionTarget: confirmation.executionTarget,
|
|
210
205
|
});
|
|
211
206
|
log.info(
|
|
212
207
|
{ tool: confirmation.toolName, pattern, scope, decision, runId },
|
|
@@ -131,9 +131,6 @@ export class RunOrchestrator {
|
|
|
131
131
|
executionTarget: msg.executionTarget,
|
|
132
132
|
allowlistOptions: msg.allowlistOptions,
|
|
133
133
|
scopeOptions: msg.scopeOptions,
|
|
134
|
-
principalKind: msg.principalKind,
|
|
135
|
-
principalId: msg.principalId,
|
|
136
|
-
principalVersion: msg.principalVersion,
|
|
137
134
|
persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
|
|
138
135
|
});
|
|
139
136
|
this.pending.set(run.id, {
|