@vellumai/assistant 0.12.2-staging.3 → 0.12.2-staging.5
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/acp/session-snapshot.ts +260 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/skill-management/TOOLS.json +1 -1
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -0
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +25 -0
- package/src/live-voice/live-voice-session.ts +210 -12
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +55 -1
- package/src/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +10 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/acp-routes.ts +17 -161
- package/src/skills/managed-store.ts +56 -0
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- package/src/tools/skills/find-similar-skills.test.ts +211 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
|
@@ -1873,6 +1873,40 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
1873
1873
|
expect(announcement?.voiceControlPrompt).toContain("first question");
|
|
1874
1874
|
});
|
|
1875
1875
|
|
|
1876
|
+
// Deepgram Flux sends interim updates through silence, each an empty
|
|
1877
|
+
// partial. A call nobody is talking on is still idle.
|
|
1878
|
+
test("an empty partial on an idle call does not hold the announcement back", async () => {
|
|
1879
|
+
const continuation = makeControlledContinuation();
|
|
1880
|
+
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
1881
|
+
const { frames, session, transcribers } = createHarness({
|
|
1882
|
+
finals: ["first question", ""],
|
|
1883
|
+
startVoiceTurn,
|
|
1884
|
+
streamTtsAudio: makeImmediateTts(),
|
|
1885
|
+
spawnBackgroundContinuation: continuation.spawnBackgroundContinuation,
|
|
1886
|
+
continuationAnnounceSilenceMs: 20,
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
await session.start();
|
|
1890
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1891
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1892
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1893
|
+
await waitFor(
|
|
1894
|
+
() => continuation.spawnBackgroundContinuation.mock.calls.length === 1,
|
|
1895
|
+
);
|
|
1896
|
+
await waitFor(() =>
|
|
1897
|
+
frames.some((frame) => frame.type === "utterance_discarded"),
|
|
1898
|
+
);
|
|
1899
|
+
await waitFor(() => transcribers.some((t) => !t.stopped));
|
|
1900
|
+
for (const transcriber of transcribers) {
|
|
1901
|
+
if (!transcriber.stopped) {
|
|
1902
|
+
transcriber.emit({ type: "partial", text: "" });
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
continuation.finish("THE_RESULT");
|
|
1907
|
+
await waitFor(() => announcementOf(calls) !== undefined);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1876
1910
|
test("an announcement persists hidden and is never delivered twice", async () => {
|
|
1877
1911
|
const continuation = makeControlledContinuation();
|
|
1878
1912
|
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
@@ -800,6 +800,54 @@ describe("parseLiveVoiceClientTextFrame", () => {
|
|
|
800
800
|
});
|
|
801
801
|
});
|
|
802
802
|
|
|
803
|
+
test("parses the lookFrames capability on the start frame", () => {
|
|
804
|
+
const result = validateLiveVoiceClientFrame({
|
|
805
|
+
type: "start",
|
|
806
|
+
lookFrames: true,
|
|
807
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
expect(result.ok).toBe(true);
|
|
811
|
+
if (!result.ok) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
expect(result.frame).toMatchObject({ type: "start", lookFrames: true });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("omits lookFrames from the start frame when false", () => {
|
|
818
|
+
// False and absent mean the same thing: no look frame is coming, so the
|
|
819
|
+
// session must not wait for one.
|
|
820
|
+
const result = validateLiveVoiceClientFrame({
|
|
821
|
+
type: "start",
|
|
822
|
+
lookFrames: false,
|
|
823
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
expect(result.ok).toBe(true);
|
|
827
|
+
if (!result.ok) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
expect("lookFrames" in result.frame).toBe(false);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("returns a typed protocol error for a non-boolean lookFrames", () => {
|
|
834
|
+
const result = validateLiveVoiceClientFrame({
|
|
835
|
+
type: "start",
|
|
836
|
+
lookFrames: 1,
|
|
837
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(result.ok).toBe(false);
|
|
841
|
+
if (result.ok) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
expect(result.error).toMatchObject({
|
|
845
|
+
code: "invalid_field",
|
|
846
|
+
field: "lookFrames",
|
|
847
|
+
frameType: "start",
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
803
851
|
test("returns typed protocol errors for missing audio configuration fields", () => {
|
|
804
852
|
const result = validateLiveVoiceClientFrame({
|
|
805
853
|
type: "start",
|
|
@@ -105,6 +105,31 @@ describe("sessionControlTeaching", () => {
|
|
|
105
105
|
);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// A client that sends a fresh frame for every look lets the session answer
|
|
109
|
+
// it, so the reply asking for the look only acknowledges, and asking again is
|
|
110
|
+
// how a share already running gets looked at as it is now.
|
|
111
|
+
test("a client that sends look frames is taught the look is answered for it", () => {
|
|
112
|
+
const teaching = sessionControlTeaching(
|
|
113
|
+
["look_screen", "look_camera"],
|
|
114
|
+
{},
|
|
115
|
+
{ lookFrames: true },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(teaching).toContain("[LOOK:SCREEN]");
|
|
119
|
+
expect(teaching).toContain(
|
|
120
|
+
"Use it even when their screen is already shared with you",
|
|
121
|
+
);
|
|
122
|
+
expect(teaching).toContain("Use it even when the camera is already on");
|
|
123
|
+
expect(teaching).not.toContain("take it from their next words");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a client that sends no look frames keeps the next-words look", () => {
|
|
127
|
+
const teaching = sessionControlTeaching(["look_screen"], {});
|
|
128
|
+
|
|
129
|
+
expect(teaching).toContain("take it from their next words");
|
|
130
|
+
expect(teaching).not.toContain("Use it even when");
|
|
131
|
+
});
|
|
132
|
+
|
|
108
133
|
test("the front-door leg keeps its verdict tokens", () => {
|
|
109
134
|
expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
|
|
110
135
|
"Never emit any other bracketed marker.",
|
|
@@ -160,6 +160,11 @@ import {
|
|
|
160
160
|
} from "./protocol.js";
|
|
161
161
|
import {
|
|
162
162
|
type ClientSessionControlRequest,
|
|
163
|
+
isLookSessionControl,
|
|
164
|
+
LOOK_FOLLOW_UP_CONTENT,
|
|
165
|
+
LOOK_FRAME_REASON,
|
|
166
|
+
lookFollowUpNote,
|
|
167
|
+
type LookSessionControl,
|
|
163
168
|
progressConfigForCadence,
|
|
164
169
|
requestedSessionControl,
|
|
165
170
|
sessionControlTeaching,
|
|
@@ -270,6 +275,18 @@ const CONTINUATION_ANNOUNCE_MAX_DRAIN_REARMS = 3;
|
|
|
270
275
|
// persisted on the user side is this marker, and it persists hidden.
|
|
271
276
|
export const CONTINUATION_DELIVERY_CONTENT =
|
|
272
277
|
"(background work finished — deliver it now)";
|
|
278
|
+
// How long a look control waits for the fresh frame the client takes for it.
|
|
279
|
+
// Long enough for a camera that has to open and warm up before its first keep;
|
|
280
|
+
// past it the look is dropped rather than answered into a later, unrelated
|
|
281
|
+
// silence.
|
|
282
|
+
const LOOK_FRAME_WAIT_MS = 10_000;
|
|
283
|
+
// How long after the look was asked for its answer may still start. Past it
|
|
284
|
+
// the floor has been taken for so long (a user talking through noise that never
|
|
285
|
+
// became a turn, say) that the look no longer answers what is on screen.
|
|
286
|
+
const LOOK_ANSWER_DEADLINE_MS = 15_000;
|
|
287
|
+
// The shortest wait before checking the floor again, for a blocker with no
|
|
288
|
+
// known end time (the look's own turn clearing, an utterance in capture).
|
|
289
|
+
const LOOK_FOLLOW_UP_REARM_MS = 250;
|
|
273
290
|
|
|
274
291
|
export type LiveVoiceStreamingTranscriberResolver = (
|
|
275
292
|
options: ResolveStreamingTranscriberOptions,
|
|
@@ -697,6 +714,10 @@ interface ActiveAssistantTurn {
|
|
|
697
714
|
// no user utterance behind it — `content` is CONTINUATION_DELIVERY_CONTENT and
|
|
698
715
|
// the answer rides the control prompt (buildLiveDeliveryNote).
|
|
699
716
|
continuationDelivery: ContinuationDelivery | null;
|
|
717
|
+
// Set only on the turn that answers a look: which look it answers. The turn
|
|
718
|
+
// has no user utterance behind it; the instruction rides the control prompt
|
|
719
|
+
// (lookFollowUpNote).
|
|
720
|
+
lookFollowUp: LookSessionControl | null;
|
|
700
721
|
// The turn's content is an internal instruction rather than user speech (the
|
|
701
722
|
// greeting that opens a session, say). The row still persists and the model
|
|
702
723
|
// still sees it; `hiddenSyntheticPrompt` keeps it out of the transcript.
|
|
@@ -838,6 +859,14 @@ function describeInterruptedRequest(request: string): string {
|
|
|
838
859
|
: "their earlier request";
|
|
839
860
|
}
|
|
840
861
|
|
|
862
|
+
// A look control waiting on its fresh frame: which look, when it was asked
|
|
863
|
+
// for, and the wait's bound.
|
|
864
|
+
interface PendingLook {
|
|
865
|
+
action: LookSessionControl;
|
|
866
|
+
armedAtMs: number;
|
|
867
|
+
timer: ReturnType<typeof setTimeout>;
|
|
868
|
+
}
|
|
869
|
+
|
|
841
870
|
// A finished background continuation waiting to be delivered: the request it
|
|
842
871
|
// took over and the answer it produced.
|
|
843
872
|
interface ContinuationDelivery {
|
|
@@ -866,13 +895,14 @@ function buildVoiceControlPrompt(
|
|
|
866
895
|
turn: ActiveAssistantTurn,
|
|
867
896
|
leg: { frontDoor?: boolean },
|
|
868
897
|
sessionControls: readonly LiveVoiceSessionControl[],
|
|
898
|
+
client: { lookFrames: boolean },
|
|
869
899
|
): string {
|
|
870
900
|
let prompt =
|
|
871
901
|
LIVE_VOICE_CONTROL_PROMPT_BASE +
|
|
872
902
|
(leg.frontDoor === true
|
|
873
903
|
? ""
|
|
874
904
|
: LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING) +
|
|
875
|
-
sessionControlTeaching(sessionControls, leg);
|
|
905
|
+
sessionControlTeaching(sessionControls, leg, client);
|
|
876
906
|
if (turn.language !== undefined) {
|
|
877
907
|
prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
|
|
878
908
|
}
|
|
@@ -885,6 +915,9 @@ function buildVoiceControlPrompt(
|
|
|
885
915
|
if (turn.handedOffRequest) {
|
|
886
916
|
prompt = `${prompt}\n\n${buildHandoffAnnouncementNote(turn.handedOffRequest)}`;
|
|
887
917
|
}
|
|
918
|
+
if (turn.lookFollowUp !== null) {
|
|
919
|
+
prompt = `${prompt}\n\n${lookFollowUpNote(turn.lookFollowUp)}`;
|
|
920
|
+
}
|
|
888
921
|
if (turn.continuationDelivery) {
|
|
889
922
|
prompt = `${prompt}\n\n${buildLiveDeliveryNote(
|
|
890
923
|
turn.continuationDelivery.request,
|
|
@@ -1135,6 +1168,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1135
1168
|
// The session controls the client declared it can carry out; the only ones
|
|
1136
1169
|
// the model is taught and the only ones a reply's marker can trigger.
|
|
1137
1170
|
private readonly sessionControls: readonly LiveVoiceSessionControl[];
|
|
1171
|
+
// The client declared `lookFrames`: it sends a fresh frame for every look
|
|
1172
|
+
// control it carries out, so the session answers a look on its own turn and
|
|
1173
|
+
// teaches the model that the reply asking for one is only the acknowledgement.
|
|
1174
|
+
private readonly lookFrames: boolean;
|
|
1138
1175
|
// How often progress updates are spoken, as the user last asked out loud.
|
|
1139
1176
|
// Session-scoped: it applies from the next turn to the end of the call.
|
|
1140
1177
|
private progressCadence: "fewer" | "normal" = "normal";
|
|
@@ -1222,6 +1259,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1222
1259
|
// answer.
|
|
1223
1260
|
private pendingAnnouncement: ContinuationDelivery | null = null;
|
|
1224
1261
|
private announcementTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1262
|
+
// A look control sent to a client that declared `lookFrames`, waiting for
|
|
1263
|
+
// the fresh frame the client takes for it. The session answers the look on a
|
|
1264
|
+
// turn of its own once that frame is in the conversation (see
|
|
1265
|
+
// answerLookWhenFloorIsFree). Cleared when the frame lands, when the wait
|
|
1266
|
+
// runs out, and when the session closes.
|
|
1267
|
+
private pendingLook: PendingLook | null = null;
|
|
1268
|
+
private lookFollowUpTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1225
1269
|
// Set when a continuation actually spawns: the request it took over, so the
|
|
1226
1270
|
// NEXT turn can tell the user the work is still running. Consumed by that
|
|
1227
1271
|
// turn; cleared when the continuation finishes (by then the result note
|
|
@@ -1236,6 +1280,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1236
1280
|
// the distance from it to a keep arriving is the client leg of the frame the
|
|
1237
1281
|
// onset asked for, measured from the daemon's own clock.
|
|
1238
1282
|
private lastSpeechStartedAtMs: number | null = null;
|
|
1283
|
+
// How many assistant turns have launched, so a look can tell whether one has
|
|
1284
|
+
// started since its frame landed. A count rather than a time: two launches
|
|
1285
|
+
// and a frame can share a millisecond.
|
|
1286
|
+
private turnsLaunched = 0;
|
|
1239
1287
|
private readonly maxPendingAudioBytes: number;
|
|
1240
1288
|
// Set on VAD speech onset; consumed when the first speech chunk is routed
|
|
1241
1289
|
// to an utterance so the metric lands on the right turn.
|
|
@@ -1413,6 +1461,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1413
1461
|
SERVER_VAD_PENDING_AUDIO_MAX_SECONDS;
|
|
1414
1462
|
this.textInput = context.startFrame.textInput === true;
|
|
1415
1463
|
this.sessionControls = context.startFrame.sessionControls ?? [];
|
|
1464
|
+
this.lookFrames = context.startFrame.lookFrames === true;
|
|
1416
1465
|
}
|
|
1417
1466
|
|
|
1418
1467
|
get finalTranscriptText(): string {
|
|
@@ -1661,6 +1710,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1661
1710
|
},
|
|
1662
1711
|
"Sight frame timing",
|
|
1663
1712
|
);
|
|
1713
|
+
// Only once the row is in: the turn that answers the look reads the
|
|
1714
|
+
// conversation, so the frame has to be there before that turn starts.
|
|
1715
|
+
if (result.ok && frame.timing?.reason === LOOK_FRAME_REASON) {
|
|
1716
|
+
this.lookFrameLanded();
|
|
1717
|
+
}
|
|
1664
1718
|
if (!result.ok && !this.isClosed) {
|
|
1665
1719
|
void this.sendFrame({
|
|
1666
1720
|
type: "error",
|
|
@@ -1866,6 +1920,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1866
1920
|
// cleared: the announcement is dead either way.
|
|
1867
1921
|
await this.deliverPendingContinuationToConversation();
|
|
1868
1922
|
this.clearContinuationAnnouncement();
|
|
1923
|
+
this.clearPendingLook();
|
|
1869
1924
|
this.stopSessionTranscriber();
|
|
1870
1925
|
// Detached continuations outlive the call. A deliberate `interrupt()`
|
|
1871
1926
|
// aborts them; ending the session leaves them running. With no next voice
|
|
@@ -2909,16 +2964,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
2909
2964
|
// already finished, so bargeIn returns it to the stash instead.
|
|
2910
2965
|
turn.continuationDelivery !== null
|
|
2911
2966
|
? "announcement_turn"
|
|
2912
|
-
: //
|
|
2913
|
-
//
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
//
|
|
2919
|
-
|
|
2920
|
-
? "
|
|
2921
|
-
:
|
|
2967
|
+
: // Nor over the answer to a look: there is no request behind it
|
|
2968
|
+
// either, and the user talking over it is them moving on.
|
|
2969
|
+
turn.lookFollowUp !== null
|
|
2970
|
+
? "look_follow_up"
|
|
2971
|
+
: // The model already finished generating (barge-in during TTS playback
|
|
2972
|
+
// of a complete reply): there is nothing to continue, so a
|
|
2973
|
+
// continuation would just re-do a finished answer.
|
|
2974
|
+
turn.assistantCompleted
|
|
2975
|
+
? "assistant_already_completed"
|
|
2976
|
+
: // A stop (interrupt/close) or a superseding invalidation landed
|
|
2977
|
+
// during the barge-in teardown: honor it.
|
|
2978
|
+
this.detachStopGeneration !== stopGeneration
|
|
2979
|
+
? "invalidated_during_barge_teardown"
|
|
2980
|
+
: null;
|
|
2922
2981
|
if (skipReason !== null) {
|
|
2923
2982
|
log.info(
|
|
2924
2983
|
{ turnId: turn.turnId, skipReason },
|
|
@@ -3565,6 +3624,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3565
3624
|
// own it leaves manual mode blind to a user who is talking right now and
|
|
3566
3625
|
// has no text yet — hence the captured-audio flag, which manual ingress
|
|
3567
3626
|
// sets from the first chunk.
|
|
3627
|
+
//
|
|
3628
|
+
// A partial counts only when it has words. Deepgram Flux sends interim
|
|
3629
|
+
// updates through silence too, each an empty partial, and one of those
|
|
3630
|
+
// would otherwise hold the floor until the user next spoke.
|
|
3568
3631
|
if (
|
|
3569
3632
|
utterance !== null &&
|
|
3570
3633
|
!utterance.completed &&
|
|
@@ -3572,13 +3635,129 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3572
3635
|
utterance.assistantTurnStarted ||
|
|
3573
3636
|
(utterance.manualAudioCaptured && opts?.ignoreManualCapture !== true) ||
|
|
3574
3637
|
utterance.finalTranscriptSegments.length > 0 ||
|
|
3575
|
-
utterance.latestPartialText
|
|
3638
|
+
(utterance.latestPartialText?.trim() ?? "").length > 0)
|
|
3576
3639
|
) {
|
|
3577
3640
|
return "utterance_in_flight";
|
|
3578
3641
|
}
|
|
3579
3642
|
return null;
|
|
3580
3643
|
}
|
|
3581
3644
|
|
|
3645
|
+
/**
|
|
3646
|
+
* Wait for the fresh frame a look control asks the client for.
|
|
3647
|
+
*
|
|
3648
|
+
* Only for a client that declared `lookFrames`: any other sends no such
|
|
3649
|
+
* frame, and its look keeps the old shape, where the user's next words are
|
|
3650
|
+
* what the frame gets answered on. A newer look replaces an older one still
|
|
3651
|
+
* waiting, and the wait is bounded so a frame that never comes (a camera
|
|
3652
|
+
* that would not open, a share the desktop refused) cannot turn up minutes
|
|
3653
|
+
* later as a reply to nothing.
|
|
3654
|
+
*/
|
|
3655
|
+
private awaitLookFrame(action: LookSessionControl): void {
|
|
3656
|
+
if (!this.lookFrames) {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3659
|
+
this.clearPendingLook();
|
|
3660
|
+
const timer = setTimeout(() => {
|
|
3661
|
+
if (this.pendingLook?.timer !== timer) {
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
this.pendingLook = null;
|
|
3665
|
+
log.info(
|
|
3666
|
+
{ conversationId: this.conversationId, action },
|
|
3667
|
+
"Live voice look dropped: no frame arrived",
|
|
3668
|
+
);
|
|
3669
|
+
}, LOOK_FRAME_WAIT_MS);
|
|
3670
|
+
this.pendingLook = { action, armedAtMs: Date.now(), timer };
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
private clearPendingLook(): void {
|
|
3674
|
+
if (this.pendingLook !== null) {
|
|
3675
|
+
clearTimeout(this.pendingLook.timer);
|
|
3676
|
+
this.pendingLook = null;
|
|
3677
|
+
}
|
|
3678
|
+
if (this.lookFollowUpTimer !== null) {
|
|
3679
|
+
clearTimeout(this.lookFollowUpTimer);
|
|
3680
|
+
this.lookFollowUpTimer = null;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
/**
|
|
3685
|
+
* The frame a look asked for is in the conversation: answer the look.
|
|
3686
|
+
*
|
|
3687
|
+
* A look frame with no look waiting (the wait ran out, or the session never
|
|
3688
|
+
* armed one) is only a frame, like any other keep.
|
|
3689
|
+
*/
|
|
3690
|
+
private lookFrameLanded(): void {
|
|
3691
|
+
const pending = this.pendingLook;
|
|
3692
|
+
if (pending === null || this.isClosed) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
clearTimeout(pending.timer);
|
|
3696
|
+
this.pendingLook = null;
|
|
3697
|
+
this.answerLookWhenFloorIsFree(pending, this.turnsLaunched, 0);
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/**
|
|
3701
|
+
* Start the turn that answers a look, once nothing else holds the floor.
|
|
3702
|
+
*
|
|
3703
|
+
* The acknowledgement ("taking a look") can still be playing when the frame
|
|
3704
|
+
* lands, and the look's own turn can still be clearing, so both are waited
|
|
3705
|
+
* out, as is the user mid-utterance. A turn that starts once the frame is in
|
|
3706
|
+
* the conversation is not: it reads the frame, so answering the look as well
|
|
3707
|
+
* would answer it twice. A turn that started before the frame landed did not
|
|
3708
|
+
* see it, so the look is still answered once that turn is done.
|
|
3709
|
+
*
|
|
3710
|
+
* `turnsAtFrame` is how many turns had launched when the frame landed.
|
|
3711
|
+
*/
|
|
3712
|
+
private answerLookWhenFloorIsFree(
|
|
3713
|
+
look: PendingLook,
|
|
3714
|
+
turnsAtFrame: number,
|
|
3715
|
+
rearms: number,
|
|
3716
|
+
): void {
|
|
3717
|
+
if (this.lookFollowUpTimer !== null) {
|
|
3718
|
+
clearTimeout(this.lookFollowUpTimer);
|
|
3719
|
+
this.lookFollowUpTimer = null;
|
|
3720
|
+
}
|
|
3721
|
+
const { action, armedAtMs } = look;
|
|
3722
|
+
// A turn launched since the frame landed read it already. Speech that
|
|
3723
|
+
// never became a turn (a cough, noise that transcribed to nothing) is only
|
|
3724
|
+
// waited out.
|
|
3725
|
+
const blockedBy =
|
|
3726
|
+
this.turnsLaunched > turnsAtFrame
|
|
3727
|
+
? "turn_since_look"
|
|
3728
|
+
: this.sessionTurnFloorBlocker();
|
|
3729
|
+
if (blockedBy === null) {
|
|
3730
|
+
void this.launchAssistantTurn(
|
|
3731
|
+
createSyntheticUtterance(),
|
|
3732
|
+
LOOK_FOLLOW_UP_CONTENT,
|
|
3733
|
+
{ lookFollowUp: action, hiddenPrompt: true },
|
|
3734
|
+
).catch((err: unknown) => {
|
|
3735
|
+
log.warn(
|
|
3736
|
+
{ err, conversationId: this.conversationId, action },
|
|
3737
|
+
"Live voice look follow-up failed to start",
|
|
3738
|
+
);
|
|
3739
|
+
});
|
|
3740
|
+
return;
|
|
3741
|
+
}
|
|
3742
|
+
const waitable =
|
|
3743
|
+
blockedBy !== "turn_since_look" && blockedBy !== "session_unavailable";
|
|
3744
|
+
if (!waitable || Date.now() - armedAtMs >= LOOK_ANSWER_DEADLINE_MS) {
|
|
3745
|
+
log.info(
|
|
3746
|
+
{ conversationId: this.conversationId, action, blockedBy, rearms },
|
|
3747
|
+
"Live voice look follow-up skipped",
|
|
3748
|
+
);
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
const drainMs = Math.max(0, this.assistantPlaybackTailUntilMs - Date.now());
|
|
3752
|
+
this.lookFollowUpTimer = setTimeout(
|
|
3753
|
+
() => {
|
|
3754
|
+
this.lookFollowUpTimer = null;
|
|
3755
|
+
this.answerLookWhenFloorIsFree(look, turnsAtFrame, rearms + 1);
|
|
3756
|
+
},
|
|
3757
|
+
Math.max(drainMs, LOOK_FOLLOW_UP_REARM_MS),
|
|
3758
|
+
);
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3582
3761
|
// Speak the finished continuation's result on a turn the session starts
|
|
3583
3762
|
// itself. The turn rides a synthetic cycle rather than the armed VAD one —
|
|
3584
3763
|
// `currentUtterance` belongs to the capture loop, and overwriting it would
|
|
@@ -4770,6 +4949,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4770
4949
|
this.pendingInterruptedRequest = null;
|
|
4771
4950
|
// ...and it hard-stops any detached background continuations.
|
|
4772
4951
|
this.abortDetachedRuns({ reason: "client_interrupt" });
|
|
4952
|
+
// ...and a look still waiting to be answered: the user stopped the call
|
|
4953
|
+
// talking, and a reply starting on its own a moment later is not a stop.
|
|
4954
|
+
this.clearPendingLook();
|
|
4773
4955
|
const utterance = this.currentUtterance;
|
|
4774
4956
|
this.stopSessionTranscriber();
|
|
4775
4957
|
if (utterance) {
|
|
@@ -4991,6 +5173,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4991
5173
|
// Set on an announcement turn: the finished continuation this turn exists
|
|
4992
5174
|
// to deliver. Its answer goes in the control prompt, not in `content`.
|
|
4993
5175
|
continuationDelivery?: ContinuationDelivery | null;
|
|
5176
|
+
// Set on the turn that answers a look: which look. Its instruction goes
|
|
5177
|
+
// in the control prompt, not in `content`.
|
|
5178
|
+
lookFollowUp?: LookSessionControl;
|
|
4994
5179
|
// Unified front-door: dispatch without releasing the utterance. The
|
|
4995
5180
|
// thinking frame and floor-holding timers are deferred until the leg's
|
|
4996
5181
|
// leading verdict commits the turn (see commitSpeculativeTurn); a hold
|
|
@@ -5085,6 +5270,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5085
5270
|
consumedAnnouncement: pending?.announcement ?? null,
|
|
5086
5271
|
pendingContextStopGeneration: this.detachStopGeneration,
|
|
5087
5272
|
continuationDelivery: opts?.continuationDelivery ?? null,
|
|
5273
|
+
lookFollowUp: opts?.lookFollowUp ?? null,
|
|
5088
5274
|
hiddenPrompt: opts?.hiddenPrompt === true,
|
|
5089
5275
|
deltaEpoch: 0,
|
|
5090
5276
|
frontDoor: null,
|
|
@@ -5100,6 +5286,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5100
5286
|
assistantAudioMimeType: "audio/pcm",
|
|
5101
5287
|
};
|
|
5102
5288
|
this.activeAssistantTurn = activeTurn;
|
|
5289
|
+
this.turnsLaunched += 1;
|
|
5103
5290
|
|
|
5104
5291
|
// A speculative turn defers the thinking frame and both floor-holding
|
|
5105
5292
|
// timers to commitSpeculativeTurn: until the verdict arrives, the pause
|
|
@@ -5342,6 +5529,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5342
5529
|
: {}),
|
|
5343
5530
|
},
|
|
5344
5531
|
this.sessionControls,
|
|
5532
|
+
{ lookFrames: this.lookFrames },
|
|
5345
5533
|
),
|
|
5346
5534
|
onApprovalPending: (requestId) => {
|
|
5347
5535
|
this.revealRoomForPendingApproval(activeTurn, requestId);
|
|
@@ -5897,6 +6085,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5897
6085
|
if (sessionControl.action === "end") {
|
|
5898
6086
|
currentTurn.minimizeRequested = false;
|
|
5899
6087
|
}
|
|
6088
|
+
// Armed before the send, so a client quick enough to answer with
|
|
6089
|
+
// its frame before this await resolves still finds the look waiting.
|
|
6090
|
+
// Never from the turn that answers a look: that turn is the answer,
|
|
6091
|
+
// and a marker it emits anyway must not chain another.
|
|
6092
|
+
if (
|
|
6093
|
+
isLookSessionControl(sessionControl.action) &&
|
|
6094
|
+
currentTurn.lookFollowUp === null
|
|
6095
|
+
) {
|
|
6096
|
+
this.awaitLookFrame(sessionControl.action);
|
|
6097
|
+
}
|
|
5900
6098
|
await this.sendFrame(
|
|
5901
6099
|
{
|
|
5902
6100
|
type: "session_control",
|
|
@@ -166,6 +166,16 @@ export interface LiveVoiceClientStartFrame {
|
|
|
166
166
|
* has never heard of.
|
|
167
167
|
*/
|
|
168
168
|
readonly sessionControls?: readonly LiveVoiceSessionControl[];
|
|
169
|
+
/**
|
|
170
|
+
* This client sends a fresh `sight_frame` right after it carries out a look
|
|
171
|
+
* control, with timing reason `look`, whether or not a share or the camera
|
|
172
|
+
* was already running. The session answers the look from that frame on a
|
|
173
|
+
* turn of its own, so the reply that asked for the look only acknowledges it.
|
|
174
|
+
*
|
|
175
|
+
* Absent means false: a client that predates the field sends no such frame,
|
|
176
|
+
* and a session waiting on one would promise a look that never comes.
|
|
177
|
+
*/
|
|
178
|
+
readonly lookFrames?: boolean;
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
const LIVE_VOICE_SESSION_CONTROLS = [
|
|
@@ -1269,6 +1279,15 @@ function validateStartFrame(
|
|
|
1269
1279
|
);
|
|
1270
1280
|
}
|
|
1271
1281
|
|
|
1282
|
+
if ("lookFrames" in value && typeof value.lookFrames !== "boolean") {
|
|
1283
|
+
return protocolError(
|
|
1284
|
+
"invalid_field",
|
|
1285
|
+
"start frame field lookFrames must be a boolean",
|
|
1286
|
+
"lookFrames",
|
|
1287
|
+
"start",
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1272
1291
|
if ("textInput" in value && typeof value.textInput !== "boolean") {
|
|
1273
1292
|
return protocolError(
|
|
1274
1293
|
"invalid_field",
|
|
@@ -1308,6 +1327,7 @@ function validateStartFrame(
|
|
|
1308
1327
|
: {}),
|
|
1309
1328
|
...(value.textInput === true ? { textInput: true } : {}),
|
|
1310
1329
|
...(sessionControls.length > 0 ? { sessionControls } : {}),
|
|
1330
|
+
...(value.lookFrames === true ? { lookFrames: true } : {}),
|
|
1311
1331
|
},
|
|
1312
1332
|
};
|
|
1313
1333
|
}
|
|
@@ -33,6 +33,55 @@ const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
|
|
|
33
33
|
mute: `- To mute their microphone (for example "mute for 30 seconds" or "mute yourself, I need to take this"), confirm in a few words, then end your reply with [MUTE:<seconds>] when they gave a duration or ${MUTE_MARKER} when they did not. While muted you cannot hear them, so mention they can unmute from the call controls unless the mute is timed.`,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The look lines for a client that declared `lookFrames`: one that sends a
|
|
38
|
+
* fresh frame the moment it carries out a look, which the session answers on
|
|
39
|
+
* a turn of its own (see {@link LOOK_FRAME_REASON}). The reply that asks for
|
|
40
|
+
* the look is therefore only the acknowledgement, and asking to look again is
|
|
41
|
+
* how the model gets a current view of a share that is already running.
|
|
42
|
+
*/
|
|
43
|
+
const LOOK_FRAME_CONTROL_LINES: Partial<
|
|
44
|
+
Record<LiveVoiceSessionControl, string>
|
|
45
|
+
> = {
|
|
46
|
+
look_screen: `- To look at their screen (for example "take a look at my screen", or "can you see it now?" after something on it changed), say in a few words that you are taking a look, then end your reply with ${LOOK_SCREEN_MARKER}. Use it even when their screen is already shared with you, to see it as it is now. You get a fresh view right after you finish speaking and answer from that, so do not describe the screen yet and do not ask them to say anything more.`,
|
|
47
|
+
look_camera: `- To look through their camera (for example "look at this" or "can you see this?" while they hold something up), say in a few words that you are taking a look, then end your reply with ${LOOK_CAMERA_MARKER}. Use it even when the camera is already on, to see what it shows now. You get a fresh view right after you finish speaking and answer from that, so do not describe it yet and do not ask them to say anything more.`,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `reason` a client's sight frame timing carries when the frame is the
|
|
52
|
+
* fresh view it took for a look control. Only a client that declared
|
|
53
|
+
* `lookFrames` sends one, and the session answers the look from it.
|
|
54
|
+
*/
|
|
55
|
+
export const LOOK_FRAME_REASON = "look";
|
|
56
|
+
|
|
57
|
+
/** The controls that end in a fresh frame the session answers from. */
|
|
58
|
+
export type LookSessionControl = Extract<
|
|
59
|
+
LiveVoiceSessionControl,
|
|
60
|
+
"look_screen" | "look_camera"
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
export function isLookSessionControl(
|
|
64
|
+
action: string,
|
|
65
|
+
): action is LookSessionControl {
|
|
66
|
+
return action === "look_screen" || action === "look_camera";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The persisted `content` of the turn that answers a look. Hidden: there is no
|
|
71
|
+
* user utterance behind the turn, and the instruction rides the control prompt
|
|
72
|
+
* ({@link lookFollowUpNote}).
|
|
73
|
+
*/
|
|
74
|
+
export const LOOK_FOLLOW_UP_CONTENT = "(fresh view taken; answer from it now)";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Appended to the control prompt of the turn that answers a look: the reply
|
|
78
|
+
* that asked for it only acknowledged, and the frame it asked for has landed.
|
|
79
|
+
*/
|
|
80
|
+
export function lookFollowUpNote(action: LookSessionControl): string {
|
|
81
|
+
const what = action === "look_screen" ? "their screen" : "their camera";
|
|
82
|
+
return `You just took a fresh look at ${what}, and the newest image in the conversation is what it shows right now. Answer what they wanted you to look at, out loud, in a few spoken sentences. If the image does not show what they meant, say briefly what you do see and ask. You have already said you were taking a look, so do not say it again and do not end with a look marker.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
36
85
|
// Always taught: narration is the session's own, so no client has to be able
|
|
37
86
|
// to carry it out.
|
|
38
87
|
const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (for example "don't give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
|
|
@@ -49,10 +98,15 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
|
|
|
49
98
|
export function sessionControlTeaching(
|
|
50
99
|
controls: readonly LiveVoiceSessionControl[],
|
|
51
100
|
leg: { frontDoor?: boolean },
|
|
101
|
+
client: { lookFrames?: boolean } = {},
|
|
52
102
|
): string {
|
|
103
|
+
const lines =
|
|
104
|
+
client.lookFrames === true
|
|
105
|
+
? { ...CLIENT_CONTROL_LINES, ...LOOK_FRAME_CONTROL_LINES }
|
|
106
|
+
: CLIENT_CONTROL_LINES;
|
|
53
107
|
return [
|
|
54
108
|
"The user can also control this call by asking you. Only when they clearly ask:",
|
|
55
|
-
...controls.map((control) =>
|
|
109
|
+
...controls.map((control) => lines[control]),
|
|
56
110
|
...lookGuidance(controls),
|
|
57
111
|
UPDATES_LINE,
|
|
58
112
|
`The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
|