@vellumai/assistant 0.11.10-dev.202609091714.3d3ebb3 → 0.11.10-dev.202609091820.7951ec9
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/calls/__tests__/voice-session-bridge.test.ts +224 -13
- package/src/calls/voice-session-bridge.ts +90 -17
- package/src/calls/voice-triage-escalate.ts +11 -9
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +3 -2
- package/src/live-voice/live-voice-session.ts +6 -5
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* covered by `src/__tests__/conversation-wait-for-idle.test.ts`.
|
|
13
13
|
*/
|
|
14
14
|
import {
|
|
15
|
+
afterEach,
|
|
15
16
|
beforeEach,
|
|
16
17
|
describe,
|
|
17
18
|
expect,
|
|
@@ -35,8 +36,18 @@ mock.module("../../daemon/conversation-store.js", () => ({
|
|
|
35
36
|
// production (a BYO provider resolves the profile key through its own column
|
|
36
37
|
// of the intent matrix), so it is scripted rather than read from a catalog.
|
|
37
38
|
let pinProfileSupportsVision = true;
|
|
39
|
+
// Vision capability of the conversation's own profile, which an escalated
|
|
40
|
+
// leg is pinned to. Scripted for the same reason.
|
|
41
|
+
let conversationProfileSupportsVision = true;
|
|
42
|
+
// Per-profile answers that outrank the two switches above, for a mix whose
|
|
43
|
+
// arms differ.
|
|
44
|
+
const visionByProfile = new Map<string, boolean>();
|
|
38
45
|
mock.module("../../plugin-api/vision-support.js", () => ({
|
|
39
|
-
doesSupportVision: () =>
|
|
46
|
+
doesSupportVision: (profile: string) =>
|
|
47
|
+
visionByProfile.get(profile) ??
|
|
48
|
+
(profile === "latency-optimized"
|
|
49
|
+
? pinProfileSupportsVision
|
|
50
|
+
: conversationProfileSupportsVision),
|
|
40
51
|
}));
|
|
41
52
|
|
|
42
53
|
// Attachment hydration for the parked-camera-frame path. Only `att-frame-*`
|
|
@@ -126,6 +137,8 @@ mock.module("../../persistence/conversation-crud.js", () => ({
|
|
|
126
137
|
}));
|
|
127
138
|
|
|
128
139
|
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
140
|
+
import { selectWinningProfile } from "../../config/llm-resolver.js";
|
|
141
|
+
import { getConfig } from "../../config/loader.js";
|
|
129
142
|
import { ABORT_WATCHDOG_MS } from "../../daemon/abort-watchdog.js";
|
|
130
143
|
import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../../plugin-api/constants.js";
|
|
131
144
|
import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
|
|
@@ -162,6 +175,9 @@ interface WaitForIdleCall {
|
|
|
162
175
|
|
|
163
176
|
interface FakeConversation {
|
|
164
177
|
conversationId: string;
|
|
178
|
+
/** Per-conversation profile pin an escalated leg follows; absent = none. */
|
|
179
|
+
inferenceProfile?: string | null;
|
|
180
|
+
inferenceProfileExpiresAt?: number | null;
|
|
165
181
|
callSessionId: string | undefined;
|
|
166
182
|
forcePromptSideEffects: boolean;
|
|
167
183
|
currentRequestId: string | undefined;
|
|
@@ -208,6 +224,10 @@ function makeFakeConversation(opts: {
|
|
|
208
224
|
workingDir?: string;
|
|
209
225
|
/** In-memory history the profile pin reads; undefined models a text-only call. */
|
|
210
226
|
messages?: Array<{ role: string; content: unknown[] }>;
|
|
227
|
+
/** Per-conversation profile pin; undefined models an unpinned conversation. */
|
|
228
|
+
inferenceProfile?: string;
|
|
229
|
+
/** Expiry of that pin; a past stamp models a lapsed session pin. */
|
|
230
|
+
inferenceProfileExpiresAt?: number;
|
|
211
231
|
}) {
|
|
212
232
|
const waitForIdleCalls: WaitForIdleCall[] = [];
|
|
213
233
|
const confirmationDecisions: Array<{ requestId: string; decision: string }> =
|
|
@@ -224,6 +244,12 @@ function makeFakeConversation(opts: {
|
|
|
224
244
|
| undefined;
|
|
225
245
|
const conversation: FakeConversation = {
|
|
226
246
|
conversationId: "conv-voice-bridge-test",
|
|
247
|
+
...(opts.inferenceProfile !== undefined
|
|
248
|
+
? { inferenceProfile: opts.inferenceProfile }
|
|
249
|
+
: {}),
|
|
250
|
+
...(opts.inferenceProfileExpiresAt !== undefined
|
|
251
|
+
? { inferenceProfileExpiresAt: opts.inferenceProfileExpiresAt }
|
|
252
|
+
: {}),
|
|
227
253
|
// The workspace boundary the reach check compares paths against. A real
|
|
228
254
|
// conversation always has one; without it the approval gate fails closed.
|
|
229
255
|
workingDir: opts.workingDir ?? "/tmp/workspace-voice-bridge-test",
|
|
@@ -2581,19 +2607,19 @@ describe("transcript hygiene (teardown pass)", () => {
|
|
|
2581
2607
|
});
|
|
2582
2608
|
});
|
|
2583
2609
|
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
];
|
|
2610
|
+
/** A persisted user message carrying a photo taken mid-call. */
|
|
2611
|
+
const PHOTO_HISTORY = [
|
|
2612
|
+
{ role: "user", content: [{ type: "text", text: "here's a photo:" }] },
|
|
2613
|
+
{
|
|
2614
|
+
role: "user",
|
|
2615
|
+
content: [
|
|
2616
|
+
{ type: "text", text: "here's a photo:" },
|
|
2617
|
+
{ type: "image", source: { type: "base64", data: "abc" } },
|
|
2618
|
+
],
|
|
2619
|
+
},
|
|
2620
|
+
];
|
|
2596
2621
|
|
|
2622
|
+
describe("startVoiceTurn image-bearing profile pin", () => {
|
|
2597
2623
|
beforeEach(() => {
|
|
2598
2624
|
pinProfileSupportsVision = true;
|
|
2599
2625
|
});
|
|
@@ -2682,3 +2708,188 @@ describe("startVoiceTurn image-bearing profile pin", () => {
|
|
|
2682
2708
|
expect(runOptions.overrideProfile).toBe("quality-optimized");
|
|
2683
2709
|
});
|
|
2684
2710
|
});
|
|
2711
|
+
|
|
2712
|
+
describe("startVoiceTurn escalated-leg profile pin", () => {
|
|
2713
|
+
beforeEach(() => {
|
|
2714
|
+
pinProfileSupportsVision = true;
|
|
2715
|
+
conversationProfileSupportsVision = true;
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
afterEach(() => {
|
|
2719
|
+
setConfig("llm", {});
|
|
2720
|
+
});
|
|
2721
|
+
|
|
2722
|
+
async function runOptionsFor(opts: {
|
|
2723
|
+
messages?: Array<{ role: string; content: unknown[] }>;
|
|
2724
|
+
inferenceProfile?: string;
|
|
2725
|
+
inferenceProfileExpiresAt?: number;
|
|
2726
|
+
turn?: Record<string, unknown>;
|
|
2727
|
+
}): Promise<Record<string, unknown>> {
|
|
2728
|
+
const fake = makeFakeConversation({
|
|
2729
|
+
processing: false,
|
|
2730
|
+
...(opts.messages ? { messages: opts.messages } : {}),
|
|
2731
|
+
...(opts.inferenceProfile
|
|
2732
|
+
? { inferenceProfile: opts.inferenceProfile }
|
|
2733
|
+
: {}),
|
|
2734
|
+
...(opts.inferenceProfileExpiresAt !== undefined
|
|
2735
|
+
? { inferenceProfileExpiresAt: opts.inferenceProfileExpiresAt }
|
|
2736
|
+
: {}),
|
|
2737
|
+
});
|
|
2738
|
+
fakeConversation = fake.conversation;
|
|
2739
|
+
let runOptions: Record<string, unknown> = {};
|
|
2740
|
+
fake.conversation.runAgentLoop = async (...args: unknown[]) => {
|
|
2741
|
+
runOptions = args[2] as Record<string, unknown>;
|
|
2742
|
+
};
|
|
2743
|
+
await startVoiceTurn({
|
|
2744
|
+
...makeTurnOptions(),
|
|
2745
|
+
routingLeg: "escalated",
|
|
2746
|
+
...(opts.turn ?? {}),
|
|
2747
|
+
});
|
|
2748
|
+
return runOptions;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
test("with no chat-model selection the leg keeps the call-site profile", async () => {
|
|
2752
|
+
const runOptions = await runOptionsFor({});
|
|
2753
|
+
|
|
2754
|
+
expect(runOptions.callSite).toBe("callAgent");
|
|
2755
|
+
expect(runOptions.overrideProfile).toBeUndefined();
|
|
2756
|
+
expect(runOptions.forceOverrideProfile).toBeUndefined();
|
|
2757
|
+
});
|
|
2758
|
+
|
|
2759
|
+
test("the workspace chat-model selection pins the leg", async () => {
|
|
2760
|
+
// `callAgent`'s chain never consults `llm.activeProfile`, so without the
|
|
2761
|
+
// pin the hand-off would land on the site's shipped default while the
|
|
2762
|
+
// same conversation's typed turns run on the user's pick.
|
|
2763
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2764
|
+
|
|
2765
|
+
const runOptions = await runOptionsFor({});
|
|
2766
|
+
|
|
2767
|
+
expect(runOptions.callSite).toBe("callAgent");
|
|
2768
|
+
expect(runOptions.overrideProfile).toBe("quality-optimized");
|
|
2769
|
+
expect(runOptions.forceOverrideProfile).toBe(true);
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
test("the conversation's own pin wins over the workspace selection", async () => {
|
|
2773
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2774
|
+
|
|
2775
|
+
const runOptions = await runOptionsFor({
|
|
2776
|
+
inferenceProfile: "cost-optimized",
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
expect(runOptions.overrideProfile).toBe("cost-optimized");
|
|
2780
|
+
expect(runOptions.forceOverrideProfile).toBe(true);
|
|
2781
|
+
});
|
|
2782
|
+
|
|
2783
|
+
test("a lapsed conversation pin falls back to the workspace selection", async () => {
|
|
2784
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2785
|
+
|
|
2786
|
+
const runOptions = await runOptionsFor({
|
|
2787
|
+
inferenceProfile: "cost-optimized",
|
|
2788
|
+
inferenceProfileExpiresAt: Date.now() - 1,
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
expect(runOptions.overrideProfile).toBe("quality-optimized");
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
test("only the escalated leg follows the conversation", async () => {
|
|
2795
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2796
|
+
|
|
2797
|
+
const frontDoor = await runOptionsFor({
|
|
2798
|
+
turn: { routingLeg: "front-door" },
|
|
2799
|
+
});
|
|
2800
|
+
expect(frontDoor.callSite).toBe("voiceFrontDoor");
|
|
2801
|
+
expect(frontDoor.overrideProfile).toBeUndefined();
|
|
2802
|
+
|
|
2803
|
+
const unrouted = await runOptionsFor({ turn: { routingLeg: undefined } });
|
|
2804
|
+
expect(unrouted.callSite).toBe("callAgent");
|
|
2805
|
+
expect(unrouted.overrideProfile).toBeUndefined();
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
test("an image stays on a conversation profile whose model takes it", async () => {
|
|
2809
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2810
|
+
|
|
2811
|
+
const runOptions = await runOptionsFor({ messages: PHOTO_HISTORY });
|
|
2812
|
+
|
|
2813
|
+
expect(runOptions.overrideProfile).toBe("quality-optimized");
|
|
2814
|
+
});
|
|
2815
|
+
|
|
2816
|
+
test("an image hands a text-only conversation profile to the image pin", async () => {
|
|
2817
|
+
// A model that rejects the image fails the whole leg, so the image pin
|
|
2818
|
+
// outranks the conversation's choice for this one turn.
|
|
2819
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2820
|
+
conversationProfileSupportsVision = false;
|
|
2821
|
+
|
|
2822
|
+
const runOptions = await runOptionsFor({ messages: PHOTO_HISTORY });
|
|
2823
|
+
|
|
2824
|
+
expect(runOptions.overrideProfile).toBe("latency-optimized");
|
|
2825
|
+
expect(runOptions.forceOverrideProfile).toBe(true);
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
test("an image with no image-capable profile anywhere keeps the conversation profile", async () => {
|
|
2829
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2830
|
+
conversationProfileSupportsVision = false;
|
|
2831
|
+
pinProfileSupportsVision = false;
|
|
2832
|
+
|
|
2833
|
+
const runOptions = await runOptionsFor({ messages: PHOTO_HISTORY });
|
|
2834
|
+
|
|
2835
|
+
expect(runOptions.overrideProfile).toBe("quality-optimized");
|
|
2836
|
+
});
|
|
2837
|
+
|
|
2838
|
+
test("an explicit routing pin wins over the conversation profile", async () => {
|
|
2839
|
+
setConfig("llm", { activeProfile: "quality-optimized" });
|
|
2840
|
+
|
|
2841
|
+
const runOptions = await runOptionsFor({
|
|
2842
|
+
turn: { overrideProfile: "balanced" },
|
|
2843
|
+
});
|
|
2844
|
+
|
|
2845
|
+
expect(runOptions.overrideProfile).toBe("balanced");
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2848
|
+
test("a mix is judged by the arm serving this conversation, not by any arm", async () => {
|
|
2849
|
+
// A mix reads as vision-capable when any arm is, but dispatch expands it
|
|
2850
|
+
// to one arm from the conversation seed. Only that arm's model sees the
|
|
2851
|
+
// image, so only that arm's capability decides whether the image pin
|
|
2852
|
+
// takes over. The pin itself stays the mix's own name, so dispatch lands
|
|
2853
|
+
// on the same arm.
|
|
2854
|
+
const llm = {
|
|
2855
|
+
activeProfile: "voice-mix",
|
|
2856
|
+
profiles: {
|
|
2857
|
+
"voice-mix": {
|
|
2858
|
+
mix: [
|
|
2859
|
+
{ profile: "quality-optimized", weight: 1 },
|
|
2860
|
+
{ profile: "cost-optimized", weight: 1 },
|
|
2861
|
+
],
|
|
2862
|
+
},
|
|
2863
|
+
},
|
|
2864
|
+
};
|
|
2865
|
+
setConfig("llm", llm);
|
|
2866
|
+
let chosenArm: string | undefined;
|
|
2867
|
+
selectWinningProfile("mainAgent", getConfig().llm, {
|
|
2868
|
+
selectionSeed: "conv-voice-bridge-test",
|
|
2869
|
+
onMixSelected: ({ chosenProfile }) => {
|
|
2870
|
+
chosenArm = chosenProfile;
|
|
2871
|
+
},
|
|
2872
|
+
});
|
|
2873
|
+
expect(chosenArm).toBeDefined();
|
|
2874
|
+
const otherArm =
|
|
2875
|
+
chosenArm === "quality-optimized"
|
|
2876
|
+
? "cost-optimized"
|
|
2877
|
+
: "quality-optimized";
|
|
2878
|
+
try {
|
|
2879
|
+
// Only the unchosen arm takes images: judged as "any arm", the mix
|
|
2880
|
+
// would keep the pin off and the image would reach a text-only model.
|
|
2881
|
+
visionByProfile.set(chosenArm!, false);
|
|
2882
|
+
visionByProfile.set(otherArm, true);
|
|
2883
|
+
const textOnlyArm = await runOptionsFor({ messages: PHOTO_HISTORY });
|
|
2884
|
+
expect(textOnlyArm.overrideProfile).toBe("latency-optimized");
|
|
2885
|
+
|
|
2886
|
+
// Only the chosen arm takes images: no pin needed, the mix stands.
|
|
2887
|
+
visionByProfile.set(chosenArm!, true);
|
|
2888
|
+
visionByProfile.set(otherArm, false);
|
|
2889
|
+
const visionArm = await runOptionsFor({ messages: PHOTO_HISTORY });
|
|
2890
|
+
expect(visionArm.overrideProfile).toBe("voice-mix");
|
|
2891
|
+
} finally {
|
|
2892
|
+
visionByProfile.clear();
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
});
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
TurnChannelContext,
|
|
23
23
|
TurnInterfaceContext,
|
|
24
24
|
} from "../channels/types.js";
|
|
25
|
+
import { selectWinningProfile } from "../config/llm-resolver.js";
|
|
25
26
|
import { getConfig } from "../config/loader.js";
|
|
26
27
|
import { ABORT_WATCHDOG_MS } from "../daemon/abort-watchdog.js";
|
|
27
28
|
import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
|
|
@@ -37,7 +38,9 @@ import { resolveAttachmentsForPersist } from "../persistence/attachments-store.j
|
|
|
37
38
|
import {
|
|
38
39
|
deleteMessageById,
|
|
39
40
|
getMessageById,
|
|
41
|
+
type OverrideProfileFields,
|
|
40
42
|
recordConversationPersistedSeq,
|
|
43
|
+
resolveOverrideProfile,
|
|
41
44
|
updateMessageContent,
|
|
42
45
|
} from "../persistence/conversation-crud.js";
|
|
43
46
|
import { VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND } from "../plugin-api/constants.js";
|
|
@@ -91,6 +94,50 @@ const log = getLogger("voice-session-bridge");
|
|
|
91
94
|
*/
|
|
92
95
|
const VOICE_IMAGE_PROFILE = "latency-optimized";
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The profile the conversation's own text turns run on, resolved the way a
|
|
99
|
+
* `mainAgent` turn resolves it: the conversation's pinned profile when it
|
|
100
|
+
* carries one, else the workspace chat-model selection (`llm.activeProfile`),
|
|
101
|
+
* else the main agent's call-site pin.
|
|
102
|
+
*
|
|
103
|
+
* The escalated voice leg runs through `callAgent`, whose chain never
|
|
104
|
+
* consults `llm.activeProfile`, so without this the hand-off lands on that
|
|
105
|
+
* site's shipped `balanced` default while the same conversation's typed
|
|
106
|
+
* turns run on whatever the user picked. Pinning the text-turn winner keeps
|
|
107
|
+
* the stronger model the front door escalates to the one the conversation is
|
|
108
|
+
* already using.
|
|
109
|
+
*
|
|
110
|
+
* `profile` is the name to pin (a mix's own name, so dispatch re-expands it
|
|
111
|
+
* to the same arm from the conversation seed); `modelProfile` is the concrete
|
|
112
|
+
* profile whose model actually runs (the chosen arm of a mix), which is what
|
|
113
|
+
* capability checks must judge: a mix reads as vision-capable when any arm
|
|
114
|
+
* is, but only one arm serves this conversation.
|
|
115
|
+
*
|
|
116
|
+
* Null when nothing above named a profile (the winner is the code-owned
|
|
117
|
+
* anchor): the leg then keeps its ordinary call-site resolution, which lands
|
|
118
|
+
* on the same anchor intent and still honors a `callAgent` site pin.
|
|
119
|
+
*/
|
|
120
|
+
function conversationProfileForEscalation(
|
|
121
|
+
conversation: OverrideProfileFields & { conversationId: string },
|
|
122
|
+
): { profile: string; modelProfile: string } | null {
|
|
123
|
+
const overrideProfile = resolveOverrideProfile(conversation);
|
|
124
|
+
let chosenArm: string | undefined;
|
|
125
|
+
const selection = selectWinningProfile("mainAgent", getConfig().llm, {
|
|
126
|
+
...(overrideProfile != null ? { overrideProfile } : {}),
|
|
127
|
+
selectionSeed: conversation.conversationId,
|
|
128
|
+
onMixSelected: ({ chosenProfile }) => {
|
|
129
|
+
chosenArm = chosenProfile;
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
if (selection.source === "default" || selection.profileName == null) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
profile: selection.profileName,
|
|
137
|
+
modelProfile: chosenArm ?? selection.profileName,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
/**
|
|
95
142
|
* Does this conversation's history carry an image?
|
|
96
143
|
*
|
|
@@ -422,10 +469,10 @@ export interface VoiceTurnOptions {
|
|
|
422
469
|
signal?: AbortSignal;
|
|
423
470
|
/**
|
|
424
471
|
* Ad-hoc inference-profile override applied to every LLM call this turn
|
|
425
|
-
* issues (forwarded to `runAgentLoop` with `forceOverrideProfile`).
|
|
426
|
-
*
|
|
427
|
-
* profile
|
|
428
|
-
* call-site default
|
|
472
|
+
* issues (forwarded to `runAgentLoop` with `forceOverrideProfile`). Wins
|
|
473
|
+
* over the bridge's own pins (the image pin, and the conversation's
|
|
474
|
+
* profile for an escalated leg). Undefined = those pins, else the
|
|
475
|
+
* call-site default.
|
|
429
476
|
*/
|
|
430
477
|
overrideProfile?: string;
|
|
431
478
|
/**
|
|
@@ -1902,23 +1949,47 @@ export async function startVoiceTurn(
|
|
|
1902
1949
|
conversation.toolsDisabledDepth++;
|
|
1903
1950
|
frontDoorToolsSuppressed = true;
|
|
1904
1951
|
}
|
|
1952
|
+
// An escalated leg follows the conversation's own model: the front
|
|
1953
|
+
// door hands off to the profile the caller's typed turns already run
|
|
1954
|
+
// on, not to `callAgent`'s shipped default. Null keeps the ordinary
|
|
1955
|
+
// call-site resolution.
|
|
1956
|
+
const conversationProfile =
|
|
1957
|
+
opts.routingLeg === "escalated"
|
|
1958
|
+
? conversationProfileForEscalation(conversation)
|
|
1959
|
+
: null;
|
|
1905
1960
|
// Resolved once here rather than inside the options literal below, so
|
|
1906
1961
|
// the history scan happens once per leg. A front-door leg is skipped:
|
|
1907
|
-
// its own call site already resolves to the same profile.
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
|
|
1962
|
+
// its own call site already resolves to the same profile. A
|
|
1963
|
+
// conversation profile whose model takes images needs no image pin
|
|
1964
|
+
// either; one that does not yields to the image pin, since a model
|
|
1965
|
+
// that rejects an image fails the whole leg. The judged profile is the
|
|
1966
|
+
// concrete arm that serves this conversation, not a mix's name. The
|
|
1967
|
+
// capability checks come before the scan because they are the cheaper
|
|
1968
|
+
// of the two and they decide whether the pin is worth anything at all.
|
|
1969
|
+
const needsImagePin =
|
|
1911
1970
|
opts.routingLeg !== "front-door" &&
|
|
1971
|
+
!(
|
|
1972
|
+
conversationProfile != null &&
|
|
1973
|
+
doesSupportVision(conversationProfile.modelProfile)
|
|
1974
|
+
) &&
|
|
1912
1975
|
doesSupportVision(VOICE_IMAGE_PROFILE) &&
|
|
1913
1976
|
conversationCarriesImage(conversation.getMessages());
|
|
1914
|
-
if (
|
|
1977
|
+
if (needsImagePin) {
|
|
1915
1978
|
log.info(
|
|
1916
1979
|
{ turnId, routingLeg: opts.routingLeg ?? null },
|
|
1917
1980
|
"Voice leg carries an image; pinning the image-capable profile",
|
|
1918
1981
|
);
|
|
1982
|
+
} else if (conversationProfile != null) {
|
|
1983
|
+
log.info(
|
|
1984
|
+
{ turnId, profile: conversationProfile.profile },
|
|
1985
|
+
"Escalated voice leg pinned to the conversation's own profile",
|
|
1986
|
+
);
|
|
1919
1987
|
}
|
|
1920
1988
|
const profilePin =
|
|
1921
|
-
opts.overrideProfile ??
|
|
1989
|
+
opts.overrideProfile ??
|
|
1990
|
+
(needsImagePin
|
|
1991
|
+
? VOICE_IMAGE_PROFILE
|
|
1992
|
+
: (conversationProfile?.profile ?? null));
|
|
1922
1993
|
await conversation.runAgentLoop(persistedContent, messageId, {
|
|
1923
1994
|
onEvent: (msg: AssistantEvent) => {
|
|
1924
1995
|
if (msg.type === "assistant_turn_start") {
|
|
@@ -1995,15 +2066,17 @@ export async function startVoiceTurn(
|
|
|
1995
2066
|
...(isEscalationContinuation
|
|
1996
2067
|
? { messageKind: VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND }
|
|
1997
2068
|
: {}),
|
|
1998
|
-
// Triage-and-escalate routing pins this turn to the fast front-door
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2069
|
+
// Triage-and-escalate routing pins this turn to the fast front-door
|
|
2070
|
+
// profile or to the conversation's own profile for the escalated
|
|
2071
|
+
// leg. `forceOverrideProfile` floats it above the callAgent call-site
|
|
2072
|
+
// layers (callAgent is not `mainAgent`, so the override would
|
|
2073
|
+
// otherwise sit below the call-site profile).
|
|
2002
2074
|
//
|
|
2003
2075
|
// An explicit routing pin wins; failing that, a leg whose history
|
|
2004
|
-
// carries an image is pinned to a profile whose model takes one
|
|
2005
|
-
//
|
|
2006
|
-
//
|
|
2076
|
+
// carries an image is pinned to a profile whose model takes one;
|
|
2077
|
+
// failing that, an escalated leg is pinned to the conversation's
|
|
2078
|
+
// profile. A front-door leg needs none of these: its own call site
|
|
2079
|
+
// already resolves there.
|
|
2007
2080
|
...(profilePin != null
|
|
2008
2081
|
? { overrideProfile: profilePin, forceOverrideProfile: true }
|
|
2009
2082
|
: {}),
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* caller is mid-thought — the leg is discarded and listening continues.
|
|
9
9
|
* - `[1]` ({@link ESCALATE_VERDICT_TOKEN}) followed by ONE short natural
|
|
10
10
|
* holding phrase: the turn is too tricky — the phrase is spoken (capped
|
|
11
|
-
* at a single sentence) while the turn re-runs on the
|
|
12
|
-
* profile, the model
|
|
13
|
-
* the holding phrase is spoken, the caller never hears the
|
|
14
|
-
* model's think-time as silence.
|
|
11
|
+
* at a single sentence) while the turn re-runs on the conversation's
|
|
12
|
+
* own profile, the model the caller's typed turns already run on.
|
|
13
|
+
* Because the holding phrase is spoken, the caller never hears the
|
|
14
|
+
* stronger model's think-time as silence.
|
|
15
15
|
* - Anything else: the output IS the answer, streamed straight to TTS
|
|
16
16
|
* (low first-token latency -> the caller hears audio fast).
|
|
17
17
|
*
|
|
@@ -38,11 +38,13 @@ export { ESCALATE_VERDICT_TOKEN, HOLD_VERDICT_TOKEN };
|
|
|
38
38
|
|
|
39
39
|
// The fast model fronting every turn is pinned by the `voiceFrontDoor` call
|
|
40
40
|
// site (see config/call-site-defaults.ts) — no per-turn profile override.
|
|
41
|
-
// The escalated leg
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
41
|
+
// The escalated leg is pinned by the bridge to the conversation's own
|
|
42
|
+
// profile: the conversation's pinned profile if it has one, else the
|
|
43
|
+
// workspace chat-model selection — the model the caller's typed turns in the
|
|
44
|
+
// same conversation already run on (see `conversationProfileForEscalation`
|
|
45
|
+
// in voice-session-bridge.ts). A conversation with no such selection keeps
|
|
46
|
+
// the ordinary call-agent resolution. That honors the user's model choice
|
|
47
|
+
// instead of dropping every hand-off onto the call site's shipped default.
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Which leg of a triaged turn a `startVoiceTurn` call represents. Undefined
|
|
@@ -194,8 +194,9 @@ describe("live-voice triage-and-escalate routing", () => {
|
|
|
194
194
|
const escalated = starter.mock.calls[1]?.[0];
|
|
195
195
|
expect(frontDoor?.overrideProfile).toBeUndefined();
|
|
196
196
|
expect(frontDoor?.routingLeg).toBe("front-door");
|
|
197
|
-
// The
|
|
198
|
-
//
|
|
197
|
+
// The session passes no override either: the bridge itself pins the
|
|
198
|
+
// escalated leg to the conversation's own profile (covered in
|
|
199
|
+
// calls/__tests__/voice-session-bridge.test.ts).
|
|
199
200
|
expect(escalated?.overrideProfile).toBeUndefined();
|
|
200
201
|
expect(escalated?.routingLeg).toBe("escalated");
|
|
201
202
|
expect(escalated?.content).toBe(ESCALATION_CONTINUATION_CONTENT);
|
|
@@ -5853,11 +5853,12 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5853
5853
|
}
|
|
5854
5854
|
}
|
|
5855
5855
|
|
|
5856
|
-
// No overrideProfile: the escalated leg
|
|
5857
|
-
//
|
|
5858
|
-
// voice-triage-escalate.ts). The bridge phrase the caller
|
|
5859
|
-
// handed along so the escalated continuation rule can
|
|
5860
|
-
// a re-announcing echo ("Let me check…" twice in a
|
|
5856
|
+
// No overrideProfile here: the bridge pins the escalated leg to the
|
|
5857
|
+
// conversation's own profile, the model the caller's typed turns already
|
|
5858
|
+
// run on (see voice-triage-escalate.ts). The bridge phrase the caller
|
|
5859
|
+
// just heard is handed along so the escalated continuation rule can
|
|
5860
|
+
// quote it and ban a re-announcing echo ("Let me check…" twice in a
|
|
5861
|
+
// row).
|
|
5861
5862
|
void this.startAssistantLeg(activeTurn, {
|
|
5862
5863
|
content: ESCALATION_CONTINUATION_CONTENT,
|
|
5863
5864
|
routingLeg: "escalated",
|