@memberjunction/server 5.40.2 → 5.42.0
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/dist/agentSessions/HostInstance.d.ts +19 -0
- package/dist/agentSessions/HostInstance.d.ts.map +1 -0
- package/dist/agentSessions/HostInstance.js +48 -0
- package/dist/agentSessions/HostInstance.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +97 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
- package/dist/agentSessions/SessionJanitor.js +222 -0
- package/dist/agentSessions/SessionJanitor.js.map +1 -0
- package/dist/agentSessions/SessionManager.d.ts +142 -0
- package/dist/agentSessions/SessionManager.d.ts.map +1 -0
- package/dist/agentSessions/SessionManager.js +308 -0
- package/dist/agentSessions/SessionManager.js.map +1 -0
- package/dist/agentSessions/index.d.ts +5 -0
- package/dist/agentSessions/index.d.ts.map +1 -0
- package/dist/agentSessions/index.js +31 -0
- package/dist/agentSessions/index.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
- package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
- package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +4 -2
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -2
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +41 -7
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +1296 -49
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +50343 -43421
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.js +1 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.js +9 -10
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -54
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +108 -0
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
- package/dist/integration/CustomColumnPromoter.js +508 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -0
- package/dist/logging/StartupLogger.d.ts +177 -0
- package/dist/logging/StartupLogger.d.ts.map +1 -0
- package/dist/logging/StartupLogger.js +354 -0
- package/dist/logging/StartupLogger.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +21 -2
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/AgentSessionResolver.js +152 -0
- package/dist/resolvers/AgentSessionResolver.js.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.js +95 -0
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +432 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js +1108 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +0 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +5 -3
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +6 -5
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +83 -74
- package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +181 -0
- package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
- package/src/__tests__/SessionJanitor.test.ts +234 -0
- package/src/__tests__/SessionManager.test.ts +465 -0
- package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
- package/src/__tests__/subscriptionRedaction.test.ts +5 -0
- package/src/agentSessions/HostInstance.ts +53 -0
- package/src/agentSessions/SessionJanitor.ts +267 -0
- package/src/agentSessions/SessionManager.ts +446 -0
- package/src/agentSessions/index.ts +32 -0
- package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
- package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
- package/src/auth/initializeProviders.ts +4 -2
- package/src/config.ts +13 -2
- package/src/context.ts +42 -7
- package/src/generated/generated.ts +33808 -29025
- package/src/generic/ResolverBase.ts +1 -1
- package/src/generic/RunViewResolver.ts +9 -9
- package/src/index.ts +128 -54
- package/src/integration/CustomColumnPromoter.ts +597 -0
- package/src/logging/StartupLogger.ts +441 -0
- package/src/logging/variablesLoggingMiddleware.ts +25 -5
- package/src/resolvers/AgentSessionResolver.ts +138 -0
- package/src/resolvers/EntityPermissionResolver.ts +73 -0
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
- package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +1122 -0
- package/src/resolvers/RunAIAgentResolver.ts +1 -9
- package/src/rest/setupRESTEndpoints.ts +5 -3
- package/src/services/ScheduledJobsService.ts +6 -5
|
@@ -0,0 +1,1774 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* @fileoverview GraphQL resolvers for the CLIENT-DIRECT realtime voice topology (P5b-ii).
|
|
15
|
+
*
|
|
16
|
+
* These resolvers are **thin**: they wire two already-built pieces together —
|
|
17
|
+
* {@link RealtimeClientSessionService} (in `@memberjunction/ai-agents`, the server-side half of the
|
|
18
|
+
* client-direct contract) and {@link SessionManager} (the durable `AIAgentSession` record manager) —
|
|
19
|
+
* over a request's `contextUser` + request-scoped `IMetadataProvider`.
|
|
20
|
+
*
|
|
21
|
+
* In the client-direct topology the browser opens its OWN provider socket (e.g. WebRTC) using a
|
|
22
|
+
* server-minted ephemeral token, but the server retains authority over the system prompt + tool set
|
|
23
|
+
* and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
|
|
24
|
+
*
|
|
25
|
+
* 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
|
|
26
|
+
* session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
|
|
27
|
+
* 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
|
|
28
|
+
* reading the target agent id from the session (NOT the client) so the browser can't swap targets.
|
|
29
|
+
* 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
|
|
30
|
+
* `Conversation Detail`.
|
|
31
|
+
*
|
|
32
|
+
* Authorization model:
|
|
33
|
+
* - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
|
|
34
|
+
* orchestration agent, so the meaningful gate is the agent doing the real work).
|
|
35
|
+
* - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
|
|
36
|
+
*
|
|
37
|
+
* @module @memberjunction/server
|
|
38
|
+
*/
|
|
39
|
+
import { Resolver, Mutation, Arg, Ctx, Int, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
|
|
40
|
+
import { AuthorizationEvaluator, LogError, LogStatus, RunView } from '@memberjunction/core';
|
|
41
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
42
|
+
import { AIEngine } from '@memberjunction/aiengine';
|
|
43
|
+
import { AIAgentPermissionHelper, AIEngineBase } from '@memberjunction/ai-engine-base';
|
|
44
|
+
import { RealtimeClientSessionService, RealtimeChannelServerHost, EvaluateRuntimeOverrideAuthorization, ParseRealtimeTypeConfiguration, ResolveEffectiveRealtimeConfig, REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION, } from '@memberjunction/ai-agents';
|
|
45
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
46
|
+
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
47
|
+
import { GetReadWriteProvider } from '../util.js';
|
|
48
|
+
import { SessionManager } from '../agentSessions/index.js';
|
|
49
|
+
/**
|
|
50
|
+
* Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
|
|
51
|
+
* in {@link import('./RunAIAgentResolver.js').RunAIAgentResolver}. Initialization/validation/finalization
|
|
52
|
+
* noise is dropped so the model only narrates meaningful work.
|
|
53
|
+
*/
|
|
54
|
+
const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
|
|
55
|
+
/**
|
|
56
|
+
* The seeded name of the internal orchestration agent that fronts a target agent in realtime
|
|
57
|
+
* sessions (voice + interactive channels). This is the GLOBAL DEFAULT co-agent — the final step of
|
|
58
|
+
* the co-agent resolution chain (see {@link RealtimeClientSessionResolver.resolveCoAgentID}).
|
|
59
|
+
* Deployments can override it per agent (`AIAgent.DefaultCoAgentID`), per agent type (an
|
|
60
|
+
* `AIAgentCoAgent` row with `TargetAgentTypeID` + `IsDefault`), or per call (the `coAgentId`
|
|
61
|
+
* mutation argument) without
|
|
62
|
+
* touching this seed.
|
|
63
|
+
*/
|
|
64
|
+
const REALTIME_CO_AGENT_NAME = 'Realtime Co-Agent';
|
|
65
|
+
/**
|
|
66
|
+
* DEPRECATED legacy seed name of {@link REALTIME_CO_AGENT_NAME}, from before the agent's rename
|
|
67
|
+
* from "Voice Co-Agent" to "Realtime Co-Agent". Deployments that have not re-synced the agent seed
|
|
68
|
+
* still carry this name, so {@link RealtimeClientSessionResolver.resolveGlobalCoAgentID} falls
|
|
69
|
+
* back to it (with a deprecation log) when no agent named {@link REALTIME_CO_AGENT_NAME} exists.
|
|
70
|
+
*/
|
|
71
|
+
const LEGACY_REALTIME_CO_AGENT_NAME = 'Voice Co-Agent';
|
|
72
|
+
/**
|
|
73
|
+
* The seeded name of the Realtime agent TYPE. Every co-agent candidate (explicit or metadata
|
|
74
|
+
* default) must be an Active agent of this type — a co-agent drives a streaming full-duplex
|
|
75
|
+
* realtime session, which only `RealtimeAgentType`-driven agents support.
|
|
76
|
+
*/
|
|
77
|
+
const REALTIME_AGENT_TYPE_NAME = 'Realtime';
|
|
78
|
+
/** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
|
|
79
|
+
const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
80
|
+
const CO_AGENT_ENTITY = 'MJ: AI Agent Co Agents';
|
|
81
|
+
const CONVERSATION_DETAIL_ENTITY = 'MJ: Conversation Details';
|
|
82
|
+
const CHANNEL_ENTITY = 'MJ: AI Agent Channels';
|
|
83
|
+
const SESSION_CHANNEL_ENTITY = 'MJ: AI Agent Session Channels';
|
|
84
|
+
const ARTIFACT_ENTITY = 'MJ: Artifacts';
|
|
85
|
+
const ARTIFACT_VERSION_ENTITY = 'MJ: Artifact Versions';
|
|
86
|
+
const ARTIFACT_TYPE_ENTITY = 'MJ: Artifact Types';
|
|
87
|
+
const CONVERSATION_DETAIL_ARTIFACT_ENTITY = 'MJ: Conversation Detail Artifacts';
|
|
88
|
+
/**
|
|
89
|
+
* The seeded name of the artifact type that {@link RealtimeClientSessionResolver.SaveSessionChannelArtifact}
|
|
90
|
+
* stamps onto saved channel-state artifacts (e.g. the live whiteboard's board JSON). Seeded in
|
|
91
|
+
* `metadata/artifact-types/` — its absence is a graceful structured failure, never a throw.
|
|
92
|
+
*/
|
|
93
|
+
const WHITEBOARD_ARTIFACT_TYPE_NAME = 'Whiteboard';
|
|
94
|
+
/**
|
|
95
|
+
* Maximum number of client-declared UI tools accepted at session mint. Sized to comfortably fit
|
|
96
|
+
* MULTIPLE interactive channels at once plus headroom: the Whiteboard channel alone declares 17 tools
|
|
97
|
+
* and the Remote Browser channel 10, so a session with both is ~27 — the old cap of 16 silently rejected
|
|
98
|
+
* the ENTIRE set (parseClientTools is all-or-nothing), leaving the co-agent with only invoke-target-agent
|
|
99
|
+
* and forcing it to delegate every channel request. This is an abuse ceiling, not a working limit.
|
|
100
|
+
*/
|
|
101
|
+
const MAX_CLIENT_TOOLS = 64;
|
|
102
|
+
/** Maximum accepted size (chars) of the serialized client tool declarations. Raised in step with
|
|
103
|
+
* {@link MAX_CLIENT_TOOLS} — multi-channel tool sets with verbose descriptions + JSON schemas run large. */
|
|
104
|
+
const MAX_CLIENT_TOOLS_JSON_CHARS = 256_000;
|
|
105
|
+
/** Maximum accepted size (chars) of a persisted channel state blob. */
|
|
106
|
+
const MAX_CHANNEL_STATE_CHARS = 2_000_000;
|
|
107
|
+
/** Maximum prior-leg transcript TURNS hydrated into a resumed session's system prompt (newest kept). */
|
|
108
|
+
const MAX_PRIOR_TRANSCRIPT_TURNS = 30;
|
|
109
|
+
/** Maximum prior-leg transcript CHARS hydrated into a resumed session's system prompt (oldest dropped). */
|
|
110
|
+
const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
|
|
111
|
+
/** Maximum prior-session chain legs walked when hydrating a resumed session's transcript. */
|
|
112
|
+
const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
|
|
113
|
+
/**
|
|
114
|
+
* Result of {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — everything the
|
|
115
|
+
* browser needs to open its own provider socket plus the durable session linkage.
|
|
116
|
+
*/
|
|
117
|
+
let StartRealtimeClientSessionResult = class StartRealtimeClientSessionResult {
|
|
118
|
+
};
|
|
119
|
+
__decorate([
|
|
120
|
+
Field(() => String),
|
|
121
|
+
__metadata("design:type", String)
|
|
122
|
+
], StartRealtimeClientSessionResult.prototype, "AgentSessionId", void 0);
|
|
123
|
+
__decorate([
|
|
124
|
+
Field(() => String),
|
|
125
|
+
__metadata("design:type", String)
|
|
126
|
+
], StartRealtimeClientSessionResult.prototype, "ConversationId", void 0);
|
|
127
|
+
__decorate([
|
|
128
|
+
Field(() => String),
|
|
129
|
+
__metadata("design:type", String)
|
|
130
|
+
], StartRealtimeClientSessionResult.prototype, "Provider", void 0);
|
|
131
|
+
__decorate([
|
|
132
|
+
Field(() => String),
|
|
133
|
+
__metadata("design:type", String)
|
|
134
|
+
], StartRealtimeClientSessionResult.prototype, "Model", void 0);
|
|
135
|
+
__decorate([
|
|
136
|
+
Field(() => String),
|
|
137
|
+
__metadata("design:type", String)
|
|
138
|
+
], StartRealtimeClientSessionResult.prototype, "EphemeralToken", void 0);
|
|
139
|
+
__decorate([
|
|
140
|
+
Field(() => String),
|
|
141
|
+
__metadata("design:type", String)
|
|
142
|
+
], StartRealtimeClientSessionResult.prototype, "ExpiresAt", void 0);
|
|
143
|
+
__decorate([
|
|
144
|
+
Field(() => String),
|
|
145
|
+
__metadata("design:type", String)
|
|
146
|
+
], StartRealtimeClientSessionResult.prototype, "SessionConfigJson", void 0);
|
|
147
|
+
__decorate([
|
|
148
|
+
Field(() => String, { nullable: true }),
|
|
149
|
+
__metadata("design:type", String)
|
|
150
|
+
], StartRealtimeClientSessionResult.prototype, "ModelName", void 0);
|
|
151
|
+
__decorate([
|
|
152
|
+
Field(() => String, { nullable: true }),
|
|
153
|
+
__metadata("design:type", String)
|
|
154
|
+
], StartRealtimeClientSessionResult.prototype, "NarrationInstructionsTemplate", void 0);
|
|
155
|
+
__decorate([
|
|
156
|
+
Field(() => String, { nullable: true }),
|
|
157
|
+
__metadata("design:type", String)
|
|
158
|
+
], StartRealtimeClientSessionResult.prototype, "PriorChannelStatesJson", void 0);
|
|
159
|
+
__decorate([
|
|
160
|
+
Field(() => Int, { nullable: true }),
|
|
161
|
+
__metadata("design:type", Number)
|
|
162
|
+
], StartRealtimeClientSessionResult.prototype, "NarrationPaceMs", void 0);
|
|
163
|
+
__decorate([
|
|
164
|
+
Field(() => String, { nullable: true }),
|
|
165
|
+
__metadata("design:type", String)
|
|
166
|
+
], StartRealtimeClientSessionResult.prototype, "EffectiveConfigJson", void 0);
|
|
167
|
+
StartRealtimeClientSessionResult = __decorate([
|
|
168
|
+
ObjectType()
|
|
169
|
+
], StartRealtimeClientSessionResult);
|
|
170
|
+
export { StartRealtimeClientSessionResult };
|
|
171
|
+
/**
|
|
172
|
+
* Result of {@link RealtimeClientSessionResolver.SaveSessionChannelArtifact} — a structured
|
|
173
|
+
* success/failure envelope (graceful failures like a missing artifact type must not throw).
|
|
174
|
+
*/
|
|
175
|
+
let SaveSessionChannelArtifactResult = class SaveSessionChannelArtifactResult {
|
|
176
|
+
};
|
|
177
|
+
__decorate([
|
|
178
|
+
Field(() => Boolean),
|
|
179
|
+
__metadata("design:type", Boolean)
|
|
180
|
+
], SaveSessionChannelArtifactResult.prototype, "Success", void 0);
|
|
181
|
+
__decorate([
|
|
182
|
+
Field(() => String, { nullable: true }),
|
|
183
|
+
__metadata("design:type", String)
|
|
184
|
+
], SaveSessionChannelArtifactResult.prototype, "ErrorMessage", void 0);
|
|
185
|
+
__decorate([
|
|
186
|
+
Field(() => String, { nullable: true }),
|
|
187
|
+
__metadata("design:type", String)
|
|
188
|
+
], SaveSessionChannelArtifactResult.prototype, "ArtifactID", void 0);
|
|
189
|
+
__decorate([
|
|
190
|
+
Field(() => String, { nullable: true }),
|
|
191
|
+
__metadata("design:type", String)
|
|
192
|
+
], SaveSessionChannelArtifactResult.prototype, "ArtifactVersionID", void 0);
|
|
193
|
+
__decorate([
|
|
194
|
+
Field(() => Boolean),
|
|
195
|
+
__metadata("design:type", Boolean)
|
|
196
|
+
], SaveSessionChannelArtifactResult.prototype, "ConversationDetailLinked", void 0);
|
|
197
|
+
SaveSessionChannelArtifactResult = __decorate([
|
|
198
|
+
ObjectType()
|
|
199
|
+
], SaveSessionChannelArtifactResult);
|
|
200
|
+
export { SaveSessionChannelArtifactResult };
|
|
201
|
+
/**
|
|
202
|
+
* Result of {@link RealtimeClientSessionResolver.CancelRealtimeSessionTool} — a structured
|
|
203
|
+
* success/failure envelope, mirroring {@link SaveSessionChannelArtifactResult}: tolerated
|
|
204
|
+
* problems (an unexpected registry error) come back as `Success: false`, never a throw, while
|
|
205
|
+
* authn/ownership violations still throw like the sibling mutations.
|
|
206
|
+
*/
|
|
207
|
+
let CancelRealtimeSessionToolResult = class CancelRealtimeSessionToolResult {
|
|
208
|
+
};
|
|
209
|
+
__decorate([
|
|
210
|
+
Field(() => Int),
|
|
211
|
+
__metadata("design:type", Number)
|
|
212
|
+
], CancelRealtimeSessionToolResult.prototype, "AbortedCount", void 0);
|
|
213
|
+
__decorate([
|
|
214
|
+
Field(() => Boolean),
|
|
215
|
+
__metadata("design:type", Boolean)
|
|
216
|
+
], CancelRealtimeSessionToolResult.prototype, "Success", void 0);
|
|
217
|
+
__decorate([
|
|
218
|
+
Field(() => String, { nullable: true }),
|
|
219
|
+
__metadata("design:type", String)
|
|
220
|
+
], CancelRealtimeSessionToolResult.prototype, "ErrorMessage", void 0);
|
|
221
|
+
CancelRealtimeSessionToolResult = __decorate([
|
|
222
|
+
ObjectType()
|
|
223
|
+
], CancelRealtimeSessionToolResult);
|
|
224
|
+
export { CancelRealtimeSessionToolResult };
|
|
225
|
+
/**
|
|
226
|
+
* Resolver for the client-direct realtime voice topology. A single {@link SessionManager} and a
|
|
227
|
+
* single {@link RealtimeClientSessionService} are shared across requests — neither holds per-user or
|
|
228
|
+
* per-provider state; every method is passed the request `contextUser` + provider explicitly.
|
|
229
|
+
*/
|
|
230
|
+
let RealtimeClientSessionResolver = class RealtimeClientSessionResolver extends ResolverBase {
|
|
231
|
+
constructor() {
|
|
232
|
+
super(...arguments);
|
|
233
|
+
this.sessionManager = new SessionManager();
|
|
234
|
+
this.clientSessionService = new RealtimeClientSessionService();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Start a client-direct realtime voice session targeting `targetAgentId`.
|
|
238
|
+
*
|
|
239
|
+
* Flow:
|
|
240
|
+
* 1. Authorize — the caller must have `CanRun` on the **target** agent; denial throws and no
|
|
241
|
+
* session is created.
|
|
242
|
+
* 2. Resolve the co-agent id via the metadata-driven resolution chain (runtime `coAgentId` →
|
|
243
|
+
* agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
|
|
244
|
+
* see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
|
|
245
|
+
* 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
|
|
246
|
+
* config server-side — this is the authoritative target for all later relays.
|
|
247
|
+
* 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
|
|
248
|
+
* On failure the just-created session is closed so no half-open session leaks.
|
|
249
|
+
*
|
|
250
|
+
* **SECURITY NOTE — `clientToolsJson`:** these are CLIENT-EXECUTED UI tools (e.g. the live
|
|
251
|
+
* whiteboard's `Whiteboard.*` surface). The server only *declares* them to the realtime model
|
|
252
|
+
* so it can call them — the server NEVER executes them, and a relayed call for one of these
|
|
253
|
+
* names falls into the standard "not available" path on the server side. They grant no
|
|
254
|
+
* server-side capability whatsoever; server-executed tools remain exclusively server-declared
|
|
255
|
+
* (`invoke-target-agent` + future action wiring). The declarations are still validated
|
|
256
|
+
* (count cap, size cap, per-tool shape) so a hostile client can't bloat the session config.
|
|
257
|
+
*
|
|
258
|
+
* @param targetAgentId The agent the co-agent voices. OPTIONAL when the resolved co-agent
|
|
259
|
+
* has pairing rows with an `IsDefault` target (`MJ: AI Agent Co Agents`) — the default
|
|
260
|
+
* pairing stands in. Required for a universal co-agent (zero pairing rows). When the
|
|
261
|
+
* co-agent HAS pairing rows, a supplied target must be in that list (clear error otherwise).
|
|
262
|
+
* @param coAgentId Optional EXPLICIT co-agent choice (`MJ: AI Agents.ID` of an Active,
|
|
263
|
+
* Realtime-type agent). When set, the server uses exactly that co-agent and FAILS with a
|
|
264
|
+
* clear reason if it can't (no silent fallback — mirroring `preferredModelId`'s contract).
|
|
265
|
+
* Omit to let metadata drive the choice: the target agent's `DefaultCoAgentID`, then the
|
|
266
|
+
* type-level `AIAgentCoAgent` default row for its agent type, then the global Realtime Co-Agent.
|
|
267
|
+
* @param configOverridesJson Optional RUNTIME configuration-override layer (the most-specific
|
|
268
|
+
* layer of the effective-config merge: type `DefaultConfiguration` ← agent
|
|
269
|
+
* `TypeConfiguration` ← this). **Authorization-gated**: requires the
|
|
270
|
+
* `Realtime: Advanced Session Controls` authorization — unauthorized callers receive a
|
|
271
|
+
* structured rejection (never a silent ignore). Must be a JSON object.
|
|
272
|
+
*
|
|
273
|
+
* @returns The ephemeral config + session linkage the browser needs to open its socket.
|
|
274
|
+
*/
|
|
275
|
+
async StartRealtimeClientSession(targetAgentId, { userPayload, providers }, conversationId, lastSessionId, preferredModelId, clientToolsJson, coAgentId, configOverridesJson) {
|
|
276
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
277
|
+
const coAgentID = await this.resolveCoAgentID(targetAgentId, coAgentId, contextUser, provider);
|
|
278
|
+
// PAIRING CONSTRAINTS: a co-agent with pairing rows is restricted to that target list
|
|
279
|
+
// (with the IsDefault row standing in when no runtime target was supplied); zero rows =
|
|
280
|
+
// universal, today's behavior untouched. Resolves the AUTHORITATIVE target id.
|
|
281
|
+
const effectiveTargetId = await this.resolveConstrainedTargetAgentID(coAgentID, targetAgentId, contextUser, provider);
|
|
282
|
+
await this.assertCanRunTarget(effectiveTargetId, contextUser, provider);
|
|
283
|
+
// RUNTIME-OVERRIDE GATE: configOverridesJson and a DEVIATING explicit model both require
|
|
284
|
+
// the 'Realtime: Advanced Session Controls' authorization (structured rejection, never a
|
|
285
|
+
// silent ignore). Plain starts and within-pairing target selection are never gated here.
|
|
286
|
+
await this.assertRuntimeOverridesAuthorized(coAgentID, configOverridesJson, preferredModelId, contextUser, provider);
|
|
287
|
+
const config = { targetAgentID: effectiveTargetId };
|
|
288
|
+
const session = await this.sessionManager.CreateSession({
|
|
289
|
+
agentID: coAgentID,
|
|
290
|
+
userID: contextUser.ID,
|
|
291
|
+
conversationID: conversationId,
|
|
292
|
+
lastSessionID: lastSessionId,
|
|
293
|
+
config: JSON.stringify(config),
|
|
294
|
+
}, contextUser, provider);
|
|
295
|
+
const clientTools = this.parseClientTools(clientToolsJson);
|
|
296
|
+
// Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
|
|
297
|
+
// checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
|
|
298
|
+
// Strictly tolerant — any problem yields no hydration, never a failed start.
|
|
299
|
+
const priorTranscript = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
|
|
300
|
+
const result = await this.prepareClientSessionOrClose(session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, priorTranscript, configOverridesJson);
|
|
301
|
+
// Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
|
|
302
|
+
// board). Strictly tolerant — any problem yields a null field, never a failed start.
|
|
303
|
+
result.PriorChannelStatesJson = (await this.loadPriorChannelStatesJson(lastSessionId, contextUser, provider)) ?? undefined;
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Execute a single tool call the browser relayed from its provider socket and return the
|
|
308
|
+
* serialized result for the browser to relay back to the model.
|
|
309
|
+
*
|
|
310
|
+
* Ownership-gated; the target agent id is read from the **session config** (authoritative), never
|
|
311
|
+
* from the client. Heartbeats the session on success.
|
|
312
|
+
*
|
|
313
|
+
* @returns The serialized tool result JSON.
|
|
314
|
+
*/
|
|
315
|
+
async ExecuteRealtimeSessionTool(agentSessionId, callId, toolName, argsJson, { userPayload, providers }, pubSub) {
|
|
316
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
317
|
+
const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
|
|
318
|
+
const config = this.readSessionConfig(session);
|
|
319
|
+
const { ResultJson, PausedRunID, Artifacts } = await this.clientSessionService.ExecuteRelayedTool({
|
|
320
|
+
AgentSessionID: agentSessionId,
|
|
321
|
+
TargetAgentID: config.targetAgentID,
|
|
322
|
+
// Nest the delegated target-agent run under the co-agent observability run (when present).
|
|
323
|
+
ParentRunID: config.coAgentRunID,
|
|
324
|
+
Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
|
|
325
|
+
OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
|
|
326
|
+
// Resume a previously-paused delegated run (if any) with the user's answer.
|
|
327
|
+
ResumeRunID: config.pendingFeedbackRunID,
|
|
328
|
+
}, contextUser, provider);
|
|
329
|
+
// Roll the paused-run id forward in the session config: clear the one we just consumed, and
|
|
330
|
+
// store a new one only if the (resumed or fresh) run paused again awaiting feedback.
|
|
331
|
+
await this.updatePendingFeedbackRunID(session, config, PausedRunID);
|
|
332
|
+
// Junction-link any artifacts the delegated run produced into the session's conversation
|
|
333
|
+
// history (best-effort) — so chat, session review, and resume carryover can all see them.
|
|
334
|
+
await this.linkDelegatedArtifactsToConversation(session, Artifacts, contextUser, provider);
|
|
335
|
+
await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
|
|
336
|
+
return ResultJson;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Builds the `OnProgress` callback threaded into the delegated target-agent run. Each significant
|
|
340
|
+
* progress event (see {@link SIGNIFICANT_PROGRESS_STEPS}) is published to
|
|
341
|
+
* {@link PUSH_STATUS_UPDATES_TOPIC} on this user's `sessionId` so the browser can correlate it to
|
|
342
|
+
* THIS voice session (via `agentSessionID` + `callID`) and feed it to the realtime model to
|
|
343
|
+
* narrate — distinct from a normal `RunAIAgentResolver` agent-run stream by `resolver`/`type`.
|
|
344
|
+
*/
|
|
345
|
+
buildDelegationProgressCallback(pubSub, userPayload, agentSessionID, callID) {
|
|
346
|
+
return (progress) => {
|
|
347
|
+
if (!SIGNIFICANT_PROGRESS_STEPS.includes(progress.step)) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
351
|
+
message: JSON.stringify({
|
|
352
|
+
resolver: 'RealtimeClientSessionResolver',
|
|
353
|
+
type: 'RealtimeDelegationProgress',
|
|
354
|
+
agentSessionID,
|
|
355
|
+
callID,
|
|
356
|
+
step: progress.step,
|
|
357
|
+
message: progress.message,
|
|
358
|
+
percentage: progress.percentage,
|
|
359
|
+
}),
|
|
360
|
+
sessionId: userPayload.sessionId,
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
|
|
366
|
+
* consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
|
|
367
|
+
* again awaiting feedback. When neither the old nor new value is set, this is a no-op (no save).
|
|
368
|
+
* Best-effort: a save failure is logged, not thrown.
|
|
369
|
+
*/
|
|
370
|
+
async updatePendingFeedbackRunID(session, config, pausedRunID) {
|
|
371
|
+
const previous = config.pendingFeedbackRunID;
|
|
372
|
+
if (!previous && !pausedRunID) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const next = {
|
|
376
|
+
targetAgentID: config.targetAgentID,
|
|
377
|
+
coAgentRunID: config.coAgentRunID,
|
|
378
|
+
promptRunID: config.promptRunID,
|
|
379
|
+
coAgentRunStepID: config.coAgentRunStepID,
|
|
380
|
+
pendingFeedbackRunID: pausedRunID,
|
|
381
|
+
};
|
|
382
|
+
session.Config_ = JSON.stringify(next);
|
|
383
|
+
if (!(await session.Save())) {
|
|
384
|
+
LogError(`RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Persist a transcript turn (user or assistant) as a `Conversation Detail` on the session's
|
|
389
|
+
* conversation. Ownership-gated; heartbeats the session on success.
|
|
390
|
+
*
|
|
391
|
+
* The co-agent observability `AIAgentRun`/`AIPromptRun` are created at session start (see
|
|
392
|
+
* {@link RealtimeClientSessionResolver.StartRealtimeClientSession}) and finalized on close;
|
|
393
|
+
* incremental usage telemetry lands on the `AIPromptRun` via
|
|
394
|
+
* {@link RealtimeClientSessionResolver.RelayRealtimeUsage}. Each persisted turn is ALSO appended
|
|
395
|
+
* to the co-agent `AIPromptRun.Messages` (best-effort), so the run captures the full conversation
|
|
396
|
+
* — observability parity with every other MJ agent run, not just token totals.
|
|
397
|
+
*
|
|
398
|
+
* @returns `true` when the transcript turn was persisted.
|
|
399
|
+
*/
|
|
400
|
+
async RelayRealtimeTranscript(agentSessionId, role, text, { userPayload, providers }, replacesPrevious) {
|
|
401
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
402
|
+
const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
|
|
403
|
+
const saved = replacesPrevious
|
|
404
|
+
? await this.replacePreviousTranscriptTurn(session, role, text, contextUser, provider)
|
|
405
|
+
: await this.persistTranscriptTurn(session, role, text, contextUser, provider);
|
|
406
|
+
if (!saved) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
// Mirror the turn onto the co-agent's long-lived prompt run so its Messages capture the full
|
|
410
|
+
// conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
|
|
411
|
+
const promptRunID = this.readPromptRunID(session);
|
|
412
|
+
if (promptRunID) {
|
|
413
|
+
await this.clientSessionService.AppendPromptRunMessage(promptRunID, this.mapTranscriptRoleToChatRole(role), text, replacesPrevious ?? false, contextUser, provider);
|
|
414
|
+
}
|
|
415
|
+
await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Maps the relayed transcript role (`'User'`/`'AI'`/`'Assistant'`, case-insensitive) to the
|
|
420
|
+
* standard chat-message role stored in `AIPromptRun.Messages`. Anything that isn't an explicit
|
|
421
|
+
* user turn is treated as the assistant (the co-agent's own speech).
|
|
422
|
+
*/
|
|
423
|
+
mapTranscriptRoleToChatRole(role) {
|
|
424
|
+
return role.trim().toLowerCase() === 'user' ? 'user' : 'assistant';
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Relays a co-agent CHANNEL tool-call (browser_ / Whiteboard_ etc.) onto the session's co-agent
|
|
428
|
+
* AIPromptRun.Messages — run-only observability so the run captures what the co-agent DID, not just
|
|
429
|
+
* what it said. Deliberately NOT a ConversationDetail turn (the chat thread stays speech-only).
|
|
430
|
+
* Ownership-gated; best-effort (a missing prompt run / save failure simply returns false).
|
|
431
|
+
*
|
|
432
|
+
* @returns `true` when the tool turn was recorded on the run.
|
|
433
|
+
*/
|
|
434
|
+
async RelayRealtimeToolTurn(agentSessionId, toolName, { userPayload, providers }, argsJson, resultJson) {
|
|
435
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
436
|
+
const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
|
|
437
|
+
const promptRunID = this.readPromptRunID(session);
|
|
438
|
+
if (!promptRunID) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return this.clientSessionService.AppendPromptRunMessage(promptRunID, 'assistant', this.formatToolTurn(toolName, argsJson, resultJson), false, contextUser, provider);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Formats a co-agent tool call into a compact, human-readable run line: `🔧 <tool> <args> → <result>`,
|
|
445
|
+
* clipping args/result so a verbose payload never bloats the Messages blob.
|
|
446
|
+
*/
|
|
447
|
+
formatToolTurn(toolName, argsJson, resultJson) {
|
|
448
|
+
const clip = (raw, max) => {
|
|
449
|
+
const t = (raw ?? '').trim();
|
|
450
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
451
|
+
};
|
|
452
|
+
const args = clip(argsJson, 200);
|
|
453
|
+
const result = clip(resultJson, 300);
|
|
454
|
+
return `🔧 ${toolName}${args ? ` ${args}` : ''}${result ? ` → ${result}` : ''}`;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Cancel in-flight relayed tool delegations for a session — the CLIENT-DIRECT cancel channel.
|
|
458
|
+
*
|
|
459
|
+
* The browser calls this on an EXPLICIT user cancel (the call overlay's per-card ✕ — a
|
|
460
|
+
* deliberate host policy: true barge-in alone never aborts delegations, since the narration
|
|
461
|
+
* design expects the user to keep talking while delegated work runs). The service-side
|
|
462
|
+
* in-flight registry aborts the matching `AbortController`(s), which fires the delegated
|
|
463
|
+
* run's `cancellationToken`; the original `ExecuteRealtimeSessionTool` mutation then resolves
|
|
464
|
+
* with the run's cancelled/failed result through its normal path.
|
|
465
|
+
*
|
|
466
|
+
* Ownership-gated like the sibling mutations. A **Closed** session is accepted — a cancel can
|
|
467
|
+
* legitimately race teardown, and aborting stragglers is exactly what the caller wants then.
|
|
468
|
+
*
|
|
469
|
+
* @param callId When supplied, only the delegation for that provider call id is aborted;
|
|
470
|
+
* omit to abort ALL in-flight delegations for the session.
|
|
471
|
+
* @returns A structured {@link CancelRealtimeSessionToolResult}. Tolerant: an unknown call id
|
|
472
|
+
* or a session with nothing in flight is `Success: true, AbortedCount: 0` (the work may
|
|
473
|
+
* simply have finished already); an unexpected registry error is a structured failure.
|
|
474
|
+
*/
|
|
475
|
+
async CancelRealtimeSessionTool(agentSessionId, { userPayload, providers }, callId) {
|
|
476
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
477
|
+
await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
478
|
+
try {
|
|
479
|
+
const aborted = this.clientSessionService.CancelInFlightDelegations(agentSessionId, callId);
|
|
480
|
+
return { AbortedCount: aborted, Success: true };
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
const message = `CancelRealtimeSessionTool failed for session ${agentSessionId}: ${error.message}`;
|
|
484
|
+
LogError(message);
|
|
485
|
+
return { AbortedCount: 0, Success: false, ErrorMessage: message };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Relay incremental usage telemetry from the browser's provider socket onto the co-agent's
|
|
490
|
+
* observability `AIPromptRun` (the run created at session start).
|
|
491
|
+
*
|
|
492
|
+
* The browser accumulates the realtime client's `OnUsage` token DELTAS and flushes them here
|
|
493
|
+
* debounced (~10s) plus once at teardown; this mutation ACCUMULATES the deltas onto the
|
|
494
|
+
* prompt run's `TokensPrompt` / `TokensCompletion` (and recomputes `TokensUsed`). Status is
|
|
495
|
+
* deliberately untouched — `FinalizeCoAgentRun` keeps stamping Success/Completed at close,
|
|
496
|
+
* and a post-close flush still lands (accumulation has no Status gate).
|
|
497
|
+
*
|
|
498
|
+
* Ownership-gated like the sibling mutations; a **Closed** session is accepted (the final
|
|
499
|
+
* teardown flush can land after `CloseAgentSession`). Everything PAST the ownership gate is
|
|
500
|
+
* best-effort and tolerant: a missing/malformed session config, an absent `promptRunID`
|
|
501
|
+
* (observability creation was skipped), a failed load, or a failed save all log and return
|
|
502
|
+
* `false` — usage relay must never break a live call.
|
|
503
|
+
*
|
|
504
|
+
* @param inputTokens Input-token DELTA to add (negative/non-finite values are clamped to 0).
|
|
505
|
+
* @param outputTokens Output-token DELTA to add (negative/non-finite values are clamped to 0).
|
|
506
|
+
* @returns `true` when the accumulated usage was persisted; `false` on any tolerated failure.
|
|
507
|
+
*/
|
|
508
|
+
async RelayRealtimeUsage(agentSessionId, inputTokens, outputTokens, { userPayload, providers }) {
|
|
509
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
510
|
+
const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
511
|
+
const inputDelta = this.clampTokenDelta(inputTokens);
|
|
512
|
+
const outputDelta = this.clampTokenDelta(outputTokens);
|
|
513
|
+
if (inputDelta === 0 && outputDelta === 0) {
|
|
514
|
+
return true; // nothing to add — a no-op flush is a success, not a failure
|
|
515
|
+
}
|
|
516
|
+
const promptRunID = this.readPromptRunID(session);
|
|
517
|
+
if (!promptRunID) {
|
|
518
|
+
return false; // no observability prompt run for this session — logged in the helper
|
|
519
|
+
}
|
|
520
|
+
// Delegate to the service so usage writes share the per-run serialization with transcript-message
|
|
521
|
+
// appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
|
|
522
|
+
return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, contextUser, provider);
|
|
523
|
+
}
|
|
524
|
+
/** Clamps a relayed token delta: negative / non-finite values become 0. */
|
|
525
|
+
clampTokenDelta(value) {
|
|
526
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
|
|
530
|
+
* throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
|
|
531
|
+
* config or absent id just logs and returns `null`.
|
|
532
|
+
*/
|
|
533
|
+
readPromptRunID(session) {
|
|
534
|
+
try {
|
|
535
|
+
const config = this.readSessionConfig(session);
|
|
536
|
+
if (config.promptRunID) {
|
|
537
|
+
return config.promptRunID;
|
|
538
|
+
}
|
|
539
|
+
LogStatus(`RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
|
|
540
|
+
'creation was skipped or failed) — usage delta dropped.');
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Persist an interactive channel's state of record (e.g. the live whiteboard's serialized
|
|
550
|
+
* scene) onto the session's `MJ: AI Agent Session Channels` row.
|
|
551
|
+
*
|
|
552
|
+
* Flow:
|
|
553
|
+
* 1. Ownership gate — like the sibling mutations, the session's `UserID` must equal the
|
|
554
|
+
* caller's. Unlike Execute/Relay, a **Closed** session is accepted: the client's final
|
|
555
|
+
* on-end flush legitimately lands after `CloseAgentSession` has run.
|
|
556
|
+
* 2. Resolve the channel definition (`MJ: AI Agent Channels`) by `channelName`. When no
|
|
557
|
+
* active definition row exists (the deployment hasn't synced the channel seed yet),
|
|
558
|
+
* return `false` gracefully and log — never throw for a missing definition.
|
|
559
|
+
* 3. Offer the payload to the session's SERVER-SIDE channel plugin (the registry row's
|
|
560
|
+
* `ServerPluginClass`, held per-session by {@link RealtimeChannelServerHost}) for
|
|
561
|
+
* validation/normalization — strictly best-effort: no plugin, a plugin failure, or an
|
|
562
|
+
* oversized replacement all fall back to persisting the original payload.
|
|
563
|
+
* 4. Upsert the session-channel row: create it (Status `Connected`) when missing, store
|
|
564
|
+
* the (possibly normalized) state in its `Config` field, and stamp `LastActiveAt`.
|
|
565
|
+
*
|
|
566
|
+
* @returns `true` when the state was persisted; `false` on any tolerated failure (missing
|
|
567
|
+
* channel definition, oversized state, save failure) — all logged.
|
|
568
|
+
*/
|
|
569
|
+
async SaveSessionChannelState(agentSessionId, channelName, stateJson, { userPayload, providers }) {
|
|
570
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
571
|
+
const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
572
|
+
if (stateJson.length > MAX_CHANNEL_STATE_CHARS) {
|
|
573
|
+
LogError(`SaveSessionChannelState: rejected oversized state for session ${agentSessionId} / channel '${channelName}' ` +
|
|
574
|
+
`(${stateJson.length} chars > ${MAX_CHANNEL_STATE_CHARS}).`);
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
const channelID = await this.resolveChannelID(channelName, contextUser, provider);
|
|
578
|
+
if (!channelID) {
|
|
579
|
+
return false; // missing/inactive channel definition — logged in resolveChannelID
|
|
580
|
+
}
|
|
581
|
+
const stateToPersist = await this.applyChannelServerPlugin(session.ID, channelName, stateJson);
|
|
582
|
+
return this.upsertSessionChannelState(session.ID, channelID, stateToPersist, contextUser, provider);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Routes a landed channel-state save through the session's server-side channel plugin (when
|
|
586
|
+
* one resolved at session start) so it can validate/normalize the payload PRE-persistence.
|
|
587
|
+
* Strictly best-effort: any host/plugin problem — including a replacement that exceeds the
|
|
588
|
+
* channel-state size cap — falls back to the client's original payload, which already passed
|
|
589
|
+
* the cap. A plugin can transform a save; it can never lose or block one.
|
|
590
|
+
*/
|
|
591
|
+
async applyChannelServerPlugin(agentSessionID, channelName, stateJson) {
|
|
592
|
+
try {
|
|
593
|
+
const processed = await RealtimeChannelServerHost.Instance.OnChannelStateSave(agentSessionID, channelName, stateJson);
|
|
594
|
+
if (processed.length > MAX_CHANNEL_STATE_CHARS) {
|
|
595
|
+
LogError(`SaveSessionChannelState: server plugin for channel '${channelName}' returned an oversized replacement ` +
|
|
596
|
+
`(${processed.length} chars > ${MAX_CHANNEL_STATE_CHARS}) — persisting the original state.`);
|
|
597
|
+
return stateJson;
|
|
598
|
+
}
|
|
599
|
+
return processed;
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
LogError(`SaveSessionChannelState: channel server plugin hook failed for session ${agentSessionID} / ` +
|
|
603
|
+
`channel '${channelName}' — persisting the original state: ${error.message}`);
|
|
604
|
+
return stateJson;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Persist an interactive channel's current state (e.g. the live whiteboard's board JSON) as a
|
|
609
|
+
* durable, user-owned **artifact** — distinct from {@link SaveSessionChannelState}, which only
|
|
610
|
+
* stores the session-scoped state of record. The artifact survives the session and shows up in
|
|
611
|
+
* the user's artifact library (and, when possible, in the conversation's history).
|
|
612
|
+
*
|
|
613
|
+
* Flow:
|
|
614
|
+
* 1. Ownership gate — like the sibling mutations (`UserID === contextUser.ID`). A **Closed**
|
|
615
|
+
* session is accepted: "save my board" legitimately arrives as/after the call ends.
|
|
616
|
+
* 2. Size cap — `contentJson` beyond {@link MAX_CHANNEL_STATE_CHARS} is a structured failure.
|
|
617
|
+
* 3. Resolve the {@link WHITEBOARD_ARTIFACT_TYPE_NAME} artifact type by name; when the seed is
|
|
618
|
+
* absent/disabled in this deployment, return a structured failure (never throw).
|
|
619
|
+
* 4. Create the `MJ: Artifacts` header (user-owned, Visibility `Always`) + its
|
|
620
|
+
* `MJ: Artifact Versions` v1 row carrying `contentJson`.
|
|
621
|
+
* 5. Best-effort extras that never fail the save:
|
|
622
|
+
* - when the session has a conversation AND a `Conversation Detail` stamped with this
|
|
623
|
+
* session id exists (the transcript relay stamps `AgentSessionID`), link the version into
|
|
624
|
+
* history via a `MJ: Conversation Detail Artifacts` junction row (Direction `Output`);
|
|
625
|
+
* - stamp `LastActiveAt` on the session-channel row when one exists.
|
|
626
|
+
*
|
|
627
|
+
* @returns A structured {@link SaveSessionChannelArtifactResult} — graceful failures (missing
|
|
628
|
+
* type seed, oversized content, save failure) come back as `Success: false`, while
|
|
629
|
+
* authn/ownership violations throw like the sibling mutations.
|
|
630
|
+
*/
|
|
631
|
+
async SaveSessionChannelArtifact(agentSessionId, channelName, name, contentJson, { userPayload, providers }) {
|
|
632
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
633
|
+
const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
634
|
+
if (contentJson.length > MAX_CHANNEL_STATE_CHARS) {
|
|
635
|
+
const message = `SaveSessionChannelArtifact: rejected oversized content for session ${agentSessionId} / channel '${channelName}' ` +
|
|
636
|
+
`(${contentJson.length} chars > ${MAX_CHANNEL_STATE_CHARS}).`;
|
|
637
|
+
LogError(message);
|
|
638
|
+
return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
|
|
639
|
+
}
|
|
640
|
+
const typeID = await this.resolveWhiteboardArtifactTypeID(contextUser, provider);
|
|
641
|
+
if (!typeID) {
|
|
642
|
+
return {
|
|
643
|
+
Success: false,
|
|
644
|
+
ErrorMessage: `The '${WHITEBOARD_ARTIFACT_TYPE_NAME}' artifact type is not configured in this deployment — ` +
|
|
645
|
+
'sync the artifact-type seed metadata to enable saving channel artifacts.',
|
|
646
|
+
ConversationDetailLinked: false,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return this.createChannelArtifact(session, channelName, name, contentJson, typeID, contextUser, provider);
|
|
650
|
+
}
|
|
651
|
+
// ----- internals -------------------------------------------------------------------------
|
|
652
|
+
/** Resolve the request user + read-write provider, throwing a clear error if unauthenticated. */
|
|
653
|
+
requireUserAndProvider(userPayload, providers) {
|
|
654
|
+
const contextUser = this.GetUserFromPayload(userPayload);
|
|
655
|
+
if (!contextUser) {
|
|
656
|
+
throw new Error('Not authenticated: no user context for realtime session operation');
|
|
657
|
+
}
|
|
658
|
+
return { contextUser, provider: GetReadWriteProvider(providers) };
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
|
|
662
|
+
* caller must be able to run the **target** agent. Denial throws (no session is created).
|
|
663
|
+
*/
|
|
664
|
+
async assertCanRunTarget(targetAgentId, contextUser, _provider) {
|
|
665
|
+
const canRun = await AIAgentPermissionHelper.HasPermission(targetAgentId, contextUser, 'run');
|
|
666
|
+
if (!canRun) {
|
|
667
|
+
throw new Error(`Not authorized: you are not permitted to run the target agent ${targetAgentId}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
|
|
672
|
+
* (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
|
|
673
|
+
*
|
|
674
|
+
* - **Zero pairing rows (universal co-agent)** — today's behavior untouched: the runtime
|
|
675
|
+
* `targetAgentId` is required (clear error when absent) and used as-is. Pairings are NEVER
|
|
676
|
+
* mandated; zero-config deployments keep working with zero metadata.
|
|
677
|
+
* - **Rows + runtime target** — the supplied target must be IN the paired list; a target
|
|
678
|
+
* outside the list is a clear structured error (the UX builds its picker from the same rows).
|
|
679
|
+
* - **Rows + no runtime target** — the `IsDefault` row stands in; when no default row exists,
|
|
680
|
+
* a clear error asks for an explicit target.
|
|
681
|
+
*
|
|
682
|
+
* Pairing rows are a TARGETING constraint layered on top of — never a replacement for — the
|
|
683
|
+
* `CanRun` security gate, which the caller applies to the resolved target id immediately
|
|
684
|
+
* after. A failed/erroring pairing query therefore degrades to the universal behavior
|
|
685
|
+
* (logged), it never breaks session starts.
|
|
686
|
+
*
|
|
687
|
+
* @param coAgentID The resolved co-agent id.
|
|
688
|
+
* @param requestedTargetId The runtime `targetAgentId` argument, when supplied.
|
|
689
|
+
* @returns The effective target agent id (canonical casing from the pairing row when matched).
|
|
690
|
+
*/
|
|
691
|
+
async resolveConstrainedTargetAgentID(coAgentID, requestedTargetId, contextUser, provider) {
|
|
692
|
+
const rows = await this.loadPairingRows(coAgentID, contextUser, provider);
|
|
693
|
+
if (rows.length === 0) {
|
|
694
|
+
if (!requestedTargetId) {
|
|
695
|
+
throw new Error('targetAgentId is required: this co-agent has no paired targets ' +
|
|
696
|
+
'(it is universal), so there is no default target to fall back to.');
|
|
697
|
+
}
|
|
698
|
+
return requestedTargetId;
|
|
699
|
+
}
|
|
700
|
+
if (requestedTargetId) {
|
|
701
|
+
const match = rows.find((r) => UUIDsEqual(r.TargetAgentID, requestedTargetId));
|
|
702
|
+
if (!match) {
|
|
703
|
+
throw new Error(`Invalid targetAgentId '${requestedTargetId}': this co-agent is paired to a specific ` +
|
|
704
|
+
'target list and the requested agent is not in it. Pick one of the paired targets ' +
|
|
705
|
+
'or use a co-agent without pairings.');
|
|
706
|
+
}
|
|
707
|
+
return match.TargetAgentID;
|
|
708
|
+
}
|
|
709
|
+
const defaultRow = rows.find((r) => r.IsDefault);
|
|
710
|
+
if (!defaultRow) {
|
|
711
|
+
throw new Error('targetAgentId is required: this co-agent is paired to a target list but no pairing is ' +
|
|
712
|
+
'marked IsDefault, so there is no default target to fall back to.');
|
|
713
|
+
}
|
|
714
|
+
return defaultRow.TargetAgentID;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Loads the co-agent's pairing rows from {@link AIEngineBase}'s cached
|
|
718
|
+
* `MJ: AI Agent Co Agents` metadata (provider-scoped engine instance, lazy `Config`),
|
|
719
|
+
* ordered by `Sequence`. Only **Active** rows of relationship **Type `'CoAgent'`** with a
|
|
720
|
+
* SPECIFIC agent target participate — type-level rows (`TargetAgentTypeID`) express the
|
|
721
|
+
* type-default in the resolution chain, not a target restriction, and reserved relationship
|
|
722
|
+
* types are ignored until their features ship. Tolerant — a failed cache load logs and
|
|
723
|
+
* returns `[]` (degrading to the universal behavior; pairing is a targeting constraint,
|
|
724
|
+
* `CanRun` remains the security gate), never throws.
|
|
725
|
+
*/
|
|
726
|
+
async loadPairingRows(coAgentID, contextUser, provider) {
|
|
727
|
+
try {
|
|
728
|
+
const engine = await this.configuredAIEngineBase(contextUser, provider);
|
|
729
|
+
return (engine.AgentCoAgents ?? [])
|
|
730
|
+
.filter((r) => r.Type === 'CoAgent' &&
|
|
731
|
+
r.Status === 'Active' &&
|
|
732
|
+
r.TargetAgentID != null &&
|
|
733
|
+
UUIDsEqual(r.CoAgentID, coAgentID))
|
|
734
|
+
.sort((a, b) => (a.Sequence ?? 0) - (b.Sequence ?? 0))
|
|
735
|
+
.map((r) => ({ TargetAgentID: r.TargetAgentID, IsDefault: r.IsDefault, Sequence: r.Sequence }));
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
LogError(`StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed for co-agent ${coAgentID} ` +
|
|
739
|
+
`(${error.message}) — treating the co-agent as universal.`);
|
|
740
|
+
return [];
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The provider-scoped {@link AIEngineBase} instance for this request's connection, lazily
|
|
745
|
+
* configured (`Config(false, ...)` is a no-op when the cache is already loaded). Pairing rows
|
|
746
|
+
* and channel definitions are small metadata tables the engine caches — reading them here
|
|
747
|
+
* replaces per-call RunViews.
|
|
748
|
+
*/
|
|
749
|
+
async configuredAIEngineBase(contextUser, provider) {
|
|
750
|
+
const engine = AIEngineBase.GetProviderInstance(provider, AIEngineBase);
|
|
751
|
+
await engine.Config(false, contextUser, provider);
|
|
752
|
+
return engine;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* The RUNTIME-OVERRIDE AUTHORIZATION GATE: `configOverridesJson` and a DEVIATING explicit
|
|
756
|
+
* `preferredModelId` both require the `Realtime: Advanced Session Controls` authorization
|
|
757
|
+
* (evaluated hierarchy-aware over the caller's roles); a deviating model is additionally
|
|
758
|
+
* subject to the effective `realtime.allowUserModelOverride` policy (which blocks even
|
|
759
|
+
* authorized callers when `false`). Denial THROWS a structured reason — never a silent
|
|
760
|
+
* ignore. Plain starts (no overrides, no explicit model) pass through untouched; an explicit
|
|
761
|
+
* model EQUAL to the co-agent's metadata-configured preference is not a deviation.
|
|
762
|
+
*
|
|
763
|
+
* Judgment calls baked in (per the approved product rules): co-agent selection (`coAgentId`)
|
|
764
|
+
* and target selection within a pairing list / for a universal co-agent are NORMAL user flow
|
|
765
|
+
* — they stay behind the existing `CanRun` gate only and are not touched here.
|
|
766
|
+
*/
|
|
767
|
+
async assertRuntimeOverridesAuthorized(coAgentID, configOverridesJson, preferredModelId, contextUser, provider) {
|
|
768
|
+
if (!configOverridesJson && !preferredModelId) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (configOverridesJson && !ParseRealtimeTypeConfiguration(configOverridesJson)) {
|
|
772
|
+
throw new Error('Invalid configOverridesJson: expected a JSON object.');
|
|
773
|
+
}
|
|
774
|
+
const baseline = this.resolveBaselineEffectiveConfig(coAgentID);
|
|
775
|
+
const decision = EvaluateRuntimeOverrideAuthorization({
|
|
776
|
+
HasConfigOverrides: !!configOverridesJson,
|
|
777
|
+
RequestedModelID: preferredModelId ?? null,
|
|
778
|
+
MetadataPreferredModelID: this.resolveMetadataPreferredModelID(baseline),
|
|
779
|
+
AllowUserModelOverride: baseline.realtime?.allowUserModelOverride,
|
|
780
|
+
CallerHasAdvancedControls: this.userHasAdvancedSessionControls(contextUser, provider),
|
|
781
|
+
});
|
|
782
|
+
if (!decision.Allowed) {
|
|
783
|
+
throw new Error(`Not authorized: ${decision.DenialReason}`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* The co-agent's BASELINE effective configuration — type `DefaultConfiguration` ← agent
|
|
788
|
+
* `TypeConfiguration`, WITHOUT the runtime layer (this feeds the gate that decides whether
|
|
789
|
+
* the runtime layer is even allowed). Tolerant of an unloaded metadata cache.
|
|
790
|
+
*/
|
|
791
|
+
resolveBaselineEffectiveConfig(coAgentID) {
|
|
792
|
+
try {
|
|
793
|
+
const agent = (AIEngine.Instance.Agents ?? []).find((a) => UUIDsEqual(a.ID, coAgentID));
|
|
794
|
+
let typeDefault = null;
|
|
795
|
+
if (agent?.TypeID) {
|
|
796
|
+
const type = (AIEngine.Instance.AgentTypes ?? []).find((t) => UUIDsEqual(t.ID, agent.TypeID));
|
|
797
|
+
typeDefault = type?.DefaultConfiguration ?? null;
|
|
798
|
+
}
|
|
799
|
+
return ResolveEffectiveRealtimeConfig(typeDefault, agent?.TypeConfiguration ?? null, null);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return {};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Resolves the baseline config's `realtime.modelPreference` (Name or ID) to an
|
|
807
|
+
* `MJ: AI Models.ID`, so a `preferredModelId` equal to it is recognized as a NON-deviation.
|
|
808
|
+
* When the preference matches no cached model (or the cache is unavailable), the RAW
|
|
809
|
+
* preference string is returned — an ID-style preference then still compares equal to the
|
|
810
|
+
* matching `preferredModelId` (case-insensitive), while a name-style preference simply won't
|
|
811
|
+
* match an id (and the gate treats the request as a deviation — the fail-safe direction).
|
|
812
|
+
* `null` only when no preference is configured at all.
|
|
813
|
+
*/
|
|
814
|
+
resolveMetadataPreferredModelID(baseline) {
|
|
815
|
+
const preference = baseline.realtime?.modelPreference;
|
|
816
|
+
if (!preference) {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const models = AIEngine.Instance.Models ?? [];
|
|
821
|
+
const wanted = preference.trim().toLowerCase();
|
|
822
|
+
const matched = models.find((m) => UUIDsEqual(m.ID, preference)) ??
|
|
823
|
+
models.find((m) => m.Name?.trim().toLowerCase() === wanted);
|
|
824
|
+
return matched?.ID ?? preference;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return preference;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Hierarchy-aware check for the `Realtime: Advanced Session Controls` authorization against
|
|
832
|
+
* the request provider's cached Authorizations + the caller's roles. FAIL-CLOSED: an absent
|
|
833
|
+
* authorization row (un-synced seed) or an evaluation error denies — runtime overrides are a
|
|
834
|
+
* privileged path, and unauthorized callers still get a fully working session without them.
|
|
835
|
+
*/
|
|
836
|
+
userHasAdvancedSessionControls(contextUser, provider) {
|
|
837
|
+
try {
|
|
838
|
+
const auths = provider.Authorizations ?? [];
|
|
839
|
+
const wanted = REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION.trim().toLowerCase();
|
|
840
|
+
const auth = auths.find((a) => a.Name?.trim().toLowerCase() === wanted);
|
|
841
|
+
if (!auth) {
|
|
842
|
+
LogError(`StartRealtimeClientSession: the '${REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION}' ` +
|
|
843
|
+
'authorization is not present in metadata — runtime overrides are denied (fail closed). ' +
|
|
844
|
+
'Sync the authorization seed metadata to enable them.');
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
return new AuthorizationEvaluator().UserCanExecuteWithAncestors(auth, contextUser, auths);
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
LogError(`StartRealtimeClientSession: authorization evaluation failed (${error.message}) — ` +
|
|
851
|
+
'runtime overrides are denied (fail closed).');
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Resolves the co-agent (the Realtime-type agent that voices the target agent) for a new
|
|
857
|
+
* client-direct session via the metadata-driven **CO-AGENT RESOLUTION CHAIN** — first match
|
|
858
|
+
* wins, evaluated in precedence order:
|
|
859
|
+
*
|
|
860
|
+
* 1. **Runtime parameter** — the mutation's explicit `coAgentId`. A per-call override; an
|
|
861
|
+
* invalid candidate (unknown, not Active, or not of the Realtime agent type) **throws**
|
|
862
|
+
* (fail loud — the caller asked for something specific, mirroring `preferredModelId`).
|
|
863
|
+
* 2. **Per-agent persona** — the target agent's `AIAgent.DefaultCoAgentID`. An invalid
|
|
864
|
+
* reference logs a warning and **falls through** to the next step (stale metadata should
|
|
865
|
+
* degrade gracefully, never break calls).
|
|
866
|
+
* 3. **Per-type default** — an Active `AIAgentCoAgent` row of Type `'CoAgent'` whose
|
|
867
|
+
* `TargetAgentTypeID` is the target agent's type and `IsDefault = 1` (lowest `Sequence`
|
|
868
|
+
* wins a tie). Same tolerant warn-and-fall-through semantics as step 2.
|
|
869
|
+
* 4. **Global default** — the seeded {@link REALTIME_CO_AGENT_NAME} agent, looked up by name
|
|
870
|
+
* (with a deprecated fallback to {@link LEGACY_REALTIME_CO_AGENT_NAME}). Throws when absent
|
|
871
|
+
* entirely (the realtime feature is unconfigured in this deployment).
|
|
872
|
+
*
|
|
873
|
+
* Every candidate from steps 1–3 is validated by {@link findValidCoAgent}: it must exist in
|
|
874
|
+
* {@link AIEngine}'s cached agents, have Status `Active`, and be of the
|
|
875
|
+
* {@link REALTIME_AGENT_TYPE_NAME} agent type. Step 4 keeps its original name-lookup contract.
|
|
876
|
+
*
|
|
877
|
+
* @param targetAgentId The agent the co-agent will voice on behalf of (drives steps 2–3).
|
|
878
|
+
* @param explicitCoAgentId The runtime `coAgentId` mutation argument, when supplied (step 1).
|
|
879
|
+
* @returns The resolved co-agent's id (canonical casing from the metadata cache).
|
|
880
|
+
*/
|
|
881
|
+
async resolveCoAgentID(targetAgentId, explicitCoAgentId, contextUser, provider) {
|
|
882
|
+
await AIEngine.Instance.Config(false, contextUser, provider);
|
|
883
|
+
// Step 1 — explicit runtime parameter: fail LOUD on any problem.
|
|
884
|
+
if (explicitCoAgentId) {
|
|
885
|
+
const { agent, problem } = this.findValidCoAgent(explicitCoAgentId);
|
|
886
|
+
if (!agent) {
|
|
887
|
+
throw new Error(`Invalid coAgentId '${explicitCoAgentId}': ${problem}`);
|
|
888
|
+
}
|
|
889
|
+
return agent.ID;
|
|
890
|
+
}
|
|
891
|
+
const targetAgent = targetAgentId
|
|
892
|
+
? (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, targetAgentId))
|
|
893
|
+
: undefined;
|
|
894
|
+
// Step 2 — the target agent's own DefaultCoAgentID (per-agent persona): warn + fall through.
|
|
895
|
+
const fromAgent = this.resolveMetadataDefault(targetAgent?.DefaultCoAgentID, `agent '${targetAgent?.Name}' (DefaultCoAgentID)`);
|
|
896
|
+
if (fromAgent) {
|
|
897
|
+
return fromAgent;
|
|
898
|
+
}
|
|
899
|
+
// Step 3 — the type-level AIAgentCoAgent default row (per-type default): warn + fall through.
|
|
900
|
+
const agentType = targetAgent?.TypeID
|
|
901
|
+
? (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, targetAgent.TypeID))
|
|
902
|
+
: undefined;
|
|
903
|
+
const typeDefaultCoAgentID = agentType
|
|
904
|
+
? this.findTypeDefaultCoAgentID(agentType.ID, contextUser, provider)
|
|
905
|
+
: undefined;
|
|
906
|
+
const fromType = this.resolveMetadataDefault(await typeDefaultCoAgentID, `agent type '${agentType?.Name}' (AIAgentCoAgent type-default row)`);
|
|
907
|
+
if (fromType) {
|
|
908
|
+
return fromType;
|
|
909
|
+
}
|
|
910
|
+
// Step 4 — global default: the seeded Realtime Co-Agent, by name (original behavior).
|
|
911
|
+
return this.resolveGlobalCoAgentID();
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Chain step 3 lookup: the TYPE-LEVEL default co-agent for an agent type — the Active
|
|
915
|
+
* `AIAgentCoAgent` row of Type `'CoAgent'` whose `TargetAgentTypeID` matches, with
|
|
916
|
+
* `IsDefault = 1` preferred and the lowest `Sequence` breaking ties (so a deployment can
|
|
917
|
+
* stage multiple type-level candidates deterministically). Reads {@link AIEngineBase}'s
|
|
918
|
+
* cached rows — no RunView. Tolerant: a failed cache read logs and returns `undefined`
|
|
919
|
+
* (the chain falls through to the global default).
|
|
920
|
+
*/
|
|
921
|
+
async findTypeDefaultCoAgentID(agentTypeID, contextUser, provider) {
|
|
922
|
+
try {
|
|
923
|
+
const engine = await this.configuredAIEngineBase(contextUser, provider);
|
|
924
|
+
const candidates = (engine.AgentCoAgents ?? [])
|
|
925
|
+
.filter((r) => r.Type === 'CoAgent' &&
|
|
926
|
+
r.Status === 'Active' &&
|
|
927
|
+
r.TargetAgentTypeID != null &&
|
|
928
|
+
UUIDsEqual(r.TargetAgentTypeID, agentTypeID))
|
|
929
|
+
.sort((a, b) => (a.IsDefault === b.IsDefault ? 0 : a.IsDefault ? -1 : 1) ||
|
|
930
|
+
(a.Sequence ?? 0) - (b.Sequence ?? 0));
|
|
931
|
+
return candidates[0]?.CoAgentID;
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
LogError(`StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed while resolving the type-level ` +
|
|
935
|
+
`default co-agent for agent type ${agentTypeID} (${error.message}) — falling through.`);
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Validates one metadata-level co-agent default (chain steps 2/3). Returns the resolved
|
|
941
|
+
* co-agent id when the reference is valid; logs a warning and returns `null` (caller falls
|
|
942
|
+
* through to the next chain step) when the reference is set but invalid — metadata drift must
|
|
943
|
+
* degrade, not break live calls. A `null`/absent reference returns `null` silently.
|
|
944
|
+
*/
|
|
945
|
+
resolveMetadataDefault(candidateId, source) {
|
|
946
|
+
if (!candidateId) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
const { agent, problem } = this.findValidCoAgent(candidateId);
|
|
950
|
+
if (agent) {
|
|
951
|
+
return agent.ID;
|
|
952
|
+
}
|
|
953
|
+
LogError(`StartRealtimeClientSession: ignoring co-agent default '${candidateId}' from ${source} — ${problem} ` +
|
|
954
|
+
'Falling through to the next step of the co-agent resolution chain.');
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Validates a co-agent candidate against {@link AIEngine}'s cached metadata. A valid co-agent
|
|
959
|
+
* must (a) exist, (b) have Status `Active`, and (c) be of the {@link REALTIME_AGENT_TYPE_NAME}
|
|
960
|
+
* agent type. Returns the cached agent on success, or a human-readable `problem` on failure —
|
|
961
|
+
* the CALLER decides whether that's fatal (explicit runtime param) or tolerated (metadata default).
|
|
962
|
+
*/
|
|
963
|
+
findValidCoAgent(candidateId) {
|
|
964
|
+
const agent = (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, candidateId));
|
|
965
|
+
if (!agent) {
|
|
966
|
+
return { problem: 'no agent with that ID exists.' };
|
|
967
|
+
}
|
|
968
|
+
if (agent.Status !== 'Active') {
|
|
969
|
+
return { problem: `agent '${agent.Name}' is not Active (Status: ${agent.Status}).` };
|
|
970
|
+
}
|
|
971
|
+
const realtimeType = (AIEngine.Instance.AgentTypes ?? []).find(t => t.Name?.trim().toLowerCase() === REALTIME_AGENT_TYPE_NAME.toLowerCase());
|
|
972
|
+
if (!realtimeType) {
|
|
973
|
+
return { problem: `the '${REALTIME_AGENT_TYPE_NAME}' agent type is not configured in this deployment.` };
|
|
974
|
+
}
|
|
975
|
+
if (!agent.TypeID || !UUIDsEqual(agent.TypeID, realtimeType.ID)) {
|
|
976
|
+
return { problem: `agent '${agent.Name}' is not of the '${REALTIME_AGENT_TYPE_NAME}' agent type.` };
|
|
977
|
+
}
|
|
978
|
+
return { agent };
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Resolves the seeded Realtime Co-Agent's id from {@link AIEngine}'s cached agents — the GLOBAL
|
|
982
|
+
* DEFAULT (step 4) of the co-agent resolution chain. The engine is already configured by
|
|
983
|
+
* {@link resolveCoAgentID}. Looks up the current {@link REALTIME_CO_AGENT_NAME} first, then
|
|
984
|
+
* falls back to the DEPRECATED {@link LEGACY_REALTIME_CO_AGENT_NAME} (pre-rename seed) with a
|
|
985
|
+
* deprecation log so un-resynced deployments keep working. Throws a clear error when neither
|
|
986
|
+
* name is present in metadata.
|
|
987
|
+
*/
|
|
988
|
+
resolveGlobalCoAgentID() {
|
|
989
|
+
const coAgent = this.findAgentByName(REALTIME_CO_AGENT_NAME);
|
|
990
|
+
if (coAgent) {
|
|
991
|
+
return coAgent.ID;
|
|
992
|
+
}
|
|
993
|
+
const legacy = this.findAgentByName(LEGACY_REALTIME_CO_AGENT_NAME);
|
|
994
|
+
if (legacy) {
|
|
995
|
+
LogStatus(`StartRealtimeClientSession: resolved the global co-agent via its DEPRECATED legacy name ` +
|
|
996
|
+
`'${LEGACY_REALTIME_CO_AGENT_NAME}'. Re-sync the agent seed metadata to rename it to ` +
|
|
997
|
+
`'${REALTIME_CO_AGENT_NAME}'.`);
|
|
998
|
+
return legacy.ID;
|
|
999
|
+
}
|
|
1000
|
+
throw new Error(`The '${REALTIME_CO_AGENT_NAME}' agent is not configured; cannot start a realtime voice session.`);
|
|
1001
|
+
}
|
|
1002
|
+
/** Case/whitespace-insensitive agent lookup by Name in {@link AIEngine}'s cached agents. */
|
|
1003
|
+
findAgentByName(name) {
|
|
1004
|
+
const wanted = name.toLowerCase();
|
|
1005
|
+
return (AIEngine.Instance.Agents ?? []).find(a => a.Name?.trim().toLowerCase() === wanted);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Mints the client session config for a just-created session. On failure, closes the session so
|
|
1009
|
+
* no half-open record leaks, then throws the service's error message.
|
|
1010
|
+
*
|
|
1011
|
+
* @param preferredModelId Optional explicit realtime model choice — threaded to the service,
|
|
1012
|
+
* which FAILS (no silent fallback) when the chosen model cannot be satisfied.
|
|
1013
|
+
* @param priorTranscript Optional capped, role-tagged transcript of the PRIOR session chain
|
|
1014
|
+
* (from {@link loadPriorTranscript}) — the service frames it into the system prompt so a
|
|
1015
|
+
* resumed session remembers the previous leg(s).
|
|
1016
|
+
*/
|
|
1017
|
+
async prepareClientSessionOrClose(session, coAgentID, targetAgentId, contextUser, provider, preferredModelId, clientTools, priorTranscript, configOverridesJson) {
|
|
1018
|
+
const prep = await this.clientSessionService.PrepareClientSession({
|
|
1019
|
+
CoAgentID: coAgentID,
|
|
1020
|
+
TargetAgentID: targetAgentId,
|
|
1021
|
+
AgentSessionID: session.ID,
|
|
1022
|
+
ConversationID: session.ConversationID ?? undefined,
|
|
1023
|
+
// MVP: conversation history is not yet hydrated into ChatMessage[]; the co-agent
|
|
1024
|
+
// companion prompt runs without prior turns. A later phase loads the session's
|
|
1025
|
+
// Conversation into ChatMessage[] for richer context.
|
|
1026
|
+
ConversationMessages: [],
|
|
1027
|
+
UserID: contextUser.ID,
|
|
1028
|
+
PreferredModelID: preferredModelId,
|
|
1029
|
+
// Client-declared, CLIENT-EXECUTED UI tools (see the mutation's SECURITY NOTE) —
|
|
1030
|
+
// merged after invoke-target-agent into the declared tool set.
|
|
1031
|
+
ExtraTools: clientTools,
|
|
1032
|
+
// Resume continuity: the prior leg(s)' transcript, framed into the system prompt.
|
|
1033
|
+
PriorTranscript: priorTranscript,
|
|
1034
|
+
// Pre-authorized runtime override layer (assertRuntimeOverridesAuthorized gated it).
|
|
1035
|
+
ConfigOverridesJson: configOverridesJson,
|
|
1036
|
+
}, contextUser, provider);
|
|
1037
|
+
if (!prep.Success || !prep.ClientConfig) {
|
|
1038
|
+
// Prep failure is an ERROR close, not an explicit user hang-up — stamp it as such.
|
|
1039
|
+
await this.sessionManager.CloseSession(session.ID, contextUser, provider, 'Error');
|
|
1040
|
+
throw new Error(prep.ErrorMessage ?? 'Failed to prepare the client realtime session.');
|
|
1041
|
+
}
|
|
1042
|
+
await this.persistObservabilityRunIDs(session, targetAgentId, prep.CoAgentRunID, prep.PromptRunID, prep.CoAgentRunStepID);
|
|
1043
|
+
const cfg = prep.ClientConfig;
|
|
1044
|
+
return {
|
|
1045
|
+
AgentSessionId: session.ID,
|
|
1046
|
+
ConversationId: session.ConversationID ?? '',
|
|
1047
|
+
Provider: cfg.Provider,
|
|
1048
|
+
Model: cfg.Model,
|
|
1049
|
+
EphemeralToken: cfg.EphemeralToken,
|
|
1050
|
+
ExpiresAt: cfg.ExpiresAt,
|
|
1051
|
+
SessionConfigJson: JSON.stringify(cfg.SessionConfig),
|
|
1052
|
+
ModelName: prep.ModelName,
|
|
1053
|
+
NarrationInstructionsTemplate: prep.NarrationInstructionsTemplate,
|
|
1054
|
+
NarrationPaceMs: prep.NarrationPaceMs,
|
|
1055
|
+
EffectiveConfigJson: prep.EffectiveConfig ? JSON.stringify(prep.EffectiveConfig) : undefined,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Writes the co-agent observability run ids into the session's `Config_` alongside the
|
|
1060
|
+
* authoritative `targetAgentID`, then saves the session. These ids are read back on close to
|
|
1061
|
+
* finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
|
|
1062
|
+
* not thrown — the voice session still proceeds, it just won't carry the run ids.
|
|
1063
|
+
*/
|
|
1064
|
+
async persistObservabilityRunIDs(session, targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID) {
|
|
1065
|
+
const config = { targetAgentID, coAgentRunID, promptRunID, coAgentRunStepID };
|
|
1066
|
+
session.Config_ = JSON.stringify(config);
|
|
1067
|
+
const saved = await session.Save();
|
|
1068
|
+
if (!saved) {
|
|
1069
|
+
LogError(`RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Loads a session, enforcing inbound ownership (`UserID === contextUser.ID`) and that it is not
|
|
1074
|
+
* `Closed`. Throws on any violation. Returns the loaded session for the caller to read from.
|
|
1075
|
+
*/
|
|
1076
|
+
async loadOwnedActiveSession(agentSessionId, contextUser, provider) {
|
|
1077
|
+
const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
|
|
1078
|
+
if (session.Status === 'Closed') {
|
|
1079
|
+
throw new Error(`Realtime session ${agentSessionId} is closed`);
|
|
1080
|
+
}
|
|
1081
|
+
return session;
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Loads a session and enforces inbound ownership (`UserID === contextUser.ID`) WITHOUT
|
|
1085
|
+
* rejecting `Closed` sessions — {@link SaveSessionChannelState}'s final on-end flush
|
|
1086
|
+
* legitimately arrives after the session closed. Throws when not found / not owned.
|
|
1087
|
+
*/
|
|
1088
|
+
async loadOwnedSession(agentSessionId, contextUser, provider) {
|
|
1089
|
+
const session = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
|
|
1090
|
+
const loaded = await session.Load(agentSessionId);
|
|
1091
|
+
if (!loaded) {
|
|
1092
|
+
throw new Error(`Realtime session ${agentSessionId} not found`);
|
|
1093
|
+
}
|
|
1094
|
+
if (!UUIDsEqual(session.UserID, contextUser.ID)) {
|
|
1095
|
+
LogError(`RealtimeClientSessionResolver: ownership check failed — user ${contextUser.ID} attempted to operate on session ${agentSessionId} owned by ${session.UserID}`);
|
|
1096
|
+
throw new Error('Not authorized: you do not own this realtime session');
|
|
1097
|
+
}
|
|
1098
|
+
return session;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Resolves an ACTIVE channel definition (`MJ: AI Agent Channels`) by name from
|
|
1102
|
+
* {@link AIEngineBase}'s cached `AgentChannels` (provider-scoped engine instance, lazy
|
|
1103
|
+
* `Config` — no per-call RunView; name matching is trim + case-insensitive, parity with the
|
|
1104
|
+
* previous SQL-collation lookup). Returns its id, or `null` (logged) when no active
|
|
1105
|
+
* definition exists — the channel seed is deployed separately, so its absence is tolerated,
|
|
1106
|
+
* never thrown.
|
|
1107
|
+
*/
|
|
1108
|
+
async resolveChannelID(channelName, contextUser, provider) {
|
|
1109
|
+
const wanted = channelName.trim().toLowerCase();
|
|
1110
|
+
let row;
|
|
1111
|
+
try {
|
|
1112
|
+
const engine = await this.configuredAIEngineBase(contextUser, provider);
|
|
1113
|
+
row = (engine.AgentChannels ?? []).find((c) => c.Name?.trim().toLowerCase() === wanted);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
LogError(`RealtimeClientSessionResolver: ${CHANNEL_ENTITY} cache read failed while resolving channel ` +
|
|
1117
|
+
`'${channelName}' (${error.message}) — channel operation skipped.`);
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
if (!row || !row.IsActive) {
|
|
1121
|
+
LogError(`RealtimeClientSessionResolver: no active '${channelName}' channel definition found in ${CHANNEL_ENTITY} — ` +
|
|
1122
|
+
'channel operation skipped (sync the channel seed metadata to enable it).');
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
return row.ID;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Upserts the session-channel row for `(agentSessionID, channelID)`: creates it with Status
|
|
1129
|
+
* `Connected` when missing, stores `stateJson` in `Config`, and stamps `LastActiveAt`.
|
|
1130
|
+
* Returns the boolean save result (logged on failure).
|
|
1131
|
+
*/
|
|
1132
|
+
async upsertSessionChannelState(agentSessionID, channelID, stateJson, contextUser, provider) {
|
|
1133
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1134
|
+
const existing = await rv.RunView({
|
|
1135
|
+
EntityName: SESSION_CHANNEL_ENTITY,
|
|
1136
|
+
ExtraFilter: `AgentSessionID='${agentSessionID}' AND ChannelID='${channelID}'`,
|
|
1137
|
+
ResultType: 'entity_object',
|
|
1138
|
+
MaxRows: 1,
|
|
1139
|
+
}, contextUser);
|
|
1140
|
+
let row = existing.Success ? existing.Results?.[0] : undefined;
|
|
1141
|
+
if (!row) {
|
|
1142
|
+
row = await provider.GetEntityObject(SESSION_CHANNEL_ENTITY, contextUser);
|
|
1143
|
+
row.NewRecord();
|
|
1144
|
+
row.AgentSessionID = agentSessionID;
|
|
1145
|
+
row.ChannelID = channelID;
|
|
1146
|
+
row.Status = 'Connected';
|
|
1147
|
+
}
|
|
1148
|
+
row.Config_ = stateJson;
|
|
1149
|
+
row.LastActiveAt = new Date();
|
|
1150
|
+
const saved = await row.Save();
|
|
1151
|
+
if (!saved) {
|
|
1152
|
+
LogError(`SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
|
|
1153
|
+
`${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1154
|
+
}
|
|
1155
|
+
return saved;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Loads the PRIOR session's persisted channel states and serializes them as a JSON object
|
|
1159
|
+
* string keyed by channel NAME (the session-channel view denormalizes the channel name as the
|
|
1160
|
+
* `Channel` virtual field). Strictly best-effort — every failure path logs and returns `null`,
|
|
1161
|
+
* a session start NEVER fails because of restore:
|
|
1162
|
+
* - no `lastSessionId` → `null` (silently — restore simply wasn't requested);
|
|
1163
|
+
* - prior session missing OR owned by a different user → `null` (logged — never leak another
|
|
1164
|
+
* user's channel state);
|
|
1165
|
+
* - rows with an empty `Config` are skipped;
|
|
1166
|
+
* - an individual state larger than {@link MAX_CHANNEL_STATE_CHARS} is dropped (logged), and
|
|
1167
|
+
* states that would push the accumulated payload past that same cap are dropped too (logged)
|
|
1168
|
+
* — the surviving states still restore;
|
|
1169
|
+
* - no surviving states → `null`.
|
|
1170
|
+
*/
|
|
1171
|
+
async loadPriorChannelStatesJson(lastSessionId, contextUser, provider) {
|
|
1172
|
+
if (!lastSessionId) {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
const prior = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
|
|
1177
|
+
if (!(await prior.Load(lastSessionId))) {
|
|
1178
|
+
LogError(`StartRealtimeClientSession: prior session ${lastSessionId} not found — skipping channel-state restore.`);
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
if (!UUIDsEqual(prior.UserID, contextUser.ID)) {
|
|
1182
|
+
LogError(`StartRealtimeClientSession: prior session ${lastSessionId} is not owned by user ${contextUser.ID} — ` +
|
|
1183
|
+
'skipping channel-state restore.');
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
const safeID = lastSessionId.replace(/'/g, "''");
|
|
1187
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1188
|
+
const result = await rv.RunView({
|
|
1189
|
+
EntityName: SESSION_CHANNEL_ENTITY,
|
|
1190
|
+
ExtraFilter: `AgentSessionID='${safeID}'`,
|
|
1191
|
+
Fields: ['Channel', 'Config'],
|
|
1192
|
+
ResultType: 'simple',
|
|
1193
|
+
}, contextUser);
|
|
1194
|
+
if (!result.Success) {
|
|
1195
|
+
LogError(`StartRealtimeClientSession: channel-state restore query failed for prior session ${lastSessionId}: ${result.ErrorMessage}`);
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
const states = this.collectChannelStates(result.Results ?? [], lastSessionId);
|
|
1199
|
+
return Object.keys(states).length > 0 ? JSON.stringify(states) : null;
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
LogError(`StartRealtimeClientSession: channel-state restore failed for prior session ${lastSessionId}: ${error.message}`);
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Loads the PRIOR session chain's transcript for model-context hydration: when a new session
|
|
1208
|
+
* carries `lastSessionId`, the chain's persisted, session-stamped `Conversation Details` are
|
|
1209
|
+
* folded into capped, role-tagged lines (`User: …` / `Assistant: …`) the prepare service
|
|
1210
|
+
* frames into the system prompt — so the model REMEMBERS the previous live leg(s).
|
|
1211
|
+
*
|
|
1212
|
+
* Chain walk + caps:
|
|
1213
|
+
* - follows `LastSessionID` BACKWARDS from `lastSessionId`, at most
|
|
1214
|
+
* {@link MAX_PRIOR_TRANSCRIPT_LEGS} legs, with a visited-set cycle guard (A→B→A stops);
|
|
1215
|
+
* - EVERY leg is ownership-checked like the channel-state restore — the FIRST leg failing
|
|
1216
|
+
* the check aborts hydration entirely (first leg) or ends the walk (deeper legs), so
|
|
1217
|
+
* another user's transcript can never leak;
|
|
1218
|
+
* - one details query covers all collected legs, chronological;
|
|
1219
|
+
* - hidden/error/empty rows are skipped, then the NEWEST {@link MAX_PRIOR_TRANSCRIPT_TURNS}
|
|
1220
|
+
* turns are kept and the total is capped at {@link MAX_PRIOR_TRANSCRIPT_CHARS} chars
|
|
1221
|
+
* (oldest dropped first).
|
|
1222
|
+
*
|
|
1223
|
+
* Strictly best-effort: every failure path logs and returns `undefined` — hydration NEVER
|
|
1224
|
+
* blocks a session start.
|
|
1225
|
+
*/
|
|
1226
|
+
async loadPriorTranscript(lastSessionId, contextUser, provider) {
|
|
1227
|
+
if (!lastSessionId) {
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
1231
|
+
const legIDs = await this.collectOwnedPriorLegIDs(lastSessionId, contextUser, provider);
|
|
1232
|
+
if (legIDs.length === 0) {
|
|
1233
|
+
return undefined;
|
|
1234
|
+
}
|
|
1235
|
+
const turns = await this.loadChainTranscriptTurns(legIDs, contextUser, provider);
|
|
1236
|
+
const lines = this.capTranscriptLines(turns);
|
|
1237
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
1238
|
+
}
|
|
1239
|
+
catch (error) {
|
|
1240
|
+
LogError(`StartRealtimeClientSession: prior-transcript hydration failed for session ${lastSessionId}: ${error.message}`);
|
|
1241
|
+
return undefined;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Walks the prior-session chain backwards from `lastSessionId`, returning the OWNED leg ids
|
|
1246
|
+
* (newest first). The first leg must exist and be owned by the caller, else `[]` (logged);
|
|
1247
|
+
* deeper-leg problems (missing, unowned, cycle) just end the walk.
|
|
1248
|
+
*/
|
|
1249
|
+
async collectOwnedPriorLegIDs(lastSessionId, contextUser, provider) {
|
|
1250
|
+
const legIDs = [];
|
|
1251
|
+
const visited = new Set();
|
|
1252
|
+
let cursor = lastSessionId;
|
|
1253
|
+
while (cursor && legIDs.length < MAX_PRIOR_TRANSCRIPT_LEGS) {
|
|
1254
|
+
const key = cursor.trim().toLowerCase();
|
|
1255
|
+
if (visited.has(key)) {
|
|
1256
|
+
LogError(`StartRealtimeClientSession: prior-session chain cycle detected at ${cursor} — stopping the transcript walk.`);
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
visited.add(key);
|
|
1260
|
+
const leg = await provider.GetEntityObject(SESSION_ENTITY, contextUser);
|
|
1261
|
+
if (!(await leg.Load(cursor))) {
|
|
1262
|
+
if (legIDs.length === 0) {
|
|
1263
|
+
LogError(`StartRealtimeClientSession: prior session ${cursor} not found — skipping transcript hydration.`);
|
|
1264
|
+
}
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
if (!UUIDsEqual(leg.UserID, contextUser.ID)) {
|
|
1268
|
+
if (legIDs.length === 0) {
|
|
1269
|
+
LogError(`StartRealtimeClientSession: prior session ${cursor} is not owned by user ${contextUser.ID} — ` +
|
|
1270
|
+
'skipping transcript hydration.');
|
|
1271
|
+
}
|
|
1272
|
+
break;
|
|
1273
|
+
}
|
|
1274
|
+
legIDs.push(leg.ID);
|
|
1275
|
+
cursor = leg.LastSessionID ?? null;
|
|
1276
|
+
}
|
|
1277
|
+
return legIDs;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Loads the chain legs' visible transcript turns (session-stamped `Conversation Details`,
|
|
1281
|
+
* `Role` User/AI, not hidden, non-empty) in ONE chronological query across all legs.
|
|
1282
|
+
*/
|
|
1283
|
+
async loadChainTranscriptTurns(legIDs, contextUser, provider) {
|
|
1284
|
+
const inList = legIDs.map((id) => `'${id.replace(/'/g, "''")}'`).join(',');
|
|
1285
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1286
|
+
const result = await rv.RunView({
|
|
1287
|
+
EntityName: CONVERSATION_DETAIL_ENTITY,
|
|
1288
|
+
ExtraFilter: `AgentSessionID IN (${inList})`,
|
|
1289
|
+
Fields: ['ID', 'Role', 'Message', 'HiddenToUser', '__mj_CreatedAt'],
|
|
1290
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
1291
|
+
ResultType: 'simple',
|
|
1292
|
+
}, contextUser);
|
|
1293
|
+
if (!result.Success) {
|
|
1294
|
+
LogError(`StartRealtimeClientSession: prior-transcript query failed: ${result.ErrorMessage}`);
|
|
1295
|
+
return [];
|
|
1296
|
+
}
|
|
1297
|
+
return (result.Results ?? []).filter((row) => !row.HiddenToUser &&
|
|
1298
|
+
(row.Role === 'User' || row.Role === 'AI') &&
|
|
1299
|
+
typeof row.Message === 'string' &&
|
|
1300
|
+
row.Message.trim().length > 0);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Maps visible turns to role-tagged lines and applies the hydration caps: the NEWEST
|
|
1304
|
+
* {@link MAX_PRIOR_TRANSCRIPT_TURNS} turns, then a total budget of
|
|
1305
|
+
* {@link MAX_PRIOR_TRANSCRIPT_CHARS} chars — oldest lines dropped first in both passes,
|
|
1306
|
+
* so the model always keeps the freshest context.
|
|
1307
|
+
*/
|
|
1308
|
+
capTranscriptLines(turns) {
|
|
1309
|
+
const newest = turns.slice(-MAX_PRIOR_TRANSCRIPT_TURNS);
|
|
1310
|
+
const lines = newest.map((t) => `${t.Role === 'User' ? 'User' : 'Assistant'}: ${t.Message.trim()}`);
|
|
1311
|
+
let total = lines.reduce((sum, line) => sum + line.length + 1, 0);
|
|
1312
|
+
while (lines.length > 0 && total > MAX_PRIOR_TRANSCRIPT_CHARS) {
|
|
1313
|
+
const dropped = lines.shift();
|
|
1314
|
+
total -= dropped.length + 1;
|
|
1315
|
+
}
|
|
1316
|
+
return lines;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Folds prior session-channel rows into a `{ channelName: stateJson }` map, skipping empty
|
|
1320
|
+
* states and enforcing the {@link MAX_CHANNEL_STATE_CHARS} cap both per-state and on the
|
|
1321
|
+
* accumulated total (oversized states are dropped with a log note; the rest survive).
|
|
1322
|
+
*/
|
|
1323
|
+
collectChannelStates(rows, lastSessionId) {
|
|
1324
|
+
const states = {};
|
|
1325
|
+
let totalChars = 0;
|
|
1326
|
+
for (const row of rows) {
|
|
1327
|
+
if (!row.Channel || !row.Config) {
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (row.Config.length > MAX_CHANNEL_STATE_CHARS || totalChars + row.Config.length > MAX_CHANNEL_STATE_CHARS) {
|
|
1331
|
+
LogError(`StartRealtimeClientSession: dropped oversized '${row.Channel}' channel state from prior session ${lastSessionId} ` +
|
|
1332
|
+
`(${row.Config.length} chars; restore payload is capped at ${MAX_CHANNEL_STATE_CHARS}).`);
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
states[row.Channel] = row.Config;
|
|
1336
|
+
totalChars += row.Config.length;
|
|
1337
|
+
}
|
|
1338
|
+
return states;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Resolves the ENABLED {@link WHITEBOARD_ARTIFACT_TYPE_NAME} artifact type's id. Returns `null`
|
|
1342
|
+
* (logged) when the type seed is absent or disabled — the artifact-type seed is deployed
|
|
1343
|
+
* separately, so its absence is a graceful structured failure, never a throw.
|
|
1344
|
+
*/
|
|
1345
|
+
async resolveWhiteboardArtifactTypeID(contextUser, provider) {
|
|
1346
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1347
|
+
const result = await rv.RunView({
|
|
1348
|
+
EntityName: ARTIFACT_TYPE_ENTITY,
|
|
1349
|
+
ExtraFilter: `Name='${WHITEBOARD_ARTIFACT_TYPE_NAME}'`,
|
|
1350
|
+
Fields: ['ID', 'IsEnabled'],
|
|
1351
|
+
ResultType: 'simple',
|
|
1352
|
+
MaxRows: 1,
|
|
1353
|
+
}, contextUser);
|
|
1354
|
+
const row = result.Success ? result.Results?.[0] : undefined;
|
|
1355
|
+
if (!row || !row.IsEnabled) {
|
|
1356
|
+
LogError(`SaveSessionChannelArtifact: no enabled '${WHITEBOARD_ARTIFACT_TYPE_NAME}' artifact type found in ${ARTIFACT_TYPE_ENTITY} — ` +
|
|
1357
|
+
'artifact not created (sync the artifact-type seed metadata to enable this).');
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
return row.ID;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Creates the user-owned artifact header + its v1 version for a channel-state save, then runs
|
|
1364
|
+
* the best-effort extras (conversation-history junction + session-channel `LastActiveAt`
|
|
1365
|
+
* stamp). Header/version save failures come back as structured failures; the extras NEVER
|
|
1366
|
+
* fail the save.
|
|
1367
|
+
*/
|
|
1368
|
+
async createChannelArtifact(session, channelName, name, contentJson, typeID, contextUser, provider) {
|
|
1369
|
+
const artifact = await provider.GetEntityObject(ARTIFACT_ENTITY, contextUser);
|
|
1370
|
+
artifact.NewRecord();
|
|
1371
|
+
artifact.Name = name;
|
|
1372
|
+
artifact.Description = `Saved from realtime agent session ${session.ID} ('${channelName}' channel).`;
|
|
1373
|
+
artifact.TypeID = typeID;
|
|
1374
|
+
artifact.UserID = contextUser.ID;
|
|
1375
|
+
artifact.Visibility = 'Always';
|
|
1376
|
+
if (!(await artifact.Save())) {
|
|
1377
|
+
const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
|
|
1378
|
+
LogError(message);
|
|
1379
|
+
return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
|
|
1380
|
+
}
|
|
1381
|
+
const version = await provider.GetEntityObject(ARTIFACT_VERSION_ENTITY, contextUser);
|
|
1382
|
+
version.NewRecord();
|
|
1383
|
+
version.ArtifactID = artifact.ID;
|
|
1384
|
+
version.VersionNumber = 1;
|
|
1385
|
+
version.Content = contentJson;
|
|
1386
|
+
version.UserID = contextUser.ID;
|
|
1387
|
+
if (!(await version.Save())) {
|
|
1388
|
+
const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
|
|
1389
|
+
LogError(message);
|
|
1390
|
+
return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
|
|
1391
|
+
}
|
|
1392
|
+
const linked = await this.linkVersionToLatestSessionDetail(session, version.ID, contextUser, provider);
|
|
1393
|
+
await this.stampSessionChannelLastActive(session.ID, channelName, contextUser, provider);
|
|
1394
|
+
return { Success: true, ArtifactID: artifact.ID, ArtifactVersionID: version.ID, ConversationDetailLinked: linked };
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Best-effort: links an artifact version into conversation history the way chat does — via a
|
|
1398
|
+
* `MJ: Conversation Detail Artifacts` junction row (Direction `Output`) against the LATEST
|
|
1399
|
+
* `Conversation Detail` stamped with this session's id (the transcript relay stamps
|
|
1400
|
+
* `AgentSessionID` on every persisted turn). Skips silently (returns `false`) when the session
|
|
1401
|
+
* has no conversation or no stamped detail exists yet; logs (and returns `false`) when the
|
|
1402
|
+
* lookup or junction save fails. Never throws.
|
|
1403
|
+
*/
|
|
1404
|
+
async linkVersionToLatestSessionDetail(session, artifactVersionID, contextUser, provider) {
|
|
1405
|
+
if (!session.ConversationID) {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
const detailID = await this.findLatestSessionDetailID(session, contextUser, provider);
|
|
1410
|
+
if (!detailID) {
|
|
1411
|
+
return false; // no transcript turn persisted yet — nothing to anchor the artifact to
|
|
1412
|
+
}
|
|
1413
|
+
return await this.saveDetailArtifactJunction(detailID, artifactVersionID, contextUser, provider);
|
|
1414
|
+
}
|
|
1415
|
+
catch (error) {
|
|
1416
|
+
LogError(`SaveSessionChannelArtifact: conversation-history link failed: ${error.message}`);
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Best-effort: junction-links the artifacts a DELEGATED target-agent run produced into the
|
|
1422
|
+
* session's conversation history — closing the gap where voice-path artifacts were created
|
|
1423
|
+
* (`RealtimeClientSessionService.createDelegatedRunArtifacts`) but never reached
|
|
1424
|
+
* `MJ: Conversation Detail Artifacts`, leaving chat, session review, and resume carryover
|
|
1425
|
+
* blind to them.
|
|
1426
|
+
*
|
|
1427
|
+
* ANCHOR CHOICE: the junction needs a `Conversation Detail`. We anchor to the MOST RECENT
|
|
1428
|
+
* detail stamped with this session's id (the transcript relay stamps `AgentSessionID` on every
|
|
1429
|
+
* persisted turn) — the turn closest to the tool call that produced the artifact. When NO
|
|
1430
|
+
* stamped detail exists yet (the delegated run finished before the browser relayed any
|
|
1431
|
+
* transcript — the relay is asynchronous), we create a minimal HIDDEN anchor detail
|
|
1432
|
+
* (`Role: 'AI'`, `HiddenToUser: true`, stamped with the session id + conversation + user)
|
|
1433
|
+
* rather than dropping the link: `HiddenToUser` keeps it out of the visible chat thread and
|
|
1434
|
+
* out of review-mode captions (both filter hidden rows), while conversation-level artifact
|
|
1435
|
+
* queries (which scan junctions across ALL details) still surface the artifact. This is the
|
|
1436
|
+
* least-invasive correct anchor — no fake visible message, no orphaned artifact.
|
|
1437
|
+
*
|
|
1438
|
+
* Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
|
|
1439
|
+
* because history linking did.
|
|
1440
|
+
*/
|
|
1441
|
+
async linkDelegatedArtifactsToConversation(session, artifacts, contextUser, provider) {
|
|
1442
|
+
if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
const detailID = (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
|
|
1447
|
+
(await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
|
|
1448
|
+
if (!detailID) {
|
|
1449
|
+
return; // anchor unavailable — logged in the helpers
|
|
1450
|
+
}
|
|
1451
|
+
for (const artifact of artifacts) {
|
|
1452
|
+
await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
catch (error) {
|
|
1456
|
+
LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${error.message}`);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Finds the LATEST `Conversation Detail` stamped with this session's id (the transcript relay
|
|
1461
|
+
* stamps `AgentSessionID` on every persisted turn). Returns its id, or `null` when none exists.
|
|
1462
|
+
*/
|
|
1463
|
+
async findLatestSessionDetailID(session, contextUser, provider) {
|
|
1464
|
+
const safeID = session.ID.replace(/'/g, "''");
|
|
1465
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1466
|
+
const result = await rv.RunView({
|
|
1467
|
+
EntityName: CONVERSATION_DETAIL_ENTITY,
|
|
1468
|
+
ExtraFilter: `AgentSessionID='${safeID}'`,
|
|
1469
|
+
Fields: ['ID'],
|
|
1470
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
1471
|
+
ResultType: 'simple',
|
|
1472
|
+
MaxRows: 1,
|
|
1473
|
+
}, contextUser);
|
|
1474
|
+
const detail = result.Success ? result.Results?.[0] : undefined;
|
|
1475
|
+
return detail?.ID ?? null;
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Creates the minimal HIDDEN anchor `Conversation Detail` for artifact junction rows when no
|
|
1479
|
+
* session-stamped transcript turn exists yet (see the anchor-choice rationale on
|
|
1480
|
+
* {@link linkDelegatedArtifactsToConversation}). Returns the new detail's id, or `null`
|
|
1481
|
+
* (logged) when the save fails.
|
|
1482
|
+
*/
|
|
1483
|
+
async createHiddenSessionAnchorDetail(session, contextUser, provider) {
|
|
1484
|
+
const detail = await provider.GetEntityObject(CONVERSATION_DETAIL_ENTITY, contextUser);
|
|
1485
|
+
detail.NewRecord();
|
|
1486
|
+
detail.ConversationID = session.ConversationID;
|
|
1487
|
+
detail.Role = 'AI';
|
|
1488
|
+
detail.HiddenToUser = true;
|
|
1489
|
+
detail.Message = 'Artifacts produced during a realtime session (system anchor).';
|
|
1490
|
+
detail.AgentSessionID = session.ID;
|
|
1491
|
+
detail.UserID = contextUser.ID;
|
|
1492
|
+
if (await detail.Save()) {
|
|
1493
|
+
return detail.ID;
|
|
1494
|
+
}
|
|
1495
|
+
LogError(`ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
|
|
1496
|
+
`${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Saves one `MJ: Conversation Detail Artifacts` junction row (Direction `Output`) linking an
|
|
1501
|
+
* artifact version to a conversation detail. Returns the boolean save result (logged on failure).
|
|
1502
|
+
*/
|
|
1503
|
+
async saveDetailArtifactJunction(conversationDetailID, artifactVersionID, contextUser, provider) {
|
|
1504
|
+
const junction = await provider.GetEntityObject(CONVERSATION_DETAIL_ARTIFACT_ENTITY, contextUser);
|
|
1505
|
+
junction.NewRecord();
|
|
1506
|
+
junction.ConversationDetailID = conversationDetailID;
|
|
1507
|
+
junction.ArtifactVersionID = artifactVersionID;
|
|
1508
|
+
junction.Direction = 'Output';
|
|
1509
|
+
const saved = await junction.Save();
|
|
1510
|
+
if (!saved) {
|
|
1511
|
+
LogError(`RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
|
|
1512
|
+
`${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1513
|
+
}
|
|
1514
|
+
return saved;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Best-effort: stamps `LastActiveAt` on the session-channel row for `(session, channelName)`
|
|
1518
|
+
* when one exists — saving an artifact IS channel activity. Missing channel definition or
|
|
1519
|
+
* session-channel row is a silent no-op; failures log and never throw.
|
|
1520
|
+
*/
|
|
1521
|
+
async stampSessionChannelLastActive(agentSessionID, channelName, contextUser, provider) {
|
|
1522
|
+
try {
|
|
1523
|
+
const channelID = await this.resolveChannelID(channelName, contextUser, provider);
|
|
1524
|
+
if (!channelID) {
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1528
|
+
const existing = await rv.RunView({
|
|
1529
|
+
EntityName: SESSION_CHANNEL_ENTITY,
|
|
1530
|
+
ExtraFilter: `AgentSessionID='${agentSessionID}' AND ChannelID='${channelID}'`,
|
|
1531
|
+
ResultType: 'entity_object',
|
|
1532
|
+
MaxRows: 1,
|
|
1533
|
+
}, contextUser);
|
|
1534
|
+
const row = existing.Success ? existing.Results?.[0] : undefined;
|
|
1535
|
+
if (!row) {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
row.LastActiveAt = new Date();
|
|
1539
|
+
if (!(await row.Save())) {
|
|
1540
|
+
LogError(`SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
|
|
1541
|
+
`${row.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
LogError(`SaveSessionChannelArtifact: LastActiveAt stamp failed: ${error.message}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Tolerantly parses + validates client-declared UI tool definitions (see the SECURITY NOTE on
|
|
1550
|
+
* {@link StartRealtimeClientSession}). Never throws — any rejection logs and returns
|
|
1551
|
+
* `undefined` (the session simply minted without client tools):
|
|
1552
|
+
* - non-JSON / non-array payloads are rejected wholesale;
|
|
1553
|
+
* - payloads larger than {@link MAX_CLIENT_TOOLS_JSON_CHARS} are rejected wholesale;
|
|
1554
|
+
* - more than {@link MAX_CLIENT_TOOLS} declarations are rejected wholesale (a legitimate
|
|
1555
|
+
* client never sends that many — a flood is a misbehaving caller, not a trim candidate);
|
|
1556
|
+
* - individual entries failing the shape check (`Name`/`Description` non-empty strings,
|
|
1557
|
+
* `ParametersSchema` a plain object) are skipped, the rest survive.
|
|
1558
|
+
*/
|
|
1559
|
+
parseClientTools(clientToolsJson) {
|
|
1560
|
+
if (!clientToolsJson) {
|
|
1561
|
+
return undefined;
|
|
1562
|
+
}
|
|
1563
|
+
if (clientToolsJson.length > MAX_CLIENT_TOOLS_JSON_CHARS) {
|
|
1564
|
+
LogError(`StartRealtimeClientSession: clientToolsJson rejected — ${clientToolsJson.length} chars exceeds the ${MAX_CLIENT_TOOLS_JSON_CHARS} cap.`);
|
|
1565
|
+
return undefined;
|
|
1566
|
+
}
|
|
1567
|
+
let parsed;
|
|
1568
|
+
try {
|
|
1569
|
+
parsed = JSON.parse(clientToolsJson);
|
|
1570
|
+
}
|
|
1571
|
+
catch {
|
|
1572
|
+
LogError('StartRealtimeClientSession: clientToolsJson rejected — not valid JSON.');
|
|
1573
|
+
return undefined;
|
|
1574
|
+
}
|
|
1575
|
+
if (!Array.isArray(parsed)) {
|
|
1576
|
+
LogError('StartRealtimeClientSession: clientToolsJson rejected — expected a JSON array of tool definitions.');
|
|
1577
|
+
return undefined;
|
|
1578
|
+
}
|
|
1579
|
+
if (parsed.length > MAX_CLIENT_TOOLS) {
|
|
1580
|
+
LogError(`StartRealtimeClientSession: clientToolsJson rejected — ${parsed.length} tools exceeds the ${MAX_CLIENT_TOOLS} cap.`);
|
|
1581
|
+
return undefined;
|
|
1582
|
+
}
|
|
1583
|
+
const valid = parsed.filter((t) => this.isValidClientTool(t));
|
|
1584
|
+
if (valid.length < parsed.length) {
|
|
1585
|
+
LogError(`StartRealtimeClientSession: skipped ${parsed.length - valid.length} malformed client tool definition(s).`);
|
|
1586
|
+
}
|
|
1587
|
+
return valid.length > 0 ? valid : undefined;
|
|
1588
|
+
}
|
|
1589
|
+
/** Shape check for one client tool declaration: Name/Description non-empty strings, ParametersSchema a plain object. */
|
|
1590
|
+
isValidClientTool(candidate) {
|
|
1591
|
+
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
const tool = candidate;
|
|
1595
|
+
return (typeof tool.Name === 'string' && tool.Name.trim().length > 0 && tool.Name.length <= 128 &&
|
|
1596
|
+
typeof tool.Description === 'string' && tool.Description.trim().length > 0 &&
|
|
1597
|
+
tool.ParametersSchema !== null && typeof tool.ParametersSchema === 'object' && !Array.isArray(tool.ParametersSchema));
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
|
|
1601
|
+
* observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
|
|
1602
|
+
* or carries no target — a relay cannot proceed without a target.
|
|
1603
|
+
*/
|
|
1604
|
+
readSessionConfig(session) {
|
|
1605
|
+
const raw = session.Config_;
|
|
1606
|
+
if (raw) {
|
|
1607
|
+
try {
|
|
1608
|
+
const parsed = JSON.parse(raw);
|
|
1609
|
+
if (typeof parsed.targetAgentID === 'string' && parsed.targetAgentID.length > 0) {
|
|
1610
|
+
return {
|
|
1611
|
+
targetAgentID: parsed.targetAgentID,
|
|
1612
|
+
coAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
|
|
1613
|
+
promptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
|
|
1614
|
+
coAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
|
|
1615
|
+
pendingFeedbackRunID: typeof parsed.pendingFeedbackRunID === 'string' ? parsed.pendingFeedbackRunID : undefined,
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
/* fall through to the error below */
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
throw new Error(`Realtime session ${session.ID} has no target agent configured`);
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Persists a single transcript turn as a `Conversation Detail` stamped with the session's
|
|
1627
|
+
* conversation, the mapped role, the turn text, the session id, and the owning user.
|
|
1628
|
+
*
|
|
1629
|
+
* @returns The boolean save result (logs `CompleteMessage` on failure).
|
|
1630
|
+
*/
|
|
1631
|
+
async persistTranscriptTurn(session, role, text, contextUser, provider) {
|
|
1632
|
+
const detail = await provider.GetEntityObject(CONVERSATION_DETAIL_ENTITY, contextUser);
|
|
1633
|
+
detail.NewRecord();
|
|
1634
|
+
detail.ConversationID = session.ConversationID;
|
|
1635
|
+
detail.Role = this.mapTranscriptRole(role);
|
|
1636
|
+
detail.Message = text;
|
|
1637
|
+
detail.AgentSessionID = session.ID;
|
|
1638
|
+
detail.UserID = contextUser.ID;
|
|
1639
|
+
const saved = await detail.Save();
|
|
1640
|
+
if (!saved) {
|
|
1641
|
+
LogError(`RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1642
|
+
}
|
|
1643
|
+
return saved;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* CORRECTION persistence (the client transcript's `ReplacesPrevious` marker — e.g.
|
|
1647
|
+
* ElevenLabs' post-barge-in `agent_response_correction`): UPDATES the session's most
|
|
1648
|
+
* recently persisted turn of the same role IN PLACE with the corrected text, instead of
|
|
1649
|
+
* appending a near-duplicate. Falls back to a plain insert when no prior turn exists
|
|
1650
|
+
* (the superseded turn may have failed to persist) — a correction is never dropped.
|
|
1651
|
+
*/
|
|
1652
|
+
async replacePreviousTranscriptTurn(session, role, text, contextUser, provider) {
|
|
1653
|
+
const mappedRole = this.mapTranscriptRole(role);
|
|
1654
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
1655
|
+
const result = await rv.RunView({
|
|
1656
|
+
EntityName: CONVERSATION_DETAIL_ENTITY,
|
|
1657
|
+
ExtraFilter: `AgentSessionID='${session.ID}' AND Role='${mappedRole}'`,
|
|
1658
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
1659
|
+
MaxRows: 1,
|
|
1660
|
+
ResultType: 'entity_object',
|
|
1661
|
+
}, contextUser);
|
|
1662
|
+
const previous = result.Success ? (result.Results?.[0] ?? null) : null;
|
|
1663
|
+
if (!previous) {
|
|
1664
|
+
return this.persistTranscriptTurn(session, role, text, contextUser, provider);
|
|
1665
|
+
}
|
|
1666
|
+
previous.Message = text;
|
|
1667
|
+
const saved = await previous.Save();
|
|
1668
|
+
if (!saved) {
|
|
1669
|
+
LogError(`RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed: ${previous.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1670
|
+
}
|
|
1671
|
+
return saved;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Maps a transport-level transcript role (`'user'` / `'assistant'`) onto the
|
|
1675
|
+
* `Conversation Detail` entity's `Role` union (`'User'` / `'AI'`). Anything not the user is
|
|
1676
|
+
* treated as the assistant/AI side.
|
|
1677
|
+
*/
|
|
1678
|
+
mapTranscriptRole(role) {
|
|
1679
|
+
return role.trim().toLowerCase() === 'user' ? 'User' : 'AI';
|
|
1680
|
+
}
|
|
1681
|
+
};
|
|
1682
|
+
__decorate([
|
|
1683
|
+
Mutation(() => StartRealtimeClientSessionResult),
|
|
1684
|
+
__param(0, Arg('targetAgentId', () => String, { nullable: true })),
|
|
1685
|
+
__param(1, Ctx()),
|
|
1686
|
+
__param(2, Arg('conversationId', () => String, { nullable: true })),
|
|
1687
|
+
__param(3, Arg('lastSessionId', () => String, { nullable: true })),
|
|
1688
|
+
__param(4, Arg('preferredModelId', () => String, { nullable: true })),
|
|
1689
|
+
__param(5, Arg('clientToolsJson', () => String, { nullable: true })),
|
|
1690
|
+
__param(6, Arg('coAgentId', () => String, { nullable: true })),
|
|
1691
|
+
__param(7, Arg('configOverridesJson', () => String, { nullable: true })),
|
|
1692
|
+
__metadata("design:type", Function),
|
|
1693
|
+
__metadata("design:paramtypes", [String, Object, String, String, String, String, String, String]),
|
|
1694
|
+
__metadata("design:returntype", Promise)
|
|
1695
|
+
], RealtimeClientSessionResolver.prototype, "StartRealtimeClientSession", null);
|
|
1696
|
+
__decorate([
|
|
1697
|
+
Mutation(() => String),
|
|
1698
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1699
|
+
__param(1, Arg('callId', () => String)),
|
|
1700
|
+
__param(2, Arg('toolName', () => String)),
|
|
1701
|
+
__param(3, Arg('argsJson', () => String)),
|
|
1702
|
+
__param(4, Ctx()),
|
|
1703
|
+
__param(5, PubSub()),
|
|
1704
|
+
__metadata("design:type", Function),
|
|
1705
|
+
__metadata("design:paramtypes", [String, String, String, String, Object, PubSubEngine]),
|
|
1706
|
+
__metadata("design:returntype", Promise)
|
|
1707
|
+
], RealtimeClientSessionResolver.prototype, "ExecuteRealtimeSessionTool", null);
|
|
1708
|
+
__decorate([
|
|
1709
|
+
Mutation(() => Boolean),
|
|
1710
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1711
|
+
__param(1, Arg('role', () => String)),
|
|
1712
|
+
__param(2, Arg('text', () => String)),
|
|
1713
|
+
__param(3, Ctx()),
|
|
1714
|
+
__param(4, Arg('replacesPrevious', () => Boolean, { nullable: true })),
|
|
1715
|
+
__metadata("design:type", Function),
|
|
1716
|
+
__metadata("design:paramtypes", [String, String, String, Object, Boolean]),
|
|
1717
|
+
__metadata("design:returntype", Promise)
|
|
1718
|
+
], RealtimeClientSessionResolver.prototype, "RelayRealtimeTranscript", null);
|
|
1719
|
+
__decorate([
|
|
1720
|
+
Mutation(() => Boolean),
|
|
1721
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1722
|
+
__param(1, Arg('toolName', () => String)),
|
|
1723
|
+
__param(2, Ctx()),
|
|
1724
|
+
__param(3, Arg('argsJson', () => String, { nullable: true })),
|
|
1725
|
+
__param(4, Arg('resultJson', () => String, { nullable: true })),
|
|
1726
|
+
__metadata("design:type", Function),
|
|
1727
|
+
__metadata("design:paramtypes", [String, String, Object, String, String]),
|
|
1728
|
+
__metadata("design:returntype", Promise)
|
|
1729
|
+
], RealtimeClientSessionResolver.prototype, "RelayRealtimeToolTurn", null);
|
|
1730
|
+
__decorate([
|
|
1731
|
+
Mutation(() => CancelRealtimeSessionToolResult),
|
|
1732
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1733
|
+
__param(1, Ctx()),
|
|
1734
|
+
__param(2, Arg('callId', () => String, { nullable: true })),
|
|
1735
|
+
__metadata("design:type", Function),
|
|
1736
|
+
__metadata("design:paramtypes", [String, Object, String]),
|
|
1737
|
+
__metadata("design:returntype", Promise)
|
|
1738
|
+
], RealtimeClientSessionResolver.prototype, "CancelRealtimeSessionTool", null);
|
|
1739
|
+
__decorate([
|
|
1740
|
+
Mutation(() => Boolean),
|
|
1741
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1742
|
+
__param(1, Arg('inputTokens', () => Int)),
|
|
1743
|
+
__param(2, Arg('outputTokens', () => Int)),
|
|
1744
|
+
__param(3, Ctx()),
|
|
1745
|
+
__metadata("design:type", Function),
|
|
1746
|
+
__metadata("design:paramtypes", [String, Number, Number, Object]),
|
|
1747
|
+
__metadata("design:returntype", Promise)
|
|
1748
|
+
], RealtimeClientSessionResolver.prototype, "RelayRealtimeUsage", null);
|
|
1749
|
+
__decorate([
|
|
1750
|
+
Mutation(() => Boolean),
|
|
1751
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1752
|
+
__param(1, Arg('channelName', () => String)),
|
|
1753
|
+
__param(2, Arg('stateJson', () => String)),
|
|
1754
|
+
__param(3, Ctx()),
|
|
1755
|
+
__metadata("design:type", Function),
|
|
1756
|
+
__metadata("design:paramtypes", [String, String, String, Object]),
|
|
1757
|
+
__metadata("design:returntype", Promise)
|
|
1758
|
+
], RealtimeClientSessionResolver.prototype, "SaveSessionChannelState", null);
|
|
1759
|
+
__decorate([
|
|
1760
|
+
Mutation(() => SaveSessionChannelArtifactResult),
|
|
1761
|
+
__param(0, Arg('agentSessionId', () => String)),
|
|
1762
|
+
__param(1, Arg('channelName', () => String)),
|
|
1763
|
+
__param(2, Arg('name', () => String)),
|
|
1764
|
+
__param(3, Arg('contentJson', () => String)),
|
|
1765
|
+
__param(4, Ctx()),
|
|
1766
|
+
__metadata("design:type", Function),
|
|
1767
|
+
__metadata("design:paramtypes", [String, String, String, String, Object]),
|
|
1768
|
+
__metadata("design:returntype", Promise)
|
|
1769
|
+
], RealtimeClientSessionResolver.prototype, "SaveSessionChannelArtifact", null);
|
|
1770
|
+
RealtimeClientSessionResolver = __decorate([
|
|
1771
|
+
Resolver()
|
|
1772
|
+
], RealtimeClientSessionResolver);
|
|
1773
|
+
export { RealtimeClientSessionResolver };
|
|
1774
|
+
//# sourceMappingURL=RealtimeClientSessionResolver.js.map
|