@vellumai/assistant 0.3.8 → 0.3.9
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +84 -1
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -0
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +10 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +31 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
handleRunSecret,
|
|
36
36
|
handleAddTrustRule,
|
|
37
37
|
} from './routes/run-routes.js';
|
|
38
|
+
import {
|
|
39
|
+
handleConfirm,
|
|
40
|
+
handleSecret,
|
|
41
|
+
handleTrustRule,
|
|
42
|
+
} from './routes/approval-routes.js';
|
|
38
43
|
import {
|
|
39
44
|
handleDeleteConversation,
|
|
40
45
|
handleChannelInbound,
|
|
@@ -566,6 +571,11 @@ export class RuntimeHttpServer {
|
|
|
566
571
|
});
|
|
567
572
|
}
|
|
568
573
|
|
|
574
|
+
// Standalone approval endpoints — keyed by requestId, orthogonal to message sending
|
|
575
|
+
if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req);
|
|
576
|
+
if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req);
|
|
577
|
+
if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
|
|
578
|
+
|
|
569
579
|
if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req);
|
|
570
580
|
if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req);
|
|
571
581
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory tracker that maps requestId to session info for pending
|
|
3
|
+
* confirmation and secret interactions.
|
|
4
|
+
*
|
|
5
|
+
* When the agent loop emits a confirmation_request or secret_request,
|
|
6
|
+
* the onEvent callback registers the interaction here. Standalone HTTP
|
|
7
|
+
* endpoints (/v1/confirm, /v1/secret, /v1/trust-rules) look up the
|
|
8
|
+
* session from this tracker to resolve the interaction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Session } from '../daemon/session.js';
|
|
12
|
+
|
|
13
|
+
export interface ConfirmationDetails {
|
|
14
|
+
toolName: string;
|
|
15
|
+
input: Record<string, unknown>;
|
|
16
|
+
riskLevel: string;
|
|
17
|
+
executionTarget?: 'sandbox' | 'host';
|
|
18
|
+
allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
|
|
19
|
+
scopeOptions: Array<{ label: string; scope: string }>;
|
|
20
|
+
persistentDecisionsAllowed?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PendingInteraction {
|
|
24
|
+
session: Session;
|
|
25
|
+
conversationId: string;
|
|
26
|
+
kind: 'confirmation' | 'secret';
|
|
27
|
+
confirmationDetails?: ConfirmationDetails;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pending = new Map<string, PendingInteraction>();
|
|
31
|
+
|
|
32
|
+
export function register(requestId: string, interaction: PendingInteraction): void {
|
|
33
|
+
pending.set(requestId, interaction);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove and return the pending interaction for the given requestId.
|
|
38
|
+
* Returns undefined if no interaction is registered.
|
|
39
|
+
*/
|
|
40
|
+
export function resolve(requestId: string): PendingInteraction | undefined {
|
|
41
|
+
const interaction = pending.get(requestId);
|
|
42
|
+
if (interaction) {
|
|
43
|
+
pending.delete(requestId);
|
|
44
|
+
}
|
|
45
|
+
return interaction;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the pending interaction without removing it.
|
|
50
|
+
* Used by trust-rule endpoint which doesn't resolve the confirmation itself.
|
|
51
|
+
*/
|
|
52
|
+
export function get(requestId: string): PendingInteraction | undefined {
|
|
53
|
+
return pending.get(requestId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return all pending interactions for a given conversation.
|
|
58
|
+
* Needed by channel approval migration (PR 3).
|
|
59
|
+
*/
|
|
60
|
+
export function getByConversation(conversationId: string): Array<{ requestId: string } & PendingInteraction> {
|
|
61
|
+
const results: Array<{ requestId: string } & PendingInteraction> = [];
|
|
62
|
+
for (const [requestId, interaction] of pending) {
|
|
63
|
+
if (interaction.conversationId === conversationId) {
|
|
64
|
+
results.push({ requestId, ...interaction });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Clear all pending interactions. Useful for testing. */
|
|
71
|
+
export function clear(): void {
|
|
72
|
+
pending.clear();
|
|
73
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handlers for standalone approval endpoints.
|
|
3
|
+
*
|
|
4
|
+
* These endpoints resolve pending confirmations, secrets, and trust rules
|
|
5
|
+
* by requestId — orthogonal to message sending.
|
|
6
|
+
*/
|
|
7
|
+
import * as pendingInteractions from '../pending-interactions.js';
|
|
8
|
+
import { addRule } from '../../permissions/trust-store.js';
|
|
9
|
+
import { getTool } from '../../tools/registry.js';
|
|
10
|
+
import { getLogger } from '../../util/logger.js';
|
|
11
|
+
|
|
12
|
+
const log = getLogger('approval-routes');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* POST /v1/confirm — resolve a pending confirmation by requestId.
|
|
16
|
+
*/
|
|
17
|
+
export async function handleConfirm(req: Request): Promise<Response> {
|
|
18
|
+
const body = await req.json() as {
|
|
19
|
+
requestId?: string;
|
|
20
|
+
decision?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const { requestId, decision } = body;
|
|
24
|
+
|
|
25
|
+
if (!requestId || typeof requestId !== 'string') {
|
|
26
|
+
return Response.json({ error: 'requestId is required' }, { status: 400 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (decision !== 'allow' && decision !== 'deny') {
|
|
30
|
+
return Response.json(
|
|
31
|
+
{ error: 'decision must be "allow" or "deny"' },
|
|
32
|
+
{ status: 400 },
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
37
|
+
if (!interaction) {
|
|
38
|
+
return Response.json(
|
|
39
|
+
{ error: 'No pending interaction found for this requestId' },
|
|
40
|
+
{ status: 404 },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interaction.session.handleConfirmationResponse(requestId, decision);
|
|
45
|
+
return Response.json({ accepted: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* POST /v1/secret — resolve a pending secret request by requestId.
|
|
50
|
+
*/
|
|
51
|
+
export async function handleSecret(req: Request): Promise<Response> {
|
|
52
|
+
const body = await req.json() as {
|
|
53
|
+
requestId?: string;
|
|
54
|
+
value?: string;
|
|
55
|
+
delivery?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const { requestId, value, delivery } = body;
|
|
59
|
+
|
|
60
|
+
if (!requestId || typeof requestId !== 'string') {
|
|
61
|
+
return Response.json({ error: 'requestId is required' }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
|
|
65
|
+
return Response.json(
|
|
66
|
+
{ error: 'delivery must be "store" or "transient_send"' },
|
|
67
|
+
{ status: 400 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const interaction = pendingInteractions.resolve(requestId);
|
|
72
|
+
if (!interaction) {
|
|
73
|
+
return Response.json(
|
|
74
|
+
{ error: 'No pending interaction found for this requestId' },
|
|
75
|
+
{ status: 404 },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interaction.session.handleSecretResponse(
|
|
80
|
+
requestId,
|
|
81
|
+
value,
|
|
82
|
+
delivery as 'store' | 'transient_send' | undefined,
|
|
83
|
+
);
|
|
84
|
+
return Response.json({ accepted: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* POST /v1/trust-rules — add a trust rule for a pending confirmation.
|
|
89
|
+
*
|
|
90
|
+
* Does NOT resolve the confirmation itself (the client still needs to
|
|
91
|
+
* POST /v1/confirm to approve/deny). Validates the pattern and scope
|
|
92
|
+
* against the server-provided allowlist options from the original
|
|
93
|
+
* confirmation_request.
|
|
94
|
+
*/
|
|
95
|
+
export async function handleTrustRule(req: Request): Promise<Response> {
|
|
96
|
+
const body = await req.json() as {
|
|
97
|
+
requestId?: string;
|
|
98
|
+
pattern?: string;
|
|
99
|
+
scope?: string;
|
|
100
|
+
decision?: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const { requestId, pattern, scope, decision } = body;
|
|
104
|
+
|
|
105
|
+
if (!requestId || typeof requestId !== 'string') {
|
|
106
|
+
return Response.json({ error: 'requestId is required' }, { status: 400 });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!pattern || typeof pattern !== 'string') {
|
|
110
|
+
return Response.json({ error: 'pattern is required' }, { status: 400 });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!scope || typeof scope !== 'string') {
|
|
114
|
+
return Response.json({ error: 'scope is required' }, { status: 400 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (decision !== 'allow' && decision !== 'deny') {
|
|
118
|
+
return Response.json({ error: 'decision must be "allow" or "deny"' }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Look up without removing — trust rule doesn't resolve the confirmation
|
|
122
|
+
const interaction = pendingInteractions.get(requestId);
|
|
123
|
+
if (!interaction) {
|
|
124
|
+
return Response.json(
|
|
125
|
+
{ error: 'No pending interaction found for this requestId' },
|
|
126
|
+
{ status: 404 },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!interaction.confirmationDetails) {
|
|
131
|
+
return Response.json(
|
|
132
|
+
{ error: 'No confirmation details available for this request' },
|
|
133
|
+
{ status: 409 },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const confirmation = interaction.confirmationDetails;
|
|
138
|
+
|
|
139
|
+
if (confirmation.persistentDecisionsAllowed === false) {
|
|
140
|
+
return Response.json(
|
|
141
|
+
{ error: 'Persistent trust rules are not allowed for this tool invocation' },
|
|
142
|
+
{ status: 403 },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate pattern against server-provided allowlist options
|
|
147
|
+
const validPatterns = (confirmation.allowlistOptions ?? []).map((o) => o.pattern);
|
|
148
|
+
if (!validPatterns.includes(pattern)) {
|
|
149
|
+
return Response.json(
|
|
150
|
+
{ error: 'pattern does not match any server-provided allowlist option' },
|
|
151
|
+
{ status: 403 },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate scope against server-provided scope options
|
|
156
|
+
const validScopes = (confirmation.scopeOptions ?? []).map((o) => o.scope);
|
|
157
|
+
if (!validScopes.includes(scope)) {
|
|
158
|
+
return Response.json(
|
|
159
|
+
{ error: 'scope does not match any server-provided scope option' },
|
|
160
|
+
{ status: 403 },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const tool = getTool(confirmation.toolName);
|
|
166
|
+
const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
|
|
167
|
+
addRule(confirmation.toolName, pattern, scope, decision, undefined, {
|
|
168
|
+
executionTarget,
|
|
169
|
+
});
|
|
170
|
+
log.info(
|
|
171
|
+
{ tool: confirmation.toolName, pattern, scope, decision, requestId },
|
|
172
|
+
'Trust rule added via HTTP (bound to pending confirmation)',
|
|
173
|
+
);
|
|
174
|
+
return Response.json({ accepted: true });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log.error({ err }, 'Failed to add trust rule');
|
|
177
|
+
return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -45,6 +45,8 @@ import type {
|
|
|
45
45
|
} from '../http-types.js';
|
|
46
46
|
import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
|
|
47
47
|
import { refreshThreadEscalation } from '../../memory/inbox-escalation-projection.js';
|
|
48
|
+
import { getConfig } from '../../config/loader.js';
|
|
49
|
+
import { emitNotificationSignal } from '../../notifications/emit-signal.js';
|
|
48
50
|
import {
|
|
49
51
|
type GuardianContext,
|
|
50
52
|
verifyGatewayOrigin,
|
|
@@ -360,9 +362,37 @@ export async function handleChannelInbound(
|
|
|
360
362
|
// Update inbox thread escalation state so the desktop UI badge is accurate
|
|
361
363
|
refreshThreadEscalation(result.conversationId, assistantId);
|
|
362
364
|
|
|
363
|
-
//
|
|
365
|
+
// Emit notification signal through the unified pipeline (fire-and-forget).
|
|
366
|
+
// This lets the decision engine route escalation alerts to all configured
|
|
367
|
+
// channels, supplementing the direct guardian notification below.
|
|
368
|
+
void emitNotificationSignal({
|
|
369
|
+
sourceEventName: 'ingress.escalation',
|
|
370
|
+
sourceChannel: sourceChannel,
|
|
371
|
+
sourceSessionId: result.conversationId,
|
|
372
|
+
assistantId,
|
|
373
|
+
attentionHints: {
|
|
374
|
+
requiresAction: true,
|
|
375
|
+
urgency: 'high',
|
|
376
|
+
isAsyncBackground: false,
|
|
377
|
+
visibleInSourceNow: false,
|
|
378
|
+
},
|
|
379
|
+
contextPayload: {
|
|
380
|
+
conversationId: result.conversationId,
|
|
381
|
+
sourceChannel,
|
|
382
|
+
externalChatId,
|
|
383
|
+
senderIdentifier: body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender',
|
|
384
|
+
eventId: result.eventId,
|
|
385
|
+
},
|
|
386
|
+
dedupeKey: `escalation:${result.eventId}`,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Notify the guardian about the pending escalation via channel delivery.
|
|
390
|
+
// When the notification system is fully active it handles channel delivery,
|
|
391
|
+
// so skip the legacy path to avoid duplicate alerts.
|
|
392
|
+
const notifCfg = getConfig().notifications;
|
|
393
|
+
const notificationsActive = notifCfg.enabled && !notifCfg.shadowMode;
|
|
364
394
|
const senderIdentifier = body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender';
|
|
365
|
-
if (body.replyCallbackUrl) {
|
|
395
|
+
if (!notificationsActive && body.replyCallbackUrl) {
|
|
366
396
|
try {
|
|
367
397
|
const notificationText = await composeApprovalMessageGenerative(
|
|
368
398
|
{
|
|
@@ -388,8 +418,13 @@ export async function handleChannelInbound(
|
|
|
388
418
|
// the pending escalation even if channel notification failed.
|
|
389
419
|
log.error({ err, conversationId: result.conversationId, guardianChatId: binding.guardianDeliveryChatId }, 'Failed to notify guardian of ingress escalation');
|
|
390
420
|
}
|
|
391
|
-
} else {
|
|
421
|
+
} else if (!notificationsActive) {
|
|
392
422
|
log.warn({ conversationId: result.conversationId }, 'Ingress escalation created but no replyCallbackUrl to notify guardian');
|
|
423
|
+
} else {
|
|
424
|
+
log.info(
|
|
425
|
+
{ conversationId: result.conversationId },
|
|
426
|
+
'Skipping legacy guardian escalation callback delivery — notification pipeline active',
|
|
427
|
+
);
|
|
393
428
|
}
|
|
394
429
|
|
|
395
430
|
return Response.json({ accepted: true, escalated: true, reason: 'policy_escalate' });
|
|
@@ -886,7 +921,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
|
|
|
886
921
|
assistantMessageChannel: sourceChannel,
|
|
887
922
|
};
|
|
888
923
|
|
|
889
|
-
const run = await orchestrator.startRun(
|
|
924
|
+
const { run } = await orchestrator.startRun(
|
|
890
925
|
conversationId,
|
|
891
926
|
content,
|
|
892
927
|
attachmentIds,
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from '../http-types.js';
|
|
23
23
|
import type { ServerMessage } from '../../daemon/ipc-protocol.js';
|
|
24
24
|
import { buildAssistantEvent } from '../assistant-event.js';
|
|
25
|
+
import * as pendingInteractions from '../pending-interactions.js';
|
|
25
26
|
import { getLogger } from '../../util/logger.js';
|
|
26
27
|
|
|
27
28
|
const log = getLogger('conversation-routes');
|
|
@@ -143,13 +144,42 @@ export function handleListMessages(
|
|
|
143
144
|
/**
|
|
144
145
|
* Build an `onEvent` callback that publishes every outbound event to the
|
|
145
146
|
* assistant event hub, maintaining ordered delivery through a serial chain.
|
|
147
|
+
*
|
|
148
|
+
* Also registers pending interactions when confirmation_request or
|
|
149
|
+
* secret_request events flow through, so standalone approval endpoints
|
|
150
|
+
* can look up the session by requestId.
|
|
146
151
|
*/
|
|
147
152
|
function makeHubPublisher(
|
|
148
153
|
deps: SendMessageDeps,
|
|
149
154
|
conversationId: string,
|
|
155
|
+
session: import('../../daemon/session.js').Session,
|
|
150
156
|
): (msg: ServerMessage) => void {
|
|
151
157
|
let hubChain: Promise<void> = Promise.resolve();
|
|
152
158
|
return (msg: ServerMessage) => {
|
|
159
|
+
// Register pending interactions for approval events
|
|
160
|
+
if (msg.type === 'confirmation_request') {
|
|
161
|
+
pendingInteractions.register(msg.requestId, {
|
|
162
|
+
session,
|
|
163
|
+
conversationId,
|
|
164
|
+
kind: 'confirmation',
|
|
165
|
+
confirmationDetails: {
|
|
166
|
+
toolName: msg.toolName,
|
|
167
|
+
input: msg.input,
|
|
168
|
+
riskLevel: msg.riskLevel,
|
|
169
|
+
executionTarget: msg.executionTarget,
|
|
170
|
+
allowlistOptions: msg.allowlistOptions,
|
|
171
|
+
scopeOptions: msg.scopeOptions,
|
|
172
|
+
persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
} else if (msg.type === 'secret_request') {
|
|
176
|
+
pendingInteractions.register(msg.requestId, {
|
|
177
|
+
session,
|
|
178
|
+
conversationId,
|
|
179
|
+
kind: 'secret',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
const msgRecord = msg as unknown as Record<string, unknown>;
|
|
154
184
|
const msgSessionId =
|
|
155
185
|
'sessionId' in msg && typeof msgRecord.sessionId === 'string'
|
|
@@ -243,7 +273,7 @@ export async function handleSendMessage(
|
|
|
243
273
|
if (deps.sendMessageDeps) {
|
|
244
274
|
const smDeps = deps.sendMessageDeps;
|
|
245
275
|
const session = await smDeps.getOrCreateSession(mapping.conversationId);
|
|
246
|
-
const onEvent = makeHubPublisher(smDeps, mapping.conversationId);
|
|
276
|
+
const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
|
|
247
277
|
|
|
248
278
|
const attachments = hasAttachments
|
|
249
279
|
? smDeps.resolveAttachments(attachmentIds)
|
|
@@ -66,7 +66,7 @@ export async function handleCreateRun(
|
|
|
66
66
|
const mapping = getOrCreateConversation(conversationKey);
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
const run = await runOrchestrator.startRun(
|
|
69
|
+
const { run } = await runOrchestrator.startRun(
|
|
70
70
|
mapping.conversationId,
|
|
71
71
|
content ?? '',
|
|
72
72
|
hasAttachments ? attachmentIds : undefined,
|
|
@@ -34,6 +34,29 @@ const log = getLogger('run-orchestrator');
|
|
|
34
34
|
// Types
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Real-time event sink for voice TTS streaming. When provided to startRun(),
|
|
39
|
+
* agent-loop events are forwarded here alongside the existing assistantEventHub
|
|
40
|
+
* publication. This enables voice relay to receive streaming text deltas for
|
|
41
|
+
* real-time text-to-speech without modifying the standard channel path.
|
|
42
|
+
*/
|
|
43
|
+
export interface VoiceRunEventSink {
|
|
44
|
+
onTextDelta(text: string): void;
|
|
45
|
+
onMessageComplete(): void;
|
|
46
|
+
onError(message: string): void;
|
|
47
|
+
onToolUse(toolName: string, input: Record<string, unknown>): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle returned by startRun() that allows callers to abort an in-flight
|
|
52
|
+
* run. Used by voice barge-in to cancel the current turn without crashing
|
|
53
|
+
* session state.
|
|
54
|
+
*/
|
|
55
|
+
export interface RunHandle {
|
|
56
|
+
run: Run;
|
|
57
|
+
abort: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
interface PendingRunState {
|
|
38
61
|
prompterRequestId: string;
|
|
39
62
|
session: Session;
|
|
@@ -92,6 +115,36 @@ export interface RunStartOptions {
|
|
|
92
115
|
commandIntent?: { type: string; payload?: string; languageCode?: string };
|
|
93
116
|
/** Resolved channel context for this turn. */
|
|
94
117
|
turnChannelContext?: TurnChannelContext;
|
|
118
|
+
/**
|
|
119
|
+
* When provided, agent-loop events are forwarded to this sink in real time.
|
|
120
|
+
* Used by voice relay for streaming TTS token delivery.
|
|
121
|
+
*/
|
|
122
|
+
eventSink?: VoiceRunEventSink;
|
|
123
|
+
/**
|
|
124
|
+
* When true, any confirmation_request from the prompter is immediately
|
|
125
|
+
* auto-denied instead of being stored for client polling. Used by the
|
|
126
|
+
* voice path when forceStrictSideEffects is active: the voice transport
|
|
127
|
+
* has no interactive approval UI, so without this flag the run would
|
|
128
|
+
* stall for the full permission timeout (300s by default).
|
|
129
|
+
*/
|
|
130
|
+
voiceAutoDenyConfirmations?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* When true, confirmation_request events are auto-approved immediately.
|
|
133
|
+
* Used for verified-guardian voice turns where there is no interactive
|
|
134
|
+
* approval UI but parity with guardian chat permissions is required.
|
|
135
|
+
*/
|
|
136
|
+
voiceAutoAllowConfirmations?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* When true, secret_request events are resolved immediately with a null
|
|
139
|
+
* value so voice turns do not stall waiting for a secret-entry UI that
|
|
140
|
+
* voice does not provide.
|
|
141
|
+
*/
|
|
142
|
+
voiceAutoResolveSecrets?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Call-control protocol prompt injected into each voice turn so the
|
|
145
|
+
* model knows to emit control markers ([ASK_GUARDIAN:], [END_CALL], etc.).
|
|
146
|
+
*/
|
|
147
|
+
voiceCallControlPrompt?: string;
|
|
95
148
|
}
|
|
96
149
|
|
|
97
150
|
// ---------------------------------------------------------------------------
|
|
@@ -116,13 +169,16 @@ export class RunOrchestrator {
|
|
|
116
169
|
/**
|
|
117
170
|
* Start a new run: persist the user message, create a run record,
|
|
118
171
|
* and fire the agent loop in the background.
|
|
172
|
+
*
|
|
173
|
+
* Returns a RunHandle containing the Run record and an abort() function
|
|
174
|
+
* that can cancel the in-flight agent loop (e.g. for voice barge-in).
|
|
119
175
|
*/
|
|
120
176
|
async startRun(
|
|
121
177
|
conversationId: string,
|
|
122
178
|
content: string,
|
|
123
179
|
attachmentIds?: string[],
|
|
124
180
|
options?: RunStartOptions,
|
|
125
|
-
): Promise<
|
|
181
|
+
): Promise<RunHandle> {
|
|
126
182
|
// Block inbound content that contains secrets — mirrors the IPC check in sessions.ts
|
|
127
183
|
const ingressCheck = checkIngressForSecrets(content);
|
|
128
184
|
if (ingressCheck.blocked) {
|
|
@@ -176,6 +232,7 @@ export class RunOrchestrator {
|
|
|
176
232
|
// (e.g. attachment scope) match the actual transport rather than always
|
|
177
233
|
// defaulting to 'macos'.
|
|
178
234
|
session.setChannelCapabilities(resolveChannelCapabilities(options?.sourceChannel ?? 'macos'));
|
|
235
|
+
session.setVoiceCallControlPrompt(options?.voiceCallControlPrompt ?? null);
|
|
179
236
|
|
|
180
237
|
// Serialized publish chain so hub subscribers observe events in order.
|
|
181
238
|
let hubChain: Promise<void> = Promise.resolve();
|
|
@@ -202,9 +259,55 @@ export class RunOrchestrator {
|
|
|
202
259
|
// When the prompter sends one of these, we record it in the run store so
|
|
203
260
|
// the client can poll and submit a decision/secret via the respective endpoint.
|
|
204
261
|
// Do NOT set hasNoClient — run sessions have a client (the HTTP caller).
|
|
262
|
+
const autoDeny = options?.voiceAutoDenyConfirmations === true;
|
|
263
|
+
const autoAllow = !autoDeny && options?.voiceAutoAllowConfirmations === true;
|
|
264
|
+
const autoResolveSecrets = options?.voiceAutoResolveSecrets === true;
|
|
205
265
|
let lastError: string | null = null;
|
|
206
266
|
session.updateClient((msg: ServerMessage) => {
|
|
207
267
|
if (msg.type === 'confirmation_request') {
|
|
268
|
+
if (autoDeny) {
|
|
269
|
+
// Voice path with strict side effects: immediately deny the
|
|
270
|
+
// confirmation request so the agent loop resumes without
|
|
271
|
+
// waiting for the full permission timeout (300s). The voice
|
|
272
|
+
// transport has no interactive approval UI, so polling would
|
|
273
|
+
// just stall. Security is preserved — the tool call is denied.
|
|
274
|
+
log.info(
|
|
275
|
+
{ runId: run.id, toolName: msg.toolName },
|
|
276
|
+
'Auto-denying confirmation request for voice turn (forceStrictSideEffects)',
|
|
277
|
+
);
|
|
278
|
+
session.handleConfirmationResponse(
|
|
279
|
+
msg.requestId,
|
|
280
|
+
'deny',
|
|
281
|
+
undefined,
|
|
282
|
+
undefined,
|
|
283
|
+
`Permission denied for "${msg.toolName}": this voice call does not have interactive approval capabilities. Side-effect tools are not available for non-guardian voice callers. In your next assistant reply, explain briefly that this action requires guardian-level access and cannot be performed during this call.`,
|
|
284
|
+
);
|
|
285
|
+
// Still publish to hub for observability, but skip run-store
|
|
286
|
+
// bookkeeping since the confirmation is already resolved.
|
|
287
|
+
publishToHub(msg);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (autoAllow) {
|
|
291
|
+
// Verified guardian voice turn: auto-approve so voice has the same
|
|
292
|
+
// permission capabilities as guardian chat despite lacking an
|
|
293
|
+
// interactive confirmation UI.
|
|
294
|
+
log.info(
|
|
295
|
+
{ runId: run.id, toolName: msg.toolName },
|
|
296
|
+
'Auto-approving confirmation request for guardian voice turn',
|
|
297
|
+
);
|
|
298
|
+
session.handleConfirmationResponse(
|
|
299
|
+
msg.requestId,
|
|
300
|
+
'allow',
|
|
301
|
+
undefined,
|
|
302
|
+
undefined,
|
|
303
|
+
`Permission approved for "${msg.toolName}": this is a verified guardian voice call.`,
|
|
304
|
+
);
|
|
305
|
+
// Publish for observability, but skip run-store pending state since
|
|
306
|
+
// the request is already resolved.
|
|
307
|
+
publishToHub(msg);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
208
311
|
runsStore.setRunConfirmation(run.id, {
|
|
209
312
|
toolName: msg.toolName,
|
|
210
313
|
toolUseId: msg.requestId,
|
|
@@ -220,6 +323,18 @@ export class RunOrchestrator {
|
|
|
220
323
|
session,
|
|
221
324
|
});
|
|
222
325
|
} else if (msg.type === 'secret_request') {
|
|
326
|
+
if (autoResolveSecrets) {
|
|
327
|
+
// Voice has no secret-entry UI, so resolve immediately to avoid
|
|
328
|
+
// waiting for the full secret prompt timeout.
|
|
329
|
+
log.info(
|
|
330
|
+
{ runId: run.id, service: msg.service, field: msg.field },
|
|
331
|
+
'Auto-resolving secret request for voice turn (no secret-entry UI)',
|
|
332
|
+
);
|
|
333
|
+
session.handleSecretResponse(msg.requestId, undefined, 'store');
|
|
334
|
+
publishToHub(msg);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
223
338
|
runsStore.setRunSecret(run.id, {
|
|
224
339
|
requestId: msg.requestId,
|
|
225
340
|
service: msg.service,
|
|
@@ -249,6 +364,7 @@ export class RunOrchestrator {
|
|
|
249
364
|
session.setGuardianContext(null);
|
|
250
365
|
session.setCommandIntent(null);
|
|
251
366
|
session.setAssistantId('self');
|
|
367
|
+
session.setVoiceCallControlPrompt(null);
|
|
252
368
|
// Reset the session's client callback to a no-op so the stale
|
|
253
369
|
// closure doesn't intercept events from future runs on the same session.
|
|
254
370
|
// Set hasNoClient=true here since the run is done and no HTTP caller
|
|
@@ -256,6 +372,8 @@ export class RunOrchestrator {
|
|
|
256
372
|
session.updateClient(() => {}, true);
|
|
257
373
|
};
|
|
258
374
|
|
|
375
|
+
const eventSink = options?.eventSink;
|
|
376
|
+
|
|
259
377
|
void (async () => {
|
|
260
378
|
try {
|
|
261
379
|
await session.runAgentLoop(content, messageId, (msg: ServerMessage) => {
|
|
@@ -270,6 +388,27 @@ export class RunOrchestrator {
|
|
|
270
388
|
// prompter (confirmation_request). Both paths must publish so SSE
|
|
271
389
|
// consumers receive the full response stream.
|
|
272
390
|
publishToHub(msg);
|
|
391
|
+
|
|
392
|
+
// Forward voice-relevant events to the real-time event sink when
|
|
393
|
+
// provided. This runs in addition to (not instead of) the hub
|
|
394
|
+
// publication above so both paths remain active.
|
|
395
|
+
if (eventSink) {
|
|
396
|
+
if (msg.type === 'assistant_text_delta') {
|
|
397
|
+
eventSink.onTextDelta(msg.text);
|
|
398
|
+
} else if (msg.type === 'message_complete') {
|
|
399
|
+
eventSink.onMessageComplete();
|
|
400
|
+
} else if (msg.type === 'generation_cancelled') {
|
|
401
|
+
// Treat cancellation as a completed turn so the voice
|
|
402
|
+
// turnComplete promise settles instead of hanging forever.
|
|
403
|
+
eventSink.onMessageComplete();
|
|
404
|
+
} else if (msg.type === 'error') {
|
|
405
|
+
eventSink.onError(msg.message);
|
|
406
|
+
} else if (msg.type === 'session_error') {
|
|
407
|
+
eventSink.onError(msg.userMessage);
|
|
408
|
+
} else if (msg.type === 'tool_use_start') {
|
|
409
|
+
eventSink.onToolUse(msg.toolName, msg.input);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
273
412
|
});
|
|
274
413
|
if (lastError) {
|
|
275
414
|
log.error({ runId: run.id, error: lastError }, 'Run failed (error event from agent loop)');
|
|
@@ -281,12 +420,28 @@ export class RunOrchestrator {
|
|
|
281
420
|
const message = err instanceof Error ? err.message : String(err);
|
|
282
421
|
log.error({ err, runId: run.id }, 'Run failed');
|
|
283
422
|
runsStore.failRun(run.id, message);
|
|
423
|
+
// Notify the voice event sink so the caller's turnComplete
|
|
424
|
+
// promise settles instead of hanging on unhandled exceptions.
|
|
425
|
+
if (eventSink) {
|
|
426
|
+
eventSink.onError(message);
|
|
427
|
+
}
|
|
284
428
|
} finally {
|
|
285
429
|
cleanup();
|
|
286
430
|
}
|
|
287
431
|
})();
|
|
288
432
|
|
|
289
|
-
return
|
|
433
|
+
return {
|
|
434
|
+
run,
|
|
435
|
+
// Scope the abort to this specific run by capturing the requestId.
|
|
436
|
+
// If the session has moved on to a new turn (different currentRequestId),
|
|
437
|
+
// this abort is stale and becomes a no-op — preventing voice barge-in
|
|
438
|
+
// from cancelling unrelated turns.
|
|
439
|
+
abort: () => {
|
|
440
|
+
if (session.currentRequestId === requestId) {
|
|
441
|
+
session.abort();
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
};
|
|
290
445
|
}
|
|
291
446
|
|
|
292
447
|
/** Read current run state from the store. */
|
|
@@ -792,7 +792,7 @@ class BrowserManager {
|
|
|
792
792
|
// Check if an unconsumed download already completed for this session
|
|
793
793
|
const existing = this.downloads.get(sessionId);
|
|
794
794
|
if (existing && existing.length > 0) {
|
|
795
|
-
const info = existing.
|
|
795
|
+
const info = existing.shift()!;
|
|
796
796
|
if (existing.length === 0) this.downloads.delete(sessionId);
|
|
797
797
|
return Promise.resolve(info);
|
|
798
798
|
}
|