@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213
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/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +4 -9
- package/package.json +1 -1
- package/src/__tests__/cancel-clears-processing.test.ts +22 -19
- package/src/__tests__/conversation-agent-loop.test.ts +48 -41
- package/src/__tests__/media-stream-server-integration.test.ts +317 -13
- package/src/__tests__/relay-server.test.ts +285 -0
- package/src/__tests__/twilio-routes.test.ts +81 -1
- package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
- package/src/calls/channel-admission-reader.ts +104 -0
- package/src/calls/media-stream-server.ts +84 -2
- package/src/calls/relay-server.ts +66 -0
- package/src/calls/relay-setup-router.ts +77 -0
- package/src/calls/twilio-routes.ts +17 -8
- package/src/daemon/conversation-agent-loop.ts +122 -56
- package/src/daemon/conversation.ts +7 -0
- package/src/daemon/handlers/conversations.ts +6 -8
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +4 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +59 -13
- package/src/telemetry/usage-telemetry-reporter.ts +28 -16
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomInt } from "node:crypto";
|
|
10
10
|
|
|
11
|
+
import type { AdmissionPolicy } from "@vellumai/gateway-client";
|
|
11
12
|
import type { ServerWebSocket } from "bun";
|
|
12
13
|
|
|
13
14
|
import {
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
updateCallSession,
|
|
48
49
|
} from "./call-store.js";
|
|
49
50
|
import { ConversationRelayTransport } from "./call-transport.js";
|
|
51
|
+
import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
|
|
50
52
|
import { finalizeCall } from "./finalize-call.js";
|
|
51
53
|
import {
|
|
52
54
|
classifyWaitUtterance,
|
|
@@ -170,6 +172,11 @@ export function setRelayBroadcast(fn: (msg: ServerMessage) => void): void {
|
|
|
170
172
|
*/
|
|
171
173
|
type RelayConnectionState =
|
|
172
174
|
| "connected"
|
|
175
|
+
// Transient state held synchronously from the start of handleSetup until
|
|
176
|
+
// routing/ACL completes. Prompts arriving in this window are dropped so a
|
|
177
|
+
// not-yet-authorized caller's speech is never persisted/emitted before the
|
|
178
|
+
// admission floor and trust ACL are applied.
|
|
179
|
+
| "setting_up"
|
|
173
180
|
| "verification_pending"
|
|
174
181
|
| "awaiting_name"
|
|
175
182
|
| "awaiting_guardian_decision"
|
|
@@ -550,15 +557,58 @@ export class RelayConnection {
|
|
|
550
557
|
"ConversationRelay setup received",
|
|
551
558
|
);
|
|
552
559
|
|
|
560
|
+
// Enter the setup-pending state synchronously, before any await. The
|
|
561
|
+
// admission-policy read below yields the event loop, and inbound frames are
|
|
562
|
+
// dispatched fire-and-forget, so a `prompt` frame can arrive before routing
|
|
563
|
+
// completes. handlePrompt drops prompts while in "setting_up" so a blocked
|
|
564
|
+
// or admission-denied caller's speech is never stored before the ACL runs.
|
|
565
|
+
this.connectionState = "setting_up";
|
|
566
|
+
|
|
553
567
|
const session = getCallSession(this.callSessionId);
|
|
554
568
|
this.recordSetupBookkeeping(session, msg);
|
|
555
569
|
|
|
570
|
+
// Resolve the phone channel's inbound admission floor. The reader fails
|
|
571
|
+
// open to `null` by contract, so a transport hiccup admits the caller.
|
|
572
|
+
const admissionPolicy = await getChannelAdmissionPolicy("phone");
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
await this.routeSetupOutcome(msg, session, admissionPolicy);
|
|
576
|
+
} catch (err) {
|
|
577
|
+
// Never leave the connection stranded in "setting_up": a setup that
|
|
578
|
+
// throws before reaching a terminal outcome would otherwise drop every
|
|
579
|
+
// prompt forever. Tear the call down instead.
|
|
580
|
+
log.error(
|
|
581
|
+
{ err, callSessionId: this.callSessionId },
|
|
582
|
+
"Setup routing failed — disconnecting",
|
|
583
|
+
);
|
|
584
|
+
this.connectionState = "disconnecting";
|
|
585
|
+
updateCallSession(this.callSessionId, {
|
|
586
|
+
status: "failed",
|
|
587
|
+
endedAt: Date.now(),
|
|
588
|
+
lastError: "Setup routing failed",
|
|
589
|
+
});
|
|
590
|
+
this.endSession("Setup routing failed");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Apply the routing/ACL outcome for a setup frame. Synchronous helpers that
|
|
596
|
+
* fire-and-forget (greeting, verification, name capture) are awaited here only
|
|
597
|
+
* when they themselves are async. Each reachable outcome transitions the
|
|
598
|
+
* connection out of "setting_up" into its terminal state.
|
|
599
|
+
*/
|
|
600
|
+
private async routeSetupOutcome(
|
|
601
|
+
msg: RelaySetupMessage,
|
|
602
|
+
session: ReturnType<typeof getCallSession>,
|
|
603
|
+
admissionPolicy: AdmissionPolicy | null,
|
|
604
|
+
): Promise<void> {
|
|
556
605
|
const { outcome, resolved } = routeSetup({
|
|
557
606
|
callSessionId: this.callSessionId,
|
|
558
607
|
session,
|
|
559
608
|
from: msg.from,
|
|
560
609
|
to: msg.to,
|
|
561
610
|
customParameters: msg.customParameters,
|
|
611
|
+
admissionPolicy,
|
|
562
612
|
});
|
|
563
613
|
|
|
564
614
|
const initialTrustContext = toTrustContext(
|
|
@@ -634,6 +684,9 @@ export class RelayConnection {
|
|
|
634
684
|
);
|
|
635
685
|
}
|
|
636
686
|
}
|
|
687
|
+
// Routing/ACL cleared — leave the setup-pending state so subsequent
|
|
688
|
+
// prompts are handled normally.
|
|
689
|
+
this.connectionState = "connected";
|
|
637
690
|
this.startNormalCallFlow(controller, outcome.isInbound);
|
|
638
691
|
return;
|
|
639
692
|
}
|
|
@@ -1822,6 +1875,13 @@ export class RelayConnection {
|
|
|
1822
1875
|
return;
|
|
1823
1876
|
}
|
|
1824
1877
|
|
|
1878
|
+
// Prompts arriving before setup routing/ACL completes are dropped — never
|
|
1879
|
+
// persisted or emitted — so a not-yet-authorized caller's speech can't be
|
|
1880
|
+
// stored ahead of the admission floor / trust ACL decision.
|
|
1881
|
+
if (this.connectionState === "setting_up") {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1825
1885
|
if (!msg.last) {
|
|
1826
1886
|
// Partial transcript, wait for final
|
|
1827
1887
|
return;
|
|
@@ -2024,6 +2084,12 @@ export class RelayConnection {
|
|
|
2024
2084
|
return;
|
|
2025
2085
|
}
|
|
2026
2086
|
|
|
2087
|
+
// Drop DTMF arriving before setup routing/ACL completes (same reasoning as
|
|
2088
|
+
// handlePrompt): don't record input from a not-yet-authorized caller.
|
|
2089
|
+
if (this.connectionState === "setting_up") {
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2027
2093
|
// Ignore DTMF during name capture and guardian decision wait
|
|
2028
2094
|
if (
|
|
2029
2095
|
this.connectionState === "awaiting_name" ||
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* next — without performing any side effects itself.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { AdmissionPolicy } from "@vellumai/gateway-client";
|
|
10
|
+
|
|
9
11
|
import { getConfig } from "../config/loader.js";
|
|
10
12
|
import { findActiveVoiceInvites } from "../memory/invite-store.js";
|
|
11
13
|
import {
|
|
@@ -14,6 +16,10 @@ import {
|
|
|
14
16
|
} from "../runtime/actor-trust-resolver.js";
|
|
15
17
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
16
18
|
import { getPendingSession } from "../runtime/channel-verification-service.js";
|
|
19
|
+
import {
|
|
20
|
+
type AdmissionPolicyResult,
|
|
21
|
+
enforceAdmissionPolicy,
|
|
22
|
+
} from "../runtime/routes/inbound-stages/admission-policy.js";
|
|
17
23
|
import { getLogger } from "../util/logger.js";
|
|
18
24
|
import type { CallSession } from "./types.js";
|
|
19
25
|
|
|
@@ -27,6 +33,12 @@ interface SetupContext {
|
|
|
27
33
|
from: string;
|
|
28
34
|
to: string;
|
|
29
35
|
customParameters?: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Per-channel inbound admission floor for the `phone` channel, supplied by
|
|
38
|
+
* the caller. When absent/`null`, the floor check is skipped entirely —
|
|
39
|
+
* preserving all pre-admission behavior.
|
|
40
|
+
*/
|
|
41
|
+
admissionPolicy?: AdmissionPolicy | null;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// ── Setup outcomes ───────────────────────────────────────────────────
|
|
@@ -184,6 +196,50 @@ export function routeSetup(ctx: SetupContext): {
|
|
|
184
196
|
// ── Inbound call ACL evaluation ─────────────────────────────────
|
|
185
197
|
const pendingChallenge = getPendingSession("phone");
|
|
186
198
|
|
|
199
|
+
// An admission floor is "active" only when a policy applies and no pending
|
|
200
|
+
// verification challenge is in flight. While active, the floor IS the access
|
|
201
|
+
// decision: an admitted caller bypasses the legacy identity flows
|
|
202
|
+
// (unverified_caller / name_capture) and connects directly. When inactive
|
|
203
|
+
// (null policy, flag off, exempt channel, reader failed open, or a pending
|
|
204
|
+
// challenge), those legacy flows are preserved unchanged.
|
|
205
|
+
const floorActive = ctx.admissionPolicy != null && !pendingChallenge;
|
|
206
|
+
|
|
207
|
+
// Inbound admission floor verdict; defaults to admitted when inactive.
|
|
208
|
+
const floorVerdict = floorActive
|
|
209
|
+
? enforceAdmissionPolicy({
|
|
210
|
+
sourceChannel: "phone",
|
|
211
|
+
trustClass: actorTrust.trustClass,
|
|
212
|
+
memberStatus: actorTrust.memberRecord?.channel.status,
|
|
213
|
+
policy: ctx.admissionPolicy!,
|
|
214
|
+
})
|
|
215
|
+
: ({ admitted: true } as const);
|
|
216
|
+
|
|
217
|
+
// Floor-deny outcome shared by the unknown-caller and member-caller branches.
|
|
218
|
+
// Live calls cannot await async re-verification, so the floor's
|
|
219
|
+
// `shouldChallenge` upgrade UX is not surfaced — same rationale as `escalate`.
|
|
220
|
+
const floorDeny = (
|
|
221
|
+
denyVerdict: Extract<AdmissionPolicyResult, { admitted: false }>,
|
|
222
|
+
) => {
|
|
223
|
+
log.info(
|
|
224
|
+
{
|
|
225
|
+
callSessionId: ctx.callSessionId,
|
|
226
|
+
from: ctx.from,
|
|
227
|
+
trustClass: actorTrust.trustClass,
|
|
228
|
+
effectivePolicy: denyVerdict.effectivePolicy,
|
|
229
|
+
},
|
|
230
|
+
"Inbound voice ACL: admission floor denied caller",
|
|
231
|
+
);
|
|
232
|
+
return {
|
|
233
|
+
outcome: {
|
|
234
|
+
action: "deny" as const,
|
|
235
|
+
message:
|
|
236
|
+
"This number is not authorized to reach the assistant right now.",
|
|
237
|
+
logReason: `Inbound voice admission floor: ${denyVerdict.effectivePolicy}`,
|
|
238
|
+
},
|
|
239
|
+
resolved,
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
187
243
|
if (
|
|
188
244
|
(actorTrust.trustClass === "unknown" ||
|
|
189
245
|
actorTrust.trustClass === "unverified_contact") &&
|
|
@@ -245,6 +301,21 @@ export function routeSetup(ctx: SetupContext): {
|
|
|
245
301
|
};
|
|
246
302
|
}
|
|
247
303
|
|
|
304
|
+
// When a floor is active it is the access decision: an admitted caller
|
|
305
|
+
// connects directly (skipping unverified_caller / name_capture), and a
|
|
306
|
+
// below-floor caller is denied. Invites (handled above) bypass the floor
|
|
307
|
+
// as an explicit grant. When the floor is inactive (null policy), fall
|
|
308
|
+
// through to the legacy identity flows below.
|
|
309
|
+
if (floorActive) {
|
|
310
|
+
if (!floorVerdict.admitted) {
|
|
311
|
+
return floorDeny(floorVerdict);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
outcome: { action: "normal_call" as const, isInbound: true },
|
|
315
|
+
resolved,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
248
319
|
// Known caller whose channel hasn't passed verification yet —
|
|
249
320
|
// mirrors the gateway's pre-intercept (twilio-voice-webhook.ts) so
|
|
250
321
|
// calls slipping past it (e.g. canonicalization mismatch between
|
|
@@ -349,6 +420,12 @@ export function routeSetup(ctx: SetupContext): {
|
|
|
349
420
|
};
|
|
350
421
|
}
|
|
351
422
|
|
|
423
|
+
// Admission floor: deny member/guardian callers below the floor (e.g.
|
|
424
|
+
// `guardian_only` denies a trusted_contact).
|
|
425
|
+
if (!floorVerdict.admitted) {
|
|
426
|
+
return floorDeny(floorVerdict);
|
|
427
|
+
}
|
|
428
|
+
|
|
352
429
|
// Guardian and trusted-contact callers proceed normally
|
|
353
430
|
return {
|
|
354
431
|
outcome: { action: "normal_call", isInbound: true },
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
releaseCallbackClaim,
|
|
55
55
|
updateCallSession,
|
|
56
56
|
} from "./call-store.js";
|
|
57
|
+
import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
|
|
57
58
|
import { routeSetup } from "./relay-setup-router.js";
|
|
58
59
|
import { resolveCallHints } from "./stt-hints.js";
|
|
59
60
|
import { resolveTelephonySttRouting } from "./telephony-stt-routing.js";
|
|
@@ -277,10 +278,10 @@ const TWIML_HEADERS = { "Content-Type": "text/xml" } as const;
|
|
|
277
278
|
* query for outbound calls). Returns a TwiML string. Throws RouteError
|
|
278
279
|
* subclasses on failure.
|
|
279
280
|
*/
|
|
280
|
-
function processVoiceWebhook(
|
|
281
|
+
async function processVoiceWebhook(
|
|
281
282
|
params: Record<string, string>,
|
|
282
283
|
callSessionId: string | null,
|
|
283
|
-
): string {
|
|
284
|
+
): Promise<string> {
|
|
284
285
|
const callSid = params.CallSid ?? null;
|
|
285
286
|
const callerFrom = params.From ?? "";
|
|
286
287
|
const callerTo = params.To ?? "";
|
|
@@ -371,7 +372,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
|
371
372
|
const params = Object.fromEntries(formBody.entries());
|
|
372
373
|
|
|
373
374
|
try {
|
|
374
|
-
return twimlResponse(processVoiceWebhook(params, callSessionId));
|
|
375
|
+
return twimlResponse(await processVoiceWebhook(params, callSessionId));
|
|
375
376
|
} catch (err) {
|
|
376
377
|
if (err instanceof RouteError) {
|
|
377
378
|
return new Response(err.message, { status: err.statusCode });
|
|
@@ -399,7 +400,7 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
|
|
|
399
400
|
* the Twilio setup payload. The persisted call session mode is the
|
|
400
401
|
* primary signal for deterministic flow selection in the relay server.
|
|
401
402
|
*/
|
|
402
|
-
function buildVoiceWebhookTwiml(
|
|
403
|
+
async function buildVoiceWebhookTwiml(
|
|
403
404
|
callSessionId: string,
|
|
404
405
|
sessionContext: {
|
|
405
406
|
task: string | null;
|
|
@@ -410,7 +411,7 @@ function buildVoiceWebhookTwiml(
|
|
|
410
411
|
inviteGuardianName: string | null;
|
|
411
412
|
} | null,
|
|
412
413
|
verificationSessionId?: string | null,
|
|
413
|
-
): string {
|
|
414
|
+
): Promise<string> {
|
|
414
415
|
const cfg = loadConfig();
|
|
415
416
|
const profile = resolveVoiceQualityProfile(cfg);
|
|
416
417
|
|
|
@@ -476,11 +477,19 @@ function buildVoiceWebhookTwiml(
|
|
|
476
477
|
const from = sessionContext?.fromNumber ?? "";
|
|
477
478
|
const to = sessionContext?.toNumber ?? "";
|
|
478
479
|
|
|
480
|
+
// Resolve the phone channel's inbound admission floor so this preflight
|
|
481
|
+
// classification matches the real enforcement at stream start — a
|
|
482
|
+
// floor-denied caller is recognized as `deny` here, not `normal_call`.
|
|
483
|
+
// The reader fails open to `null` by contract, so a transport hiccup
|
|
484
|
+
// admits the caller.
|
|
485
|
+
const admissionPolicy = await getChannelAdmissionPolicy("phone");
|
|
486
|
+
|
|
479
487
|
const { outcome } = routeSetup({
|
|
480
488
|
callSessionId,
|
|
481
489
|
session: session ?? null,
|
|
482
490
|
from,
|
|
483
491
|
to,
|
|
492
|
+
admissionPolicy,
|
|
484
493
|
});
|
|
485
494
|
|
|
486
495
|
// The media-stream transport supports normal_call and deny (which speaks
|
|
@@ -780,9 +789,9 @@ const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response/>';
|
|
|
780
789
|
* Internal voice-webhook handler for gateway→runtime forwarding.
|
|
781
790
|
* Accepts JSON body `{ params, originalUrl? }` from the gateway.
|
|
782
791
|
*/
|
|
783
|
-
export function handleInternalVoiceWebhook({
|
|
792
|
+
export async function handleInternalVoiceWebhook({
|
|
784
793
|
body = {},
|
|
785
|
-
}: RouteHandlerArgs): RouteResponse {
|
|
794
|
+
}: RouteHandlerArgs): Promise<RouteResponse> {
|
|
786
795
|
const { params = {}, originalUrl } = body as {
|
|
787
796
|
params?: Record<string, string>;
|
|
788
797
|
originalUrl?: string;
|
|
@@ -798,7 +807,7 @@ export function handleInternalVoiceWebhook({
|
|
|
798
807
|
}
|
|
799
808
|
}
|
|
800
809
|
|
|
801
|
-
const twiml = processVoiceWebhook(params, callSessionId);
|
|
810
|
+
const twiml = await processVoiceWebhook(params, callSessionId);
|
|
802
811
|
return new RouteResponse(twiml, TWIML_HEADERS);
|
|
803
812
|
}
|
|
804
813
|
|
|
@@ -187,6 +187,67 @@ export interface AssistantSurface {
|
|
|
187
187
|
activationMoment?: ActivationMomentParam;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// ── abort watchdog ───────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generous backstop that drives an aborted turn to its `finally` even if some
|
|
194
|
+
* awaited operation fails to observe the abort signal.
|
|
195
|
+
*
|
|
196
|
+
* Abort is otherwise cooperative and already wired into the slow paths: the
|
|
197
|
+
* provider call forwards the signal to its HTTP/streaming fetch, and tool
|
|
198
|
+
* execution races the signal so a stuck tool can't block cancellation. This
|
|
199
|
+
* watchdog only fires when a future code path silently ignores abort — without
|
|
200
|
+
* it, such a path would hang the loop forever and latch the conversation's
|
|
201
|
+
* `processing` flag true (the wedged "Thinking…" indicator). It is
|
|
202
|
+
* defense-in-depth, not the primary mechanism, so the timeout is deliberately
|
|
203
|
+
* generous; in the common case abort settles in-flight work well before it.
|
|
204
|
+
*/
|
|
205
|
+
const ABORT_WATCHDOG_MS = 45_000;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Race `work` against an abort watchdog. The watchdog stays disarmed until the
|
|
209
|
+
* signal fires; once it does, the turn has `timeoutMs` to settle before the
|
|
210
|
+
* watchdog rejects with the signal's own abort reason so the caller unwinds to
|
|
211
|
+
* its `finally` and the reason classifies as a user cancellation downstream.
|
|
212
|
+
* The abandoned `work` promise keeps running detached — its eventual rejection
|
|
213
|
+
* is swallowed so it can't surface as an unhandled rejection.
|
|
214
|
+
*/
|
|
215
|
+
async function withAbortWatchdog<T>(
|
|
216
|
+
work: Promise<T>,
|
|
217
|
+
signal: AbortSignal,
|
|
218
|
+
timeoutMs: number,
|
|
219
|
+
onFire: () => void,
|
|
220
|
+
): Promise<T> {
|
|
221
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
let onAbort: (() => void) | undefined;
|
|
223
|
+
const watchdog = new Promise<never>((_, reject) => {
|
|
224
|
+
const arm = () => {
|
|
225
|
+
timer = setTimeout(() => {
|
|
226
|
+
onFire();
|
|
227
|
+
// Propagate the signal's own reason (an `AbortReason` for daemon-owned
|
|
228
|
+
// cancels) so the loop's catch classifies this as a user cancellation;
|
|
229
|
+
// fall back to a tagged AbortError when the signal carries no reason.
|
|
230
|
+
reject(
|
|
231
|
+
signal.reason ??
|
|
232
|
+
new DOMException("The operation was aborted", "AbortError"),
|
|
233
|
+
);
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
};
|
|
236
|
+
if (signal.aborted) arm();
|
|
237
|
+
else {
|
|
238
|
+
onAbort = arm;
|
|
239
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
return await Promise.race([work, watchdog]);
|
|
244
|
+
} finally {
|
|
245
|
+
if (timer) clearTimeout(timer);
|
|
246
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
247
|
+
void work.catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
190
251
|
// ── runAgentLoop ─────────────────────────────────────────────────────
|
|
191
252
|
|
|
192
253
|
export async function runAgentLoopImpl(
|
|
@@ -902,22 +963,36 @@ export async function runAgentLoopImpl(
|
|
|
902
963
|
msgs: Message[],
|
|
903
964
|
compactInPlace = false,
|
|
904
965
|
): Promise<Message[]> => {
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
966
|
+
const watchdogMs = ctx.abortWatchdogMs ?? ABORT_WATCHDOG_MS;
|
|
967
|
+
const { history, exitReason, newMessages } = await withAbortWatchdog(
|
|
968
|
+
ctx.agentLoop.run({
|
|
969
|
+
messages: msgs,
|
|
970
|
+
onEvent: eventHandler,
|
|
971
|
+
signal: abortController.signal,
|
|
972
|
+
requestId: reqId,
|
|
973
|
+
onCheckpoint,
|
|
974
|
+
callSite: turnCallSite,
|
|
975
|
+
trust: loopTrust,
|
|
976
|
+
overrideProfile: turnOverrideProfile,
|
|
977
|
+
...(forceOverrideProfile ? { forceOverrideProfile: true } : {}),
|
|
978
|
+
resolveOverrideProfile: resolveCurrentOverrideProfile,
|
|
979
|
+
resolveContextWindow,
|
|
980
|
+
compactInPlace,
|
|
981
|
+
isNonInteractive,
|
|
982
|
+
modelProfileKey,
|
|
983
|
+
}),
|
|
984
|
+
abortController.signal,
|
|
985
|
+
watchdogMs,
|
|
986
|
+
() =>
|
|
987
|
+
rlog.error(
|
|
988
|
+
{
|
|
989
|
+
conversationId: ctx.conversationId,
|
|
990
|
+
requestId: reqId,
|
|
991
|
+
timeoutMs: watchdogMs,
|
|
992
|
+
},
|
|
993
|
+
"Abort watchdog fired — agent loop did not settle after cancel; forcing turn to finally",
|
|
994
|
+
),
|
|
995
|
+
);
|
|
921
996
|
lastRunNewMessages = newMessages;
|
|
922
997
|
if (exitReason === "handoff") {
|
|
923
998
|
yieldedForHandoff = true;
|
|
@@ -1450,46 +1525,37 @@ export async function runAgentLoopImpl(
|
|
|
1450
1525
|
|
|
1451
1526
|
ctx.profiler.emitSummary(ctx.traceEmitter, reqId);
|
|
1452
1527
|
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
// Consolidation deferred to compaction: keeping assistant + tool_result
|
|
1485
|
-
// messages unconsolidated preserves the exact message structure sent to
|
|
1486
|
-
// the API, enabling stable prefix caching across turns. Compaction
|
|
1487
|
-
// consolidates when it summarizes old messages (cache miss is expected).
|
|
1488
|
-
|
|
1489
|
-
ctx.drainQueue(
|
|
1490
|
-
yieldedForHandoff ? "checkpoint_handoff" : "loop_complete",
|
|
1491
|
-
);
|
|
1492
|
-
}
|
|
1528
|
+
// Tear down this turn's per-turn state. Abort reliably drives the loop to
|
|
1529
|
+
// this `finally` within a bounded time — cooperative signal propagation
|
|
1530
|
+
// (provider fetch + tool race) backed by the abort watchdog — so a
|
|
1531
|
+
// cancelled turn always unwinds before any resend can start a new one.
|
|
1532
|
+
// There is therefore only ever one turn alive, and clearing the shared
|
|
1533
|
+
// state below cannot clobber a concurrent turn.
|
|
1534
|
+
ctx.abortController = null;
|
|
1535
|
+
ctx.setProcessing(false);
|
|
1536
|
+
ctx.onConfirmationOutcome = undefined;
|
|
1537
|
+
ctx.surfaceActionRequestIds.delete(ctx.currentRequestId ?? "");
|
|
1538
|
+
ctx.approvedViaPromptThisTurn = false;
|
|
1539
|
+
ctx.currentRequestId = undefined;
|
|
1540
|
+
ctx.currentActiveSurfaceId = undefined;
|
|
1541
|
+
ctx.allowedToolNames = undefined;
|
|
1542
|
+
ctx.diskPressureCleanupModeActive = false;
|
|
1543
|
+
ctx.preactivatedSkillIds = undefined;
|
|
1544
|
+
ctx.currentTurnOverrideProfile = undefined;
|
|
1545
|
+
// Channel command intents (e.g. Telegram /start) are single-turn metadata.
|
|
1546
|
+
// Clear at turn end so they never leak into subsequent unrelated messages.
|
|
1547
|
+
ctx.commandIntent = undefined;
|
|
1548
|
+
// taskRunId scopes ephemeral task-run permissions to a single turn. Clear
|
|
1549
|
+
// before drainQueue so queued/drained turns on a reused conversation can't
|
|
1550
|
+
// inherit stale in-task-run scope from the turn that just finished.
|
|
1551
|
+
ctx.taskRunId = undefined;
|
|
1552
|
+
|
|
1553
|
+
// Consolidation deferred to compaction: keeping assistant + tool_result
|
|
1554
|
+
// messages unconsolidated preserves the exact message structure sent to
|
|
1555
|
+
// the API, enabling stable prefix caching across turns. Compaction
|
|
1556
|
+
// consolidates when it summarizes old messages (cache miss is expected).
|
|
1557
|
+
|
|
1558
|
+
ctx.drainQueue(yieldedForHandoff ? "checkpoint_handoff" : "loop_complete");
|
|
1493
1559
|
|
|
1494
1560
|
// Clear conversation tags so they don't leak into unrelated error captures
|
|
1495
1561
|
// (e.g. unhandledRejection from a different async chain).
|
|
@@ -394,6 +394,13 @@ export class Conversation {
|
|
|
394
394
|
workspaceDir: string,
|
|
395
395
|
) => Pick<WorkspaceGitService, "ensureInitialized">;
|
|
396
396
|
/** @internal */ commitTurnChanges?: typeof commitTurnChanges;
|
|
397
|
+
/**
|
|
398
|
+
* Abort-watchdog timeout (ms) for the agent loop's bounded-unwind backstop.
|
|
399
|
+
* Overridable in tests to fire the watchdog quickly; defaults to the
|
|
400
|
+
* production constant in the agent loop when unset.
|
|
401
|
+
* @internal
|
|
402
|
+
*/
|
|
403
|
+
abortWatchdogMs?: number;
|
|
397
404
|
/**
|
|
398
405
|
* The conversation's immutable creation type (`interactive`, `background`,
|
|
399
406
|
* `scheduled`, …) as stored on the DB row. Cached on load (and set directly
|
|
@@ -106,14 +106,12 @@ export function cancelGeneration(conversationId: string): boolean {
|
|
|
106
106
|
// being cancelled, so enqueuing synthetic messages would trigger
|
|
107
107
|
// unwanted model activity after the user pressed stop.
|
|
108
108
|
getSubagentManager().abortAllForParent(conversationId);
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// the invalidation nor clobbers a turn started after the cancel.
|
|
116
|
-
conversation.setProcessing(false);
|
|
109
|
+
// The processing flag is cleared by the in-flight turn's `finally`, not here.
|
|
110
|
+
// Abort propagates into the provider call and tool execution (and is backed
|
|
111
|
+
// by the agent loop's abort watchdog), so the turn reaches its `finally`
|
|
112
|
+
// within a bounded time and tears down its own state there — which publishes
|
|
113
|
+
// the metadata sync invalidation that drives clients to the authoritative
|
|
114
|
+
// idle state.
|
|
117
115
|
return true;
|
|
118
116
|
}
|
|
119
117
|
|
|
@@ -33,11 +33,13 @@ describe("enforceAdmissionPolicy — exempt channels", () => {
|
|
|
33
33
|
expect(result.admitted).toBe(true);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("phone
|
|
36
|
+
test("phone is enforced (not exempt): the floor applies", () => {
|
|
37
|
+
// Voice ingress is wired, so `phone` is no longer exempt — an `unknown`
|
|
38
|
+
// caller is denied under `no_one`.
|
|
37
39
|
const result = enforceAdmissionPolicy(
|
|
38
40
|
makeInput({ sourceChannel: "phone", trustClass: "unknown", policy: "no_one" }),
|
|
39
41
|
);
|
|
40
|
-
expect(result.admitted).toBe(
|
|
42
|
+
expect(result.admitted).toBe(false);
|
|
41
43
|
});
|
|
42
44
|
});
|
|
43
45
|
|