@memberjunction/realtime-runtime 6.2.0-edge.3 → 6.2.0-edge.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/channels/base-realtime-channel-client.d.ts +535 -28
- package/dist/channels/base-realtime-channel-client.d.ts.map +1 -1
- package/dist/channels/base-realtime-channel-client.js +864 -10
- package/dist/channels/base-realtime-channel-client.js.map +1 -1
- package/dist/channels/channel-action-dispatcher.d.ts +70 -0
- package/dist/channels/channel-action-dispatcher.d.ts.map +1 -0
- package/dist/channels/channel-action-dispatcher.js +136 -0
- package/dist/channels/channel-action-dispatcher.js.map +1 -0
- package/dist/channels/channel-catalog-note.d.ts +48 -0
- package/dist/channels/channel-catalog-note.d.ts.map +1 -0
- package/dist/channels/channel-catalog-note.js +119 -0
- package/dist/channels/channel-catalog-note.js.map +1 -0
- package/dist/channels/channel-contract-types.d.ts +107 -0
- package/dist/channels/channel-contract-types.d.ts.map +1 -0
- package/dist/channels/channel-contract-types.js +12 -0
- package/dist/channels/channel-contract-types.js.map +1 -0
- package/dist/channels/channel-descriptor-synthesis.d.ts +56 -0
- package/dist/channels/channel-descriptor-synthesis.d.ts.map +1 -0
- package/dist/channels/channel-descriptor-synthesis.js +90 -0
- package/dist/channels/channel-descriptor-synthesis.js.map +1 -0
- package/dist/channels/channel-perception.d.ts +82 -0
- package/dist/channels/channel-perception.d.ts.map +1 -0
- package/dist/channels/channel-perception.js +120 -0
- package/dist/channels/channel-perception.js.map +1 -0
- package/dist/channels/channel-schema-format.d.ts +17 -0
- package/dist/channels/channel-schema-format.d.ts.map +1 -0
- package/dist/channels/channel-schema-format.js +45 -0
- package/dist/channels/channel-schema-format.js.map +1 -0
- package/dist/channels/channel-state-delta.d.ts +59 -0
- package/dist/channels/channel-state-delta.d.ts.map +1 -0
- package/dist/channels/channel-state-delta.js +116 -0
- package/dist/channels/channel-state-delta.js.map +1 -0
- package/dist/channels/channel-surface-placement.d.ts +25 -0
- package/dist/channels/channel-surface-placement.d.ts.map +1 -0
- package/dist/channels/channel-surface-placement.js +29 -0
- package/dist/channels/channel-surface-placement.js.map +1 -0
- package/dist/channels/channel-visual-pump.d.ts +126 -0
- package/dist/channels/channel-visual-pump.d.ts.map +1 -0
- package/dist/channels/channel-visual-pump.js +174 -0
- package/dist/channels/channel-visual-pump.js.map +1 -0
- package/dist/hosts/IRealtimeMediaHost.d.ts +86 -34
- package/dist/hosts/IRealtimeMediaHost.d.ts.map +1 -1
- package/dist/hosts/IRealtimeMediaHost.js +2 -1
- package/dist/hosts/IRealtimeMediaHost.js.map +1 -1
- package/dist/index.d.ts +26 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/session/RealtimeSessionRuntime.d.ts +984 -111
- package/dist/session/RealtimeSessionRuntime.d.ts.map +1 -1
- package/dist/session/RealtimeSessionRuntime.js +2396 -381
- package/dist/session/RealtimeSessionRuntime.js.map +1 -1
- package/dist/session/RecordingShardDrain.d.ts +59 -0
- package/dist/session/RecordingShardDrain.d.ts.map +1 -0
- package/dist/session/RecordingShardDrain.js +135 -0
- package/dist/session/RecordingShardDrain.js.map +1 -0
- package/dist/session/RecordingShardQueue.d.ts +94 -0
- package/dist/session/RecordingShardQueue.d.ts.map +1 -0
- package/dist/session/RecordingShardQueue.js +176 -0
- package/dist/session/RecordingShardQueue.js.map +1 -0
- package/dist/session/RecordingShardStore.d.ts +57 -0
- package/dist/session/RecordingShardStore.d.ts.map +1 -0
- package/dist/session/RecordingShardStore.js +184 -0
- package/dist/session/RecordingShardStore.js.map +1 -0
- package/dist/session/RecordingShardWindow.d.ts +42 -0
- package/dist/session/RecordingShardWindow.d.ts.map +1 -0
- package/dist/session/RecordingShardWindow.js +88 -0
- package/dist/session/RecordingShardWindow.js.map +1 -0
- package/dist/session/RecordingTransport.d.ts +66 -0
- package/dist/session/RecordingTransport.d.ts.map +1 -0
- package/dist/session/RecordingTransport.js +61 -0
- package/dist/session/RecordingTransport.js.map +1 -0
- package/dist/session/app-client-tool-registry.d.ts +76 -0
- package/dist/session/app-client-tool-registry.d.ts.map +1 -0
- package/dist/session/app-client-tool-registry.js +100 -0
- package/dist/session/app-client-tool-registry.js.map +1 -0
- package/dist/session/avatar-notice.d.ts +43 -0
- package/dist/session/avatar-notice.d.ts.map +1 -0
- package/dist/session/avatar-notice.js +60 -0
- package/dist/session/avatar-notice.js.map +1 -0
- package/dist/session/call-end.d.ts +21 -0
- package/dist/session/call-end.d.ts.map +1 -0
- package/dist/session/call-end.js +19 -0
- package/dist/session/call-end.js.map +1 -0
- package/dist/session/channel-exposure-preferences.d.ts +70 -0
- package/dist/session/channel-exposure-preferences.d.ts.map +1 -0
- package/dist/session/channel-exposure-preferences.js +138 -0
- package/dist/session/channel-exposure-preferences.js.map +1 -0
- package/dist/session/channel-session-scope.d.ts +142 -0
- package/dist/session/channel-session-scope.d.ts.map +1 -0
- package/dist/session/channel-session-scope.js +128 -0
- package/dist/session/channel-session-scope.js.map +1 -0
- package/dist/session/client-session-deadline.d.ts +28 -0
- package/dist/session/client-session-deadline.d.ts.map +1 -0
- package/dist/session/client-session-deadline.js +50 -0
- package/dist/session/client-session-deadline.js.map +1 -0
- package/dist/session/realtime-captures.d.ts +236 -0
- package/dist/session/realtime-captures.d.ts.map +1 -0
- package/dist/session/realtime-captures.js +383 -0
- package/dist/session/realtime-captures.js.map +1 -0
- package/dist/session/realtime-microphone.d.ts +50 -0
- package/dist/session/realtime-microphone.d.ts.map +1 -0
- package/dist/session/realtime-microphone.js +51 -0
- package/dist/session/realtime-microphone.js.map +1 -0
- package/dist/session/session-event-hub.d.ts +72 -0
- package/dist/session/session-event-hub.d.ts.map +1 -0
- package/dist/session/session-event-hub.js +151 -0
- package/dist/session/session-event-hub.js.map +1 -0
- package/dist/session/session-launcher.d.ts +128 -0
- package/dist/session/session-launcher.d.ts.map +1 -0
- package/dist/session/session-launcher.js +160 -0
- package/dist/session/session-launcher.js.map +1 -0
- package/package.json +10 -9
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import { Subject } from 'rxjs';
|
|
2
|
+
import { IsPlainObject } from '@memberjunction/global';
|
|
3
|
+
import { CHANNEL_INBOUND_VIDEO_TRACK } from '@memberjunction/ai';
|
|
4
|
+
import { ChannelInboundVideoBridge } from '@memberjunction/ai-realtime-client';
|
|
5
|
+
import { CompareExposure, DescribeExposureLimit, DescribeWithheldVerb, IsVerbWithheld, MinExposure, WithheldVerbs, ValidateJsonAgainstSchemaSubset, } from '@memberjunction/ai-core-plus';
|
|
6
|
+
import { SynthesizeChannelDescriptor } from './channel-descriptor-synthesis.js';
|
|
7
|
+
import { ChannelPerceptionCoalescer, DEFAULT_CHANNEL_PERCEPTION_OPTIONS } from './channel-perception.js';
|
|
8
|
+
import { FormatChannelNote } from './channel-state-delta.js';
|
|
9
|
+
import { VisualPerceptionPump } from './channel-visual-pump.js';
|
|
10
|
+
import { DEFAULT_CHANNEL_SURFACE_PLACEMENT } from './channel-surface-placement.js';
|
|
1
11
|
/**
|
|
2
12
|
* Base class for CLIENT-SIDE interactive-channel plugins (per
|
|
3
13
|
* `plans/ai-agent-sessions.md` → "Interactive Channels" / "Pluggable Channel Interfaces").
|
|
@@ -32,10 +42,13 @@
|
|
|
32
42
|
*
|
|
33
43
|
* ### Lifecycle — ONE INSTANCE PER SESSION (not a singleton)
|
|
34
44
|
* `ClassFactory.CreateInstance` → {@link Initialize}(ctx) → zero or more
|
|
35
|
-
* {@link BindSurface}/{@link UnbindSurface} cycles (the
|
|
36
|
-
*
|
|
45
|
+
* {@link BindSurface}/{@link UnbindSurface} cycles (the Angular overlay creates the surface the
|
|
46
|
+
* first time it is shown and keeps it until the channel leaves the session; another host may
|
|
47
|
+
* recreate it), each telling the plugin when its surface goes in and out of sight
|
|
48
|
+
* ({@link OnSurfaceVisibilityChange}) and where it is placed ({@link OnSurfacePlacementChange})
|
|
49
|
+
* → {@link Dispose} at teardown.
|
|
37
50
|
* {@link ApplyAgentTool} MUST work with NO surface bound (apply to the state engine
|
|
38
|
-
* directly; skip the UI garnish) — tool calls can arrive
|
|
51
|
+
* directly; skip the UI garnish) — tool calls can arrive before the surface is first shown.
|
|
39
52
|
*
|
|
40
53
|
* @typeParam TSurface The plugin's Angular surface component type. The host only ever
|
|
41
54
|
* sees the default (`object`) — the typed parameter exists so concrete plugins get a
|
|
@@ -48,6 +61,7 @@ export class BaseRealtimeChannelClient {
|
|
|
48
61
|
* `null` outside that window — guard with `?.` in any code that can run early/late.
|
|
49
62
|
*/
|
|
50
63
|
this.Context = null;
|
|
64
|
+
this.surfacePlacement = DEFAULT_CHANNEL_SURFACE_PLACEMENT;
|
|
51
65
|
/**
|
|
52
66
|
* Max time {@link ResolveAgentSessionId} waits for the session id to bind before giving up, and the
|
|
53
67
|
* poll interval it re-checks on. Protected so tests can shrink the wait; production keeps the
|
|
@@ -55,6 +69,62 @@ export class BaseRealtimeChannelClient {
|
|
|
55
69
|
*/
|
|
56
70
|
this.SessionIdWaitTimeoutMs = 8000;
|
|
57
71
|
this.SessionIdWaitIntervalMs = 200;
|
|
72
|
+
this.channelEventsSubject = new Subject();
|
|
73
|
+
this.channelOutputSubject = new Subject();
|
|
74
|
+
/**
|
|
75
|
+
* Everything this channel streams out, as typed events: state changes, discrete user actions, frames
|
|
76
|
+
* pushed, opened/completed. Hosts subscribe (the embeddable element re-emits these as DOM events);
|
|
77
|
+
* the model is told about a change through a coalesced note instead — see {@link RecordChange}.
|
|
78
|
+
* Completes at {@link Dispose}.
|
|
79
|
+
*/
|
|
80
|
+
this.Events$ = this.channelEventsSubject.asObservable();
|
|
81
|
+
/** What the channel handed back when it completed (see {@link Complete}). Completes at {@link Dispose}. */
|
|
82
|
+
this.Output$ = this.channelOutputSubject.asObservable();
|
|
83
|
+
// ── Contract v2: change tracking + structured perception ───────────────────
|
|
84
|
+
/** Monotonic id of the latest state change; tags state events and the frames that describe them. */
|
|
85
|
+
this.channelChangeSeq = 0;
|
|
86
|
+
/** Re-entrancy guard for the default {@link ApplyVerb} ↔ {@link ApplyAgentTool} bridge. */
|
|
87
|
+
this.bridgingVerbAndNativeTool = false;
|
|
88
|
+
this.channelPerception = null;
|
|
89
|
+
/** Dev-check messages already logged, so a drifting state warns once, not on every note. */
|
|
90
|
+
this.warnedStateIssueKeys = new Set();
|
|
91
|
+
/** Tuning for the perception coalescer (debounce window, max note size). Override to change it. */
|
|
92
|
+
this.PerceptionOptions = DEFAULT_CHANNEL_PERCEPTION_OPTIONS;
|
|
93
|
+
/** Why exposure is below the channel's ceiling, as sentences the agent can be told. */
|
|
94
|
+
this.exposureReasons = [];
|
|
95
|
+
// ── Contract v2: visual perception (the change-driven frame pump) ──────────
|
|
96
|
+
/** The frame source the visual pump captures from, once {@link EnableVisualPerception} ran. */
|
|
97
|
+
this.visualFrameProvider = null;
|
|
98
|
+
this.visualPerceptionOptions = {};
|
|
99
|
+
this.visualFramePump = null;
|
|
100
|
+
/**
|
|
101
|
+
* The shared frame bridge that carries frames to the model's inbound video track. Deliberately NOT named
|
|
102
|
+
* `videoBridge`: channels that predate the pump (the Remote Browser, app channels) declare their own
|
|
103
|
+
* private `videoBridge`, and a second private of the same name in the base class would stop them compiling.
|
|
104
|
+
*/
|
|
105
|
+
this.VisualVideoBridge = null;
|
|
106
|
+
/** Stops refreshing the model's picture when the bridge's source is sent again; set while the bridge exists. */
|
|
107
|
+
this.stopRefreshOnSourceActivated = null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The shared name prefix of every tool this channel exposes (e.g. `'Whiteboard_'`).
|
|
111
|
+
* The host registers ONE local-execution route per plugin: tool calls whose name starts
|
|
112
|
+
* with this prefix go to {@link ApplyAgentTool} instead of the server relay.
|
|
113
|
+
*
|
|
114
|
+
* Default `''` — a v2 channel with NO native tools (it is reached only through the `ContextTool`
|
|
115
|
+
* proxy). The host registers no route for an empty prefix: an empty prefix would match *every*
|
|
116
|
+
* tool name and hijack the server relay.
|
|
117
|
+
*/
|
|
118
|
+
get ToolNamePrefix() {
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
/** Label for the channel's tab on the overlay's surface panel (e.g. `'Whiteboard'`). Defaults to {@link ChannelName}. */
|
|
122
|
+
get TabTitle() {
|
|
123
|
+
return this.ChannelName;
|
|
124
|
+
}
|
|
125
|
+
/** Font Awesome icon class for the channel's tab (e.g. `'fa-solid fa-chalkboard'`). Defaults to a generic puzzle-piece. */
|
|
126
|
+
get TabIcon() {
|
|
127
|
+
return 'fa-solid fa-puzzle-piece';
|
|
58
128
|
}
|
|
59
129
|
/**
|
|
60
130
|
* OPTIONAL accent color for the channel's tab (a CSS color string, e.g. an `hsl()` /
|
|
@@ -66,6 +136,45 @@ export class BaseRealtimeChannelClient {
|
|
|
66
136
|
get TabColor() {
|
|
67
137
|
return null;
|
|
68
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The channel's CLIENT-EXECUTED tool declarations, aggregated by the session service
|
|
141
|
+
* into the `clientTools` set declared to the realtime model at session mint. The server
|
|
142
|
+
* only DECLARES these — execution stays in the browser via {@link ApplyAgentTool}.
|
|
143
|
+
*
|
|
144
|
+
* Default `[]` — a v2 channel whose verbs are reached through the proxy only.
|
|
145
|
+
*/
|
|
146
|
+
GetToolDefinitions() {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Executes ONE agent tool call locally (the ACTION direction) and returns the result
|
|
151
|
+
* JSON string fed back to the model as the `tool_response`. Called for every tool whose
|
|
152
|
+
* name starts with {@link ToolNamePrefix}. Must work both WITH a bound surface (apply +
|
|
153
|
+
* UI garnish) and WITHOUT one (apply to the state engine directly — the surface may not
|
|
154
|
+
* exist yet, e.g. it has not been shown). Should not throw: return a
|
|
155
|
+
* `{ success: false, error }` payload so the model can narrate the failure (the host
|
|
156
|
+
* additionally wraps anything thrown).
|
|
157
|
+
*
|
|
158
|
+
* **This is the LEGACY entry point.** Channels written against the v2 contract override
|
|
159
|
+
* {@link ApplyVerb} instead and inherit this method: the default maps the tool name back to its verb,
|
|
160
|
+
* calls {@link ApplyVerb} as the agent, and serializes the structured result. Channels written
|
|
161
|
+
* against v1 override THIS and inherit {@link ApplyVerb}, which adapts it. A channel must override
|
|
162
|
+
* at least one of the two — overriding neither is reported as a failure result, never a recursion.
|
|
163
|
+
*/
|
|
164
|
+
ApplyAgentTool(toolName, argsJson) {
|
|
165
|
+
if (this.bridgingVerbAndNativeTool) {
|
|
166
|
+
return JSON.stringify({ success: false, error: this.implementsNeitherMessage() });
|
|
167
|
+
}
|
|
168
|
+
this.bridgingVerbAndNativeTool = true;
|
|
169
|
+
let result;
|
|
170
|
+
try {
|
|
171
|
+
result = this.ApplyVerb(this.ResolveVerbForTool(toolName), parseToolArguments(argsJson), 'agent');
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
this.bridgingVerbAndNativeTool = false;
|
|
175
|
+
}
|
|
176
|
+
return result instanceof Promise ? result.then(serializeVerbResult) : serializeVerbResult(result);
|
|
177
|
+
}
|
|
69
178
|
/**
|
|
70
179
|
* The Angular component the overlay creates dynamically as this channel's tab pane, or `null`
|
|
71
180
|
* for a **server-only** channel that renders no MJ surface (its surface, if any, lives on the
|
|
@@ -107,14 +216,77 @@ export class BaseRealtimeChannelClient {
|
|
|
107
216
|
return null;
|
|
108
217
|
}
|
|
109
218
|
/**
|
|
110
|
-
* Called by the host
|
|
111
|
-
*
|
|
219
|
+
* Called by the host right after it created the surface component (and BEFORE the
|
|
220
|
+
* component's first change detection, so inputs set here are visible in its `ngOnInit`).
|
|
221
|
+
* The plugin — which knows its own component type — sets inputs (state engine, agent
|
|
222
|
+
* name, …) and subscribes outputs here, wiring perception/garnish flows back through
|
|
223
|
+
* {@link Context}. May be called again with a NEW instance after an
|
|
224
|
+
* {@link UnbindSurface} (the host recreated the surface).
|
|
225
|
+
*/
|
|
226
|
+
BindSurface(_instance) {
|
|
227
|
+
// default: a channel with no surface has nothing to bind
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Called by the host when the surface component is being destroyed (the channel left
|
|
231
|
+
* the session / the overlay was torn down). Drop the instance reference and unsubscribe any
|
|
112
232
|
* output subscriptions — after this, {@link ApplyAgentTool} runs in its no-surface
|
|
113
233
|
* mode. Default: no-op.
|
|
114
234
|
*/
|
|
115
235
|
UnbindSurface() {
|
|
116
236
|
// default: nothing to release
|
|
117
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Called by the host when the bound surface comes into sight or goes out of it (the panel collapsed or hidden,
|
|
240
|
+
* another tab active, the call minimized). The surface stays bound and keeps its state; a channel can pause work
|
|
241
|
+
* nobody sees, such as a screenshot poll, and resume it when the surface is shown again. The host calls it right
|
|
242
|
+
* after {@link BindSurface} with the surface's current visibility, then on every change, and not after
|
|
243
|
+
* {@link UnbindSurface}. Default: no-op, so a surface keeps running out of sight.
|
|
244
|
+
*/
|
|
245
|
+
OnSurfaceVisibilityChange(_visible) {
|
|
246
|
+
// default: nothing to pause
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Called by the host when the bound surface moves: to the stage (it fills the call), to its tab, or out of sight
|
|
250
|
+
* because the user hid it. A channel can adapt its own chrome, such as a "Move to stage" button that has nothing to
|
|
251
|
+
* do once the surface is on the stage. Called right after {@link BindSurface} with the current placement, then on
|
|
252
|
+
* every move, and not after {@link UnbindSurface}. Default: no-op.
|
|
253
|
+
*/
|
|
254
|
+
OnSurfacePlacementChange(_placement) {
|
|
255
|
+
// default: a surface looks the same wherever it is placed
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Where this channel's surface shows when a call starts, and where the user may move it: from the channel's registry
|
|
259
|
+
* row (`UIConfig.Placement` and `UIConfig.AllowedPlacements`). A channel without a row, or whose row says nothing,
|
|
260
|
+
* starts on its tab and can go anywhere.
|
|
261
|
+
*/
|
|
262
|
+
get SurfacePlacement() {
|
|
263
|
+
return this.surfacePlacement;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Sets {@link SurfacePlacement}. The runtime calls it with the registry row's placement when it builds the channel, before
|
|
267
|
+
* the channel mounts, as it applies exposure policy with {@link ApplyExposure}.
|
|
268
|
+
*/
|
|
269
|
+
ApplySurfacePlacement(placement) {
|
|
270
|
+
this.surfacePlacement = placement;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Whether the user may share this channel's surface on its own: a host that offers it lists the surface, named by
|
|
274
|
+
* {@link TabTitle} with {@link TabIcon}, under "This panel" in its Share menu while the surface is on screen, and the
|
|
275
|
+
* share then shows only that surface. Default `false`, so a channel opts in with this getter. A surface that shows the
|
|
276
|
+
* agent its own call (the camera, the screen share, the agent's video) never should.
|
|
277
|
+
*/
|
|
278
|
+
get SurfaceShareable() {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The runtime capture this channel fronts (`'camera'` or `'screen'`), or `null`. Such a channel is the capture's policy:
|
|
283
|
+
* the runtime starts the capture only while the channel is in the session and the session's policy lets the agent see
|
|
284
|
+
* pixels through it, and the user's "agent can see" choice for the channel decides whether the capture's frames reach
|
|
285
|
+
* the model. Default `null`.
|
|
286
|
+
*/
|
|
287
|
+
get CaptureKind() {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
118
290
|
/**
|
|
119
291
|
* Binds the host context and invokes the {@link OnInitialize} hook. Called exactly once
|
|
120
292
|
* per session, right after ClassFactory instantiation and before any tool call or
|
|
@@ -134,12 +306,37 @@ export class BaseRealtimeChannelClient {
|
|
|
134
306
|
}
|
|
135
307
|
/**
|
|
136
308
|
* Subclass hook invoked once the realtime session is connected and live (the client driver
|
|
137
|
-
* is created, connected, and media tracks negotiated)
|
|
138
|
-
* (e.g. video streaming) can start them here when
|
|
139
|
-
*
|
|
309
|
+
* is created, connected, and media tracks negotiated), or when the channel is opened in a live
|
|
310
|
+
* session. Channels that establish media bridges (e.g. video streaming) can start them here when
|
|
311
|
+
* `Context.Client` is available.
|
|
312
|
+
* Default: a channel with visual perception adds the inbound video track it sources when the call
|
|
313
|
+
* has none (it was opened mid-call) and brings its frame bridge up; any other channel does nothing.
|
|
314
|
+
* An override that keeps visual perception calls `super.OnSessionStarted()`.
|
|
140
315
|
*/
|
|
141
316
|
OnSessionStarted() {
|
|
142
|
-
//
|
|
317
|
+
// A channel with visual perception brings its frame bridge up now: the video track is only
|
|
318
|
+
// negotiated once the session connects, so this is the first moment it can be established.
|
|
319
|
+
if (this.visualFrameProvider) {
|
|
320
|
+
this.addVisualTrackMidCall();
|
|
321
|
+
this.EnsureVideoBridge();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Adds the inbound video track this channel sources ({@link GetSourcedTracks}) when the call is live without one. A
|
|
326
|
+
* channel open when the call connected had its track negotiated then, so this does nothing for it. A channel opened
|
|
327
|
+
* mid-call (`on-demand`) was not part of that negotiation: without its own track it could only send on a track a camera
|
|
328
|
+
* or screen share added, which goes when that capture stops. Only on a model that takes inbound video.
|
|
329
|
+
*/
|
|
330
|
+
addVisualTrackMidCall() {
|
|
331
|
+
const client = this.Context?.Client;
|
|
332
|
+
if (!client?.SupportsInboundVideo || client.IsTrackEstablished('video', 'inbound')) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
for (const track of this.GetSourcedTracks()) {
|
|
336
|
+
if (track.Direction === 'inbound' && String(track.Modality).trim().toLowerCase() === 'video') {
|
|
337
|
+
client.AddTrack(track);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
143
340
|
}
|
|
144
341
|
/**
|
|
145
342
|
* Resolves the live {@link RealtimeChannelContext.AgentSessionID}, briefly WAITING for it when it
|
|
@@ -210,15 +407,600 @@ export class BaseRealtimeChannelClient {
|
|
|
210
407
|
* Default `[]`.
|
|
211
408
|
*/
|
|
212
409
|
GetSourcedTracks() {
|
|
213
|
-
|
|
410
|
+
// A channel that enabled visual perception sources the inbound video track the pump writes to — unless the
|
|
411
|
+
// agent's policy has already ruled pixels out (the user's own toggle does not: a user who turns the agent's
|
|
412
|
+
// view back on mid-call needs the track to exist). Not requesting video is also what keeps a session out of
|
|
413
|
+
// the shorter session limits video carries.
|
|
414
|
+
return this.visualFrameProvider && this.policyAllowsPixels() ? [CHANNEL_INBOUND_VIDEO_TRACK] : [];
|
|
214
415
|
}
|
|
215
416
|
/**
|
|
216
417
|
* Media tracks this client channel can SINK — samples flowing from the model OUT.
|
|
217
418
|
* Default `[]`.
|
|
419
|
+
*
|
|
420
|
+
* The session requests them when it connects, so the driver establishes the ones the model supports. A channel that
|
|
421
|
+
* sinks outbound video shows the agent's video: it follows {@link RealtimeChannelContext.AgentVideo$}, and counts as
|
|
422
|
+
* used once the video arrives, so the host shows its surface.
|
|
218
423
|
*/
|
|
219
424
|
GetSunkTracks() {
|
|
220
425
|
return [];
|
|
221
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* Whether this channel shows the agent's video: it sinks outbound video ({@link GetSunkTracks}). The runtime marks it
|
|
429
|
+
* as used when the video arrives, and a host presents its surface as the agent (the call overlay puts it in the agent's
|
|
430
|
+
* place in the call rather than in the focus layout).
|
|
431
|
+
*/
|
|
432
|
+
get ShowsAgentVideo() {
|
|
433
|
+
return this.GetSunkTracks().some((t) => t.Direction === 'outbound' && String(t.Modality).trim().toLowerCase() === 'video');
|
|
434
|
+
}
|
|
435
|
+
// ── Contract v2: descriptor, state, verbs, events, open/complete ───────────
|
|
436
|
+
/**
|
|
437
|
+
* The channel's stable primary-instance id. A single-instance channel has exactly one; a
|
|
438
|
+
* {@link RealtimeChannelDescriptor.MultiInstance} channel owns further instance ids itself and
|
|
439
|
+
* routes by the `instanceId` argument of {@link ApplyVerb}. Appears in every structured note
|
|
440
|
+
* (`[channel:<Key>#<instance>] …`) and every {@link RealtimeChannelEvent}.
|
|
441
|
+
*/
|
|
442
|
+
get InstanceId() {
|
|
443
|
+
return PRIMARY_CHANNEL_INSTANCE_ID;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The channel's SELF-DESCRIPTION — what it is, what it holds, what it accepts, how it behaves in a
|
|
447
|
+
* session. This is the contract an agent reads to operate a channel it has never seen, and what the
|
|
448
|
+
* runtime reads to scope, advertise and validate. Pure data; called often, so keep it cheap.
|
|
449
|
+
*
|
|
450
|
+
* Default: a descriptor SYNTHESIZED from the v1 members (tools → verbs, intro → instructions),
|
|
451
|
+
* stamped with the legacy contract version — so a channel that implements nothing new is still
|
|
452
|
+
* describable and works exactly as before. A v2 channel overrides this with an authored descriptor.
|
|
453
|
+
* (Build verbs from {@link GetToolDefinitions} with `BuildToolBackedVerbs` when they are the
|
|
454
|
+
* native tools, so a subclass that adds a tool does not leave the descriptor lying.)
|
|
455
|
+
*/
|
|
456
|
+
GetDescriptor() {
|
|
457
|
+
return SynthesizeChannelDescriptor(this);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* A snapshot of the channel's things (the descriptor's nouns), keyed by noun name. Perception notes
|
|
461
|
+
* are computed as deltas between successive snapshots, so this must be a pure read.
|
|
462
|
+
*
|
|
463
|
+
* Default: the parsed {@link SerializeState} when that is a JSON object, else `{}` — which makes a
|
|
464
|
+
* v1 channel's state of record its (single, opaque) snapshot with no code change.
|
|
465
|
+
*/
|
|
466
|
+
GetState() {
|
|
467
|
+
const serialized = this.SerializeState();
|
|
468
|
+
if (serialized === null) {
|
|
469
|
+
return {};
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const parsed = JSON.parse(serialized);
|
|
473
|
+
// JSON.parse output is JSON by construction, so narrowing it to JSONObject is not a type lie.
|
|
474
|
+
return IsPlainObject(parsed) ? parsed : { state: parsed };
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
// A channel whose state of record is not JSON has no structured snapshot to offer; say so once
|
|
478
|
+
// (this runs on every perception note) rather than silently presenting it as empty.
|
|
479
|
+
this.warnStateIssueOnce('unparseable-serialized-state', `SerializeState() did not return JSON, so GetState() reports an empty snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
|
480
|
+
return {};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Checks the current {@link GetState} snapshot against the descriptor's noun schemas and returns
|
|
485
|
+
* every violation (empty when valid). Useful in a channel's own tests; the runtime also runs it on
|
|
486
|
+
* every perception note when {@link StrictStateChecks} is on.
|
|
487
|
+
*/
|
|
488
|
+
ValidateState() {
|
|
489
|
+
const state = this.GetState();
|
|
490
|
+
const issues = [];
|
|
491
|
+
for (const noun of this.GetDescriptor().Nouns) {
|
|
492
|
+
if (noun.Name in state) {
|
|
493
|
+
issues.push(...ValidateJsonAgainstSchemaSubset(state[noun.Name], noun.Schema, `$.${noun.Name}`));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return issues;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Whether state snapshots are validated against the descriptor's noun schemas as notes are built —
|
|
500
|
+
* a development aid that surfaces a channel whose state drifted from its own declaration. On by
|
|
501
|
+
* default only when a Node-style environment reports a non-production `NODE_ENV` (tests, tooling);
|
|
502
|
+
* a browser production build never pays for it. Hosts may set it explicitly.
|
|
503
|
+
*/
|
|
504
|
+
static { this.StrictStateChecks = isDevelopmentEnvironment(); }
|
|
505
|
+
/**
|
|
506
|
+
* Runs one verb of this channel — the contract-v2 entry point, reached through the `ContextTool`
|
|
507
|
+
* proxy (and, via {@link ApplyAgentTool}, by native tool calls).
|
|
508
|
+
*
|
|
509
|
+
* The runtime has ALREADY resolved the verb against the descriptor, enforced `InvokableBy`, and
|
|
510
|
+
* validated `args` against the verb's parameter schema before calling this, so an override can
|
|
511
|
+
* trust its inputs' shape. It must work with no surface bound, and should not throw — return a
|
|
512
|
+
* failure result so the model can recover.
|
|
513
|
+
*
|
|
514
|
+
* Default: adapts the legacy {@link ApplyAgentTool} (the verb's native tool name, args as JSON),
|
|
515
|
+
* so every v1 channel is reachable through the proxy with no change. A v2 channel overrides this.
|
|
516
|
+
*
|
|
517
|
+
* @param verb The verb name (as declared in the descriptor).
|
|
518
|
+
* @param args The validated parameters.
|
|
519
|
+
* @param actor Who is acting: the agent (a model call) or the user (their own interaction).
|
|
520
|
+
* @param instanceId Which instance of a multi-instance channel; `undefined` for the primary instance.
|
|
521
|
+
*/
|
|
522
|
+
ApplyVerb(verb, args, actor, instanceId) {
|
|
523
|
+
if (this.bridgingVerbAndNativeTool) {
|
|
524
|
+
return { Success: false, ErrorCode: 'verb_failed', Error: this.implementsNeitherMessage() };
|
|
525
|
+
}
|
|
526
|
+
this.bridgingVerbAndNativeTool = true;
|
|
527
|
+
let outcome;
|
|
528
|
+
try {
|
|
529
|
+
outcome = this.ApplyAgentTool(this.ResolveNativeToolName(verb), JSON.stringify(args));
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
this.bridgingVerbAndNativeTool = false;
|
|
533
|
+
}
|
|
534
|
+
return outcome instanceof Promise ? outcome.then(parseLegacyVerbResult) : parseLegacyVerbResult(outcome);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Maps a native tool name back to its verb: the descriptor's `NativeToolName` match first, then the
|
|
538
|
+
* name minus {@link ToolNamePrefix}. Protected so a channel with an unusual naming scheme can
|
|
539
|
+
* override it.
|
|
540
|
+
*/
|
|
541
|
+
ResolveVerbForTool(toolName) {
|
|
542
|
+
const declared = this.GetDescriptor().Verbs.find((v) => v.NativeToolName === toolName);
|
|
543
|
+
if (declared) {
|
|
544
|
+
return declared.Name;
|
|
545
|
+
}
|
|
546
|
+
const prefix = this.ToolNamePrefix;
|
|
547
|
+
return prefix.length > 0 && toolName.length > prefix.length && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
|
|
548
|
+
}
|
|
549
|
+
/** Maps a verb to the native tool name it is reachable as (inverse of {@link ResolveVerbForTool}). */
|
|
550
|
+
ResolveNativeToolName(verb) {
|
|
551
|
+
const wanted = verb.trim().toLowerCase();
|
|
552
|
+
const declared = this.GetDescriptor().Verbs.find((v) => v.Name.toLowerCase() === wanted);
|
|
553
|
+
if (declared?.NativeToolName) {
|
|
554
|
+
return declared.NativeToolName;
|
|
555
|
+
}
|
|
556
|
+
const prefix = this.ToolNamePrefix;
|
|
557
|
+
return prefix.length > 0 && !verb.startsWith(prefix) ? `${prefix}${verb}` : verb;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The descriptor verb a native tool call runs, or `undefined` when the tool is not one of the channel's verbs
|
|
561
|
+
* (the runtime uses it to apply {@link RefuseVerbForExposure} on the native-tool route as well as the `ContextTool` one).
|
|
562
|
+
*
|
|
563
|
+
* @param toolName The native tool name the model called.
|
|
564
|
+
*/
|
|
565
|
+
FindVerbForNativeTool(toolName) {
|
|
566
|
+
const wanted = this.ResolveVerbForTool(toolName).toLowerCase();
|
|
567
|
+
return this.GetDescriptor().Verbs.find((v) => v.Name.toLowerCase() === wanted);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Whether the agent may be given this verb's result at the channel's current exposure. A verb that declares
|
|
571
|
+
* `ReturnsChannelData` is refused whole when exposure is below that level, because its result would hand the agent
|
|
572
|
+
* what the user or a zero-data-retention policy held back from it. Policy, not redaction: the result is never filtered.
|
|
573
|
+
*
|
|
574
|
+
* Applies to the AGENT (the dispatcher and the native-tool route call it); a user acting through the surface is not restricted.
|
|
575
|
+
*
|
|
576
|
+
* @param verb The verb the agent is calling.
|
|
577
|
+
* @returns The sentence to give the agent, or `null` when the call may proceed.
|
|
578
|
+
*/
|
|
579
|
+
RefuseVerbForExposure(verb) {
|
|
580
|
+
const effective = this.Exposure;
|
|
581
|
+
return IsVerbWithheld(verb, effective) ? DescribeWithheldVerb(verb.Name, this.GetDescriptor().DisplayName, effective, this.exposureReasons) : null;
|
|
582
|
+
}
|
|
583
|
+
/** Why a channel that overrides neither entry point cannot run anything. */
|
|
584
|
+
implementsNeitherMessage() {
|
|
585
|
+
return `Channel '${this.ChannelName}' implements neither ApplyVerb nor ApplyAgentTool.`;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Opens the channel — mounts it for use and seeds it with `inputs`. Called by the runtime when the
|
|
589
|
+
* agent (or user) opens an `on-demand` channel, after the channel has been initialized.
|
|
590
|
+
*
|
|
591
|
+
* Validates `inputs` against the descriptor's `Inputs` schema (a violation is a structured
|
|
592
|
+
* `'invalid_params'` failure, never a half-opened channel), runs the {@link OnOpen} hook, then tells
|
|
593
|
+
* the model the channel is open and what it currently holds (`opened` note with a state snapshot) —
|
|
594
|
+
* and takes that snapshot as the baseline later changes are described against.
|
|
595
|
+
*
|
|
596
|
+
* @param inputs What the agent asked to seed the channel with.
|
|
597
|
+
*/
|
|
598
|
+
async Open(inputs = {}) {
|
|
599
|
+
const descriptor = this.GetDescriptor();
|
|
600
|
+
if (descriptor.Inputs) {
|
|
601
|
+
const issues = ValidateJsonAgainstSchemaSubset(inputs, descriptor.Inputs);
|
|
602
|
+
if (issues.length > 0) {
|
|
603
|
+
return {
|
|
604
|
+
Success: false,
|
|
605
|
+
ErrorCode: 'invalid_params',
|
|
606
|
+
Error: `The inputs for opening ${descriptor.Key} are invalid.`,
|
|
607
|
+
Details: issues
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const opened = await this.OnOpen(inputs);
|
|
612
|
+
if (opened && !opened.Success) {
|
|
613
|
+
return opened;
|
|
614
|
+
}
|
|
615
|
+
// A multi-instance channel names the instance it just created; every other channel has one id.
|
|
616
|
+
const instanceId = opened?.Instance ?? this.InstanceId;
|
|
617
|
+
this.EmitChannelEvent('opened', { inputs }, undefined, instanceId);
|
|
618
|
+
if (this.Exposure === 'none') {
|
|
619
|
+
// The agent opened it and may use it, but exposure policy keeps what it holds from the model.
|
|
620
|
+
this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'opened', { exposure: 'none' }));
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
const state = this.GetState();
|
|
624
|
+
this.ensurePerception().SetBaseline(state);
|
|
625
|
+
this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'opened', { state }));
|
|
626
|
+
}
|
|
627
|
+
const extra = opened && IsPlainObject(opened.Result) ? opened.Result : {};
|
|
628
|
+
return { Success: true, Result: { opened: true, channel: descriptor.Key, instance: instanceId, ...extra } };
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Subclass hook for {@link Open}: apply the (already validated) seed `inputs`. Return a failure
|
|
632
|
+
* result to refuse the open; return nothing for success. Default: nothing to do.
|
|
633
|
+
*/
|
|
634
|
+
OnOpen(_inputs) {
|
|
635
|
+
// default: a channel with no seed data has nothing to apply
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The channel finished and is handing something back (a submitted form, a chosen option, a
|
|
639
|
+
* finished game). Emits on {@link Output$} and {@link Events$} and tells the model with a
|
|
640
|
+
* `completed` note. The output is validated against the descriptor's `Output` schema when
|
|
641
|
+
* {@link StrictStateChecks} is on; a mismatch is logged, never swallowed into silence or thrown.
|
|
642
|
+
*
|
|
643
|
+
* @param output What the channel hands back.
|
|
644
|
+
* @param instanceId The instance that completed, for a multi-instance channel (default: the primary instance).
|
|
645
|
+
*/
|
|
646
|
+
Complete(output, instanceId = this.InstanceId) {
|
|
647
|
+
const descriptor = this.GetDescriptor();
|
|
648
|
+
if (descriptor.Output && BaseRealtimeChannelClient.StrictStateChecks) {
|
|
649
|
+
const issues = ValidateJsonAgainstSchemaSubset(output, descriptor.Output, '$');
|
|
650
|
+
if (issues.length > 0) {
|
|
651
|
+
console.warn(`[RealtimeChannel:${descriptor.Key}] Complete() output does not match the descriptor's Output schema: ${issues.join('; ')}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
this.channelOutputSubject.next({ Channel: descriptor.Key, Instance: instanceId, Output: output, OccurredAt: Date.now() });
|
|
655
|
+
this.EmitChannelEvent('completed', output, undefined, instanceId);
|
|
656
|
+
// The output is what the channel holds (a submitted form, a chosen option): state exposure decides
|
|
657
|
+
// whether the model is told it, or only that the channel finished.
|
|
658
|
+
this.Context?.SendContextNote(FormatChannelNote(descriptor.Key, instanceId, 'completed', this.Exposure === 'none' ? { exposure: 'none' } : output));
|
|
659
|
+
}
|
|
660
|
+
/** The id of the latest state change (0 before any). */
|
|
661
|
+
get CurrentChangeId() {
|
|
662
|
+
return this.channelChangeSeq;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Records that the channel's state changed. Call this on EVERY mutation, whoever caused it.
|
|
666
|
+
*
|
|
667
|
+
* It (1) assigns the change a monotonic id, (2) emits a `state_changed` event on {@link Events$}
|
|
668
|
+
* immediately (observers see every change), and (3) — unless `Perceive` is `false` — schedules a
|
|
669
|
+
* COALESCED structured note to the model: a burst of N changes inside the debounce window produces
|
|
670
|
+
* ONE `[channel:<Key>#<instance>] state_changed {"changeId":…,"changes":N,"delta":{…}}` note carrying
|
|
671
|
+
* what differs from what the model was last told (the first note is a full snapshot). Notes that
|
|
672
|
+
* would be too large degrade to a list of changed paths.
|
|
673
|
+
*
|
|
674
|
+
* @param options `Author` — who made the change (carried on the event); `Perceive` — whether the
|
|
675
|
+
* model should be told (default `true`; a channel that already feeds the model its own perception
|
|
676
|
+
* — like the Whiteboard's scene deltas — passes `false`).
|
|
677
|
+
* @returns The change's id.
|
|
678
|
+
*/
|
|
679
|
+
RecordChange(options = {}) {
|
|
680
|
+
const id = ++this.channelChangeSeq;
|
|
681
|
+
this.EmitChannelEvent('state_changed', options.Author ? { author: options.Author } : {}, id);
|
|
682
|
+
// Observers of Events$ see every change; the MODEL is told only what exposure policy lets it perceive.
|
|
683
|
+
if (options.Perceive !== false && this.Exposure !== 'none') {
|
|
684
|
+
this.ensurePerception().Record(id);
|
|
685
|
+
}
|
|
686
|
+
return id;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Emits a typed event on {@link Events$} (NOT to the model — use {@link RecordChange} for state the
|
|
690
|
+
* model should perceive, or send an explicit note for a discrete event it must hear about).
|
|
691
|
+
*
|
|
692
|
+
* @param name The event name (declare it in the descriptor's `Events`).
|
|
693
|
+
* @param payload The event payload.
|
|
694
|
+
* @param changeId The change id the event relates to, when it relates to one.
|
|
695
|
+
* @param instanceId The instance the event is about, for a multi-instance channel (default: the primary instance).
|
|
696
|
+
*/
|
|
697
|
+
EmitChannelEvent(name, payload = {}, changeId, instanceId = this.InstanceId) {
|
|
698
|
+
const event = {
|
|
699
|
+
Channel: this.ChannelName,
|
|
700
|
+
Instance: instanceId,
|
|
701
|
+
Name: name,
|
|
702
|
+
Payload: payload,
|
|
703
|
+
OccurredAt: Date.now()
|
|
704
|
+
};
|
|
705
|
+
if (changeId !== undefined) {
|
|
706
|
+
event.ChangeId = changeId;
|
|
707
|
+
}
|
|
708
|
+
this.channelEventsSubject.next(event);
|
|
709
|
+
}
|
|
710
|
+
/** Sends any pending coalesced perception note now (e.g. before a surface is torn down). */
|
|
711
|
+
FlushPerception() {
|
|
712
|
+
this.channelPerception?.Flush();
|
|
713
|
+
}
|
|
714
|
+
/** The perception coalescer, created on first use. */
|
|
715
|
+
ensurePerception() {
|
|
716
|
+
if (!this.channelPerception) {
|
|
717
|
+
const channel = this;
|
|
718
|
+
this.channelPerception = new ChannelPerceptionCoalescer({
|
|
719
|
+
get ChannelKey() {
|
|
720
|
+
return channel.ChannelName;
|
|
721
|
+
},
|
|
722
|
+
get InstanceId() {
|
|
723
|
+
return channel.InstanceId;
|
|
724
|
+
},
|
|
725
|
+
GetState: () => this.getStateChecked(),
|
|
726
|
+
SendNote: (text) => this.Context?.SendContextNote(text),
|
|
727
|
+
OnError: (error) => console.error(`[RealtimeChannel:${this.ChannelName}] Perception note failed:`, error)
|
|
728
|
+
}, this.PerceptionOptions);
|
|
729
|
+
}
|
|
730
|
+
return this.channelPerception;
|
|
731
|
+
}
|
|
732
|
+
/** {@link GetState}, with the development-time schema check applied when {@link StrictStateChecks} is on. */
|
|
733
|
+
getStateChecked() {
|
|
734
|
+
const state = this.GetState();
|
|
735
|
+
if (BaseRealtimeChannelClient.StrictStateChecks) {
|
|
736
|
+
for (const issue of this.ValidateState()) {
|
|
737
|
+
this.warnStateIssueOnce(issue, `State does not match its descriptor: ${issue}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return state;
|
|
741
|
+
}
|
|
742
|
+
/** Logs a state problem once per channel instance — these checks run on every perception note. */
|
|
743
|
+
warnStateIssueOnce(key, message) {
|
|
744
|
+
if (this.warnedStateIssueKeys.has(key)) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
this.warnedStateIssueKeys.add(key);
|
|
748
|
+
console.warn(`[RealtimeChannel:${this.ChannelName}] ${message}`);
|
|
749
|
+
}
|
|
750
|
+
// ── Contract v2: artifacts from delegated runs ─────────────────────────────
|
|
751
|
+
/**
|
|
752
|
+
* Whether this channel wants to be offered the artifacts a delegated run just produced (the agent asked
|
|
753
|
+
* Skip or Sage to build something, and it came back as artifacts). A channel that hosts artifacts says yes
|
|
754
|
+
* for the ones it can show — and for an artifact it ALREADY shows, so a newer version replaces it in place.
|
|
755
|
+
*
|
|
756
|
+
* Asked of every channel in the session, mounted or merely advertised. For an advertised channel it is
|
|
757
|
+
* asked before the channel has been initialized, so it must not depend on {@link Context}; the channel's
|
|
758
|
+
* resolved configuration is passed in for exactly that reason. Saying yes for an advertised channel
|
|
759
|
+
* mounts it, so only answer yes when you will do something with the artifacts.
|
|
760
|
+
*
|
|
761
|
+
* Default: `false` — a channel that does not host artifacts is never bothered.
|
|
762
|
+
*
|
|
763
|
+
* @param artifacts What the delegated run produced.
|
|
764
|
+
* @param config This channel's resolved configuration (host defaults beneath agent/app config).
|
|
765
|
+
*/
|
|
766
|
+
AcceptsDelegationArtifacts(_artifacts, _config) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* A delegated run produced artifacts this channel said it wants ({@link AcceptsDelegationArtifacts}). The
|
|
771
|
+
* channel is mounted and initialized by now. Never throw into the runtime: handle your own failures and
|
|
772
|
+
* report them (the runtime logs anything that escapes, but cannot recover the work).
|
|
773
|
+
*
|
|
774
|
+
* Default: nothing.
|
|
775
|
+
*
|
|
776
|
+
* @param artifacts What the delegated run produced.
|
|
777
|
+
*/
|
|
778
|
+
OnDelegationArtifacts(_artifacts) {
|
|
779
|
+
// default: a channel that does not host artifacts ignores them
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* How much of this channel the model may perceive without asking: the lowest of the channel's own ceiling
|
|
783
|
+
* (`GetDescriptor().MaxExposure`), the server policy and the user's choice. A channel nobody has applied a
|
|
784
|
+
* policy to simply has its ceiling, which is what every channel had before exposure policy existed.
|
|
785
|
+
*
|
|
786
|
+
* - `'none'`: no state notes and no frames.
|
|
787
|
+
* - `'state'`: structured state notes; no frames.
|
|
788
|
+
* - `'pixels'`: state notes and frames.
|
|
789
|
+
*
|
|
790
|
+
* It governs what flows to the model UNPROMPTED (perception notes, the contents of `opened` / `completed`
|
|
791
|
+
* notes, frames). It does not REDACT what a verb's result contains: a verb is all-or-nothing. A verb whose
|
|
792
|
+
* result would show the model something above this level declares `ReturnsChannelData` on the verb, and
|
|
793
|
+
* when this level is below it the verb is refused whole (`exposure_restricted`) and listed to the model as
|
|
794
|
+
* unavailable; see {@link RefuseVerbForExposure}. A verb that does not declare it returns what the channel
|
|
795
|
+
* says it returns, so a channel whose verbs reveal sensitive data must either declare it or lower its own
|
|
796
|
+
* `MaxExposure`.
|
|
797
|
+
*/
|
|
798
|
+
get Exposure() {
|
|
799
|
+
return MinExposure(this.GetDescriptor().MaxExposure, this.policyExposure, this.userExposure);
|
|
800
|
+
}
|
|
801
|
+
/** Why {@link Exposure} is below the channel's ceiling; empty when it is not. */
|
|
802
|
+
get ExposureReasons() {
|
|
803
|
+
return this.exposureReasons;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Whether the server policy (not the user) leaves room for pixels. Deliberately does not consult the
|
|
807
|
+
* descriptor: a synthesized descriptor derives its `MaxExposure` from {@link GetSourcedTracks}, which asks
|
|
808
|
+
* this, so reading it here would recurse. A channel that enabled visual perception has declared pixels.
|
|
809
|
+
*/
|
|
810
|
+
policyAllowsPixels() {
|
|
811
|
+
return this.policyExposure === undefined || this.policyExposure === 'pixels';
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Applies exposure policy: the server's decision and the user's own choice. The runtime calls this when
|
|
815
|
+
* it mounts the channel and again whenever the user changes their choice.
|
|
816
|
+
*
|
|
817
|
+
* Withdrawing exposure takes effect immediately: a pending perception note is dropped, a pending frame
|
|
818
|
+
* is dropped, and the channel's video source stops being forwarded. Restoring it re-baselines (the
|
|
819
|
+
* model's picture of the channel is stale, so the next note is a full snapshot) and sends a fresh frame.
|
|
820
|
+
* When the call is live the model is told about the change, with the reason, so it never assumes it
|
|
821
|
+
* can still see what it can't.
|
|
822
|
+
*
|
|
823
|
+
* @param settings `Policy` — the server-decided exposure; `User` — the user's choice (omit for none);
|
|
824
|
+
* `Reasons` — why the policy lowered it, from the server's limits.
|
|
825
|
+
*/
|
|
826
|
+
ApplyExposure(settings) {
|
|
827
|
+
const previous = this.Exposure;
|
|
828
|
+
this.policyExposure = settings.Policy;
|
|
829
|
+
this.userExposure = settings.User;
|
|
830
|
+
this.exposureReasons = [...(settings.Reasons ?? [])];
|
|
831
|
+
const next = this.Exposure;
|
|
832
|
+
if (previous === next) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
this.applyExposureEffects(previous, next);
|
|
836
|
+
this.announceExposureChange(previous, next);
|
|
837
|
+
}
|
|
838
|
+
/** Makes a change in exposure real: stops what is no longer allowed, restarts what is again. */
|
|
839
|
+
applyExposureEffects(previous, next) {
|
|
840
|
+
const hadState = CompareExposure(previous, 'state') >= 0;
|
|
841
|
+
const hasState = CompareExposure(next, 'state') >= 0;
|
|
842
|
+
if (hadState && !hasState) {
|
|
843
|
+
this.channelPerception?.CancelPending();
|
|
844
|
+
}
|
|
845
|
+
else if (!hadState && hasState) {
|
|
846
|
+
// The model's picture of this channel went stale while it could not see it.
|
|
847
|
+
this.channelPerception?.ResetBaseline();
|
|
848
|
+
if (this.Context?.Client) {
|
|
849
|
+
this.ensurePerception().Record(this.channelChangeSeq);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const hadPixels = previous === 'pixels';
|
|
853
|
+
const hasPixels = next === 'pixels';
|
|
854
|
+
this.VisualVideoBridge?.SetSourceEnabled?.(hasPixels, false);
|
|
855
|
+
if (hadPixels && !hasPixels) {
|
|
856
|
+
this.visualFramePump?.CancelPending();
|
|
857
|
+
}
|
|
858
|
+
else if (!hadPixels && hasPixels) {
|
|
859
|
+
void this.NotifyVisualChange();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** Emits the change on {@link Events$} and, when the call is live, tells the model what it can now perceive and why. */
|
|
863
|
+
announceExposureChange(previous, next) {
|
|
864
|
+
const ceiling = this.GetDescriptor().MaxExposure;
|
|
865
|
+
const limit = DescribeExposureLimit(next, ceiling, this.exposureReasons);
|
|
866
|
+
const payload = { exposure: next, was: previous };
|
|
867
|
+
if (limit) {
|
|
868
|
+
payload['limit'] = limit;
|
|
869
|
+
}
|
|
870
|
+
const unavailable = WithheldVerbs(this.GetDescriptor().Verbs, next).map((v) => v.Name);
|
|
871
|
+
if (unavailable.length > 0) {
|
|
872
|
+
payload['unavailableActions'] = unavailable;
|
|
873
|
+
}
|
|
874
|
+
this.EmitChannelEvent('exposure_changed', payload);
|
|
875
|
+
if (this.Context?.Client) {
|
|
876
|
+
this.Context.SendContextNote(FormatChannelNote(this.ChannelName, this.InstanceId, 'exposure_changed', payload));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** Default frame cadence when the track negotiated none (1 fps — the Gemini Live ceiling). */
|
|
880
|
+
static { this.DEFAULT_VISUAL_CADENCE_MS = 1000; }
|
|
881
|
+
/** Floor for the negotiated cadence (4 fps) so a fast model never makes a channel spin. */
|
|
882
|
+
static { this.MIN_VISUAL_CADENCE_MS = 250; }
|
|
883
|
+
/**
|
|
884
|
+
* Opts this channel into VISUAL perception: its surface can be shown to a video-capable model as
|
|
885
|
+
* frames. Call it from {@link OnInitialize} with the channel's frame source.
|
|
886
|
+
*
|
|
887
|
+
* The channel then needs only to say *when* its picture changed — {@link NotifyVisualChange} after a
|
|
888
|
+
* user edit, {@link ConfirmVisualChange} after a successful agent edit — and the base class does the
|
|
889
|
+
* rest: change-driven (no idle heartbeat), paced to the negotiated cadence, a trailing settle so the
|
|
890
|
+
* model ends on the final state of a burst, dedupe of identical frames, and a single confirmation
|
|
891
|
+
* frame (with a "do not narrate" note) after an agent edit. Every frame is tagged with the change id
|
|
892
|
+
* of the state it was captured at (`frame_pushed` events), so state and pixels can be cross-checked.
|
|
893
|
+
*
|
|
894
|
+
* Also makes {@link GetSourcedTracks} report the inbound video track, which is what gets it
|
|
895
|
+
* negotiated. Idempotent: calling it again replaces the provider and options.
|
|
896
|
+
*
|
|
897
|
+
* @param provider Renders the channel's current picture as a base64 JPEG (or `null`).
|
|
898
|
+
* @param options See {@link VisualPerceptionOptions}.
|
|
899
|
+
*/
|
|
900
|
+
EnableVisualPerception(provider, options = {}) {
|
|
901
|
+
this.visualFrameProvider = provider;
|
|
902
|
+
this.visualPerceptionOptions = options;
|
|
903
|
+
if (this.visualFramePump) {
|
|
904
|
+
this.visualFramePump.CancelPending();
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
this.visualFramePump = this.createVisualPump(provider);
|
|
908
|
+
}
|
|
909
|
+
this.EnsureVideoBridge();
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* The channel's picture changed because of a USER action (or a scene replacement): push a frame,
|
|
913
|
+
* respecting the cadence. Resolves when the decision has been carried out; never rejects. A no-op
|
|
914
|
+
* until {@link EnableVisualPerception} ran and the session has an inbound video track.
|
|
915
|
+
*/
|
|
916
|
+
NotifyVisualChange() {
|
|
917
|
+
return this.visualFramePump ? this.visualFramePump.OnChange() : Promise.resolve();
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* An AGENT action succeeded and changed the channel's picture: push exactly one confirmation frame
|
|
921
|
+
* now and tell the model not to narrate its own change. Call only on SUCCESS — confirming a failed
|
|
922
|
+
* action would assert an edit landed that did not. A no-op until {@link EnableVisualPerception} ran.
|
|
923
|
+
*/
|
|
924
|
+
ConfirmVisualChange() {
|
|
925
|
+
return this.visualFramePump ? this.visualFramePump.Confirm() : Promise.resolve();
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Returns the shared frame bridge, creating it if needed and (only when the channel opted into the
|
|
929
|
+
* legacy poller) starting its fixed-rate poll once the video track is up. `null` when no context is bound.
|
|
930
|
+
*/
|
|
931
|
+
EnsureVideoBridge() {
|
|
932
|
+
if (!this.VisualVideoBridge && this.Context && this.visualFrameProvider) {
|
|
933
|
+
// The channel registers as a SOURCE with the session's video arbiter (the single writer of inbound
|
|
934
|
+
// video) rather than writing to the model itself, so when several sources are live the arbiter decides
|
|
935
|
+
// which one the model sees and tells it. The id and label are how the "agent can see" UI names it.
|
|
936
|
+
const descriptor = this.GetDescriptor();
|
|
937
|
+
this.VisualVideoBridge = new ChannelInboundVideoBridge(() => this.Context?.Client, this.visualFrameProvider, {
|
|
938
|
+
SourceID: ChannelVideoSourceID(descriptor.Key, this.InstanceId),
|
|
939
|
+
Label: descriptor.DisplayName,
|
|
940
|
+
Kind: 'surface',
|
|
941
|
+
ChannelKey: descriptor.Key,
|
|
942
|
+
});
|
|
943
|
+
// The pump sends only on change, and a frame the arbiter refused (another source had the model's only stream, or
|
|
944
|
+
// this one was off) never reached the model: when the arbiter sends this source again, send the current picture.
|
|
945
|
+
this.stopRefreshOnSourceActivated = this.VisualVideoBridge.OnSourceActivated(() => {
|
|
946
|
+
void this.visualFramePump?.Refresh();
|
|
947
|
+
});
|
|
948
|
+
if (this.Exposure !== 'pixels') {
|
|
949
|
+
this.VisualVideoBridge.SetSourceEnabled?.(false, false);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// List the source with the arbiter as soon as the video track is up, so the user can see and switch it
|
|
953
|
+
// before it has sent a frame (a source switched off never sends one, and would otherwise be unlistable).
|
|
954
|
+
// (Optional call: channels' tests install minimal bridge stubs that predate source registration, as with Start below.)
|
|
955
|
+
this.VisualVideoBridge?.Register?.();
|
|
956
|
+
if (this.visualPerceptionOptions.StartBridgePoller &&
|
|
957
|
+
this.VisualVideoBridge &&
|
|
958
|
+
!this.VisualVideoBridge.IsActive &&
|
|
959
|
+
this.Context?.Client?.IsTrackEstablished('video', 'inbound')) {
|
|
960
|
+
this.VisualVideoBridge.Start?.();
|
|
961
|
+
}
|
|
962
|
+
return this.VisualVideoBridge;
|
|
963
|
+
}
|
|
964
|
+
/** Builds the pump, wiring it to this channel's context, bridge and event stream. */
|
|
965
|
+
createVisualPump(provider) {
|
|
966
|
+
return new VisualPerceptionPump({
|
|
967
|
+
GetSink: () => this.EnsureVideoBridge(),
|
|
968
|
+
IsInboundVideoEstablished: () => this.Context?.Client?.IsTrackEstablished('video', 'inbound') ?? false,
|
|
969
|
+
IsPermitted: () => this.Exposure === 'pixels',
|
|
970
|
+
GetCadenceMs: () => this.getNegotiatedVisualCadenceMs(),
|
|
971
|
+
CaptureFrame: async () => provider.GetLatestFrame(),
|
|
972
|
+
GetChangeId: () => this.channelChangeSeq,
|
|
973
|
+
OnFramePushed: (frame, reason, changeId) => this.EmitChannelEvent('frame_pushed', { reason, bytes: frame.length }, changeId),
|
|
974
|
+
SendConfirmationNote: (changeId) => this.Context?.SendContextNote(this.confirmationNote(changeId)),
|
|
975
|
+
OnError: (context, error) => console.error(`[RealtimeChannel:${this.ChannelName}] Error in the visual ${context} path:`, error)
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
/** The "do not narrate your own change" note sent with a confirmation frame. */
|
|
979
|
+
confirmationNote(changeId) {
|
|
980
|
+
const configured = this.visualPerceptionOptions.ConfirmationNote;
|
|
981
|
+
if (typeof configured === 'string') {
|
|
982
|
+
return configured;
|
|
983
|
+
}
|
|
984
|
+
if (typeof configured === 'function') {
|
|
985
|
+
return configured(changeId);
|
|
986
|
+
}
|
|
987
|
+
return FormatChannelNote(this.ChannelName, this.InstanceId, 'frame_confirmed', {
|
|
988
|
+
changeId,
|
|
989
|
+
guidance: 'visual confirmation of your action (background — do NOT narrate or announce your own change; continue naturally)'
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* The frame cadence in ms: the rate the inbound video track negotiated (`1000 / Rate`), clamped to
|
|
994
|
+
* a 250ms floor; 1000ms when nothing was negotiated.
|
|
995
|
+
*/
|
|
996
|
+
getNegotiatedVisualCadenceMs() {
|
|
997
|
+
const tracks = this.Context?.Client?.EstablishedTracks;
|
|
998
|
+
const rate = tracks?.find((t) => t.Descriptor.Modality === 'video' && t.Descriptor.Direction === 'inbound')?.Descriptor.Rate;
|
|
999
|
+
if (typeof rate === 'number' && rate > 0) {
|
|
1000
|
+
return Math.max(BaseRealtimeChannelClient.MIN_VISUAL_CADENCE_MS, Math.floor(1000 / rate));
|
|
1001
|
+
}
|
|
1002
|
+
return BaseRealtimeChannelClient.DEFAULT_VISUAL_CADENCE_MS;
|
|
1003
|
+
}
|
|
222
1004
|
/**
|
|
223
1005
|
* Tears the plugin down at session end: release the surface binding, unsubscribe
|
|
224
1006
|
* state-engine subscriptions, then drop the context. Subclasses overriding this MUST
|
|
@@ -227,7 +1009,79 @@ export class BaseRealtimeChannelClient {
|
|
|
227
1009
|
*/
|
|
228
1010
|
Dispose() {
|
|
229
1011
|
this.UnbindSurface();
|
|
1012
|
+
this.channelPerception?.Dispose();
|
|
1013
|
+
this.channelPerception = null;
|
|
1014
|
+
this.visualFramePump?.Dispose();
|
|
1015
|
+
this.visualFramePump = null;
|
|
1016
|
+
this.stopRefreshOnSourceActivated?.();
|
|
1017
|
+
this.stopRefreshOnSourceActivated = null;
|
|
1018
|
+
this.VisualVideoBridge?.Stop();
|
|
1019
|
+
this.VisualVideoBridge = null;
|
|
1020
|
+
this.channelEventsSubject.complete();
|
|
1021
|
+
this.channelOutputSubject.complete();
|
|
230
1022
|
this.Context = null;
|
|
231
1023
|
}
|
|
232
1024
|
}
|
|
1025
|
+
/** The primary instance id of a single-instance channel. */
|
|
1026
|
+
const PRIMARY_CHANNEL_INSTANCE_ID = '1';
|
|
1027
|
+
/**
|
|
1028
|
+
* The id of a channel's video source at the session's `VideoSourceArbiter`: `<channel key>#<instance id>`. The channel's
|
|
1029
|
+
* own frame bridge and the runtime's {@link RealtimeChannelContext.SendVideoFrame} register under it, so a channel is
|
|
1030
|
+
* one source however it sends, and the "agent can see" control lists it once.
|
|
1031
|
+
*
|
|
1032
|
+
* @param channelKey The channel's descriptor key.
|
|
1033
|
+
* @param instanceId The channel instance.
|
|
1034
|
+
*/
|
|
1035
|
+
export function ChannelVideoSourceID(channelKey, instanceId) {
|
|
1036
|
+
return `${channelKey}#${instanceId}`;
|
|
1037
|
+
}
|
|
1038
|
+
/** True when a Node-style environment reports a non-production `NODE_ENV` (never in a plain browser). */
|
|
1039
|
+
function isDevelopmentEnvironment() {
|
|
1040
|
+
return typeof process !== 'undefined' && !!process.env && process.env['NODE_ENV'] !== undefined && process.env['NODE_ENV'] !== 'production';
|
|
1041
|
+
}
|
|
1042
|
+
/** Tolerantly parses native tool arguments into a plain object (`{}` for anything else). */
|
|
1043
|
+
function parseToolArguments(argsJson) {
|
|
1044
|
+
try {
|
|
1045
|
+
const parsed = argsJson ? JSON.parse(argsJson) : {};
|
|
1046
|
+
// JSON.parse output is JSON by construction, so narrowing it to JSONObject is not a type lie.
|
|
1047
|
+
return IsPlainObject(parsed) ? parsed : {};
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
return {};
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/** Serializes a structured verb result for the native tool path (the model reads this JSON). */
|
|
1054
|
+
function serializeVerbResult(result) {
|
|
1055
|
+
if (result.Success) {
|
|
1056
|
+
return JSON.stringify(result.Result === undefined ? { success: true } : { success: true, result: result.Result });
|
|
1057
|
+
}
|
|
1058
|
+
const failure = { success: false, error: result.Error ?? 'The action could not be performed.' };
|
|
1059
|
+
if (result.ErrorCode) {
|
|
1060
|
+
failure['errorCode'] = result.ErrorCode;
|
|
1061
|
+
}
|
|
1062
|
+
if (result.Details) {
|
|
1063
|
+
failure['details'] = result.Details;
|
|
1064
|
+
}
|
|
1065
|
+
return JSON.stringify(failure);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Adapts a legacy tool result string to a structured {@link RealtimeChannelVerbResult}: a JSON object
|
|
1069
|
+
* with `success: false` is a failure (its `error`/`output`/`message` becomes the message); any other
|
|
1070
|
+
* JSON is the result; a non-JSON string is the result as text.
|
|
1071
|
+
*/
|
|
1072
|
+
function parseLegacyVerbResult(resultJson) {
|
|
1073
|
+
let parsed;
|
|
1074
|
+
try {
|
|
1075
|
+
parsed = JSON.parse(resultJson);
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
return { Success: true, Result: resultJson };
|
|
1079
|
+
}
|
|
1080
|
+
if (IsPlainObject(parsed) && parsed['success'] === false) {
|
|
1081
|
+
const message = [parsed['error'], parsed['output'], parsed['message']].find((m) => typeof m === 'string' && m.length > 0);
|
|
1082
|
+
return { Success: false, ErrorCode: 'verb_failed', Error: message ?? 'The action could not be performed.', Result: parsed };
|
|
1083
|
+
}
|
|
1084
|
+
// JSON.parse output is JSON by construction, so narrowing it to JSONValue is not a type lie.
|
|
1085
|
+
return { Success: true, Result: parsed };
|
|
1086
|
+
}
|
|
233
1087
|
//# sourceMappingURL=base-realtime-channel-client.js.map
|