@unoverse-platform/studio 0.1.96 → 0.1.98
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/bin/studio.mjs +3 -3
- package/local/authoring.ts +1 -1
- package/local/catalog.ts +22 -2
- package/local/keys.ts +1 -1
- package/local/nodes.ts +29 -42
- package/local/plugin.ts +75 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +51 -22
- package/local/source.ts +4 -9
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityHeader.tsx +1 -1
- package/screens/IdentityView.tsx +206 -0
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +5 -2
- package/src/App.tsx +14 -4
- package/src/NodeKeys.tsx +1 -1
- package/src/host.ts +3 -16
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
- package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +33 -25
- package/vendor/base/src/items/publish.ts +31 -7
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +6 -1
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +81 -10
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +12 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/index.ts +1 -1
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/manifests/source.ts +1 -1
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/README.md +1 -1
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +31 -12
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/vite.config.ts +1 -1
|
@@ -30,6 +30,51 @@ export interface AudioLane {
|
|
|
30
30
|
setAudioDataHandler?(fn: (sessionId: string, data: ArrayBuffer) => void | Promise<void>): void;
|
|
31
31
|
setControlMessageHandler?(fn: (sessionId: string, message: any) => void | Promise<void>): void;
|
|
32
32
|
startAudioSession?(sessionId: string, conversationId: string): void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* WHAT THIS LANE SPEAKS, when it is not the browser.
|
|
36
|
+
*
|
|
37
|
+
* `rate` is the lane's sample rate and `codec` how its samples are packed. Absent means the
|
|
38
|
+
* browser lane's own shape (16 kHz PCM16), which is what every lane was until there was a
|
|
39
|
+
* second one.
|
|
40
|
+
*
|
|
41
|
+
* It is on the LANE and not in a node's config for a reason worth keeping: this is a fact of
|
|
42
|
+
* the far end, not a preference. A phone is 8 kHz mu-law because the phone network is, and a
|
|
43
|
+
* wrong value is an error nowhere — the vendor transcribes at the wrong speed and it reads as
|
|
44
|
+
* poor accuracy. Nobody choosing it can choose better than the lane already knows.
|
|
45
|
+
*/
|
|
46
|
+
rate?: number;
|
|
47
|
+
codec?: "pcm16" | "mulaw";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* IS ANYONE THERE YET? Resolves true once the far end is listening, false if it never will be.
|
|
51
|
+
*
|
|
52
|
+
* ABSENT MEANS ALREADY CONNECTED, which is the browser: the page dialled us, so its socket
|
|
53
|
+
* exists before the node runs and there is nothing to wait for. A lane that has to REACH
|
|
54
|
+
* someone is the other case, and the difference matters more than it sounds. A telephone rings
|
|
55
|
+
* for twenty or thirty seconds, and without this the vendor session opens immediately, the
|
|
56
|
+
* model speaks its opening line to a ringing tone, and by the time a person says hello the
|
|
57
|
+
* greeting has already happened and been thrown away (observed live 2026-09-20).
|
|
58
|
+
*
|
|
59
|
+
* `false` is a call that was never answered: busy, declined, or rung out. The session must not
|
|
60
|
+
* open at all for one of those, or a realtime model sits billing by the minute against a
|
|
61
|
+
* telephone nobody picked up.
|
|
62
|
+
*/
|
|
63
|
+
whenConnected?(conversationId: string): Promise<boolean>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* END THE CALL AT THE FAR END. Not closing our socket to the vendor: that stops the MODEL,
|
|
67
|
+
* and the two are different things on every lane that reaches a person.
|
|
68
|
+
*
|
|
69
|
+
* ABSENT MEANS THERE IS NOTHING TO HANG UP, which is the browser: the page holds the session
|
|
70
|
+
* and closing it is the page's own business. A lane that REACHED someone is the other case,
|
|
71
|
+
* and until this existed it could not let them go. Measured live 2026-09-22: the model said
|
|
72
|
+
* goodbye, the vendor session closed on time, and the telephone stayed open with the person
|
|
73
|
+
* listening to silence until THEY hung up, because no code anywhere ran in this direction.
|
|
74
|
+
*
|
|
75
|
+
* Called once, from the session's `finish`, so every way a call can end ends the call.
|
|
76
|
+
*/
|
|
77
|
+
hangUp?(conversationId: string): void;
|
|
33
78
|
}
|
|
34
79
|
|
|
35
80
|
|
|
@@ -37,9 +82,67 @@ export interface AudioLane {
|
|
|
37
82
|
const BYTES_PER_SAMPLE = 2;
|
|
38
83
|
const DEFAULT_COALESCE_BYTES = 32768;
|
|
39
84
|
const DEFAULT_COALESCE_MS = 50;
|
|
40
|
-
/** What the browser captures at.
|
|
85
|
+
/** What the browser captures at. The default when a lane does not declare its own rate. */
|
|
41
86
|
export const CLIENT_RATE = 16000;
|
|
42
87
|
|
|
88
|
+
/**
|
|
89
|
+
* G.711 mu-law, the codec of the telephone network, and the only non-PCM lane here.
|
|
90
|
+
*
|
|
91
|
+
* ONE BYTE PER SAMPLE instead of two, logarithmically companded: it spends its resolution
|
|
92
|
+
* where a human ear is sensitive and throws it away where it is not. That is why a phone call
|
|
93
|
+
* at 8 kHz and 64 kbit/s is intelligible at all, and also why converting is a TABLE rather than
|
|
94
|
+
* a scale — the relationship between a byte and a sample is not linear.
|
|
95
|
+
*
|
|
96
|
+
* Both directions are written out rather than pulled from a package, because the encoding is
|
|
97
|
+
* twelve lines, fixed since 1972, and a dependency that has to be audited is worse than a
|
|
98
|
+
* function that can be read. The tests are the reference: a round trip is lossy by design (16
|
|
99
|
+
* bits into 8 and back), so they assert closeness, never equality.
|
|
100
|
+
*/
|
|
101
|
+
const MULAW_BIAS = 0x84;
|
|
102
|
+
const MULAW_CLIP = 32635;
|
|
103
|
+
|
|
104
|
+
/** PCM16 sample -> one mu-law byte. */
|
|
105
|
+
function encodeMulawSample(sample: number): number {
|
|
106
|
+
let sign = (sample >> 8) & 0x80;
|
|
107
|
+
if (sign !== 0) sample = -sample;
|
|
108
|
+
if (sample > MULAW_CLIP) sample = MULAW_CLIP;
|
|
109
|
+
sample += MULAW_BIAS;
|
|
110
|
+
// The exponent is the position of the highest set bit: mu-law is a floating point format
|
|
111
|
+
// with a 3-bit exponent and a 4-bit mantissa, which is exactly what the companding IS.
|
|
112
|
+
let exponent = 7;
|
|
113
|
+
for (let mask = 0x4000; (sample & mask) === 0 && exponent > 0; exponent--, mask >>= 1);
|
|
114
|
+
const mantissa = (sample >> (exponent + 3)) & 0x0f;
|
|
115
|
+
// Inverted on the wire, which is the part everyone forgets: a decoder fed un-inverted bytes
|
|
116
|
+
// produces sound, just the wrong sound, so nothing errors and the call is simply noise.
|
|
117
|
+
return ~(sign | (exponent << 4) | mantissa) & 0xff;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One mu-law byte -> PCM16 sample. */
|
|
121
|
+
function decodeMulawByte(byte: number): number {
|
|
122
|
+
byte = ~byte & 0xff;
|
|
123
|
+
const sign = byte & 0x80;
|
|
124
|
+
const exponent = (byte >> 4) & 0x07;
|
|
125
|
+
const mantissa = byte & 0x0f;
|
|
126
|
+
let sample = ((mantissa << 3) + MULAW_BIAS) << exponent;
|
|
127
|
+
sample -= MULAW_BIAS;
|
|
128
|
+
return sign !== 0 ? -sample : sample;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A buffer of mu-law bytes -> PCM16 little-endian. One byte in, two bytes out. */
|
|
132
|
+
export function mulawToPcm16(input: Buffer): Buffer {
|
|
133
|
+
const out = Buffer.alloc(input.length * BYTES_PER_SAMPLE);
|
|
134
|
+
for (let i = 0; i < input.length; i++) out.writeInt16LE(decodeMulawByte(input[i]), i * BYTES_PER_SAMPLE);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** PCM16 little-endian -> mu-law bytes. Two bytes in, one byte out. */
|
|
139
|
+
export function pcm16ToMulaw(input: Buffer): Buffer {
|
|
140
|
+
const samples = Math.floor(input.length / BYTES_PER_SAMPLE);
|
|
141
|
+
const out = Buffer.alloc(samples);
|
|
142
|
+
for (let i = 0; i < samples; i++) out[i] = encodeMulawSample(input.readInt16LE(i * BYTES_PER_SAMPLE));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
43
146
|
/**
|
|
44
147
|
* Resample PCM16 by linear interpolation.
|
|
45
148
|
*
|
|
@@ -76,11 +179,50 @@ export function resamplePcm16(input: Buffer, fromRate: number, toRate: number):
|
|
|
76
179
|
* audio must be thrown away, because the user is already talking over the sentence it belongs
|
|
77
180
|
* to. Transcribed from the retired WebSocketAudioPublisher.
|
|
78
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* PCM16 at the vendor's rate -> whatever this lane speaks.
|
|
184
|
+
*
|
|
185
|
+
* IDENTITY FOR THE BROWSER LANE, and that is load-bearing rather than an optimisation. The
|
|
186
|
+
* browser lane declares no rate and no codec, so this returns the very same Buffer object it
|
|
187
|
+
* was given — byte for byte what shipped before there was a second lane, and what the long
|
|
188
|
+
* comment in `flush` below is about. A lane that says nothing gets nothing done to it.
|
|
189
|
+
*/
|
|
190
|
+
function forLane(lane: AudioLane, pcm: Buffer, vendorRate: number, passthrough = false): Buffer {
|
|
191
|
+
/**
|
|
192
|
+
* NOTHING TO DO, because the vendor is already speaking the lane's own codec.
|
|
193
|
+
*
|
|
194
|
+
* A telephone is 8 kHz mu-law and several vendors accept 8 kHz mu-law, so when a node says it
|
|
195
|
+
* asked for that (`in.laneCodecs` in api/audio.yaml) these bytes ARE the bytes to put on the
|
|
196
|
+
* wire. Converting them would decode to PCM, resample 8 kHz to 24 kHz and back, and re-encode,
|
|
197
|
+
* to arrive at audio no better than it started: a phone call cannot be made higher fidelity by
|
|
198
|
+
* being carried at a higher rate, only rounder at the edges.
|
|
199
|
+
*
|
|
200
|
+
* OPT-IN, and that is the point of the flag. A vendor that was never told to speak mu-law is
|
|
201
|
+
* still sending PCM, and passing that through would be noise at the wrong speed. Absent the
|
|
202
|
+
* declaration this is exactly the code that shipped.
|
|
203
|
+
*/
|
|
204
|
+
if (passthrough) return pcm;
|
|
205
|
+
if (lane.rate === undefined && lane.codec === undefined) return pcm;
|
|
206
|
+
const resampled = resamplePcm16(pcm, vendorRate, lane.rate ?? CLIENT_RATE);
|
|
207
|
+
return lane.codec === "mulaw" ? pcm16ToMulaw(resampled) : resampled;
|
|
208
|
+
}
|
|
209
|
+
|
|
79
210
|
export function makeAudioBuffer(
|
|
80
211
|
lane: AudioLane,
|
|
81
212
|
conversationId: string,
|
|
82
213
|
targetBytes = DEFAULT_COALESCE_BYTES,
|
|
83
214
|
maxDelayMs = DEFAULT_COALESCE_MS,
|
|
215
|
+
/**
|
|
216
|
+
* What the VENDOR is sending, so `forLane` knows what it is converting FROM. Defaults to the
|
|
217
|
+
* browser's rate, which is also the no-conversion case, so an existing caller is unchanged.
|
|
218
|
+
*/
|
|
219
|
+
vendorRate = CLIENT_RATE,
|
|
220
|
+
/**
|
|
221
|
+
* The vendor is sending this lane's OWN codec, so the frames need no conversion at all. Set
|
|
222
|
+
* by the session when a node's `in.laneCodecs` names what the lane speaks. Default false: an
|
|
223
|
+
* existing caller converts exactly as it always did.
|
|
224
|
+
*/
|
|
225
|
+
passthrough = false,
|
|
84
226
|
) {
|
|
85
227
|
let chunks: Buffer[] = [];
|
|
86
228
|
let total = 0;
|
|
@@ -108,6 +250,20 @@ export function makeAudioBuffer(
|
|
|
108
250
|
if (!chunks.length) return;
|
|
109
251
|
const combined = Buffer.concat(chunks);
|
|
110
252
|
|
|
253
|
+
/**
|
|
254
|
+
* ONE BYTE PER SAMPLE, so there is no odd byte to hold back.
|
|
255
|
+
*
|
|
256
|
+
* Everything below is PCM16 arithmetic: two bytes per sample, an odd length is half a sample,
|
|
257
|
+
* and the client's `new Int16Array(buffer)` throws on one. Mu-law is one byte per sample, so
|
|
258
|
+
* every length is already whole and aligning would hold back a complete sample for no reason.
|
|
259
|
+
*/
|
|
260
|
+
if (passthrough) {
|
|
261
|
+
chunks = [];
|
|
262
|
+
total = 0;
|
|
263
|
+
lane.sendAudio(conversationId, forLane(lane, combined, vendorRate, true));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
111
267
|
/**
|
|
112
268
|
* SEND A WHOLE BUFFER, NEVER A VIEW. This is what the retired node did and the drift from it
|
|
113
269
|
* was audible: nothing but a light crackle.
|
|
@@ -124,7 +280,7 @@ export function makeAudioBuffer(
|
|
|
124
280
|
if (combined.length % 2 === 0) {
|
|
125
281
|
chunks = [];
|
|
126
282
|
total = 0;
|
|
127
|
-
lane.sendAudio(conversationId, combined);
|
|
283
|
+
lane.sendAudio(conversationId, forLane(lane, combined, vendorRate, passthrough));
|
|
128
284
|
return;
|
|
129
285
|
}
|
|
130
286
|
|
|
@@ -143,7 +299,7 @@ export function makeAudioBuffer(
|
|
|
143
299
|
}
|
|
144
300
|
chunks = [Buffer.from(combined.subarray(aligned))];
|
|
145
301
|
total = 1;
|
|
146
|
-
lane.sendAudio(conversationId, Buffer.from(combined.subarray(0, aligned)));
|
|
302
|
+
lane.sendAudio(conversationId, forLane(lane, Buffer.from(combined.subarray(0, aligned)), vendorRate, passthrough));
|
|
147
303
|
};
|
|
148
304
|
|
|
149
305
|
return {
|
|
@@ -40,7 +40,7 @@ import { assertAllowedHost } from "../http/allowedHosts.js";
|
|
|
40
40
|
import type { Emitter } from "../events.js";
|
|
41
41
|
// The audio lane lives next door: this file owns the VENDOR socket, that one owns the bytes and
|
|
42
42
|
// the browser. See the header of audioLane.ts for why they are separate files.
|
|
43
|
-
import { makeAudioBuffer, resamplePcm16, CLIENT_RATE, type AudioLane } from "./audioLane.js";
|
|
43
|
+
import { makeAudioBuffer, resamplePcm16, mulawToPcm16, CLIENT_RATE, type AudioLane } from "./audioLane.js";
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* PLATFORM CEILINGS, independent of anything a manifest says — the same arrangement `poll` has,
|
|
@@ -55,11 +55,15 @@ import { makeAudioBuffer, resamplePcm16, CLIENT_RATE, type AudioLane } from "./a
|
|
|
55
55
|
* MAX a session that is active but has run absurdly long, e.g. an open mic in an empty room
|
|
56
56
|
* keeping VAD busy indefinitely.
|
|
57
57
|
*/
|
|
58
|
+
/** How long the line stays open after the last audio frame, once the model has asked to hang up. */
|
|
59
|
+
const HANG_UP_DRAIN_MS = 1500;
|
|
60
|
+
|
|
58
61
|
const IDLE_TIMEOUT_MS = 30_000;
|
|
59
62
|
const MAX_SESSION_MS = 30 * 60_000;
|
|
60
63
|
|
|
61
64
|
import { offerDiscovered, parseMaybeJson, recordToolTrace, type ToolBridge } from "../tools/toolloop.js";
|
|
62
65
|
import { applyInForce, renderSkillsText, type InForceSkill } from "../tools/regionSkills.js";
|
|
66
|
+
import { registerLiveInput, liveInputKey } from "../../../platform/liveInput.js";
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
/** Resolve one manifest message against the run scope, with `extra` layered on top. */
|
|
@@ -69,7 +73,7 @@ async function message(spec: unknown, ctx: RunContext, extra: Record<string, unk
|
|
|
69
73
|
|
|
70
74
|
export interface DuplexResult {
|
|
71
75
|
/** Why the session ended, for the caller to record. */
|
|
72
|
-
reason: "closed" | "ended" | "error" | "idle" | "maxDuration";
|
|
76
|
+
reason: "closed" | "ended" | "error" | "idle" | "maxDuration" | "notConnected";
|
|
73
77
|
error?: string;
|
|
74
78
|
}
|
|
75
79
|
|
|
@@ -110,11 +114,98 @@ export async function runDuplexSession(opts: {
|
|
|
110
114
|
// A session always authenticates, hence `true` rather than reading the auth block.
|
|
111
115
|
assertAllowedHost(url, node.allowedHosts, node.type, true);
|
|
112
116
|
|
|
117
|
+
/**
|
|
118
|
+
* WHAT THE CONVERSATION HAS ACCUMULATED, which no single event carries.
|
|
119
|
+
*
|
|
120
|
+
* seen the latest event of each type. A reply names the request it answers (a vendor's
|
|
121
|
+
* delegation id), and that request arrived on an earlier event than the reply.
|
|
122
|
+
* said the text the vendor has streamed so far, in order, one entry per run of the same
|
|
123
|
+
* event type. A hand-off carries no text of its own, so a row describing it reads
|
|
124
|
+
* what was said before it; the transcript at the end is the whole list.
|
|
125
|
+
*
|
|
126
|
+
* Given to every events row and every `send` rule. Audio deltas never reach either: they
|
|
127
|
+
* leave on the audio lane before this is written.
|
|
128
|
+
*
|
|
129
|
+
* SET UP BEFORE THE CONNECT GATE BELOW, and that ordering is load-bearing. A call that rings
|
|
130
|
+
* out returns early, and the events table still runs its `complete` rows on the way out — so
|
|
131
|
+
* a row reading `said` for the transcript threw "unknown identifier 'said'" and the node
|
|
132
|
+
* FAILED instead of finishing (observed live 2026-09-20). A call nobody answered is an
|
|
133
|
+
* ordinary outcome, not a broken node: the scope exists for the whole run, and the transcript
|
|
134
|
+
* of a call that never happened is simply empty.
|
|
135
|
+
*/
|
|
136
|
+
const seen: Record<string, any> = {};
|
|
137
|
+
const said: { type: string; text: string }[] = [];
|
|
138
|
+
emitter.extend({ seen, said });
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* THE RINGING AND THE GETTING READY HAPPEN AT THE SAME TIME.
|
|
142
|
+
*
|
|
143
|
+
* A lane that has to REACH its far end says so with `whenConnected` (audioLane.ts). We start
|
|
144
|
+
* it here, which for a telephone is the moment it begins to ring, AND WE DO NOT WAIT: the
|
|
145
|
+
* vendor socket is opened, the handshake exchanged and the opening line composed against the
|
|
146
|
+
* ringing tone, so that all of it is finished before anybody picks up.
|
|
147
|
+
*
|
|
148
|
+
* IT USED TO WAIT, and the reason was that a realtime session is billed from the moment it
|
|
149
|
+
* opens, so a call nobody answers would be paid for in full. That was the wrong thing to
|
|
150
|
+
* optimise. Waiting moved the entire setup — socket, handshake, and the model composing its
|
|
151
|
+
* first sentence — to AFTER the person said hello, and they heard every second of it as
|
|
152
|
+
* silence (measured live 2026-09-20: six seconds between answer and first word). A few cents
|
|
153
|
+
* on a call that rings out is cheaper than a call that sounds broken on every answer.
|
|
154
|
+
*
|
|
155
|
+
* The greeting cannot be played to a ringing tone, and it is not: the lane holds what is
|
|
156
|
+
* generated early and flushes it the instant the call connects (twilioAudio.ts). So the only
|
|
157
|
+
* thing that still waits for the answer is the sound itself.
|
|
158
|
+
*
|
|
159
|
+
* The browser lane declares no `whenConnected` at all, because the page dialled US and its
|
|
160
|
+
* socket is already there. Nothing below changes for it.
|
|
161
|
+
*/
|
|
162
|
+
let answered: Promise<boolean> | null = null;
|
|
163
|
+
if (lane?.whenConnected) {
|
|
164
|
+
lane.startAudioSession?.(conversationId, conversationId);
|
|
165
|
+
answered = lane.whenConnected(conversationId);
|
|
166
|
+
}
|
|
167
|
+
|
|
113
168
|
const ws = new WebSocket(url, { headers });
|
|
114
|
-
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* WHAT THE VENDOR SENDS, which the lane converts FROM. `in.resampleTo` is the rate this
|
|
173
|
+
* session's manifest asks the vendor to LISTEN at, and every vendor here is symmetric — the
|
|
174
|
+
* handshake in api/run.yaml sets one format for input and output together — so it is also the
|
|
175
|
+
* rate the vendor SPEAKS at. Stated here rather than inferred at the far end, because a lane
|
|
176
|
+
* that guessed wrong would sound fine and simply be the wrong speed.
|
|
177
|
+
*/
|
|
178
|
+
const vendorRate = inSpec?.resampleTo ?? CLIENT_RATE;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* IS THE VENDOR ALREADY SPEAKING THIS LANE'S LANGUAGE?
|
|
182
|
+
*
|
|
183
|
+
* A node may declare in `api/audio.yaml` which lane codecs its vendor can take raw
|
|
184
|
+
* (`in.laneCodecs: [mulaw]`), and ask for that codec in its own handshake. When the lane
|
|
185
|
+
* speaks one of them, the bytes need no conversion in either direction: a telephone's 8 kHz
|
|
186
|
+
* mu-law goes to the vendor untouched and comes back untouched.
|
|
187
|
+
*
|
|
188
|
+
* The conversion it skips is not merely wasted, it is LOSSY IN BOTH DIRECTIONS: decode to
|
|
189
|
+
* PCM, interpolate 8 kHz up to 24 kHz, and at the far end interpolate back down and
|
|
190
|
+
* re-compand. A phone call is 8 kHz whatever is done to it, so all that work can only round
|
|
191
|
+
* the edges off it.
|
|
192
|
+
*
|
|
193
|
+
* ABSENT MEANS TODAY'S BEHAVIOUR, exactly. A node that declares nothing (Realtime, Grok, and
|
|
194
|
+
* every browser lane) converts as it always has, because a vendor that was not asked for
|
|
195
|
+
* mu-law is not sending it, and handing its PCM straight to a telephone would be noise.
|
|
196
|
+
*/
|
|
197
|
+
const passthrough = !!lane?.codec && (inSpec?.laneCodecs ?? []).includes(lane.codec);
|
|
198
|
+
|
|
199
|
+
const buffer =
|
|
200
|
+
lane && outSpec
|
|
201
|
+
? makeAudioBuffer(lane, conversationId, outSpec.coalesceBytes, outSpec.coalesceMs, vendorRate, passthrough)
|
|
202
|
+
: null;
|
|
115
203
|
|
|
116
204
|
let settled = false;
|
|
117
205
|
let closing = false;
|
|
206
|
+
/** Set once `control.endWhen` has fired; cleared only by the hang-up it schedules. */
|
|
207
|
+
let ending = false;
|
|
208
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
118
209
|
/**
|
|
119
210
|
* Is the assistant mid-utterance? The vendor has no "about to speak" event, so the FIRST audio
|
|
120
211
|
* delta after silence is the start, and the client gates playback on being told. Transcribed from
|
|
@@ -136,6 +227,10 @@ export async function runDuplexSession(opts: {
|
|
|
136
227
|
*/
|
|
137
228
|
const skills = new Map<string, InForceSkill>();
|
|
138
229
|
|
|
230
|
+
|
|
231
|
+
/** Set while the session is open and takes `CONTINUE` inputs; cleared when it ends. */
|
|
232
|
+
let unregisterLive: (() => void) | undefined;
|
|
233
|
+
|
|
139
234
|
const send = (payload: unknown) => {
|
|
140
235
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
141
236
|
ws.send(JSON.stringify(payload));
|
|
@@ -183,14 +278,68 @@ export async function runDuplexSession(opts: {
|
|
|
183
278
|
}, IDLE_TIMEOUT_MS);
|
|
184
279
|
};
|
|
185
280
|
|
|
281
|
+
/**
|
|
282
|
+
* THE ONE PLACE A SESSION ENDS, and now the one place a CALL ends with it.
|
|
283
|
+
*
|
|
284
|
+
* Closing the vendor socket stops the model. It does not reach the person: on a telephone
|
|
285
|
+
* the carrier holds the line and will hold it until somebody hangs up. Those are two far
|
|
286
|
+
* ends and both have to be let go, so the lane is told here rather than at any one of the
|
|
287
|
+
* ways a session can finish — every one of them comes through this function, and a hang-up
|
|
288
|
+
* written beside only the deliberate one is a hang-up that never happens on a timeout, an
|
|
289
|
+
* error, or a vendor that dropped.
|
|
290
|
+
*/
|
|
186
291
|
const finish = (result: DuplexResult) => {
|
|
187
292
|
if (settled) return;
|
|
188
293
|
settled = true;
|
|
294
|
+
unregisterLive?.();
|
|
189
295
|
if (idleTimer) clearTimeout(idleTimer);
|
|
296
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
190
297
|
clearTimeout(maxTimer);
|
|
298
|
+
try {
|
|
299
|
+
lane?.hangUp?.(conversationId);
|
|
300
|
+
} catch (err: any) {
|
|
301
|
+
// A far end we could not let go of is worth saying out loud and is not worth failing
|
|
302
|
+
// the run over: the work the call did is already done and already emitted.
|
|
303
|
+
onError?.(`hang up failed: ${err?.message ?? err}`);
|
|
304
|
+
}
|
|
191
305
|
resolve(result);
|
|
192
306
|
};
|
|
193
307
|
|
|
308
|
+
/**
|
|
309
|
+
* HANGING UP AFTER THE LAST WORD, not during it.
|
|
310
|
+
*
|
|
311
|
+
* `control.endWhen` is true the moment the vendor reports the tool call, and the goodbye it
|
|
312
|
+
* was called alongside is still going down the wire. Shutting down there cuts the line
|
|
313
|
+
* mid-sentence, which is heard as the call dropping rather than ending (2026-09-22).
|
|
314
|
+
*
|
|
315
|
+
* So the decision and the hang-up are two things. The decision stands; the hang-up waits
|
|
316
|
+
* for the audio to stop arriving. Every frame that follows pushes this back, so a long
|
|
317
|
+
* closing sentence is never truncated, and a model that says nothing after calling the tool
|
|
318
|
+
* still hangs up within the settle.
|
|
319
|
+
*/
|
|
320
|
+
const hangUpOnceQuiet = () => {
|
|
321
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
322
|
+
drainTimer = setTimeout(() => {
|
|
323
|
+
buffer?.flush();
|
|
324
|
+
void shutdown("ended").then((reason) => finish({ reason: reason ?? "ended" }));
|
|
325
|
+
}, HANG_UP_DRAIN_MS);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* NOBODY EVER PICKED UP. Not an error — a call that rang out is a thing that happens, and the
|
|
330
|
+
* workflow should read it as such rather than as a broken node. The session we opened in front
|
|
331
|
+
* of it is closed here, which is the price of having been ready.
|
|
332
|
+
*/
|
|
333
|
+
void answered?.then((ok) => {
|
|
334
|
+
if (ok) return;
|
|
335
|
+
try {
|
|
336
|
+
ws.close();
|
|
337
|
+
} catch {
|
|
338
|
+
/* never opened */
|
|
339
|
+
}
|
|
340
|
+
finish({ reason: "notConnected" });
|
|
341
|
+
});
|
|
342
|
+
|
|
194
343
|
ws.on("open", async () => {
|
|
195
344
|
touch();
|
|
196
345
|
try {
|
|
@@ -236,15 +385,76 @@ export async function runDuplexSession(opts: {
|
|
|
236
385
|
for (const m of ([] as any[]).concat(built ?? [])) if (m) send(m);
|
|
237
386
|
}
|
|
238
387
|
|
|
388
|
+
/**
|
|
389
|
+
* TAKE `CONTINUE` INPUTS WHILE THE CALL RUNS (calls-that-loop.md, `transport: ws`).
|
|
390
|
+
*
|
|
391
|
+
* Another node's output, wired into this node's CONTINUE input, arrives here rather than
|
|
392
|
+
* starting the node again, which would open a second conversation. The `send` rules with
|
|
393
|
+
* `on: input` say what the vendor is told; the value is in scope as `input.value`.
|
|
394
|
+
*
|
|
395
|
+
* Registered AFTER the handshake, so nothing can be sent to a session the vendor has not
|
|
396
|
+
* been told about yet.
|
|
397
|
+
*/
|
|
398
|
+
const key = liveInputKey(ctx.scope?.executionId, ctx.scope?.nodeId);
|
|
399
|
+
if (key) {
|
|
400
|
+
unregisterLive = registerLiveInput(key, async (handle, value) => {
|
|
401
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
402
|
+
touch();
|
|
403
|
+
const scope = { input: { handle, value }, seen, said };
|
|
404
|
+
let sent = 0;
|
|
405
|
+
for (const rule of call.send ?? []) {
|
|
406
|
+
if (rule.on !== "input") continue;
|
|
407
|
+
if (rule.when && !(await evaluate(rule.when, { ...ctx, ...scope } as any))) continue;
|
|
408
|
+
for (const m of ([] as any[]).concat((await message(rule.message, ctx, scope)) ?? [])) {
|
|
409
|
+
if (m) {
|
|
410
|
+
send(m);
|
|
411
|
+
sent++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Named, not silent: an input wired to a node that says nothing about it is a
|
|
416
|
+
// canvas edge that looks connected and does nothing.
|
|
417
|
+
console.log(
|
|
418
|
+
sent
|
|
419
|
+
? `[manifests] ${node.type}: input "${handle}" taken mid-call, ${sent} message(s) sent`
|
|
420
|
+
: `[manifests] ${node.type}: input "${handle}" arrived mid-call but no send rule has on: input`,
|
|
421
|
+
);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
239
426
|
if (lane && inSpec) {
|
|
240
|
-
lane
|
|
427
|
+
// Already started above for a lane that had to reach its far end, and starting a phone
|
|
428
|
+
// lane twice would place a second call.
|
|
429
|
+
if (!lane.whenConnected) lane.startAudioSession?.(conversationId, conversationId);
|
|
241
430
|
|
|
242
431
|
lane.setAudioDataHandler?.(async (sessionId, data) => {
|
|
243
432
|
if (sessionId !== conversationId || ws.readyState !== WebSocket.OPEN) return;
|
|
244
433
|
touch();
|
|
245
434
|
const raw = Buffer.from(data);
|
|
246
435
|
const rate = inSpec.resampleTo ?? CLIENT_RATE;
|
|
247
|
-
|
|
436
|
+
|
|
437
|
+
// NOTHING TO DO: the vendor asked for this lane's own codec (see `passthrough`
|
|
438
|
+
// above), so these are the bytes to send. The rate travels as the LANE's, because
|
|
439
|
+
// that is what the samples actually are.
|
|
440
|
+
if (passthrough) {
|
|
441
|
+
send(
|
|
442
|
+
await message(inSpec.message, ctx, {
|
|
443
|
+
audio: { base64: raw.toString("base64"), bytes: raw.length, rate: lane.rate ?? CLIENT_RATE },
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* DECODE BEFORE RESAMPLING. A mu-law byte is one sample, not half of one, so
|
|
450
|
+
* interpolating the raw bytes would treat pairs of samples as single ones and
|
|
451
|
+
* produce half-speed noise. And mu-law is logarithmic, so even the arithmetic
|
|
452
|
+
* would be wrong: the midpoint of two bytes is not the midpoint of two sounds.
|
|
453
|
+
*
|
|
454
|
+
* The browser lane declares neither, so both lines are identity for it.
|
|
455
|
+
*/
|
|
456
|
+
const decoded = lane.codec === "mulaw" ? mulawToPcm16(raw) : raw;
|
|
457
|
+
const pcm = resamplePcm16(decoded, lane.rate ?? CLIENT_RATE, rate);
|
|
248
458
|
send(
|
|
249
459
|
await message(inSpec.message, ctx, {
|
|
250
460
|
audio: { base64: pcm.toString("base64"), bytes: pcm.length, rate },
|
|
@@ -275,17 +485,57 @@ export async function runDuplexSession(opts: {
|
|
|
275
485
|
}
|
|
276
486
|
const type = String(event?.type ?? "");
|
|
277
487
|
|
|
488
|
+
// A REJECTED SESSION MUST SAY WHY. Every vendor this runtime speaks names its complaint in
|
|
489
|
+
// an `error` event and then closes; without this line a refused handshake looks exactly like
|
|
490
|
+
// a quiet call, with nothing in any log to say which field was refused.
|
|
491
|
+
if (type === "error") {
|
|
492
|
+
console.log(`[manifests] ${node.type}: vendor error ${JSON.stringify(event.error ?? event)}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
278
495
|
// Billed per RESPONSE, so a ten-turn call carries ten usage blocks and the collector sums
|
|
279
496
|
// them. Above the dispatch, because the frame carrying usage must not depend on which
|
|
280
497
|
// branch claims it. `sniffUsage` already reads `payload.response.usage` — no manifest change.
|
|
281
498
|
usage?.see(event);
|
|
282
499
|
|
|
500
|
+
/**
|
|
501
|
+
* THE MODEL HANGING UP, which nothing else can express.
|
|
502
|
+
*
|
|
503
|
+
* `control.endOn` is the LANE's list: a carrier's `stop` frame, a browser tab closing.
|
|
504
|
+
* Both mean the PERSON left. Nothing there can say the model is finished, and GPT-Live
|
|
505
|
+
* sends no end-of-call event of its own, so a call whose business was done simply stayed
|
|
506
|
+
* open: the assistant said goodbye, stopped talking, and the line was held until the idle
|
|
507
|
+
* timer fired minutes later (observed live 2026-09-22, and heard as silence).
|
|
508
|
+
*
|
|
509
|
+
* `control.endWhen` is that missing half: an expression over each vendor event, and when
|
|
510
|
+
* it is true the session shuts down exactly as a hang-up does. A manifest decides what
|
|
511
|
+
* counts — for a vendor with function calling it is a tool named `end_call`.
|
|
512
|
+
*
|
|
513
|
+
* ABSENT MEANS TODAY'S BEHAVIOUR. A node that declares nothing runs the same code it
|
|
514
|
+
* always did, which is why Realtime and Grok are untouched.
|
|
515
|
+
*/
|
|
516
|
+
if (audio.control?.endWhen && !closing) {
|
|
517
|
+
let done = false;
|
|
518
|
+
try {
|
|
519
|
+
done = !!(await evaluate(audio.control.endWhen, { ...ctx, response: event } as any));
|
|
520
|
+
} catch {
|
|
521
|
+
// A malformed test must not kill a live call: the only safe reading is "not finished".
|
|
522
|
+
}
|
|
523
|
+
if (done && !ending) {
|
|
524
|
+
// DECIDED, NOT DONE. `hangUpOnceQuiet` waits for the goodbye to finish going out;
|
|
525
|
+
// see its comment for why this is not a shutdown on the spot.
|
|
526
|
+
ending = true;
|
|
527
|
+
hangUpOnceQuiet();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
283
531
|
try {
|
|
284
532
|
// AUDIO, the one thing that bypasses an output connector, and only because MCP cannot
|
|
285
533
|
// carry it.
|
|
286
534
|
if (outSpec && type === outSpec.match) {
|
|
287
535
|
// SPEECH_STARTED, once per utterance. The client sets up playback on it, so audio sent
|
|
288
536
|
// before it is audio nobody hears.
|
|
537
|
+
// Still talking, so the hang-up waits: every frame pushes the settle back.
|
|
538
|
+
if (ending) hangUpOnceQuiet();
|
|
289
539
|
if (!speaking) {
|
|
290
540
|
speaking = true;
|
|
291
541
|
lane?.sendControl(conversationId, {
|
|
@@ -347,6 +597,48 @@ export async function runDuplexSession(opts: {
|
|
|
347
597
|
if (audio.control?.userStopped && type === audio.control.userStopped) {
|
|
348
598
|
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "USER_SPEECH_ENDED" });
|
|
349
599
|
}
|
|
600
|
+
// Work done off the call (a vendor's hand-off to a backend), for the call state's "thinking".
|
|
601
|
+
if (audio.control?.toolUse && type === audio.control.toolUse) {
|
|
602
|
+
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE" });
|
|
603
|
+
}
|
|
604
|
+
if (audio.control?.toolUseCompleted && type === audio.control.toolUseCompleted) {
|
|
605
|
+
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE_COMPLETED" });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* WHAT THE VENDOR ACTUALLY SENDS, on demand. Three fixes in a row were built on a guess
|
|
610
|
+
* about which event carries a tool call's id, and each one was wrong in a new way,
|
|
611
|
+
* because nothing here ever printed the truth. DUPLEX_TRACE=1 prints every inbound event
|
|
612
|
+
* type once, and prints in full any event carrying a call id or a delegation — the two
|
|
613
|
+
* things a reply has to be addressed to.
|
|
614
|
+
*/
|
|
615
|
+
// Remembered BEFORE the rows run, so a row firing on this event can read it too.
|
|
616
|
+
seen[type] = event;
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* AND UNDER WHATEVER NAME THE MANIFEST ASKED FOR. `seen[type]` keeps the latest event of
|
|
620
|
+
* each type, which loses one thing to another whenever a vendor gives two different
|
|
621
|
+
* things the same type: OpenAI ends a function call and the model's own message both as
|
|
622
|
+
* `response.output_item.done`, so the message overwrites the call and its `call_id`
|
|
623
|
+
* goes with it. A reply with no id to address is a model left waiting, heard on the call
|
|
624
|
+
* as silence (measured 2026-09-22).
|
|
625
|
+
*
|
|
626
|
+
* The manifest says which events matter to it, so the runtime keeps no vendor knowledge.
|
|
627
|
+
* A test that throws keeps the slot as it was: remembering nothing is recoverable,
|
|
628
|
+
* killing the session over a bad expression is not.
|
|
629
|
+
*/
|
|
630
|
+
for (const [name, test] of Object.entries(call.keep ?? {})) {
|
|
631
|
+
try {
|
|
632
|
+
if (await evaluate(test as string, { ...ctx, response: event } as any)) seen[name] = event;
|
|
633
|
+
} catch {
|
|
634
|
+
/* a malformed test must not take the call down */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (typeof event?.delta === "string" && event.delta) {
|
|
638
|
+
const last = said[said.length - 1];
|
|
639
|
+
if (last && last.type === type) last.text += event.delta;
|
|
640
|
+
else said.push({ type, text: event.delta });
|
|
641
|
+
}
|
|
350
642
|
|
|
351
643
|
// EVERYTHING ELSE, to the events table, exactly as an sse reply. This is the path that
|
|
352
644
|
// reaches the client over MCP streaming.
|
|
@@ -463,7 +755,7 @@ export async function runDuplexSession(opts: {
|
|
|
463
755
|
}
|
|
464
756
|
// The same timeline row an HTTP agent's tool call draws. `runToolLoop` has recorded
|
|
465
757
|
// these since the migration; the socket never called it, so voice tools were invisible.
|
|
466
|
-
recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk);
|
|
758
|
+
recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk, tools.kindOf?.(name) ?? "tool");
|
|
467
759
|
// After the catch, so a tool that THREW still stops the dots. Spinning forever is
|
|
468
760
|
// worse than no indicator: it promises something still coming.
|
|
469
761
|
lane?.sendControl(conversationId, { type: "AUDIO_STATE", state: "TOOL_USE_COMPLETED" });
|
|
@@ -486,9 +778,9 @@ export async function runDuplexSession(opts: {
|
|
|
486
778
|
// AWAITED, for the same reason the audio path is: `evaluate` is async, so `!promise` is
|
|
487
779
|
// always false and an un-awaited `when` never blocks anything. Every reactive send would
|
|
488
780
|
// fire regardless of its condition — silently, since firing too often looks like working.
|
|
489
|
-
if (rule.when && !(await evaluate(rule.when, { ...ctx, response: event } as any))) continue;
|
|
781
|
+
if (rule.when && !(await evaluate(rule.when, { ...ctx, response: event, seen, said } as any))) continue;
|
|
490
782
|
// A list, because one rule may need several messages (add the item, then ask for a reply).
|
|
491
|
-
for (const m of ([] as any[]).concat((await message(rule.message, ctx, { response: event })) ?? [])) {
|
|
783
|
+
for (const m of ([] as any[]).concat((await message(rule.message, ctx, { response: event, seen, said })) ?? [])) {
|
|
492
784
|
if (m) send(m);
|
|
493
785
|
}
|
|
494
786
|
}
|
|
@@ -503,7 +795,10 @@ export async function runDuplexSession(opts: {
|
|
|
503
795
|
finish({ reason: "error", error: err?.message ?? String(err) });
|
|
504
796
|
});
|
|
505
797
|
|
|
506
|
-
ws.on("close", () => {
|
|
798
|
+
ws.on("close", (code, reason) => {
|
|
799
|
+
// The vendor hanging up on its own is the other half of a refusal: its close code and
|
|
800
|
+
// reason are often the only explanation it gives.
|
|
801
|
+
if (!closing) console.log(`[manifests] ${node.type}: vendor closed the session (${code}) ${String(reason || "")}`);
|
|
507
802
|
buffer?.flush();
|
|
508
803
|
finish({ reason: closing ? "ended" : "closed" });
|
|
509
804
|
});
|