@vellumai/assistant 0.11.2-dev.202608071820.0d9e564 → 0.11.2-dev.202608072015.e50ee9a
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/voice-session-bridge.ts +18 -15
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/live-voice-session.ts +23 -10
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/index.ts +10 -0
package/package.json
CHANGED
|
@@ -406,18 +406,6 @@ export interface VoiceTurnHandle {
|
|
|
406
406
|
* on a live phone call" framing (the session system prompt already
|
|
407
407
|
* provides assistant identity) and guardian context (injected separately).
|
|
408
408
|
*/
|
|
409
|
-
/**
|
|
410
|
-
* Steering shared by every voice channel. A sign-in flow opens a browser
|
|
411
|
-
* window mid-call that the caller may be unable to see or complete, whether it
|
|
412
|
-
* is reached through a ui-surface tool or through shell and CLI tools (e.g.
|
|
413
|
-
* `assistant oauth connect`). Tell the model to speak the limitation and defer
|
|
414
|
-
* the flow to text chat instead.
|
|
415
|
-
*
|
|
416
|
-
* This outlives the ui-surface restriction it was written alongside: a
|
|
417
|
-
* live-voice call can now show surfaces, but a browser window handing control
|
|
418
|
-
* to a third party mid-call is a different problem, and one a minimized room
|
|
419
|
-
* does not solve.
|
|
420
|
-
*/
|
|
421
409
|
/**
|
|
422
410
|
* How long a live-voice call waits on an approval before deciding it itself.
|
|
423
411
|
*
|
|
@@ -428,8 +416,23 @@ export interface VoiceTurnHandle {
|
|
|
428
416
|
*/
|
|
429
417
|
const VOICE_APPROVAL_TIMEOUT_MS = 45_000;
|
|
430
418
|
|
|
431
|
-
|
|
432
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Telephony-only steering. A sign-in flow opens a browser window on a screen
|
|
421
|
+
* the caller does not have in front of them, whether it is reached through a
|
|
422
|
+
* ui-surface tool or through shell and CLI tools (e.g. `assistant oauth
|
|
423
|
+
* connect`). Tell the model to speak the limitation and defer the flow to text
|
|
424
|
+
* chat instead.
|
|
425
|
+
*
|
|
426
|
+
* Scoped to the phone because the screen is what decides it. A phone call has
|
|
427
|
+
* no screen, so the `open_url` signal a CLI tool can reach lands somewhere the
|
|
428
|
+
* caller will never see, and that signal bus carries no capability or
|
|
429
|
+
* conversation context, so this rule is the only thing standing in front of it
|
|
430
|
+
* here. A live-voice call is the opposite case: the user is holding the screen,
|
|
431
|
+
* and the room minimizes itself to hand it back (see
|
|
432
|
+
* LIVE_VOICE_SETUP_FLOW_TEACHING).
|
|
433
|
+
*/
|
|
434
|
+
const PHONE_NO_SETUP_FLOWS_RULE =
|
|
435
|
+
"Never start account connections, OAuth or sign-in flows, or any other action that opens a browser window or needs the user's screen during this call, not even through shell or CLI tools. If the task needs one, say so briefly and offer to finish it in text chat after the call.";
|
|
433
436
|
|
|
434
437
|
function buildVoiceCallControlPrompt(opts: {
|
|
435
438
|
isInbound: boolean;
|
|
@@ -523,7 +526,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
523
526
|
"9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.",
|
|
524
527
|
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
|
|
525
528
|
`11. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links) or emojis in your spoken responses. Write plain conversational text only. Protocol markers like ${opts.isCallerGuardian ? "[END_CALL]" : "[ASK_GUARDIAN: ...] and [END_CALL]"} are not spoken text and should still be used normally.`,
|
|
526
|
-
`12. ${
|
|
529
|
+
`12. ${PHONE_NO_SETUP_FLOWS_RULE}`,
|
|
527
530
|
);
|
|
528
531
|
|
|
529
532
|
// Triage-and-escalate routing rules. The front-door leg decides and may
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
VoiceTurnCallbacks,
|
|
5
5
|
VoiceTurnOptions,
|
|
6
6
|
} from "../../calls/voice-session-bridge.js";
|
|
7
|
-
import { VOICE_NO_SETUP_FLOWS_RULE } from "../../calls/voice-session-bridge.js";
|
|
8
7
|
import type {
|
|
9
8
|
StreamingTranscriber,
|
|
10
9
|
SttStreamServerEvent,
|
|
@@ -254,14 +253,14 @@ describe("LiveVoiceSession archive and metrics events", () => {
|
|
|
254
253
|
userMessageInterface: "macos",
|
|
255
254
|
assistantMessageInterface: "macos",
|
|
256
255
|
// Pins the full production control prompt for the FRONT-DOOR leg (the
|
|
257
|
-
// first leg of every routed turn): the speech-first rule
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
256
|
+
// first leg of every routed turn): the speech-first rule, and nothing
|
|
257
|
+
// else. This leg is toolless, so both the screen-reveal and setup-flow
|
|
258
|
+
// teachings are deliberately absent. It has nothing to show and no
|
|
259
|
+
// connection to run, and its capability digest already routes anything
|
|
260
|
+
// needing a tool to the escalated leg (see
|
|
261
|
+
// live-voice-triage-escalate.test.ts).
|
|
262
262
|
voiceControlPrompt:
|
|
263
|
-
"You are speaking in a local live voice session. Keep replies brief and conversational. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. "
|
|
264
|
-
VOICE_NO_SETUP_FLOWS_RULE,
|
|
263
|
+
"You are speaking in a local live voice session. Keep replies brief and conversational. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. ",
|
|
265
264
|
});
|
|
266
265
|
callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
|
|
267
266
|
callbacks?.message_complete?.(makeMessageComplete());
|
|
@@ -190,6 +190,35 @@ describe("live-voice triage-and-escalate routing", () => {
|
|
|
190
190
|
expect(escalated?.content).toBe(ESCALATION_CONTINUATION_CONTENT);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
test("no leg is told to refuse setup flows, and the escalated leg is told to run them", async () => {
|
|
194
|
+
const { starter } = scriptedStartVoiceTurn({
|
|
195
|
+
frontDoor: ["[1] ", "Let me think about that."],
|
|
196
|
+
escalated: ["The detailed answer is 42."],
|
|
197
|
+
});
|
|
198
|
+
const { frames, session } = createHarness(starter);
|
|
199
|
+
|
|
200
|
+
await driveTurn(session);
|
|
201
|
+
await waitFor(() => starter.mock.calls.length >= 2);
|
|
202
|
+
await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
|
|
203
|
+
|
|
204
|
+
const frontDoorPrompt =
|
|
205
|
+
starter.mock.calls[0]?.[0]?.voiceControlPrompt ?? "";
|
|
206
|
+
const escalatedPrompt =
|
|
207
|
+
starter.mock.calls[1]?.[0]?.voiceControlPrompt ?? "";
|
|
208
|
+
// The phone's no-setup-flows rule must not leak onto a channel that has a
|
|
209
|
+
// screen: it contradicts the base prompt's "never tell the user you cannot
|
|
210
|
+
// show them something".
|
|
211
|
+
expect(frontDoorPrompt).not.toContain("Never start account connections");
|
|
212
|
+
expect(escalatedPrompt).not.toContain("Never start account connections");
|
|
213
|
+
expect(frontDoorPrompt).not.toContain("finish it in text chat");
|
|
214
|
+
expect(escalatedPrompt).not.toContain("finish it in text chat");
|
|
215
|
+
// The leg that can actually run one is told to.
|
|
216
|
+
expect(escalatedPrompt).toContain("This includes connecting accounts");
|
|
217
|
+
// The toolless fast leg is not: a connection is not its to run, and its
|
|
218
|
+
// capability digest already routes anything needing a tool here.
|
|
219
|
+
expect(frontDoorPrompt).not.toContain("This includes connecting accounts");
|
|
220
|
+
});
|
|
221
|
+
|
|
193
222
|
test("the screen-reveal teaching reaches the escalated leg's prompt but never the front-door leg's", async () => {
|
|
194
223
|
const { starter } = scriptedStartVoiceTurn({
|
|
195
224
|
frontDoor: ["[1] ", "Let me think about that."],
|
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
getConversationTurnTeardown,
|
|
19
19
|
resolveProcessingWaitMs,
|
|
20
|
-
VOICE_NO_SETUP_FLOWS_RULE,
|
|
21
20
|
waitForPriorTurnTeardown,
|
|
22
21
|
} from "../calls/voice-session-bridge.js";
|
|
23
22
|
import {
|
|
@@ -744,6 +743,20 @@ const LIVE_VOICE_CONTROL_PROMPT_BASE =
|
|
|
744
743
|
const LIVE_VOICE_SCREEN_REVEAL_TEACHING =
|
|
745
744
|
"The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later. Never emit bracketed markers of any kind. ";
|
|
746
745
|
|
|
746
|
+
// The setup-flow case, spelled out because it is the one the model gets wrong
|
|
747
|
+
// on its own: connecting an account reads as something a call cannot do, so it
|
|
748
|
+
// declines and offers to do it later in text, which is the exact thing the base
|
|
749
|
+
// prompt above forbids.
|
|
750
|
+
//
|
|
751
|
+
// It can do it. The connect card is a ui surface like any other, so showing it
|
|
752
|
+
// minimizes the room on the same latch (`revealsUiSurface`), and the user is at
|
|
753
|
+
// the screen the browser window opens on. Appended alongside the screen-reveal
|
|
754
|
+
// teaching, to the same legs, for the same reason: the front-door leg is
|
|
755
|
+
// toolless, so a setup flow is not its to run, and its capability digest
|
|
756
|
+
// already tells it to escalate anything needing a tool rather than refuse.
|
|
757
|
+
const LIVE_VOICE_SETUP_FLOW_TEACHING =
|
|
758
|
+
"This includes connecting accounts. If a task needs an account connected or a sign-in completed, put the connection up on screen and let the user do it now. Do not decline it, and do not defer it to text chat. Say what you are connecting and that it is in front of them. ";
|
|
759
|
+
|
|
747
760
|
// System-level guidance appended to a barge-in turn's control prompt so the
|
|
748
761
|
// model treats the new utterance as a continuation of the request it was cut
|
|
749
762
|
// off answering, rather than a fresh follow-up. Reaches the model only; it is
|
|
@@ -800,21 +813,21 @@ function buildLiveDeliveryNote(request: string, answer: string): string {
|
|
|
800
813
|
return `The user interrupted you earlier, and in the background you finished ${what}. The call is still live and nobody is speaking, so tell them briefly that it is done and give them the result. Do not re-run any tool calls; the work is already complete, and do not repeat it verbatim if it no longer fits. What you produced was:\n\n${answer}`;
|
|
801
814
|
}
|
|
802
815
|
|
|
803
|
-
// Assemble a leg's model-facing control prompt: the base live-voice rules,
|
|
804
|
-
//
|
|
805
|
-
// LIVE_VOICE_SCREEN_REVEAL_TEACHING),
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
// never render as user bubbles.
|
|
816
|
+
// Assemble a leg's model-facing control prompt: the base live-voice rules, the
|
|
817
|
+
// screen-reveal and setup-flow teaching (both withheld from the front-door leg,
|
|
818
|
+
// see LIVE_VOICE_SCREEN_REVEAL_TEACHING), plus any pending barge-in merge
|
|
819
|
+
// context, completed-continuation context, and/or the announcement instruction.
|
|
820
|
+
// A turn can carry several (a barge-in follow-up that also has a continuation
|
|
821
|
+
// result waiting); the notes are model-only and never render as user bubbles.
|
|
810
822
|
function buildVoiceControlPrompt(
|
|
811
823
|
turn: ActiveAssistantTurn,
|
|
812
824
|
leg: { frontDoor?: boolean },
|
|
813
825
|
): string {
|
|
814
826
|
let prompt =
|
|
815
827
|
LIVE_VOICE_CONTROL_PROMPT_BASE +
|
|
816
|
-
(leg.frontDoor === true
|
|
817
|
-
|
|
828
|
+
(leg.frontDoor === true
|
|
829
|
+
? ""
|
|
830
|
+
: LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING);
|
|
818
831
|
if (turn.interruptedRequest) {
|
|
819
832
|
prompt = `${prompt}\n\n${buildInterruptionMergeNote(turn.interruptedRequest)}`;
|
|
820
833
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards that `resolveOauthCallbackUrl` reaches plugins.
|
|
3
|
+
*
|
|
4
|
+
* The shim written into `<workspaceDir>/node_modules/@vellumai/plugin-api`
|
|
5
|
+
* re-binds whatever `Object.keys()` finds on the plugin-api namespace, so an
|
|
6
|
+
* export missing from `index.ts` is missing from every installed plugin with
|
|
7
|
+
* no other signal than a plugin failing to import it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { PLUGIN_API_EXPORTS } from "../../embedded/plugin-api.js";
|
|
13
|
+
import * as pluginApi from "../index.js";
|
|
14
|
+
|
|
15
|
+
describe("plugin-api OAuth callback export", () => {
|
|
16
|
+
test("is exported from the plugin-api surface", () => {
|
|
17
|
+
expect(typeof pluginApi.resolveOauthCallbackUrl).toEqual("function");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is carried into the generated shim's binding list", () => {
|
|
21
|
+
expect(PLUGIN_API_EXPORTS).toContain("resolveOauthCallbackUrl");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("takes no arguments, so a plugin cannot vary the URI", () => {
|
|
25
|
+
// The value a Client ID Metadata Document publishes and the value the
|
|
26
|
+
// host later authorizes with have to be the same string.
|
|
27
|
+
expect(pluginApi.resolveOauthCallbackUrl.length).toEqual(0);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -180,6 +180,16 @@ export {
|
|
|
180
180
|
// the same resolution as `webhooks register`, and registers the callback route
|
|
181
181
|
// on the managed branches. The plugin defaults to the one in context.
|
|
182
182
|
export { resolveWebhookUrl, type WebhookUrlOptions } from "./webhook-url.js";
|
|
183
|
+
// Resolve the redirect URI an authorization server should send a user back
|
|
184
|
+
// to after consent. One shared route serves every OAuth flow in the
|
|
185
|
+
// assistant, demultiplexed by OAuth `state`, so this takes no arguments and
|
|
186
|
+
// returns the same URL for every caller and every attempt. A plugin needs it
|
|
187
|
+
// when it publishes something that has to name the redirect URI ahead of the
|
|
188
|
+
// flow, such as a Client ID Metadata Document, whose `redirect_uris` an
|
|
189
|
+
// authorization server matches exactly. Throws when no public ingress is
|
|
190
|
+
// configured and the assistant is not connected to the platform, which is the
|
|
191
|
+
// case where no URL would work.
|
|
192
|
+
export { resolveOauthCallbackUrl } from "../inbound/oauth-callback-url.js";
|
|
183
193
|
// Resolve a provider for a call site (optionally overriding the profile) so a
|
|
184
194
|
// plugin can run inference through the workspace's configured profiles and
|
|
185
195
|
// credentials — managed-proxy or BYOK — without supplying its own API key.
|