dsh-realtime-agent 0.2.6 → 0.2.8
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/README.md +38 -0
- package/lib/index.d.ts +43 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +184 -21
- package/lib/index.js.map +1 -1
- package/lib/transcript.d.ts +9 -4
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +10 -4
- package/lib/transcript.js.map +1 -1
- package/lib/types.d.ts +56 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +236 -28
- package/src/transcript.ts +11 -5
- package/src/types.ts +59 -0
package/README.md
CHANGED
|
@@ -75,6 +75,44 @@ A session is opened only when `autoStart` is set. `maxTranscriptChars` bounds th
|
|
|
75
75
|
on each request: it is the **oldest** lines that are dropped, and the most recent line is always kept,
|
|
76
76
|
because a buffer that can hold nothing can answer nothing.
|
|
77
77
|
|
|
78
|
+
## What can be changed while it runs
|
|
79
|
+
|
|
80
|
+
`docs/control-plane-fields.md` is the gate, and it classifies this plugin's fields in three ways.
|
|
81
|
+
`delegationTimeoutMs` and `maxTranscriptChars` are **live**: read at the moment they are used, so the
|
|
82
|
+
seam's settings surface changes them without a restart, and a change lands on the next delegation.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
set realtime-agent.delegationTimeoutMs=90000
|
|
86
|
+
set realtime-agent.maxTranscriptChars=12000
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`provider`, `model`, `voice` and `instructions` are **session-bound** — they travelled in the
|
|
90
|
+
provider's `session.start`, so only a new session can carry new ones, and the UI that offers them must
|
|
91
|
+
say *reconnect* rather than pretending an instant change is possible.
|
|
92
|
+
|
|
93
|
+
`autoStart` is **restart-bound**, and this is a correction rather than a convenience: its only read site
|
|
94
|
+
is the boot, so nothing a running process could do would honour a change. A `set` on it is refused with
|
|
95
|
+
the restart it needs — `realtime-agent.autoStart` is claimed when the plugin loads, so restart to change
|
|
96
|
+
it — rather than accepted and quietly ignored.
|
|
97
|
+
|
|
98
|
+
## Asking it what it is doing
|
|
99
|
+
|
|
100
|
+
Two of this plugin's three session events **return** what they did, so a caller that is waiting can have
|
|
101
|
+
the answer while a caller that only emits is unaffected:
|
|
102
|
+
|
|
103
|
+
- `realtime-agent/status` — a query. `{open, provider, model, voice?, sessionId?}`, read from the session
|
|
104
|
+
the provider actually accepted while one is open, and from what a start *would* use while none is. A
|
|
105
|
+
mounted agent always answers, so `undefined` from the dispatch means the row is absent — which is a
|
|
106
|
+
different fact from a session that is merely closed.
|
|
107
|
+
- `realtime-agent/start` / `realtime-agent/stop` — the transport emits these and ignores the result; the
|
|
108
|
+
control channel dispatches them with `serial` and answers with the **outcome**: `{ok, voice, refusal?}`
|
|
109
|
+
rather than an acknowledgement that the request was made. A `start` that replied *requested* while the
|
|
110
|
+
open silently failed is exactly the collapse this project has already paid for once.
|
|
111
|
+
|
|
112
|
+
A failed request carries a **structured refusal** — the seam's machine code and the `remedy` written to be
|
|
113
|
+
relayed verbatim — and never the failure's message. This plugin holds no credential to redact against; the
|
|
114
|
+
adapter does, and that is why its journal records the class of a session failure rather than the text.
|
|
115
|
+
|
|
78
116
|
## How answers are kept deliverable
|
|
79
117
|
|
|
80
118
|
- **A long answer is cut, not rejected.** The seam bounds an append at 2000 characters and *throws*
|
package/lib/index.d.ts
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import Schema from '@deepseek-ai/schemastery';
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
-
import type { RealtimeDelegationSettlement, RealtimeSession, RealtimeSessionHandlers } from 'dsh-realtime';
|
|
25
|
+
import type { RealtimeDelegationProgress, RealtimeDelegationSettlement, RealtimeSession, RealtimeSessionHandlers } from 'dsh-realtime';
|
|
26
26
|
import { type DelegationAppend, type DelegationAsker } from './bridge.ts';
|
|
27
27
|
import { TranscriptBuffer } from './transcript.ts';
|
|
28
|
-
import type { DelegationAnswer, DelegationRequest, RealtimeAgentConfig } from './types.ts';
|
|
28
|
+
import type { DelegationAnswer, DelegationRequest, RealtimeAgentConfig, RealtimeSessionRequestOutcome, RealtimeVoiceStatus } from './types.ts';
|
|
29
29
|
declare module '@deepseek-ai/cordis' {
|
|
30
30
|
interface Events {
|
|
31
31
|
/**
|
|
@@ -52,6 +52,18 @@ declare module '@deepseek-ai/cordis' {
|
|
|
52
52
|
* user's ear without any of them re-deriving why the turn produced nothing.
|
|
53
53
|
*/
|
|
54
54
|
'realtime-agent/delegation-settled'(settlement: RealtimeDelegationSettlement): void;
|
|
55
|
+
/**
|
|
56
|
+
* One step of a delegated turn, on its way to an ear or to the model's own context.
|
|
57
|
+
*
|
|
58
|
+
* Emitted by whichever application is running the turn, as the steps happen — it is the only thing that
|
|
59
|
+
* knows what the turn is doing. This plugin appends it, because it holds the session: `commentary` is
|
|
60
|
+
* spoken aloud, `thinking` is carried silently, and the choice is the emitter's.
|
|
61
|
+
*
|
|
62
|
+
* A step for a delegation this plugin is **not** waiting on is dropped rather than appended: the
|
|
63
|
+
* provider only accepts an append for a delegation it knows, and a finished turn's words must not be
|
|
64
|
+
* spoken into a conversation that has moved on.
|
|
65
|
+
*/
|
|
66
|
+
'realtime-agent/delegation-progress'(progress: RealtimeDelegationProgress): void;
|
|
55
67
|
/**
|
|
56
68
|
* Output audio: PCM16 in the session's declared output format.
|
|
57
69
|
*
|
|
@@ -74,15 +86,27 @@ declare module '@deepseek-ai/cordis' {
|
|
|
74
86
|
* Emitted by a transport when an authenticated client arrives, so that connecting a microphone is
|
|
75
87
|
* enough to be heard — with no profile option and no dependence on a model choosing to call
|
|
76
88
|
* `voice_start`. The session belongs to the agent, so the transport asks rather than opens one itself.
|
|
89
|
+
*
|
|
90
|
+
* Returns the request's **outcome** so a caller that is waiting for the answer can have it —
|
|
91
|
+
* `ctx.serial` from the control channel — while the transport, which only *emits*, is unaffected.
|
|
77
92
|
*/
|
|
78
|
-
'realtime-agent/start'():
|
|
93
|
+
'realtime-agent/start'(): RealtimeSessionRequestOutcome | undefined | Promise<RealtimeSessionRequestOutcome | undefined>;
|
|
79
94
|
/**
|
|
80
95
|
* Close the voice session.
|
|
81
96
|
*
|
|
82
97
|
* Emitted by a transport when its last client goes away, including when the transport itself is
|
|
83
98
|
* disposed — a session outliving the microphone that asked for it is a socket nobody is listening to.
|
|
99
|
+
* Returns the outcome, for the same reason `realtime-agent/start` does.
|
|
84
100
|
*/
|
|
85
|
-
'realtime-agent/stop'():
|
|
101
|
+
'realtime-agent/stop'(): RealtimeSessionRequestOutcome | undefined | Promise<RealtimeSessionRequestOutcome | undefined>;
|
|
102
|
+
/**
|
|
103
|
+
* The voice session's state — a **query**, so nothing emits it.
|
|
104
|
+
*
|
|
105
|
+
* `undefined` means no listener answered, which is the honest answer for a composition with no agent
|
|
106
|
+
* row: it is distinguishable from a session that is merely closed, because the agent that is mounted
|
|
107
|
+
* always answers.
|
|
108
|
+
*/
|
|
109
|
+
'realtime-agent/status'(): RealtimeVoiceStatus | undefined | Promise<RealtimeVoiceStatus | undefined>;
|
|
86
110
|
}
|
|
87
111
|
}
|
|
88
112
|
export * from './types.ts';
|
|
@@ -125,8 +149,14 @@ export interface HandlerDeps {
|
|
|
125
149
|
readonly session: () => RealtimeSession | undefined;
|
|
126
150
|
/** How to reach a responder. */
|
|
127
151
|
readonly ask: DelegationAsker;
|
|
128
|
-
/**
|
|
129
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Bound on waiting for one, read at the moment the delegation is answered.
|
|
154
|
+
*
|
|
155
|
+
* An accessor because `delegationTimeoutMs` is a **live** field: the window the voice model waits in
|
|
156
|
+
* can be changed while the plugin runs, and the change must apply to the next delegation rather than
|
|
157
|
+
* to the next boot.
|
|
158
|
+
*/
|
|
159
|
+
readonly timeoutMs: () => number;
|
|
130
160
|
/** Called when the session ended, so the caller can drop its reference. */
|
|
131
161
|
readonly onClosed: () => void;
|
|
132
162
|
/** Where a session-scoped failure is reported. */
|
|
@@ -140,6 +170,13 @@ export interface HandlerDeps {
|
|
|
140
170
|
* whether a speaker ever rendered it. See the seam's session contract and invariant 6.
|
|
141
171
|
*/
|
|
142
172
|
readonly onAcknowledged: (append: DelegationAppend, delegationId: string) => void;
|
|
173
|
+
/**
|
|
174
|
+
* Called once a delegation arrives, before it is answered, with the session it is being answered on — so
|
|
175
|
+
* a caller can tell which turns are still waiting, and *where* each one is waiting.
|
|
176
|
+
*/
|
|
177
|
+
readonly onDelegationStarted?: (delegationId: string, session: RealtimeSession) => void;
|
|
178
|
+
/** Called once a delegation has finished, however it ended. */
|
|
179
|
+
readonly onDelegationSettled?: (delegationId: string) => void;
|
|
143
180
|
}
|
|
144
181
|
/**
|
|
145
182
|
* Build the session handlers.
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAsB,4BAA4B,EAAE,eAAe,EAAE,uBAAuB,EAAsB,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* @module dsh-realtime-agent
|
|
22
22
|
*/
|
|
23
23
|
import Schema from '@deepseek-ai/schemastery';
|
|
24
|
+
import { REALTIME_ERROR_CODES, RealtimeError } from 'dsh-realtime';
|
|
24
25
|
import { answerDelegation } from './bridge.js';
|
|
25
26
|
import { voiceToolDefinitions } from './tools.js';
|
|
26
27
|
import { TranscriptBuffer } from './transcript.js';
|
|
@@ -64,34 +65,109 @@ export function createHandlers(deps) {
|
|
|
64
65
|
// would be a failure raised from inside a handler, which is the worst place to raise one.
|
|
65
66
|
if (session === undefined)
|
|
66
67
|
return;
|
|
68
|
+
// Marked waiting before the answer is dispatched, so a step that arrives while the turn is running is
|
|
69
|
+
// appended rather than dropped for having beaten this line.
|
|
70
|
+
deps.onDelegationStarted?.(delegation.id, session);
|
|
67
71
|
// Handlers are synchronous, so the answer is dispatched rather than awaited. A rejection is
|
|
68
72
|
// reported rather than thrown: an unanswered delegation is already the failure path.
|
|
69
|
-
void answerDelegation(session, delegation, deps.transcript.lines(), deps.ask, deps.timeoutMs, deps.onAcknowledged).catch(deps.onSessionError);
|
|
73
|
+
void answerDelegation(session, delegation, deps.transcript.lines(), deps.ask, deps.timeoutMs(), deps.onAcknowledged).catch(deps.onSessionError).finally(() => { deps.onDelegationSettled?.(delegation.id); });
|
|
70
74
|
},
|
|
71
75
|
onAudio: (pcm16) => { deps.onAudio(pcm16); },
|
|
72
76
|
onClosed: () => { deps.onClosed(); },
|
|
73
77
|
onError: (error) => { deps.onSessionError(error); },
|
|
74
78
|
};
|
|
75
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* A count this plugin can actually be run with: a whole, positive number.
|
|
82
|
+
*
|
|
83
|
+
* Local to the plugin rather than shared from the seam, deliberately: the reason text is part of this
|
|
84
|
+
* plugin's own onboarding, and the seam has no business knowing that this plugin measures a timeout in
|
|
85
|
+
* milliseconds and a transcript in characters.
|
|
86
|
+
* @param field - the setting's own name, for the reason.
|
|
87
|
+
* @param value - the proposed value.
|
|
88
|
+
* @returns the value, when it is usable.
|
|
89
|
+
*/
|
|
90
|
+
function requireCount(field, value) {
|
|
91
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
92
|
+
throw new RealtimeError(`${field} must be a positive whole number`, REALTIME_ERROR_CODES.INVALID_SETTING);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
76
96
|
/**
|
|
77
97
|
* Hold a session and bridge what the voice model delegates.
|
|
78
98
|
* @param ctx - the Cordis context, which must already provide the `realtime` service.
|
|
79
99
|
* @param config - validated configuration.
|
|
80
100
|
*/
|
|
81
101
|
export function apply(ctx, config) {
|
|
82
|
-
const transcript = new TranscriptBuffer(config.maxTranscriptChars);
|
|
83
|
-
// This plugin owns the session, so it is the only one that can honestly write these entries. The audio
|
|
84
|
-
// route emits a *request* to open a session and deliberately records nothing for it: recording a
|
|
85
|
-
// request as an event is how a journal starts lying.
|
|
86
102
|
const journal = ctx.realtime.journal;
|
|
103
|
+
/**
|
|
104
|
+
* The two fields this plugin reads at the moment of use.
|
|
105
|
+
*
|
|
106
|
+
* `autoStart` is deliberately **not** here as a changeable value. Its only read site is the boot
|
|
107
|
+
* below, so a running process has nothing that could honour a change; the gate was corrected to say
|
|
108
|
+
* so, and it is registered below as restart-bound — which is what makes that correction bite rather
|
|
109
|
+
* than merely being written down.
|
|
110
|
+
*/
|
|
111
|
+
const live = { delegationTimeoutMs: config.delegationTimeoutMs, maxTranscriptChars: config.maxTranscriptChars };
|
|
112
|
+
const transcript = new TranscriptBuffer(() => live.maxTranscriptChars);
|
|
87
113
|
let session;
|
|
114
|
+
// The live fields `docs/control-plane-fields.md` lists for this plugin, plus the one it reclassified:
|
|
115
|
+
// `autoStart` is registered without a setter, so a change to it is refused with the restart it needs
|
|
116
|
+
// instead of being accepted and quietly ignored.
|
|
117
|
+
ctx.effect(function* () {
|
|
118
|
+
const release = ctx.realtime.settings.register(name, [
|
|
119
|
+
{
|
|
120
|
+
field: 'delegationTimeoutMs',
|
|
121
|
+
kind: 'number',
|
|
122
|
+
scope: 'live',
|
|
123
|
+
describe: 'How long the voice model waits for a responder',
|
|
124
|
+
get: () => live.delegationTimeoutMs,
|
|
125
|
+
set: (value) => { live.delegationTimeoutMs = requireCount('delegationTimeoutMs', value); },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
field: 'maxTranscriptChars',
|
|
129
|
+
kind: 'number',
|
|
130
|
+
scope: 'live',
|
|
131
|
+
describe: 'Character budget for the transcript carried on a delegation',
|
|
132
|
+
get: () => live.maxTranscriptChars,
|
|
133
|
+
set: (value) => { live.maxTranscriptChars = requireCount('maxTranscriptChars', value); },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
field: 'autoStart',
|
|
137
|
+
kind: 'boolean',
|
|
138
|
+
scope: 'restart',
|
|
139
|
+
describe: 'Open a session as soon as the plugin mounts',
|
|
140
|
+
get: () => config.autoStart,
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
yield () => { release(); };
|
|
144
|
+
}, 'realtime-agent.settings');
|
|
145
|
+
/**
|
|
146
|
+
* The one place an acknowledgement can honestly be recorded: the seam's appends resolve on the provider's
|
|
147
|
+
* confirmation, not on the send. Note what is deliberately absent beside it — no entry anywhere claims the
|
|
148
|
+
* audio was heard, which is the pair invariant 6 exists to keep apart. Shared with the narration path, so
|
|
149
|
+
* a spoken step and an answer are accounted for by the same rule.
|
|
150
|
+
*/
|
|
151
|
+
const onAcknowledged = (append, delegationId) => {
|
|
152
|
+
journal.record('append.acknowledged', { append, delegationId });
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* The delegations still waiting for an answer, with the session each one is being answered on.
|
|
156
|
+
*
|
|
157
|
+
* The session is stored *with* the id rather than read back from the plugin when a step arrives, and that
|
|
158
|
+
* is not a micro-optimisation: it collapses two conditions into one. "Nothing is waiting on this" and
|
|
159
|
+
* "there is no session to speak into" cannot drift apart into two guards, one of which — the session
|
|
160
|
+
* dropped while its id is still waiting — no sequence of events can actually produce.
|
|
161
|
+
*/
|
|
162
|
+
const waiting = new Map();
|
|
88
163
|
const handlers = createHandlers({
|
|
89
164
|
transcript,
|
|
90
165
|
session: () => session,
|
|
91
166
|
ask: request => ctx.serial('realtime-agent/delegation', request),
|
|
92
|
-
timeoutMs:
|
|
167
|
+
timeoutMs: () => live.delegationTimeoutMs,
|
|
93
168
|
onClosed: () => {
|
|
94
169
|
session = undefined;
|
|
170
|
+
waiting.clear();
|
|
95
171
|
journal.record('session.closed', {});
|
|
96
172
|
},
|
|
97
173
|
onSessionError: (error) => {
|
|
@@ -106,13 +182,37 @@ export function apply(ctx, config) {
|
|
|
106
182
|
// Handed to the transport: all the host can observe, and no more (invariant 6).
|
|
107
183
|
journal.record('speech.sent', { bytes: String(pcm16.byteLength) });
|
|
108
184
|
},
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// anywhere claims the audio was heard, which is the pair invariant 6 exists to keep apart.
|
|
113
|
-
journal.record('append.acknowledged', { append, delegationId });
|
|
114
|
-
},
|
|
185
|
+
onDelegationStarted: (delegationId, current) => { waiting.set(delegationId, current); },
|
|
186
|
+
onDelegationSettled: (delegationId) => { waiting.delete(delegationId); },
|
|
187
|
+
onAcknowledged,
|
|
115
188
|
});
|
|
189
|
+
/**
|
|
190
|
+
* Narrate a delegated turn's steps, on the session that is running it.
|
|
191
|
+
*
|
|
192
|
+
* A contribution like any other, so the fiber that registered it releases it. The emitter decides whether
|
|
193
|
+
* a step is spoken or silent — this half only carries it, because the session belongs here and nowhere
|
|
194
|
+
* else does.
|
|
195
|
+
*
|
|
196
|
+
* A failed append is a **dropped step**, not a broken turn: it is recorded so it is not silent, and it
|
|
197
|
+
* must not be confused with an answer that never arrived.
|
|
198
|
+
*/
|
|
199
|
+
ctx.effect(function* () {
|
|
200
|
+
const dispose = ctx.on('realtime-agent/delegation-progress', (progress) => {
|
|
201
|
+
// One condition, not two: the session a turn is waiting on *is* the authorisation to speak into it, and
|
|
202
|
+
// it travels with the delegation. A step for anything else has nowhere to go — the provider refuses an
|
|
203
|
+
// append for an id it does not know, and a finished turn's words must not be spoken into the next one.
|
|
204
|
+
const current = waiting.get(progress.id);
|
|
205
|
+
if (current === undefined)
|
|
206
|
+
return;
|
|
207
|
+
const append = progress.channel === 'commentary'
|
|
208
|
+
? current.appendCommentary(progress.text, progress.id)
|
|
209
|
+
: current.appendThinking(progress.text, progress.id);
|
|
210
|
+
void append
|
|
211
|
+
.then(() => { onAcknowledged(progress.channel, progress.id); })
|
|
212
|
+
.catch(() => { journal.record('progress.dropped', { id: progress.id, channel: progress.channel }); });
|
|
213
|
+
});
|
|
214
|
+
yield () => { dispose(); };
|
|
215
|
+
}, 'realtime-agent.progress');
|
|
116
216
|
/**
|
|
117
217
|
* Open a session, or return the one already open.
|
|
118
218
|
*
|
|
@@ -145,6 +245,70 @@ export function apply(ctx, config) {
|
|
|
145
245
|
session = undefined;
|
|
146
246
|
await current?.close();
|
|
147
247
|
};
|
|
248
|
+
/**
|
|
249
|
+
* The session's state, as only this plugin can report it.
|
|
250
|
+
*
|
|
251
|
+
* With nothing open it answers with what a start *would* use, rather than with nothing: "no session,
|
|
252
|
+
* and it would be `openai-live`/`gpt-live-1`" is a different — and more useful — answer than an
|
|
253
|
+
* absence, and it is the one a status surface needs to render a sensible control.
|
|
254
|
+
*/
|
|
255
|
+
const status = () => {
|
|
256
|
+
const current = session;
|
|
257
|
+
if (current === undefined) {
|
|
258
|
+
return {
|
|
259
|
+
open: false,
|
|
260
|
+
provider: config.provider,
|
|
261
|
+
model: config.model,
|
|
262
|
+
...config.voice === undefined ? {} : { voice: config.voice },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
open: true,
|
|
267
|
+
provider: current.started.provider,
|
|
268
|
+
model: current.started.model,
|
|
269
|
+
...current.started.voice === undefined ? {} : { voice: current.started.voice },
|
|
270
|
+
sessionId: current.id,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Classify a failed request, carrying what a caller can act on and never the message.
|
|
275
|
+
*
|
|
276
|
+
* A seam failure carries its machine code and the remedy written to be relayed; anything else carries
|
|
277
|
+
* its **class** alone. The message is deliberately dropped: a provider error is exactly where a key
|
|
278
|
+
* turns up, and this plugin holds no credential to redact against — the same reason its journal
|
|
279
|
+
* records the class of a session failure rather than the text.
|
|
280
|
+
* @param error - whatever the attempt threw.
|
|
281
|
+
* @returns the structured refusal.
|
|
282
|
+
*/
|
|
283
|
+
const refusalFor = (error) => {
|
|
284
|
+
if (error instanceof RealtimeError) {
|
|
285
|
+
return {
|
|
286
|
+
code: error.code,
|
|
287
|
+
...error.detail?.remedy === undefined ? {} : { remedy: error.detail.remedy },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { class: error instanceof Error ? error.name : typeof error };
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Run a session request and report what it produced.
|
|
294
|
+
*
|
|
295
|
+
* The result is **returned as well as** reported on the bus, because a caller may be waiting for it:
|
|
296
|
+
* the control channel dispatches these with `serial`, so `start` can answer with the session that
|
|
297
|
+
* opened rather than with an acknowledgement that it asked. The failure still goes onto the bus, so a
|
|
298
|
+
* transport that merely emits keeps the behaviour it always had.
|
|
299
|
+
* @param attempt - the request to run.
|
|
300
|
+
* @returns whether it achieved what it asked for, and the state afterwards.
|
|
301
|
+
*/
|
|
302
|
+
const requestSession = async (attempt) => {
|
|
303
|
+
try {
|
|
304
|
+
await attempt();
|
|
305
|
+
return { ok: true, voice: status() };
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
ctx.emit('realtime-agent/error', error);
|
|
309
|
+
return { ok: false, voice: status(), refusal: refusalFor(error) };
|
|
310
|
+
}
|
|
311
|
+
};
|
|
148
312
|
// Tools are an effect, like every other contribution this plugin makes: the fiber that mounted them
|
|
149
313
|
// releases them, so there is no separate teardown path to forget.
|
|
150
314
|
ctx.effect(function* () {
|
|
@@ -176,17 +340,16 @@ export function apply(ctx, config) {
|
|
|
176
340
|
yield () => { dispose(); };
|
|
177
341
|
}, 'realtime-agent.mic');
|
|
178
342
|
// Session requests from the transport. The route knows when an authenticated client connects and the
|
|
179
|
-
// agent owns the session, so one event is the whole of the wiring between them.
|
|
343
|
+
// agent owns the session, so one event is the whole of the wiring between them. Each listener returns
|
|
344
|
+
// its outcome: `emit` ignores it, `serial` waits for it, and that is how the control channel can
|
|
345
|
+
// answer *what happened* rather than *that it was asked*.
|
|
180
346
|
ctx.effect(function* () {
|
|
181
347
|
const disposers = [
|
|
182
|
-
ctx.on('realtime-agent/start', () =>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
ctx.on('realtime-agent/stop', () => {
|
|
188
|
-
void stop().catch((error) => { ctx.emit('realtime-agent/error', error); });
|
|
189
|
-
}),
|
|
348
|
+
ctx.on('realtime-agent/start', () => requestSession(() => open())),
|
|
349
|
+
ctx.on('realtime-agent/stop', () => requestSession(() => stop())),
|
|
350
|
+
// A query, and the only listener that answers one. A mounted agent always answers, so a caller
|
|
351
|
+
// that gets `undefined` from the dispatch learns the agent row is absent rather than guessing.
|
|
352
|
+
ctx.on('realtime-agent/status', () => status()),
|
|
190
353
|
];
|
|
191
354
|
yield () => { for (const dispose of disposers)
|
|
192
355
|
dispose(); };
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAG7C,OAAO,EAAE,gBAAgB,EAA+C,MAAM,aAAa,CAAA;AAC3F,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/lib/transcript.d.ts
CHANGED
|
@@ -12,11 +12,16 @@ export declare class TranscriptBuffer {
|
|
|
12
12
|
private readonly buffered;
|
|
13
13
|
private chars;
|
|
14
14
|
/**
|
|
15
|
-
* @param maxChars - character budget
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* @param maxChars - character budget, read at every eviction.
|
|
16
|
+
*
|
|
17
|
+
* An accessor rather than a number because `maxTranscriptChars` is a **live** field
|
|
18
|
+
* (`docs/control-plane-fields.md`): the budget can change while the conversation runs, and a copy
|
|
19
|
+
* taken at construction would make the change look applied while the buffer kept the old one.
|
|
20
|
+
* Every value is accepted: a budget smaller than one line degrades to keeping exactly the most recent
|
|
21
|
+
* line rather than to keeping nothing, because a buffer that can hold nothing cannot answer any
|
|
22
|
+
* delegation at all.
|
|
18
23
|
*/
|
|
19
|
-
constructor(maxChars: number);
|
|
24
|
+
constructor(maxChars: () => number);
|
|
20
25
|
/**
|
|
21
26
|
* Record one fragment.
|
|
22
27
|
* @param kind - which side spoke.
|
package/lib/transcript.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAQrD;;;;;;;GAOG;AACH,qBAAa,gBAAgB;
|
|
1
|
+
{"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAQrD;;;;;;;GAOG;AACH,qBAAa,gBAAgB;IAcf,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAbrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C,OAAO,CAAC,KAAK,CAAI;IAEjB;;;;;;;;;OASG;IACH,YAA6B,QAAQ,EAAE,MAAM,MAAM,EAAI;IAEvD;;;;;OAKG;IACH,MAAM,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAanE;IAED;;;OAGG;IACH,KAAK,IAAI,mBAAmB,EAAE,CAE7B;IAED,yFAAyF;IACzF,OAAO,CAAC,KAAK;CAQd"}
|
package/lib/transcript.js
CHANGED
|
@@ -11,9 +11,14 @@ export class TranscriptBuffer {
|
|
|
11
11
|
buffered = [];
|
|
12
12
|
chars = 0;
|
|
13
13
|
/**
|
|
14
|
-
* @param maxChars - character budget
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* @param maxChars - character budget, read at every eviction.
|
|
15
|
+
*
|
|
16
|
+
* An accessor rather than a number because `maxTranscriptChars` is a **live** field
|
|
17
|
+
* (`docs/control-plane-fields.md`): the budget can change while the conversation runs, and a copy
|
|
18
|
+
* taken at construction would make the change look applied while the buffer kept the old one.
|
|
19
|
+
* Every value is accepted: a budget smaller than one line degrades to keeping exactly the most recent
|
|
20
|
+
* line rather than to keeping nothing, because a buffer that can hold nothing cannot answer any
|
|
21
|
+
* delegation at all.
|
|
17
22
|
*/
|
|
18
23
|
constructor(maxChars) {
|
|
19
24
|
this.maxChars = maxChars;
|
|
@@ -47,7 +52,8 @@ export class TranscriptBuffer {
|
|
|
47
52
|
}
|
|
48
53
|
/** Drop the oldest lines until the budget is met, never dropping the most recent one. */
|
|
49
54
|
evict() {
|
|
50
|
-
|
|
55
|
+
const budget = this.maxChars();
|
|
56
|
+
while (this.chars > budget && this.buffered.length > 1) {
|
|
51
57
|
// The loop condition proves a first element exists, so this assertion is an invariant rather
|
|
52
58
|
// than a hope — and it leaves no unreachable branch for the coverage gate to flag.
|
|
53
59
|
this.chars -= this.buffered.shift().text.length;
|
package/lib/transcript.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAQA;;;;;;;GAOG;AACH,MAAM,OAAO,gBAAgB;
|
|
1
|
+
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAQA;;;;;;;GAOG;AACH,MAAM,OAAO,gBAAgB;IAcE,QAAQ;IAbpB,QAAQ,GAAmB,EAAE,CAAA;IACtC,KAAK,GAAG,CAAC,CAAA;IAEjB;;;;;;;;;OASG;IACH,YAA6B,QAAsB;wBAAtB,QAAQ;IAAiB,CAAC;IAEvD;;;;;OAKG;IACH,MAAM,CAAC,IAAwB,EAAE,IAAY,EAAE,KAAc;QAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAM;QAC7B,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,CAAA;QAEzB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;QACpD,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;YAC7D,IAAI,CAAC,IAAI,IAAI,IAAI,CAAA;YACjB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;QACrB,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAA;QACnD,CAAC;QAED,IAAI,CAAC,KAAK,EAAE,CAAA;IACd,CAAC;IAED;;;OAGG;IACH,KAAK;QACH,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;IAC1E,CAAC;IAED,yFAAyF;IACjF,KAAK;QACX,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAA;QAC9B,OAAO,IAAI,CAAC,KAAK,GAAG,MAAM,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACvD,6FAA6F;YAC7F,mFAAmF;YACnF,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAG,CAAC,IAAI,CAAC,MAAM,CAAA;QAClD,CAAC;IACH,CAAC;CACF"}
|
package/lib/types.d.ts
CHANGED
|
@@ -68,4 +68,60 @@ export interface RealtimeAgentConfig {
|
|
|
68
68
|
/** Character budget for the transcript carried on a delegation request. */
|
|
69
69
|
maxTranscriptChars: number;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The voice session's state, as the plugin that owns it is the only one able to report it.
|
|
73
|
+
*
|
|
74
|
+
* A **query's** answer rather than an event's payload: nothing emits this, and a caller that needs it
|
|
75
|
+
* asks through `realtime-agent/status` — which is also what makes "is voice live" answerable without
|
|
76
|
+
* opening anything to find out.
|
|
77
|
+
*/
|
|
78
|
+
export interface RealtimeVoiceStatus {
|
|
79
|
+
/** Whether a provider session is open now. */
|
|
80
|
+
readonly open: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* The registered route the session is on — or, when none is open, the one a start *would* use.
|
|
83
|
+
*
|
|
84
|
+
* Read from the session the provider actually accepted when there is one, because a provider may
|
|
85
|
+
* alias what was asked for.
|
|
86
|
+
*/
|
|
87
|
+
readonly provider: string;
|
|
88
|
+
/** The model the provider accepted, or the one configured when nothing is open. */
|
|
89
|
+
readonly model: string;
|
|
90
|
+
/** The output voice, when one has been settled. */
|
|
91
|
+
readonly voice?: string;
|
|
92
|
+
/** The provider's own session id. Present only while a session is open. */
|
|
93
|
+
readonly sessionId?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Why a session request did not succeed, in the shape a caller can act on.
|
|
97
|
+
*
|
|
98
|
+
* **Never the failure's message**, and that is a deliberate limitation rather than an oversight: the
|
|
99
|
+
* plugin that holds the credential is the adapter, and this one holds none to redact against — which is
|
|
100
|
+
* the same rule its journal follows, where a session failure is recorded as its *class*. What it can
|
|
101
|
+
* carry instead is more useful than the message: the seam's own machine code, and the `remedy` written
|
|
102
|
+
* to be relayed verbatim, both of which name a *setting* rather than its value.
|
|
103
|
+
*/
|
|
104
|
+
export interface RealtimeSessionRefusal {
|
|
105
|
+
/** The seam's machine code, when the failure was one of its classified ones (`NOT_CONFIGURED`, `RATE_LIMITED`, …). */
|
|
106
|
+
readonly code?: string;
|
|
107
|
+
/** What to do about it, written to be relayed verbatim to whoever is trying to use the feature. */
|
|
108
|
+
readonly remedy?: string;
|
|
109
|
+
/** The failing class, when there was no code to carry. A class, never an instance's message. */
|
|
110
|
+
readonly class?: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What a request to open or close the voice session produced.
|
|
114
|
+
*
|
|
115
|
+
* The **outcome**, not an acknowledgement, because the two are the difference between "asked" and "it
|
|
116
|
+
* worked" — and collapsing them is the failure this project has already paid for once: a `start` that
|
|
117
|
+
* replied *requested* while the open silently failed teaches a user that the plugin is broken.
|
|
118
|
+
*/
|
|
119
|
+
export interface RealtimeSessionRequestOutcome {
|
|
120
|
+
/** Whether the request achieved what it asked for. */
|
|
121
|
+
readonly ok: boolean;
|
|
122
|
+
/** The state **after** the attempt. */
|
|
123
|
+
readonly voice: RealtimeVoiceStatus;
|
|
124
|
+
/** Present exactly when `ok` is false. */
|
|
125
|
+
readonly refusal?: RealtimeSessionRefusal;
|
|
126
|
+
}
|
|
71
127
|
//# sourceMappingURL=types.d.ts.map
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iFAAiF;AACjF,MAAM,WAAW,mBAAmB;IAClC,wBAAwB;IACxB,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;IACxB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,qFAAqF;IACrF,EAAE,EAAE,MAAM,CAAA;IACV,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAA;IAChB,0FAA0F;IAC1F,UAAU,EAAE,SAAS,mBAAmB,EAAE,CAAA;IAC1C,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAC3B;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAA;IAChB,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAA;IAClB,wGAAwG;IACxG,mBAAmB,EAAE,MAAM,CAAA;IAC3B,2EAA2E;IAC3E,kBAAkB,EAAE,MAAM,CAAA;CAC3B"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iFAAiF;AACjF,MAAM,WAAW,mBAAmB;IAClC,wBAAwB;IACxB,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;IACxB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,qFAAqF;IACrF,EAAE,EAAE,MAAM,CAAA;IACV,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAA;IAChB,0FAA0F;IAC1F,UAAU,EAAE,SAAS,mBAAmB,EAAE,CAAA;IAC1C,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAC3B;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAA;IAChB,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iDAAiD;IACjD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAA;IAClB,wGAAwG;IACxG,mBAAmB,EAAE,MAAM,CAAA;IAC3B,2EAA2E;IAC3E,kBAAkB,EAAE,MAAM,CAAA;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,mFAAmF;IACnF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,mDAAmD;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,sBAAsB;IACrC,sHAAsH;IACtH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,mGAAmG;IACnG,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,gGAAgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,6BAA6B;IAC5C,sDAAsD;IACtD,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,uCAAuC;IACvC,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAA;IACnC,0CAA0C;IAC1C,QAAQ,CAAC,OAAO,CAAC,EAAE,sBAAsB,CAAA;CAC1C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-realtime-agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Delegation bridge for the dsh-realtime seam: answers what the voice model delegates, or says plainly that it cannot.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"registry": "https://registry.npmjs.org"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"dsh-realtime": "^0.2.
|
|
45
|
+
"dsh-realtime": "^0.2.4"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@deepseek-ai/cordis": "^4.0.3",
|
package/src/index.ts
CHANGED
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
|
|
24
24
|
import Schema from '@deepseek-ai/schemastery'
|
|
25
25
|
import type { Context } from '@deepseek-ai/cordis'
|
|
26
|
-
import type { RealtimeDelegation, RealtimeDelegationSettlement, RealtimeSession, RealtimeSessionHandlers, RealtimeTranscript } from 'dsh-realtime'
|
|
26
|
+
import type { RealtimeDelegation, RealtimeDelegationProgress, RealtimeDelegationSettlement, RealtimeSession, RealtimeSessionHandlers, RealtimeTranscript } from 'dsh-realtime'
|
|
27
|
+
import { REALTIME_ERROR_CODES, RealtimeError } from 'dsh-realtime'
|
|
27
28
|
import { answerDelegation, type DelegationAppend, type DelegationAsker } from './bridge.ts'
|
|
28
29
|
import { voiceToolDefinitions } from './tools.ts'
|
|
29
30
|
import { TranscriptBuffer } from './transcript.ts'
|
|
30
|
-
import type { DelegationAnswer, DelegationRequest, RealtimeAgentConfig } from './types.ts'
|
|
31
|
+
import type { DelegationAnswer, DelegationRequest, RealtimeAgentConfig, RealtimeSessionRefusal, RealtimeSessionRequestOutcome, RealtimeVoiceStatus } from './types.ts'
|
|
31
32
|
|
|
32
33
|
declare module '@deepseek-ai/cordis' {
|
|
33
34
|
interface Events {
|
|
@@ -55,6 +56,18 @@ declare module '@deepseek-ai/cordis' {
|
|
|
55
56
|
* user's ear without any of them re-deriving why the turn produced nothing.
|
|
56
57
|
*/
|
|
57
58
|
'realtime-agent/delegation-settled'(settlement: RealtimeDelegationSettlement): void
|
|
59
|
+
/**
|
|
60
|
+
* One step of a delegated turn, on its way to an ear or to the model's own context.
|
|
61
|
+
*
|
|
62
|
+
* Emitted by whichever application is running the turn, as the steps happen — it is the only thing that
|
|
63
|
+
* knows what the turn is doing. This plugin appends it, because it holds the session: `commentary` is
|
|
64
|
+
* spoken aloud, `thinking` is carried silently, and the choice is the emitter's.
|
|
65
|
+
*
|
|
66
|
+
* A step for a delegation this plugin is **not** waiting on is dropped rather than appended: the
|
|
67
|
+
* provider only accepts an append for a delegation it knows, and a finished turn's words must not be
|
|
68
|
+
* spoken into a conversation that has moved on.
|
|
69
|
+
*/
|
|
70
|
+
'realtime-agent/delegation-progress'(progress: RealtimeDelegationProgress): void
|
|
58
71
|
/**
|
|
59
72
|
* Output audio: PCM16 in the session's declared output format.
|
|
60
73
|
*
|
|
@@ -77,15 +90,27 @@ declare module '@deepseek-ai/cordis' {
|
|
|
77
90
|
* Emitted by a transport when an authenticated client arrives, so that connecting a microphone is
|
|
78
91
|
* enough to be heard — with no profile option and no dependence on a model choosing to call
|
|
79
92
|
* `voice_start`. The session belongs to the agent, so the transport asks rather than opens one itself.
|
|
93
|
+
*
|
|
94
|
+
* Returns the request's **outcome** so a caller that is waiting for the answer can have it —
|
|
95
|
+
* `ctx.serial` from the control channel — while the transport, which only *emits*, is unaffected.
|
|
80
96
|
*/
|
|
81
|
-
'realtime-agent/start'():
|
|
97
|
+
'realtime-agent/start'(): RealtimeSessionRequestOutcome | undefined | Promise<RealtimeSessionRequestOutcome | undefined>
|
|
82
98
|
/**
|
|
83
99
|
* Close the voice session.
|
|
84
100
|
*
|
|
85
101
|
* Emitted by a transport when its last client goes away, including when the transport itself is
|
|
86
102
|
* disposed — a session outliving the microphone that asked for it is a socket nobody is listening to.
|
|
103
|
+
* Returns the outcome, for the same reason `realtime-agent/start` does.
|
|
104
|
+
*/
|
|
105
|
+
'realtime-agent/stop'(): RealtimeSessionRequestOutcome | undefined | Promise<RealtimeSessionRequestOutcome | undefined>
|
|
106
|
+
/**
|
|
107
|
+
* The voice session's state — a **query**, so nothing emits it.
|
|
108
|
+
*
|
|
109
|
+
* `undefined` means no listener answered, which is the honest answer for a composition with no agent
|
|
110
|
+
* row: it is distinguishable from a session that is merely closed, because the agent that is mounted
|
|
111
|
+
* always answers.
|
|
87
112
|
*/
|
|
88
|
-
'realtime-agent/
|
|
113
|
+
'realtime-agent/status'(): RealtimeVoiceStatus | undefined | Promise<RealtimeVoiceStatus | undefined>
|
|
89
114
|
}
|
|
90
115
|
}
|
|
91
116
|
|
|
@@ -125,8 +150,14 @@ export interface HandlerDeps {
|
|
|
125
150
|
readonly session: () => RealtimeSession | undefined
|
|
126
151
|
/** How to reach a responder. */
|
|
127
152
|
readonly ask: DelegationAsker
|
|
128
|
-
/**
|
|
129
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Bound on waiting for one, read at the moment the delegation is answered.
|
|
155
|
+
*
|
|
156
|
+
* An accessor because `delegationTimeoutMs` is a **live** field: the window the voice model waits in
|
|
157
|
+
* can be changed while the plugin runs, and the change must apply to the next delegation rather than
|
|
158
|
+
* to the next boot.
|
|
159
|
+
*/
|
|
160
|
+
readonly timeoutMs: () => number
|
|
130
161
|
/** Called when the session ended, so the caller can drop its reference. */
|
|
131
162
|
readonly onClosed: () => void
|
|
132
163
|
/** Where a session-scoped failure is reported. */
|
|
@@ -140,6 +171,13 @@ export interface HandlerDeps {
|
|
|
140
171
|
* whether a speaker ever rendered it. See the seam's session contract and invariant 6.
|
|
141
172
|
*/
|
|
142
173
|
readonly onAcknowledged: (append: DelegationAppend, delegationId: string) => void
|
|
174
|
+
/**
|
|
175
|
+
* Called once a delegation arrives, before it is answered, with the session it is being answered on — so
|
|
176
|
+
* a caller can tell which turns are still waiting, and *where* each one is waiting.
|
|
177
|
+
*/
|
|
178
|
+
readonly onDelegationStarted?: (delegationId: string, session: RealtimeSession) => void
|
|
179
|
+
/** Called once a delegation has finished, however it ended. */
|
|
180
|
+
readonly onDelegationSettled?: (delegationId: string) => void
|
|
143
181
|
}
|
|
144
182
|
|
|
145
183
|
/**
|
|
@@ -157,6 +195,9 @@ export function createHandlers(deps: HandlerDeps): RealtimeSessionHandlers {
|
|
|
157
195
|
// A delegation cannot be answered into a session that is gone. Speaking into a closed transport
|
|
158
196
|
// would be a failure raised from inside a handler, which is the worst place to raise one.
|
|
159
197
|
if (session === undefined) return
|
|
198
|
+
// Marked waiting before the answer is dispatched, so a step that arrives while the turn is running is
|
|
199
|
+
// appended rather than dropped for having beaten this line.
|
|
200
|
+
deps.onDelegationStarted?.(delegation.id, session)
|
|
160
201
|
// Handlers are synchronous, so the answer is dispatched rather than awaited. A rejection is
|
|
161
202
|
// reported rather than thrown: an unanswered delegation is already the failure path.
|
|
162
203
|
void answerDelegation(
|
|
@@ -164,9 +205,9 @@ export function createHandlers(deps: HandlerDeps): RealtimeSessionHandlers {
|
|
|
164
205
|
delegation,
|
|
165
206
|
deps.transcript.lines(),
|
|
166
207
|
deps.ask,
|
|
167
|
-
deps.timeoutMs,
|
|
208
|
+
deps.timeoutMs(),
|
|
168
209
|
deps.onAcknowledged,
|
|
169
|
-
).catch(deps.onSessionError)
|
|
210
|
+
).catch(deps.onSessionError).finally(() => { deps.onDelegationSettled?.(delegation.id) })
|
|
170
211
|
},
|
|
171
212
|
onAudio: (pcm16: Uint8Array): void => { deps.onAudio(pcm16) },
|
|
172
213
|
onClosed: (): void => { deps.onClosed() },
|
|
@@ -174,26 +215,104 @@ export function createHandlers(deps: HandlerDeps): RealtimeSessionHandlers {
|
|
|
174
215
|
}
|
|
175
216
|
}
|
|
176
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A count this plugin can actually be run with: a whole, positive number.
|
|
220
|
+
*
|
|
221
|
+
* Local to the plugin rather than shared from the seam, deliberately: the reason text is part of this
|
|
222
|
+
* plugin's own onboarding, and the seam has no business knowing that this plugin measures a timeout in
|
|
223
|
+
* milliseconds and a transcript in characters.
|
|
224
|
+
* @param field - the setting's own name, for the reason.
|
|
225
|
+
* @param value - the proposed value.
|
|
226
|
+
* @returns the value, when it is usable.
|
|
227
|
+
*/
|
|
228
|
+
function requireCount(field: string, value: number): number {
|
|
229
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
230
|
+
throw new RealtimeError(`${field} must be a positive whole number`, REALTIME_ERROR_CODES.INVALID_SETTING)
|
|
231
|
+
}
|
|
232
|
+
return value
|
|
233
|
+
}
|
|
234
|
+
|
|
177
235
|
/**
|
|
178
236
|
* Hold a session and bridge what the voice model delegates.
|
|
179
237
|
* @param ctx - the Cordis context, which must already provide the `realtime` service.
|
|
180
238
|
* @param config - validated configuration.
|
|
181
239
|
*/
|
|
182
240
|
export function apply(ctx: Context, config: RealtimeAgentConfig): void {
|
|
183
|
-
const transcript = new TranscriptBuffer(config.maxTranscriptChars)
|
|
184
|
-
// This plugin owns the session, so it is the only one that can honestly write these entries. The audio
|
|
185
|
-
// route emits a *request* to open a session and deliberately records nothing for it: recording a
|
|
186
|
-
// request as an event is how a journal starts lying.
|
|
187
241
|
const journal = ctx.realtime.journal
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The two fields this plugin reads at the moment of use.
|
|
245
|
+
*
|
|
246
|
+
* `autoStart` is deliberately **not** here as a changeable value. Its only read site is the boot
|
|
247
|
+
* below, so a running process has nothing that could honour a change; the gate was corrected to say
|
|
248
|
+
* so, and it is registered below as restart-bound — which is what makes that correction bite rather
|
|
249
|
+
* than merely being written down.
|
|
250
|
+
*/
|
|
251
|
+
const live = { delegationTimeoutMs: config.delegationTimeoutMs, maxTranscriptChars: config.maxTranscriptChars }
|
|
252
|
+
|
|
253
|
+
const transcript = new TranscriptBuffer(() => live.maxTranscriptChars)
|
|
188
254
|
let session: RealtimeSession | undefined
|
|
189
255
|
|
|
256
|
+
// The live fields `docs/control-plane-fields.md` lists for this plugin, plus the one it reclassified:
|
|
257
|
+
// `autoStart` is registered without a setter, so a change to it is refused with the restart it needs
|
|
258
|
+
// instead of being accepted and quietly ignored.
|
|
259
|
+
ctx.effect(function* () {
|
|
260
|
+
const release = ctx.realtime.settings.register(name, [
|
|
261
|
+
{
|
|
262
|
+
field: 'delegationTimeoutMs',
|
|
263
|
+
kind: 'number',
|
|
264
|
+
scope: 'live',
|
|
265
|
+
describe: 'How long the voice model waits for a responder',
|
|
266
|
+
get: () => live.delegationTimeoutMs,
|
|
267
|
+
set: (value: number) => { live.delegationTimeoutMs = requireCount('delegationTimeoutMs', value) },
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
field: 'maxTranscriptChars',
|
|
271
|
+
kind: 'number',
|
|
272
|
+
scope: 'live',
|
|
273
|
+
describe: 'Character budget for the transcript carried on a delegation',
|
|
274
|
+
get: () => live.maxTranscriptChars,
|
|
275
|
+
set: (value: number) => { live.maxTranscriptChars = requireCount('maxTranscriptChars', value) },
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
field: 'autoStart',
|
|
279
|
+
kind: 'boolean',
|
|
280
|
+
scope: 'restart',
|
|
281
|
+
describe: 'Open a session as soon as the plugin mounts',
|
|
282
|
+
get: () => config.autoStart,
|
|
283
|
+
},
|
|
284
|
+
])
|
|
285
|
+
yield () => { release() }
|
|
286
|
+
}, 'realtime-agent.settings')
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The one place an acknowledgement can honestly be recorded: the seam's appends resolve on the provider's
|
|
290
|
+
* confirmation, not on the send. Note what is deliberately absent beside it — no entry anywhere claims the
|
|
291
|
+
* audio was heard, which is the pair invariant 6 exists to keep apart. Shared with the narration path, so
|
|
292
|
+
* a spoken step and an answer are accounted for by the same rule.
|
|
293
|
+
*/
|
|
294
|
+
const onAcknowledged = (append: DelegationAppend, delegationId: string): void => {
|
|
295
|
+
journal.record('append.acknowledged', { append, delegationId })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The delegations still waiting for an answer, with the session each one is being answered on.
|
|
300
|
+
*
|
|
301
|
+
* The session is stored *with* the id rather than read back from the plugin when a step arrives, and that
|
|
302
|
+
* is not a micro-optimisation: it collapses two conditions into one. "Nothing is waiting on this" and
|
|
303
|
+
* "there is no session to speak into" cannot drift apart into two guards, one of which — the session
|
|
304
|
+
* dropped while its id is still waiting — no sequence of events can actually produce.
|
|
305
|
+
*/
|
|
306
|
+
const waiting = new Map<string, RealtimeSession>()
|
|
307
|
+
|
|
190
308
|
const handlers = createHandlers({
|
|
191
309
|
transcript,
|
|
192
310
|
session: () => session,
|
|
193
311
|
ask: request => ctx.serial('realtime-agent/delegation', request),
|
|
194
|
-
timeoutMs:
|
|
312
|
+
timeoutMs: () => live.delegationTimeoutMs,
|
|
195
313
|
onClosed: () => {
|
|
196
314
|
session = undefined
|
|
315
|
+
waiting.clear()
|
|
197
316
|
journal.record('session.closed', {})
|
|
198
317
|
},
|
|
199
318
|
onSessionError: (error) => {
|
|
@@ -208,14 +327,38 @@ export function apply(ctx: Context, config: RealtimeAgentConfig): void {
|
|
|
208
327
|
// Handed to the transport: all the host can observe, and no more (invariant 6).
|
|
209
328
|
journal.record('speech.sent', { bytes: String(pcm16.byteLength) })
|
|
210
329
|
},
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// anywhere claims the audio was heard, which is the pair invariant 6 exists to keep apart.
|
|
215
|
-
journal.record('append.acknowledged', { append, delegationId })
|
|
216
|
-
},
|
|
330
|
+
onDelegationStarted: (delegationId, current) => { waiting.set(delegationId, current) },
|
|
331
|
+
onDelegationSettled: (delegationId) => { waiting.delete(delegationId) },
|
|
332
|
+
onAcknowledged,
|
|
217
333
|
})
|
|
218
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Narrate a delegated turn's steps, on the session that is running it.
|
|
337
|
+
*
|
|
338
|
+
* A contribution like any other, so the fiber that registered it releases it. The emitter decides whether
|
|
339
|
+
* a step is spoken or silent — this half only carries it, because the session belongs here and nowhere
|
|
340
|
+
* else does.
|
|
341
|
+
*
|
|
342
|
+
* A failed append is a **dropped step**, not a broken turn: it is recorded so it is not silent, and it
|
|
343
|
+
* must not be confused with an answer that never arrived.
|
|
344
|
+
*/
|
|
345
|
+
ctx.effect(function* () {
|
|
346
|
+
const dispose = ctx.on('realtime-agent/delegation-progress', (progress: RealtimeDelegationProgress) => {
|
|
347
|
+
// One condition, not two: the session a turn is waiting on *is* the authorisation to speak into it, and
|
|
348
|
+
// it travels with the delegation. A step for anything else has nowhere to go — the provider refuses an
|
|
349
|
+
// append for an id it does not know, and a finished turn's words must not be spoken into the next one.
|
|
350
|
+
const current = waiting.get(progress.id)
|
|
351
|
+
if (current === undefined) return
|
|
352
|
+
const append = progress.channel === 'commentary'
|
|
353
|
+
? current.appendCommentary(progress.text, progress.id)
|
|
354
|
+
: current.appendThinking(progress.text, progress.id)
|
|
355
|
+
void append
|
|
356
|
+
.then(() => { onAcknowledged(progress.channel, progress.id) })
|
|
357
|
+
.catch(() => { journal.record('progress.dropped', { id: progress.id, channel: progress.channel }) })
|
|
358
|
+
})
|
|
359
|
+
yield () => { dispose() }
|
|
360
|
+
}, 'realtime-agent.progress')
|
|
361
|
+
|
|
219
362
|
/**
|
|
220
363
|
* Open a session, or return the one already open.
|
|
221
364
|
*
|
|
@@ -249,6 +392,72 @@ export function apply(ctx: Context, config: RealtimeAgentConfig): void {
|
|
|
249
392
|
await current?.close()
|
|
250
393
|
}
|
|
251
394
|
|
|
395
|
+
/**
|
|
396
|
+
* The session's state, as only this plugin can report it.
|
|
397
|
+
*
|
|
398
|
+
* With nothing open it answers with what a start *would* use, rather than with nothing: "no session,
|
|
399
|
+
* and it would be `openai-live`/`gpt-live-1`" is a different — and more useful — answer than an
|
|
400
|
+
* absence, and it is the one a status surface needs to render a sensible control.
|
|
401
|
+
*/
|
|
402
|
+
const status = (): RealtimeVoiceStatus => {
|
|
403
|
+
const current = session
|
|
404
|
+
if (current === undefined) {
|
|
405
|
+
return {
|
|
406
|
+
open: false,
|
|
407
|
+
provider: config.provider,
|
|
408
|
+
model: config.model,
|
|
409
|
+
...config.voice === undefined ? {} : { voice: config.voice },
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
open: true,
|
|
414
|
+
provider: current.started.provider,
|
|
415
|
+
model: current.started.model,
|
|
416
|
+
...current.started.voice === undefined ? {} : { voice: current.started.voice },
|
|
417
|
+
sessionId: current.id,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Classify a failed request, carrying what a caller can act on and never the message.
|
|
423
|
+
*
|
|
424
|
+
* A seam failure carries its machine code and the remedy written to be relayed; anything else carries
|
|
425
|
+
* its **class** alone. The message is deliberately dropped: a provider error is exactly where a key
|
|
426
|
+
* turns up, and this plugin holds no credential to redact against — the same reason its journal
|
|
427
|
+
* records the class of a session failure rather than the text.
|
|
428
|
+
* @param error - whatever the attempt threw.
|
|
429
|
+
* @returns the structured refusal.
|
|
430
|
+
*/
|
|
431
|
+
const refusalFor = (error: unknown): RealtimeSessionRefusal => {
|
|
432
|
+
if (error instanceof RealtimeError) {
|
|
433
|
+
return {
|
|
434
|
+
code: error.code,
|
|
435
|
+
...error.detail?.remedy === undefined ? {} : { remedy: error.detail.remedy },
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { class: error instanceof Error ? error.name : typeof error }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run a session request and report what it produced.
|
|
443
|
+
*
|
|
444
|
+
* The result is **returned as well as** reported on the bus, because a caller may be waiting for it:
|
|
445
|
+
* the control channel dispatches these with `serial`, so `start` can answer with the session that
|
|
446
|
+
* opened rather than with an acknowledgement that it asked. The failure still goes onto the bus, so a
|
|
447
|
+
* transport that merely emits keeps the behaviour it always had.
|
|
448
|
+
* @param attempt - the request to run.
|
|
449
|
+
* @returns whether it achieved what it asked for, and the state afterwards.
|
|
450
|
+
*/
|
|
451
|
+
const requestSession = async (attempt: () => Promise<unknown>): Promise<RealtimeSessionRequestOutcome> => {
|
|
452
|
+
try {
|
|
453
|
+
await attempt()
|
|
454
|
+
return { ok: true, voice: status() }
|
|
455
|
+
} catch (error) {
|
|
456
|
+
ctx.emit('realtime-agent/error', error as Error)
|
|
457
|
+
return { ok: false, voice: status(), refusal: refusalFor(error) }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
252
461
|
// Tools are an effect, like every other contribution this plugin makes: the fiber that mounted them
|
|
253
462
|
// releases them, so there is no separate teardown path to forget.
|
|
254
463
|
ctx.effect(function* () {
|
|
@@ -279,17 +488,16 @@ export function apply(ctx: Context, config: RealtimeAgentConfig): void {
|
|
|
279
488
|
}, 'realtime-agent.mic')
|
|
280
489
|
|
|
281
490
|
// Session requests from the transport. The route knows when an authenticated client connects and the
|
|
282
|
-
// agent owns the session, so one event is the whole of the wiring between them.
|
|
491
|
+
// agent owns the session, so one event is the whole of the wiring between them. Each listener returns
|
|
492
|
+
// its outcome: `emit` ignores it, `serial` waits for it, and that is how the control channel can
|
|
493
|
+
// answer *what happened* rather than *that it was asked*.
|
|
283
494
|
ctx.effect(function* () {
|
|
284
495
|
const disposers = [
|
|
285
|
-
ctx.on('realtime-agent/start', () =>
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
ctx.on('realtime-agent/stop', () => {
|
|
291
|
-
void stop().catch((error: Error) => { ctx.emit('realtime-agent/error', error) })
|
|
292
|
-
}),
|
|
496
|
+
ctx.on('realtime-agent/start', () => requestSession(() => open())),
|
|
497
|
+
ctx.on('realtime-agent/stop', () => requestSession(() => stop())),
|
|
498
|
+
// A query, and the only listener that answers one. A mounted agent always answers, so a caller
|
|
499
|
+
// that gets `undefined` from the dispatch learns the agent row is absent rather than guessing.
|
|
500
|
+
ctx.on('realtime-agent/status', () => status()),
|
|
293
501
|
]
|
|
294
502
|
yield () => { for (const dispose of disposers) dispose() }
|
|
295
503
|
}, 'realtime-agent.session-requests')
|
package/src/transcript.ts
CHANGED
|
@@ -19,11 +19,16 @@ export class TranscriptBuffer {
|
|
|
19
19
|
private chars = 0
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* @param maxChars - character budget
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* @param maxChars - character budget, read at every eviction.
|
|
23
|
+
*
|
|
24
|
+
* An accessor rather than a number because `maxTranscriptChars` is a **live** field
|
|
25
|
+
* (`docs/control-plane-fields.md`): the budget can change while the conversation runs, and a copy
|
|
26
|
+
* taken at construction would make the change look applied while the buffer kept the old one.
|
|
27
|
+
* Every value is accepted: a budget smaller than one line degrades to keeping exactly the most recent
|
|
28
|
+
* line rather than to keeping nothing, because a buffer that can hold nothing cannot answer any
|
|
29
|
+
* delegation at all.
|
|
25
30
|
*/
|
|
26
|
-
constructor(private readonly maxChars: number) {}
|
|
31
|
+
constructor(private readonly maxChars: () => number) {}
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* Record one fragment.
|
|
@@ -56,7 +61,8 @@ export class TranscriptBuffer {
|
|
|
56
61
|
|
|
57
62
|
/** Drop the oldest lines until the budget is met, never dropping the most recent one. */
|
|
58
63
|
private evict(): void {
|
|
59
|
-
|
|
64
|
+
const budget = this.maxChars()
|
|
65
|
+
while (this.chars > budget && this.buffered.length > 1) {
|
|
60
66
|
// The loop condition proves a first element exists, so this assertion is an invariant rather
|
|
61
67
|
// than a hope — and it leaves no unreachable branch for the coverage gate to flag.
|
|
62
68
|
this.chars -= this.buffered.shift()!.text.length
|
package/src/types.ts
CHANGED
|
@@ -72,3 +72,62 @@ export interface RealtimeAgentConfig {
|
|
|
72
72
|
/** Character budget for the transcript carried on a delegation request. */
|
|
73
73
|
maxTranscriptChars: number
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The voice session's state, as the plugin that owns it is the only one able to report it.
|
|
78
|
+
*
|
|
79
|
+
* A **query's** answer rather than an event's payload: nothing emits this, and a caller that needs it
|
|
80
|
+
* asks through `realtime-agent/status` — which is also what makes "is voice live" answerable without
|
|
81
|
+
* opening anything to find out.
|
|
82
|
+
*/
|
|
83
|
+
export interface RealtimeVoiceStatus {
|
|
84
|
+
/** Whether a provider session is open now. */
|
|
85
|
+
readonly open: boolean
|
|
86
|
+
/**
|
|
87
|
+
* The registered route the session is on — or, when none is open, the one a start *would* use.
|
|
88
|
+
*
|
|
89
|
+
* Read from the session the provider actually accepted when there is one, because a provider may
|
|
90
|
+
* alias what was asked for.
|
|
91
|
+
*/
|
|
92
|
+
readonly provider: string
|
|
93
|
+
/** The model the provider accepted, or the one configured when nothing is open. */
|
|
94
|
+
readonly model: string
|
|
95
|
+
/** The output voice, when one has been settled. */
|
|
96
|
+
readonly voice?: string
|
|
97
|
+
/** The provider's own session id. Present only while a session is open. */
|
|
98
|
+
readonly sessionId?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Why a session request did not succeed, in the shape a caller can act on.
|
|
103
|
+
*
|
|
104
|
+
* **Never the failure's message**, and that is a deliberate limitation rather than an oversight: the
|
|
105
|
+
* plugin that holds the credential is the adapter, and this one holds none to redact against — which is
|
|
106
|
+
* the same rule its journal follows, where a session failure is recorded as its *class*. What it can
|
|
107
|
+
* carry instead is more useful than the message: the seam's own machine code, and the `remedy` written
|
|
108
|
+
* to be relayed verbatim, both of which name a *setting* rather than its value.
|
|
109
|
+
*/
|
|
110
|
+
export interface RealtimeSessionRefusal {
|
|
111
|
+
/** The seam's machine code, when the failure was one of its classified ones (`NOT_CONFIGURED`, `RATE_LIMITED`, …). */
|
|
112
|
+
readonly code?: string
|
|
113
|
+
/** What to do about it, written to be relayed verbatim to whoever is trying to use the feature. */
|
|
114
|
+
readonly remedy?: string
|
|
115
|
+
/** The failing class, when there was no code to carry. A class, never an instance's message. */
|
|
116
|
+
readonly class?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a request to open or close the voice session produced.
|
|
121
|
+
*
|
|
122
|
+
* The **outcome**, not an acknowledgement, because the two are the difference between "asked" and "it
|
|
123
|
+
* worked" — and collapsing them is the failure this project has already paid for once: a `start` that
|
|
124
|
+
* replied *requested* while the open silently failed teaches a user that the plugin is broken.
|
|
125
|
+
*/
|
|
126
|
+
export interface RealtimeSessionRequestOutcome {
|
|
127
|
+
/** Whether the request achieved what it asked for. */
|
|
128
|
+
readonly ok: boolean
|
|
129
|
+
/** The state **after** the attempt. */
|
|
130
|
+
readonly voice: RealtimeVoiceStatus
|
|
131
|
+
/** Present exactly when `ok` is false. */
|
|
132
|
+
readonly refusal?: RealtimeSessionRefusal
|
|
133
|
+
}
|