@memberjunction/ai-agents 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.
Files changed (88) hide show
  1. package/README.md +53 -0
  2. package/dist/AgentRunner.d.ts +5 -2
  3. package/dist/AgentRunner.d.ts.map +1 -1
  4. package/dist/AgentRunner.js +14 -4
  5. package/dist/AgentRunner.js.map +1 -1
  6. package/dist/MemoryWriteManager.d.ts +188 -0
  7. package/dist/MemoryWriteManager.d.ts.map +1 -0
  8. package/dist/MemoryWriteManager.js +299 -0
  9. package/dist/MemoryWriteManager.js.map +1 -0
  10. package/dist/agent-context-injector.d.ts +29 -0
  11. package/dist/agent-context-injector.d.ts.map +1 -1
  12. package/dist/agent-context-injector.js +90 -32
  13. package/dist/agent-context-injector.js.map +1 -1
  14. package/dist/agent-memory-context-builder.d.ts +100 -0
  15. package/dist/agent-memory-context-builder.d.ts.map +1 -0
  16. package/dist/agent-memory-context-builder.js +172 -0
  17. package/dist/agent-memory-context-builder.js.map +1 -0
  18. package/dist/agent-types/index.d.ts +1 -0
  19. package/dist/agent-types/index.d.ts.map +1 -1
  20. package/dist/agent-types/index.js +1 -0
  21. package/dist/agent-types/index.js.map +1 -1
  22. package/dist/agent-types/loop-agent-response-type.d.ts +12 -1
  23. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  24. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  25. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  26. package/dist/agent-types/loop-agent-type.js +4 -0
  27. package/dist/agent-types/loop-agent-type.js.map +1 -1
  28. package/dist/agent-types/realtime-agent-type.d.ts +146 -0
  29. package/dist/agent-types/realtime-agent-type.d.ts.map +1 -0
  30. package/dist/agent-types/realtime-agent-type.js +176 -0
  31. package/dist/agent-types/realtime-agent-type.js.map +1 -0
  32. package/dist/base-agent.d.ts +386 -39
  33. package/dist/base-agent.d.ts.map +1 -1
  34. package/dist/base-agent.js +1121 -261
  35. package/dist/base-agent.js.map +1 -1
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +17 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/memory-manager-agent.d.ts +99 -4
  41. package/dist/memory-manager-agent.d.ts.map +1 -1
  42. package/dist/memory-manager-agent.js +349 -117
  43. package/dist/memory-manager-agent.js.map +1 -1
  44. package/dist/realtime/bridge-realtime-session-factory.d.ts +111 -0
  45. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -0
  46. package/dist/realtime/bridge-realtime-session-factory.js +163 -0
  47. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -0
  48. package/dist/realtime/bridge-room-transcript-sink.d.ts +58 -0
  49. package/dist/realtime/bridge-room-transcript-sink.d.ts.map +1 -0
  50. package/dist/realtime/bridge-room-transcript-sink.js +127 -0
  51. package/dist/realtime/bridge-room-transcript-sink.js.map +1 -0
  52. package/dist/realtime/meeting-controls-channel-server.d.ts +198 -0
  53. package/dist/realtime/meeting-controls-channel-server.d.ts.map +1 -0
  54. package/dist/realtime/meeting-controls-channel-server.js +319 -0
  55. package/dist/realtime/meeting-controls-channel-server.js.map +1 -0
  56. package/dist/realtime/meeting-controls-state.d.ts +191 -0
  57. package/dist/realtime/meeting-controls-state.d.ts.map +1 -0
  58. package/dist/realtime/meeting-controls-state.js +219 -0
  59. package/dist/realtime/meeting-controls-state.js.map +1 -0
  60. package/dist/realtime/realtime-channel-server-host.d.ts +166 -0
  61. package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -0
  62. package/dist/realtime/realtime-channel-server-host.js +378 -0
  63. package/dist/realtime/realtime-channel-server-host.js.map +1 -0
  64. package/dist/realtime/realtime-client-session-service.d.ts +1026 -0
  65. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -0
  66. package/dist/realtime/realtime-client-session-service.js +1607 -0
  67. package/dist/realtime/realtime-client-session-service.js.map +1 -0
  68. package/dist/realtime/realtime-coagent-config.d.ts +258 -0
  69. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -0
  70. package/dist/realtime/realtime-coagent-config.js +408 -0
  71. package/dist/realtime/realtime-coagent-config.js.map +1 -0
  72. package/dist/realtime/realtime-narration.d.ts +67 -0
  73. package/dist/realtime/realtime-narration.d.ts.map +1 -0
  74. package/dist/realtime/realtime-narration.js +127 -0
  75. package/dist/realtime/realtime-narration.js.map +1 -0
  76. package/dist/realtime/realtime-session-runner.d.ts +383 -0
  77. package/dist/realtime/realtime-session-runner.d.ts.map +1 -0
  78. package/dist/realtime/realtime-session-runner.js +532 -0
  79. package/dist/realtime/realtime-session-runner.js.map +1 -0
  80. package/dist/realtime/realtime-tool-broker.d.ts +294 -0
  81. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -0
  82. package/dist/realtime/realtime-tool-broker.js +206 -0
  83. package/dist/realtime/realtime-tool-broker.js.map +1 -0
  84. package/dist/realtime/whiteboard-channel-server.d.ts +50 -0
  85. package/dist/realtime/whiteboard-channel-server.d.ts.map +1 -0
  86. package/dist/realtime/whiteboard-channel-server.js +85 -0
  87. package/dist/realtime/whiteboard-channel-server.js.map +1 -0
  88. package/package.json +17 -17
@@ -14,19 +14,24 @@ import { FileStorageEngineBase } from '@memberjunction/core-entities';
14
14
  import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase } from '@memberjunction/core';
15
15
  import { AgentRunWatchdog } from './agent-run-watchdog.js';
16
16
  import { AIPromptRunner } from '@memberjunction/ai-prompts';
17
+ import { BaseRealtimeModel, GetAIAPIKey } from '@memberjunction/ai';
17
18
  import { BaseAgentType } from './agent-types/base-agent-type.js';
18
- import { CopyScalarsAndArrays, JSONValidator, SafeExpressionEvaluator, UUIDsEqual } from '@memberjunction/global';
19
+ import { CopyScalarsAndArrays, JSONValidator, MJGlobal, SafeExpressionEvaluator, UUIDsEqual } from '@memberjunction/global';
20
+ import { RealtimeSessionRunner } from './realtime/realtime-session-runner.js';
21
+ import { ResolveNarrationInstructionsTemplate } from './realtime/realtime-narration.js';
22
+ import { BuildRealtimeOverridesJson, BuildVoiceMannerSection, GetNarrationPaceMs, GetProviderVoiceSettings, ResolveEffectiveRealtimeConfig } from './realtime/realtime-coagent-config.js';
23
+ import { RealtimeClientSessionService } from './realtime/realtime-client-session-service.js';
24
+ import { BuildRealtimeAgentFraming } from './realtime/realtime-tool-broker.js';
19
25
  import { AIEngine } from '@memberjunction/aiengine';
20
26
  import { ActionEngineServer } from '@memberjunction/actions';
21
27
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
22
- import { AgentContextInjector } from './agent-context-injector.js';
23
- import { AgentPreExecutionRAG } from './agent-pre-execution-rag.js';
24
- import { RerankerService } from '@memberjunction/ai-reranker';
25
- import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy } from '@memberjunction/ai-core-plus';
28
+ import { AgentMemoryContextBuilder } from './agent-memory-context-builder.js';
29
+ import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue } from '@memberjunction/ai-core-plus';
26
30
  import { AgentRunner } from './AgentRunner.js';
27
31
  import { PayloadManager } from './PayloadManager.js';
28
32
  import { ScratchpadManager } from './ScratchpadManager.js';
29
33
  import { ArtifactToolManager } from './ArtifactToolManager.js';
34
+ import { MemoryWriteManager } from './MemoryWriteManager.js';
30
35
  import { PipelineExecutor, PipelineToolRegistry, ActionInvocable, ArtifactToolInvocable, BuildPipelineToolDocs, formatFinalOutput, summarizePipelineStages, } from './pipeline/index.js';
31
36
  import { AgentDataPreloader } from './AgentDataPreloader.js';
32
37
  import { ClientToolRequestManager } from './ClientToolRequestManager.js';
@@ -89,20 +94,13 @@ export class BaseAgent {
89
94
  */
90
95
  this._promptRunner = new AIPromptRunner();
91
96
  /**
92
- * List of pending database save Promises for observability step records.
93
- * Awaited concurrently in finalizeAgentRun() to prevent blocking.
97
+ * Fire-and-forget save orchestration for this run's observability step records: the create INSERT is
98
+ * fired without blocking the agent flow (the PK is client-generated by `NewRecord()`), each finalize
99
+ * UPDATE chains after its step's INSERT and force-persists (`IgnoreDirtyState`), and all pending saves
100
+ * are flushed (`allSettled`) in {@link finalizeAgentRun}. The pattern lives once in
101
+ * {@link AgentRunStepSaveQueue} (shared with `@memberjunction/computer-use-engine`'s step tracker).
94
102
  */
95
- this._pendingSaves = [];
96
- /**
97
- * Queue map to chain database saves sequentially per step entity.
98
- * Prevents UPDATE queries running before INSERT queries on quick steps.
99
- *
100
- * Keyed by the step ENTITY INSTANCE, not its `ID`: a new step's `ID` is empty at create time and
101
- * only gets populated during its INSERT `Save()`, so keying by `ID` would file the create and the
102
- * finalize under different buckets and defeat the chain — letting the UPDATE race ahead of the
103
- * INSERT on millisecond-fast steps (e.g. pipelines), which left them stuck at `Running`.
104
- */
105
- this._stepSavePromises = new Map();
103
+ this._stepSaveQueue = new AgentRunStepSaveQueue();
106
104
  /**
107
105
  * Active per-request metadata provider, set at the start of Execute().
108
106
  * Defaults to the global Metadata.Provider; overridden when a per-request
@@ -209,6 +207,11 @@ export class BaseAgent {
209
207
  * Allows agents to explore input artifacts on demand.
210
208
  */
211
209
  this._artifactToolManager = new ArtifactToolManager();
210
+ /**
211
+ * Manages in-flight durable memory writes for the current agent run.
212
+ * Only consulted when the agent has AllowMemoryWrite enabled.
213
+ */
214
+ this._memoryWriteManager = new MemoryWriteManager();
212
215
  /**
213
216
  * Effective actions available to this agent after applying actionChanges.
214
217
  * Populated during gatherPromptTemplateData() and used for validation in executeActionsStep().
@@ -253,21 +256,16 @@ export class BaseAgent {
253
256
  * @private
254
257
  */
255
258
  this.MAX_RECOVERY_ATTEMPTS = 1;
256
- /**
257
- * Storage for injected memory context to prepend to prompts
258
- */
259
- this._memoryContext = '';
260
259
  /**
261
260
  * Storage for injected notes and examples to include in result
262
261
  */
263
262
  this._injectedMemory = { notes: [], examples: [] };
264
263
  /**
265
264
  * Storage for injected pre-execution RAG context (Phase 1C of search-scopes-rag-plus).
266
- * Contains the formatted `<retrieved_context>` system-message block actually injected
267
- * into `conversationMessages`, plus the structured per-scope / combined result detail
268
- * for downstream observability and artifact persistence.
265
+ * Contains the structured per-scope / combined result detail for downstream observability
266
+ * and artifact persistence. The formatted `<retrieved_context>` system-message block is
267
+ * unshifted onto `conversationMessages` by the shared {@link AgentMemoryContextBuilder}.
269
268
  */
270
- this._ragContext = '';
271
269
  this._injectedRAG = null;
272
270
  /**
273
271
  * Determines the request type ID based on the Chat step's context.
@@ -1006,6 +1004,7 @@ export class BaseAgent {
1006
1004
  // Reset scratchpad and artifact tools for each new execution (ephemeral per run)
1007
1005
  this._scratchpadManager.Clear();
1008
1006
  this._artifactToolManager.Clear();
1007
+ this._memoryWriteManager.Clear();
1009
1008
  // Initialize artifact tools with any input artifacts attached to the run.
1010
1009
  // Artifacts arrive as a typed first-class field on ExecuteAgentParams —
1011
1010
  // they are NOT routed through `data` because prompt-template rendering
@@ -1146,6 +1145,21 @@ export class BaseAgent {
1146
1145
  // Must wait for config from Phase 2 because it needs the resolved agent type and
1147
1146
  // prompt configuration to initialize the type-specific state machine.
1148
1147
  await this.initializeAgentType(wrappedParams, config);
1148
+ // =====================================================================================
1149
+ // SESSION-DRIVEN BRANCH (Realtime agent type)
1150
+ //
1151
+ // For session-driven agent types (the Realtime / Realtime Co-Agent type, marked by
1152
+ // `IsSessionDriven === true`), we do NOT enter the iterative reasoning loop. Instead we
1153
+ // hand control to a RealtimeSessionRunner that drives a long-lived duplex model session.
1154
+ //
1155
+ // This is the ONLY entry point into the realtime path. Loop and Flow agent types do not
1156
+ // expose `IsSessionDriven`, so `isSessionDrivenAgentType(...)` returns false for them and
1157
+ // their execution falls through to `executeAgentInternal` below — byte-for-byte unchanged.
1158
+ // =====================================================================================
1159
+ if (this.isSessionDrivenAgentType(this.AgentTypeInstance)) {
1160
+ this.logStatus(`🎙️ Agent '${params.agent.Name}' is session-driven — routing to RealtimeSessionRunner`, true, params);
1161
+ return await this.executeRealtimeSession(wrappedParams, config);
1162
+ }
1149
1163
  // Execute the agent's internal logic with wrapped parameters
1150
1164
  this.logStatus(`🚀 Executing agent '${params.agent.Name}' internal logic`, true, params);
1151
1165
  const executionResult = await this.executeAgentInternal(wrappedParams, config);
@@ -1201,6 +1215,676 @@ export class BaseAgent {
1201
1215
  params.cancellationToken = upstreamToken;
1202
1216
  }
1203
1217
  }
1218
+ // =====================================================================================
1219
+ // REALTIME (SESSION-DRIVEN) AGENT SUPPORT
1220
+ //
1221
+ // The methods below back the session-driven branch taken in Execute() for the Realtime
1222
+ // agent type. They are entered ONLY via that guarded branch; Loop/Flow agents never reach
1223
+ // them. The bulk of the work is building a RealtimeSessionRunnerDeps from BaseAgent's real
1224
+ // collaborators (model resolution, sub-agent delegation, tool execution, transcript
1225
+ // persistence, and usage checkpointing) and then driving RealtimeSessionRunner.Run().
1226
+ // =====================================================================================
1227
+ /**
1228
+ * Type guard for whether the resolved agent-type instance is session-driven.
1229
+ *
1230
+ * Detects the Realtime agent type without importing it (and without `instanceof`, which is
1231
+ * brittle under bundler class-duplication) by duck-typing the `IsSessionDriven` getter that
1232
+ * `RealtimeAgentType` adds. `BaseAgentType` (and Loop/Flow) do not expose this member, so the
1233
+ * guard returns `false` for them and the iterative loop runs unchanged.
1234
+ *
1235
+ * @param agentType The resolved agent-type instance for this run.
1236
+ * @returns `true` only when the type explicitly marks itself session-driven.
1237
+ */
1238
+ isSessionDrivenAgentType(agentType) {
1239
+ return agentType.IsSessionDriven === true;
1240
+ }
1241
+ /**
1242
+ * Drives a session-driven (Realtime) agent run end-to-end.
1243
+ *
1244
+ * Resolves the realtime model, assembles the session parameters (system prompt + memory/context),
1245
+ * builds the {@link RealtimeSessionRunnerDeps} from this agent's collaborators, runs the
1246
+ * {@link RealtimeSessionRunner}, and maps the result onto the finalized `AIAgentRun`.
1247
+ *
1248
+ * If no realtime model can be resolved (expected today, before the P3 drivers / P4 model
1249
+ * metadata land), it finalizes the run as a clean FAILED result with an actionable message
1250
+ * rather than throwing — a mis-provisioned environment must not crash the caller.
1251
+ *
1252
+ * @template R The caller's expected payload type (unused on the realtime path; the session
1253
+ * produces transcript/usage rather than a structured payload).
1254
+ * @param params The wrapped execution parameters.
1255
+ * @param config The loaded agent configuration (provides the system prompt, if any).
1256
+ * @returns The finalized {@link ExecuteAgentResult}.
1257
+ */
1258
+ async executeRealtimeSession(params, config) {
1259
+ // 1) Resolve the realtime model (overridable seam — tests inject a mock).
1260
+ const modelResolution = await this.resolveRealtimeModel(params);
1261
+ if (!modelResolution) {
1262
+ const message = `Agent '${params.agent.Name}' is session-driven (Realtime) but no usable Realtime model could be ` +
1263
+ `resolved. Configure a model of AIModelType 'Realtime' with an active vendor DriverClass and a ` +
1264
+ `valid API key (e.g. AI_VENDOR_API_KEY__<driver>). This is expected until the realtime drivers ` +
1265
+ `and model metadata are provisioned.`;
1266
+ this.logError(message, { agent: params.agent, category: 'RealtimeSession' });
1267
+ return await this.createFailureResult(message, params.contextUser);
1268
+ }
1269
+ // 2) Create the single long-lived AIPromptRun that usage is checkpointed onto.
1270
+ const promptRun = await this.createRealtimePromptRun(params, config, modelResolution);
1271
+ // 3) Build the injected deps and run the session.
1272
+ try {
1273
+ const deps = await this.buildRealtimeSessionDeps(params, config, modelResolution, promptRun);
1274
+ const runner = new RealtimeSessionRunner(deps);
1275
+ const sessionResult = await runner.Run();
1276
+ return await this.finalizeRealtimeRun(params, sessionResult);
1277
+ }
1278
+ catch (error) {
1279
+ const msg = error instanceof Error ? error.message : String(error);
1280
+ this.logError(`Realtime session failed for agent '${params.agent.Name}': ${msg}`, {
1281
+ agent: params.agent,
1282
+ category: 'RealtimeSession'
1283
+ });
1284
+ return await this.createFailureResult(msg, params.contextUser);
1285
+ }
1286
+ }
1287
+ /**
1288
+ * Opens a **raw** {@link IRealtimeSession} for this agent — the duplex model connection a Realtime
1289
+ * Bridge hands to `AIBridgeEngine.StartBridgeSession` so the agent can talk + hear over a media
1290
+ * transport (a LiveKit room, a Zoom/Teams meeting, a phone call). The bridge engine owns turn-taking
1291
+ * and the transport seam, so this deliberately returns the **session itself**, NOT a
1292
+ * {@link RealtimeSessionRunner} (which is the client-direct topology's own orchestration loop).
1293
+ *
1294
+ * It reuses the EXACT same resolution + assembly as {@link executeRealtimeSession} — model selection
1295
+ * ({@link resolveRealtimeModel}), agent configuration ({@link loadAgentConfiguration}), effective-config
1296
+ * persona/voice ({@link resolveRealtimeEffectiveConfig}), and the system-prompt + memory context
1297
+ * ({@link buildRealtimeSessionParams}) — then opens the session via
1298
+ * {@link BaseRealtimeModel.StartSession}. Tools are intentionally NOT pre-populated: the
1299
+ * `invoke-target-agent` + interactive-surface tools are a runner concern; a bridge that needs them
1300
+ * registers them on the returned session itself.
1301
+ *
1302
+ * @param params The execution parameters (agent + context user + the request-scoped provider). A fresh
1303
+ * bridge session typically passes an empty `conversationMessages` array.
1304
+ * @returns The live realtime session.
1305
+ * @throws When the agent configuration fails to load or no usable Realtime model can be resolved.
1306
+ */
1307
+ async StartBridgeRealtimeSession(params) {
1308
+ // Mirror Execute()'s provider wiring so the realtime helpers operate on the request-scoped provider.
1309
+ this._activeProvider = params.provider ?? Metadata.Provider;
1310
+ const provider = params.provider ?? Metadata.Provider;
1311
+ // A LiveKit / Zoom / Teams bridge is a thin TRANSPORT over the realtime co-agent — it does NOT build
1312
+ // session prep itself. It CONSUMES the one shared producer
1313
+ // ({@link RealtimeClientSessionService.PrepareRealtimeSessionParams}) so the agent's identity (it
1314
+ // speaks first-person AS the target — Sage / Marketing Agent / …), the model + voice precedence
1315
+ // cascade, the tool set (always incl. invoke-target-agent), and memory are byte-for-byte identical to
1316
+ // the native realtime chat. Bridges differ ONLY in opening the session server-side (StartSession) and
1317
+ // their media transport. See plans/realtime/realtime-core-host-convergence.md.
1318
+ // ONE service instance: it produces the prep AND wires the long-lived runtime, so the in-flight
1319
+ // delegation registry (barge-in cancel) is shared between them.
1320
+ const service = new RealtimeClientSessionService();
1321
+ const input = this.buildBridgePrepInput(params);
1322
+ const contextUser = params.contextUser;
1323
+ const prep = await service.PrepareRealtimeSessionParams(input, contextUser, provider);
1324
+ if (!prep.Success || !prep.Resolution || !prep.SessionParams) {
1325
+ throw new Error(prep.ErrorMessage ?? `Failed to prepare a realtime session for agent '${params.agent.Name}'. ` +
1326
+ `Configure an Active AIModelType 'Realtime' model with an active vendor whose DriverClass has a ` +
1327
+ `resolvable API key.`);
1328
+ }
1329
+ const session = await prep.Resolution.Model.StartSession(prep.SessionParams);
1330
+ // Phase 2: wire the SAME core runtime the native chat uses — real `invoke-target-agent` delegation
1331
+ // (target runs via AgentRunner, nested + tracked) + co-agent run/prompt-run observability, finalized
1332
+ // when the bridge calls `session.Close()`. No host-local tool re-implementation. The runtime handle's
1333
+ // side effects live on `session` (OnToolCall + a finalize-wrapped Close), so the bridge just owns the
1334
+ // session. See plans/realtime/realtime-core-host-convergence.md (Phase 2).
1335
+ await service.WireBridgeRealtimeSession(session, input, prep, contextUser, provider);
1336
+ return session;
1337
+ }
1338
+ /**
1339
+ * Adapts {@link ExecuteAgentParams} → the core {@link PrepareClientSessionInput} for a server-bridged
1340
+ * session. The CO-AGENT is the executed agent; the TARGET agent + the per-session model/voice override
1341
+ * ride `params.data` (the same conduit the native dev picker uses, funneled into the one
1342
+ * `ConfigOverridesJson` cascade slot via {@link BuildRealtimeOverridesJson}). Tools are left empty — a
1343
+ * bridge host injects its OWN UX tools (none for LiveKit audio today); identity/precedence/invoke-target
1344
+ * come from the core. `AgentSessionID` groups this session's observability runs (see
1345
+ * {@link RealtimeClientSessionService.WireBridgeRealtimeSession}).
1346
+ *
1347
+ * @param params The bridge execution parameters.
1348
+ * @returns The core prep input.
1349
+ */
1350
+ buildBridgePrepInput(params) {
1351
+ const modelID = params.data?.realtimeModelID?.trim() || undefined;
1352
+ const voice = params.data?.realtimeVoice?.trim() || undefined;
1353
+ const targetID = params.data?.targetAgentID?.trim() || '';
1354
+ // Multi-agent meeting signal (set by the room coordinator when the agent joins a room that already
1355
+ // has agents): disable the model's blind auto-response + add meeting discipline to the prompt so it
1356
+ // hears everything but speaks only when addressed. SelfNames feed only the prompt phrasing; the
1357
+ // addressing GATE is the bridge's matcher. See plans/realtime/multi-agent-meeting-turn-taking.md.
1358
+ const meetingMode = params.data?.realtimeMeetingMode === true;
1359
+ const selfNames = Array.isArray(params.data?.realtimeSelfNames)
1360
+ ? (params.data?.realtimeSelfNames).filter((n) => typeof n === 'string')
1361
+ : undefined;
1362
+ return {
1363
+ CoAgent: params.agent,
1364
+ TargetAgentID: targetID,
1365
+ AgentSessionID: params.data?.agentSessionId ?? '',
1366
+ PreferredModelID: modelID,
1367
+ ConfigOverridesJson: BuildRealtimeOverridesJson(modelID, voice) ?? undefined,
1368
+ ConversationMessages: params.conversationMessages,
1369
+ UserID: params.contextUser?.ID,
1370
+ DisableAutoResponse: meetingMode || undefined,
1371
+ SelfNames: selfNames,
1372
+ };
1373
+ }
1374
+ /**
1375
+ * Resolves the realtime model + vendor driver + API key for a session-driven run.
1376
+ *
1377
+ * **Overridable seam.** This is the single injection point that test subclasses override to
1378
+ * return a mock {@link BaseRealtimeModel}, so {@link executeRealtimeSession} can be exercised
1379
+ * without provider SDKs or DB metadata.
1380
+ *
1381
+ * Production resolution: pick the highest-power active model of AIModelType `Realtime`; then
1382
+ * pick its highest-priority active vendor whose `DriverClass` has a resolvable API key; then
1383
+ * instantiate the driver via the `ClassFactory`. Returns `null` (never throws) if any step
1384
+ * can't be satisfied — the caller turns that into a clean FAILED result. (Per-agent realtime
1385
+ * model preference can later be wired through the agent's prompt-model config, the same path
1386
+ * loop agents use for `ModelSelectionMode`; the AI Agent entity has no direct model FK.)
1387
+ *
1388
+ * @param params The execution parameters (for the agent + context user).
1389
+ * @returns The resolved model instance plus its model/vendor identifiers, or `null`.
1390
+ */
1391
+ async resolveRealtimeModel(params, overrideModelID) {
1392
+ // Walk candidates in resolution order (preference first, then highest PowerRank), returning the
1393
+ // FIRST that FULLY resolves (active vendor + resolvable API key + ClassFactory driver). Single-pick
1394
+ // would dead-end whenever the top model lacked a key — e.g. a power-11 model with no env key
1395
+ // (Inworld/AssemblyAI) outranking GPT Realtime — and surface "No usable Realtime model" even though
1396
+ // a usable model exists. This mirrors the same fix in RealtimeClientSessionService.
1397
+ const candidates = this.selectRealtimeModelCandidates(params.agent, overrideModelID);
1398
+ for (const model of candidates) {
1399
+ const vendor = this.selectRealtimeVendor(model.ID);
1400
+ if (!vendor) {
1401
+ continue;
1402
+ }
1403
+ const apiKey = GetAIAPIKey(vendor.driverClass);
1404
+ if (!apiKey) {
1405
+ continue;
1406
+ }
1407
+ const instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRealtimeModel, vendor.driverClass, apiKey);
1408
+ if (!instance) {
1409
+ continue;
1410
+ }
1411
+ return { model: instance, modelID: model.ID, vendorID: vendor.vendorID, apiName: vendor.apiName, driverClass: vendor.driverClass };
1412
+ }
1413
+ return null;
1414
+ }
1415
+ /**
1416
+ * The active `Realtime`-AIModelType models to try, in resolution order — the candidate list
1417
+ * {@link resolveRealtimeModel} walks until one yields a usable vendor + key + driver. Returns ALL
1418
+ * candidates (not just the top pick) so a keyless / undriveable higher-power model falls through to
1419
+ * the next usable one instead of dead-ending the whole resolution.
1420
+ *
1421
+ * Ordering: an effective-config model preference (`realtime.modelPreference`, an MJ: AI Models Name
1422
+ * or ID) goes FIRST when it resolves, followed by the rest by descending PowerRank (so even a keyless
1423
+ * preferred model degrades gracefully). An unsatisfiable preference logs and is ignored.
1424
+ *
1425
+ * @param agent The agent being executed.
1426
+ * @returns The candidate models in resolution order (empty when none are active).
1427
+ */
1428
+ selectRealtimeModelCandidates(agent, overrideModelID) {
1429
+ const isRealtime = (m) => typeof m.AIModelType === 'string' && m.AIModelType.trim().toLowerCase() === 'realtime';
1430
+ const realtimeModels = AIEngine.Instance.Models.filter(m => m.IsActive && isRealtime(m));
1431
+ if (realtimeModels.length === 0) {
1432
+ return [];
1433
+ }
1434
+ const byPower = [...realtimeModels].sort((a, b) => (b.PowerRank ?? 0) - (a.PowerRank ?? 0));
1435
+ // A per-session override (a dev picking a specific Realtime model for this bridged agent) wins over
1436
+ // the config's modelPreference — same "preferred first, rest by power as fallback" semantics.
1437
+ const preference = (overrideModelID && overrideModelID.trim().length > 0)
1438
+ ? overrideModelID.trim()
1439
+ : this.resolveRealtimeEffectiveConfig(agent).realtime?.modelPreference;
1440
+ if (preference) {
1441
+ const wanted = preference.trim().toLowerCase();
1442
+ const preferred = realtimeModels.find(m => UUIDsEqual(m.ID, preference))
1443
+ ?? realtimeModels.find(m => m.Name?.trim().toLowerCase() === wanted);
1444
+ if (preferred) {
1445
+ // Preference first, the rest (by power) as fallback so a keyless preferred model still
1446
+ // falls through to a usable one rather than dead-ending.
1447
+ return [preferred, ...byPower.filter(m => !UUIDsEqual(m.ID, preferred.ID))];
1448
+ }
1449
+ this.logError(`Realtime model preference '${preference}' for agent '${agent.Name}' matches no Active Realtime ` +
1450
+ 'model — falling through to default (highest-PowerRank) selection.', { agent, category: 'RealtimeSession' });
1451
+ }
1452
+ return byPower;
1453
+ }
1454
+ /**
1455
+ * Resolves the agent's EFFECTIVE realtime configuration — the agent TYPE's
1456
+ * `DefaultConfiguration` (base layer) deep-merged with the agent's `TypeConfiguration`
1457
+ * (per-agent layer; the server-bridged path has no runtime-override layer). Tolerant:
1458
+ * malformed layers contribute nothing and an unloaded type cache yields no type defaults.
1459
+ * See `realtime/realtime-coagent-config.ts` for the merge contract.
1460
+ *
1461
+ * @param agent The session-driven (Realtime) agent.
1462
+ * @returns The normalized effective configuration (possibly empty, never `null`).
1463
+ */
1464
+ resolveRealtimeEffectiveConfig(agent) {
1465
+ let typeDefault = null;
1466
+ try {
1467
+ if (agent.TypeID) {
1468
+ const type = (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, agent.TypeID));
1469
+ typeDefault = type?.DefaultConfiguration ?? null;
1470
+ }
1471
+ }
1472
+ catch {
1473
+ typeDefault = null;
1474
+ }
1475
+ return ResolveEffectiveRealtimeConfig(typeDefault, agent.TypeConfiguration ?? null, null);
1476
+ }
1477
+ /**
1478
+ * Selects the highest-priority active vendor for a model whose `DriverClass` has a resolvable
1479
+ * API key. Mirrors the vendor-selection pattern used by prompt execution.
1480
+ *
1481
+ * @param modelID The chosen model's ID.
1482
+ * @returns The vendor driver/api identifiers, or `null` when none has a usable key.
1483
+ */
1484
+ selectRealtimeVendor(modelID) {
1485
+ const vendors = AIEngine.Instance.ModelVendors
1486
+ .filter(mv => UUIDsEqual(mv.ModelID, modelID) && mv.Status === 'Active' && mv.DriverClass != null)
1487
+ .sort((a, b) => (b.Priority ?? 0) - (a.Priority ?? 0));
1488
+ for (const v of vendors) {
1489
+ if (GetAIAPIKey(v.DriverClass)) {
1490
+ return { vendorID: v.VendorID ?? '', driverClass: v.DriverClass, apiName: v.APIName ?? '' };
1491
+ }
1492
+ }
1493
+ return null;
1494
+ }
1495
+ /**
1496
+ * Creates the single long-lived `AIPromptRun` that realtime usage is checkpointed onto.
1497
+ *
1498
+ * One run is created per session (not per turn) so {@link RealtimeSessionRunnerDeps.CheckpointUsage}
1499
+ * can incrementally update the same record — crash-safe by design. Returns `null` on failure;
1500
+ * the session still runs (usage checkpoints simply become no-ops).
1501
+ *
1502
+ * @param params The execution parameters.
1503
+ * @param config The agent configuration (provides the system prompt id, if any).
1504
+ * @param modelResolution The resolved model/vendor identifiers.
1505
+ * @returns The persisted prompt run, or `null` if it could not be created.
1506
+ */
1507
+ async createRealtimePromptRun(params, config, modelResolution) {
1508
+ try {
1509
+ const md = params.provider || this._activeProvider;
1510
+ const promptRun = await md.GetEntityObject('MJ: AI Prompt Runs', params.contextUser);
1511
+ promptRun.NewRecord();
1512
+ if (config.systemPrompt) {
1513
+ promptRun.PromptID = config.systemPrompt.ID;
1514
+ }
1515
+ promptRun.ModelID = modelResolution.modelID;
1516
+ promptRun.VendorID = modelResolution.vendorID || null;
1517
+ promptRun.AgentID = params.agent.ID;
1518
+ promptRun.AgentRunID = this._agentRun?.ID ?? null;
1519
+ promptRun.Status = 'Running';
1520
+ promptRun.RunAt = new Date();
1521
+ promptRun.StreamingEnabled = true;
1522
+ promptRun.Cancelled = false;
1523
+ promptRun.CacheHit = false;
1524
+ if (!await promptRun.Save()) {
1525
+ this.logError(`Failed to create realtime AIPromptRun: ${promptRun.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1526
+ agent: params.agent,
1527
+ category: 'RealtimeSession'
1528
+ });
1529
+ return null;
1530
+ }
1531
+ return promptRun;
1532
+ }
1533
+ catch (error) {
1534
+ const msg = error instanceof Error ? error.message : String(error);
1535
+ this.logError(`Error creating realtime AIPromptRun: ${msg}`, { agent: params.agent, category: 'RealtimeSession' });
1536
+ return null;
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Builds the fully-populated {@link RealtimeSessionRunnerDeps} from this agent's collaborators.
1541
+ *
1542
+ * Each dependency is a thin closure over BaseAgent state so the runner stays decoupled from
1543
+ * metadata/DB. The closures cover: target delegation (via {@link ExecuteSubAgent}), non-target
1544
+ * tool execution, transcript persistence (as `ConversationDetail`), and usage checkpointing
1545
+ * (onto the long-lived prompt run).
1546
+ *
1547
+ * @param params The execution parameters.
1548
+ * @param config The agent configuration.
1549
+ * @param modelResolution The resolved realtime model + identifiers.
1550
+ * @param promptRun The long-lived prompt run for usage checkpoints (may be `null`).
1551
+ * @returns The assembled deps object.
1552
+ */
1553
+ async buildRealtimeSessionDeps(params, config, modelResolution, promptRun) {
1554
+ const effectiveConfig = this.resolveRealtimeEffectiveConfig(params.agent);
1555
+ const sessionParams = await this.buildRealtimeSessionParams(params, config, modelResolution.apiName, effectiveConfig, modelResolution.driverClass);
1556
+ return {
1557
+ Model: modelResolution.model,
1558
+ SessionParams: sessionParams,
1559
+ DelegateToTarget: (request) => this.delegateRealtimeToTarget(params, config, request),
1560
+ ExecuteTool: (call) => this.executeRealtimeTool(params, call),
1561
+ PersistTranscript: (transcript) => this.persistRealtimeTranscript(params, transcript),
1562
+ CheckpointUsage: (usage) => this.checkpointRealtimeUsage(promptRun, usage),
1563
+ // DB-driven spoken-progress wording (shared lookup with the client-direct path);
1564
+ // null → the runner's documented built-in first-person fallback.
1565
+ NarrationInstructionsTemplate: ResolveNarrationInstructionsTemplate(),
1566
+ // Effective-config narration pacing (realtime.narration.paceMs); null → runner default.
1567
+ NarrationPaceMs: GetNarrationPaceMs(effectiveConfig),
1568
+ LogStatus: (message, verboseOnly) => this.logStatus(message, verboseOnly ?? false, params),
1569
+ LogError: (error) => this.logError(error, { agent: params.agent, category: 'RealtimeSession' })
1570
+ };
1571
+ }
1572
+ /**
1573
+ * Assembles the {@link RealtimeSessionParams} for the session.
1574
+ *
1575
+ * The system prompt is framed as a companion "voice for the target agent". The base system
1576
+ * prompt text (when an agent-level system prompt exists) plus the same memory/context a loop
1577
+ * agent would assemble (via {@link AgentMemoryContextBuilder}) are concatenated. The
1578
+ * always-present `invoke-target-agent` tool is added by the runner itself, so it is NOT
1579
+ * populated here.
1580
+ *
1581
+ * @param params The execution parameters.
1582
+ * @param config The agent configuration.
1583
+ * @param modelApiName The vendor API name of the resolved realtime model.
1584
+ * @returns The session parameters.
1585
+ */
1586
+ async buildRealtimeSessionParams(params, config, modelApiName, effectiveConfig, driverClass) {
1587
+ // Identity framing comes from the ONE shared producer so the agent speaks first-person AS the
1588
+ // TARGET (Sage / Marketing Agent / …), identical to every other realtime host — not as the co-agent.
1589
+ // See BuildRealtimeAgentFraming + plans/realtime/realtime-core-host-convergence.md.
1590
+ const targetAgent = this.resolveRealtimeTargetAgent(params);
1591
+ const framing = BuildRealtimeAgentFraming(targetAgent?.Name ?? 'the configured target agent');
1592
+ const basePrompt = config.systemPrompt?.TemplateText ? config.systemPrompt.TemplateText : '';
1593
+ // Effective-config voice persona (realtime.voice.default) → short "Voice & manner" section.
1594
+ const voiceManner = BuildVoiceMannerSection(effectiveConfig);
1595
+ const memoryContext = await this.assembleRealtimeContext(params);
1596
+ const systemPrompt = [framing, basePrompt, voiceManner, memoryContext]
1597
+ .filter(part => part && part.trim().length > 0)
1598
+ .join('\n\n');
1599
+ // Provider-matched voice settings (realtime.voice.providers.<provider>) flow into the
1600
+ // driver's open Config bag — the same pact every other config entry rides.
1601
+ const providerVoice = GetProviderVoiceSettings(effectiveConfig, driverClass ?? null);
1602
+ return {
1603
+ Model: modelApiName,
1604
+ SystemPrompt: systemPrompt,
1605
+ InitialContext: memoryContext || undefined,
1606
+ // JSONObjectLike -> JSONObject: safe — the settings object came from JSON.parse.
1607
+ Config: providerVoice ? providerVoice : undefined
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Assembles the same memory/context block a loop agent injects, reusing
1612
+ * {@link AgentMemoryContextBuilder} so there is no duplicated retrieval logic. The builder
1613
+ * unshifts a system message onto a throwaway array, which we pull back out as plain text to
1614
+ * feed the realtime model's session context.
1615
+ *
1616
+ * @param params The execution parameters.
1617
+ * @returns The concatenated context text (empty string when nothing was injected).
1618
+ */
1619
+ async assembleRealtimeContext(params) {
1620
+ const lastUserMessage = params.conversationMessages.filter(m => m.role === 'user').pop();
1621
+ const inputText = typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : '';
1622
+ const scratch = [];
1623
+ const builder = new AgentMemoryContextBuilder();
1624
+ await builder.InjectContextMemory(inputText, params.agent, params.userId || params.contextUser?.ID, params.companyId, params.contextUser, scratch, undefined, undefined, undefined, null, undefined, (message, verboseOnly) => this.logStatus(message, verboseOnly ?? false, params));
1625
+ return scratch
1626
+ .map(m => (typeof m.content === 'string' ? m.content : ''))
1627
+ .filter(c => c.length > 0)
1628
+ .join('\n\n');
1629
+ }
1630
+ /**
1631
+ * Delegates an `invoke-target-agent` tool call to the top-level target agent.
1632
+ *
1633
+ * Threads the runner-owned {@link DelegateToTargetRequest.AbortSignal} into the child run's
1634
+ * `cancellationToken` (so barge-in cancels the delegated work), and links the child run to this
1635
+ * run via `parentRun` (→ `ParentRunID`) while propagating `agentSessionID` so both runs group
1636
+ * under the same session.
1637
+ *
1638
+ * **Target source.** The target agent id comes from `params.data.targetAgentID` when present
1639
+ * (the Realtime Co-Agent receives its target as a runtime parameter), falling back to the agent's
1640
+ * own `DefaultModelID`-style config is NOT applicable here; absent a target the delegation
1641
+ * returns a failed {@link DelegatedResult} the model can narrate.
1642
+ *
1643
+ * @param params The (parent) execution parameters.
1644
+ * @param config The agent configuration (unused today; reserved for target-from-config wiring).
1645
+ * @param request The delegation request derived from the tool call.
1646
+ * @returns The delegated result for the model's tool_response.
1647
+ */
1648
+ async delegateRealtimeToTarget(params, config, request) {
1649
+ const targetAgent = this.resolveRealtimeTargetAgent(params);
1650
+ if (!targetAgent) {
1651
+ return {
1652
+ CallID: request.CallID,
1653
+ Success: false,
1654
+ Output: 'No target agent is configured for this voice session, so the request could not be performed.'
1655
+ };
1656
+ }
1657
+ try {
1658
+ const requestText = this.parseDelegateRequestText(request.Arguments);
1659
+ const runner = new AgentRunner(params.provider || this._activeProvider);
1660
+ const result = await runner.RunAgent({
1661
+ agent: targetAgent,
1662
+ conversationMessages: [{ role: 'user', content: requestText }],
1663
+ contextUser: params.contextUser,
1664
+ cancellationToken: request.AbortSignal,
1665
+ parentRun: this._agentRun ?? undefined,
1666
+ agentSessionID: params.agentSessionID,
1667
+ parentAgentHierarchy: this._agentHierarchy,
1668
+ parentDepth: this._depth,
1669
+ configurationId: params.configurationId,
1670
+ apiKeys: params.apiKeys,
1671
+ data: params.data,
1672
+ verbose: params.verbose,
1673
+ // Progress streams BOTH to the runner's narration consumer (request.OnProgress —
1674
+ // it paces SendContextNote/RequestSpokenUpdate over the live socket) AND to any
1675
+ // host-level onProgress the parent execution carries.
1676
+ onProgress: this.combineProgressCallbacks(request.OnProgress, params.onProgress)
1677
+ });
1678
+ return {
1679
+ CallID: request.CallID,
1680
+ Success: result.success,
1681
+ Output: result.success
1682
+ ? (result.agentRun?.Message || 'The target agent completed the request.')
1683
+ : (result.agentRun?.ErrorMessage || 'The target agent failed to complete the request.')
1684
+ };
1685
+ }
1686
+ catch (error) {
1687
+ const msg = error instanceof Error ? error.message : String(error);
1688
+ return { CallID: request.CallID, Success: false, Output: `Delegation failed: ${msg}` };
1689
+ }
1690
+ }
1691
+ /**
1692
+ * Combines the runner-supplied delegation progress callback with the host-level one so a
1693
+ * single `onProgress` fans out to both. Returns the lone callback when only one exists, and
1694
+ * `undefined` when neither does. A throw from one consumer never starves the other.
1695
+ */
1696
+ combineProgressCallbacks(first, second) {
1697
+ if (!first) {
1698
+ return second;
1699
+ }
1700
+ if (!second) {
1701
+ return first;
1702
+ }
1703
+ return (progress) => {
1704
+ try {
1705
+ first(progress);
1706
+ }
1707
+ catch {
1708
+ /* one consumer failing must not starve the other */
1709
+ }
1710
+ second(progress);
1711
+ };
1712
+ }
1713
+ /**
1714
+ * Resolves the top-level target agent for the voice session.
1715
+ *
1716
+ * The target is supplied as a runtime parameter on `params.data.targetAgentID` (the Voice
1717
+ * Co-Agent voices on behalf of a target chosen at session start). Returns `null` when no
1718
+ * resolvable target is configured.
1719
+ *
1720
+ * @param params The execution parameters.
1721
+ * @returns The target agent entity, or `null`.
1722
+ */
1723
+ resolveRealtimeTargetAgent(params) {
1724
+ const targetID = params.data?.targetAgentID;
1725
+ if (!targetID) {
1726
+ return null;
1727
+ }
1728
+ return AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, targetID)) ?? null;
1729
+ }
1730
+ /**
1731
+ * Parses the natural-language request text out of an `invoke-target-agent` call's arguments.
1732
+ * Falls back to the raw argument string when it is not the expected `{ request: string }` JSON.
1733
+ *
1734
+ * @param argumentsJson The raw arguments string emitted by the model.
1735
+ * @returns The request text to hand to the target agent.
1736
+ */
1737
+ parseDelegateRequestText(argumentsJson) {
1738
+ try {
1739
+ const parsed = JSON.parse(argumentsJson);
1740
+ if (typeof parsed.request === 'string') {
1741
+ return parsed.request;
1742
+ }
1743
+ }
1744
+ catch {
1745
+ /* not JSON — fall through to raw */
1746
+ }
1747
+ return argumentsJson;
1748
+ }
1749
+ /**
1750
+ * Executes a non-target realtime tool call by routing it through the agent's existing action
1751
+ * execution under the session context user.
1752
+ *
1753
+ * Today this maps the realtime call onto the agent's configured actions by name; unknown tools
1754
+ * return a failed {@link ToolExecutionResult} the model can narrate. (The richer client/UI tool
1755
+ * routing is wired in a later phase; this keeps server actions usable now.)
1756
+ *
1757
+ * @param params The execution parameters.
1758
+ * @param call The non-target tool call.
1759
+ * @returns The tool execution result for the model's tool_response.
1760
+ */
1761
+ async executeRealtimeTool(params, call) {
1762
+ const action = this.getEffectiveActionsForValidation(params.agent.ID).find(a => a.Name === call.ToolName);
1763
+ if (!action) {
1764
+ return {
1765
+ CallID: call.CallID,
1766
+ Success: false,
1767
+ Output: `Tool '${call.ToolName}' is not available to this agent.`
1768
+ };
1769
+ }
1770
+ try {
1771
+ const agentAction = { name: action.Name, params: this.parseRealtimeToolParams(call.Arguments) };
1772
+ const result = await this.ExecuteSingleAction(params, agentAction, action, params.contextUser);
1773
+ return {
1774
+ CallID: call.CallID,
1775
+ Success: result.Success,
1776
+ Output: result.Message || (result.Success ? 'Tool completed.' : 'Tool failed.')
1777
+ };
1778
+ }
1779
+ catch (error) {
1780
+ const msg = error instanceof Error ? error.message : String(error);
1781
+ return { CallID: call.CallID, Success: false, Output: `Tool execution failed: ${msg}` };
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Parses a realtime tool call's JSON arguments into an action parameter map.
1786
+ *
1787
+ * @param argumentsJson The raw arguments string.
1788
+ * @returns A record of parameter name → value (empty when not parseable).
1789
+ */
1790
+ parseRealtimeToolParams(argumentsJson) {
1791
+ try {
1792
+ const parsed = JSON.parse(argumentsJson);
1793
+ if (parsed && typeof parsed === 'object') {
1794
+ return parsed;
1795
+ }
1796
+ }
1797
+ catch {
1798
+ /* ignore — return empty params */
1799
+ }
1800
+ return {};
1801
+ }
1802
+ /**
1803
+ * Persists a single realtime transcript turn as a `ConversationDetail` stamped with the
1804
+ * session id. User turns are written as `Role='User'`, assistant turns as `Role='AI'`. Only
1805
+ * final transcripts are persisted (interim/partial updates are skipped to avoid churn).
1806
+ *
1807
+ * @param params The execution parameters (provides conversation id + context user).
1808
+ * @param transcript The transcript turn emitted by the model.
1809
+ */
1810
+ async persistRealtimeTranscript(params, transcript) {
1811
+ if (!transcript.IsFinal || !transcript.Text?.trim()) {
1812
+ return;
1813
+ }
1814
+ const conversationID = params.data?.conversationId;
1815
+ if (!conversationID) {
1816
+ return; // Without a conversation we have nowhere to durably attach the turn.
1817
+ }
1818
+ const md = params.provider || this._activeProvider;
1819
+ const detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1820
+ detail.NewRecord();
1821
+ detail.ConversationID = conversationID;
1822
+ detail.Role = transcript.Role === 'user' ? 'User' : 'AI';
1823
+ detail.Message = transcript.Text;
1824
+ if (params.agentSessionID) {
1825
+ detail.AgentSessionID = params.agentSessionID;
1826
+ }
1827
+ if (!await detail.Save()) {
1828
+ this.logError(`Failed to persist realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1829
+ agent: params.agent,
1830
+ category: 'RealtimeSession'
1831
+ });
1832
+ }
1833
+ }
1834
+ /**
1835
+ * Checkpoints accumulated realtime usage onto the single long-lived prompt run. This is the
1836
+ * incremental, crash-safe write the runner invokes on a debounced cadence and at close.
1837
+ *
1838
+ * @param promptRun The long-lived prompt run (no-op when `null`).
1839
+ * @param usage The cumulative usage snapshot to persist.
1840
+ */
1841
+ async checkpointRealtimeUsage(promptRun, usage) {
1842
+ if (!promptRun) {
1843
+ return;
1844
+ }
1845
+ promptRun.TokensPrompt = usage.InputTokens;
1846
+ promptRun.TokensCompletion = usage.OutputTokens;
1847
+ promptRun.TokensUsed = usage.InputTokens + usage.OutputTokens;
1848
+ if (!await promptRun.Save()) {
1849
+ this.logError(`Failed to checkpoint realtime usage: ${promptRun.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1850
+ category: 'RealtimeSession'
1851
+ });
1852
+ }
1853
+ }
1854
+ /**
1855
+ * Maps a completed {@link RealtimeSessionResult} onto the finalized `AIAgentRun` and returns
1856
+ * the {@link ExecuteAgentResult}. A clean close finalizes as success; a session error finalizes
1857
+ * as failure with the error message.
1858
+ *
1859
+ * @template R The caller's payload type (unused on the realtime path).
1860
+ * @param params The execution parameters.
1861
+ * @param sessionResult The result returned by {@link RealtimeSessionRunner.Run}.
1862
+ * @returns The finalized agent result.
1863
+ */
1864
+ async finalizeRealtimeRun(params, sessionResult) {
1865
+ if (sessionResult.Success) {
1866
+ this.logStatus(`🎙️ Realtime session for '${params.agent.Name}' completed: ${sessionResult.TranscriptTurnCount} turn(s), ` +
1867
+ `${sessionResult.FinalUsage.InputTokens + sessionResult.FinalUsage.OutputTokens} token(s).`, true, params);
1868
+ const successStep = this.createSessionSuccessStep();
1869
+ return await this.finalizeAgentRun(successStep, undefined, params.contextUser);
1870
+ }
1871
+ const message = sessionResult.ErrorMessage || 'Realtime session ended with an error.';
1872
+ return await this.createFailureResult(message, params.contextUser);
1873
+ }
1874
+ /**
1875
+ * Builds a terminal `Success` step describing the completion of a realtime session, used to
1876
+ * finalize the run through the shared {@link finalizeAgentRun} path.
1877
+ *
1878
+ * @template R The caller's payload type.
1879
+ * @returns A terminal success step.
1880
+ */
1881
+ createSessionSuccessStep() {
1882
+ return {
1883
+ step: 'Success',
1884
+ terminate: true,
1885
+ message: 'Realtime session completed.'
1886
+ };
1887
+ }
1204
1888
  /**
1205
1889
  * Sub-classes can override this method to perform any specialized initialization
1206
1890
  * @param params
@@ -1338,8 +2022,13 @@ export class BaseAgent {
1338
2022
  * @protected
1339
2023
  */
1340
2024
  async initializeEngines(contextUser) {
1341
- await AIEngine.Instance.Config(false, contextUser);
2025
+ // Load the Action engine BEFORE the AI engine. AIEngine.RefreshActions()
2026
+ // (invoked by AIEngine.Config) reuses already-cached 'MJ: Actions'
2027
+ // metadata via BaseEngineRegistry, so priming ActionEngineServer first
2028
+ // lets AIEngine skip loading a second copy into ActionEngineBase —
2029
+ // eliminating the duplicate-RunView telemetry warning at agent startup.
1342
2030
  await ActionEngineServer.Instance.Config(false, contextUser);
2031
+ await AIEngine.Instance.Config(false, contextUser);
1343
2032
  }
1344
2033
  /**
1345
2034
  * Determine the scope label for a note based on its scope fields.
@@ -1393,72 +2082,16 @@ export class BaseAgent {
1393
2082
  * @returns Object containing injected notes and examples
1394
2083
  */
1395
2084
  async InjectContextMemory(input, agent, userId, companyId, contextUser, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, secondaryScopeConfig) {
1396
- // Check if injection is enabled
1397
- if (!agent.InjectNotes && !agent.InjectExamples) {
1398
- return { notes: [], examples: [] };
1399
- }
1400
- const injector = new AgentContextInjector();
1401
- // Parse reranker configuration if present
1402
- const rerankerConfigJson = agent.RerankerConfiguration;
1403
- const rerankerConfig = RerankerService.Instance.parseConfiguration(rerankerConfigJson);
1404
- // Get notes if injection enabled
1405
- const notes = agent.InjectNotes
1406
- ? await injector.GetNotesForContext({
1407
- agentId: agent.ID,
1408
- userId,
1409
- companyId,
1410
- currentInput: input,
1411
- strategy: agent.NoteInjectionStrategy,
1412
- maxNotes: agent.MaxNotesToInject || 5,
1413
- contextUser: contextUser,
1414
- rerankerConfig,
1415
- primaryScopeEntityId,
1416
- primaryScopeRecordId,
1417
- secondaryScopes,
1418
- secondaryScopeConfig,
1419
- // Pass observability context for run step tracking
1420
- observability: this._agentRun ? {
1421
- agentRunID: this._agentRun.ID,
1422
- stepNumber: (this._agentRun.Steps?.length || 0) + 1
1423
- } : undefined
1424
- })
1425
- : [];
1426
- this.logStatus(`BaseAgent: Got ${notes.length} notes from injector`, true);
1427
- // Get examples if injection enabled
1428
- const examples = agent.InjectExamples
1429
- ? await injector.GetExamplesForContext({
1430
- agentId: agent.ID,
1431
- userId,
1432
- companyId,
1433
- currentInput: input,
1434
- strategy: agent.ExampleInjectionStrategy,
1435
- maxExamples: agent.MaxExamplesToInject || 3,
1436
- contextUser: contextUser,
1437
- primaryScopeEntityId,
1438
- primaryScopeRecordId,
1439
- secondaryScopes,
1440
- secondaryScopeConfig
1441
- })
1442
- : [];
1443
- // Format and inject memory context into conversation messages
1444
- if ((notes.length > 0 || examples.length > 0) && conversationMessages) {
1445
- const notesText = injector.FormatNotesForInjection(notes);
1446
- const examplesText = injector.FormatExamplesForInjection(examples);
1447
- this._memoryContext = '';
1448
- if (notesText)
1449
- this._memoryContext += notesText + '\n\n';
1450
- if (examplesText)
1451
- this._memoryContext += examplesText + '\n\n';
1452
- // Inject as system message at the start
1453
- conversationMessages.unshift({
1454
- role: 'system',
1455
- content: this._memoryContext
1456
- });
1457
- this.logStatus(`💾 Injected ${notes.length} notes and ${examples.length} examples into conversation context`, true);
1458
- }
1459
- // Store for inclusion in result
1460
- this._injectedMemory = { notes, examples };
1461
- return { notes, examples };
2085
+ // Delegate the orchestration to the shared, reusable builder so both BaseAgent and the
2086
+ // Realtime agent type inject memory identically. The observability context and verbose
2087
+ // status logging are derived from this instance and passed through.
2088
+ const observability = this._agentRun
2089
+ ? { agentRunID: this._agentRun.ID, stepNumber: (this._agentRun.Steps?.length || 0) + 1 }
2090
+ : undefined;
2091
+ const result = await new AgentMemoryContextBuilder().InjectContextMemory(input, agent, userId, companyId, contextUser, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, secondaryScopeConfig, observability, (message, verboseOnly) => this.logStatus(message, verboseOnly));
2092
+ // Store for inclusion in result (externally observable behavior preserved)
2093
+ this._injectedMemory = result;
2094
+ return result;
1462
2095
  }
1463
2096
  /**
1464
2097
  * Inject pre-execution RAG context for this agent using scoped search.
@@ -1486,40 +2119,12 @@ export class BaseAgent {
1486
2119
  * @returns The structured RAG result, or `null` if no scopes produced results.
1487
2120
  */
1488
2121
  async InjectPreExecutionRAG(lastUserMessage, agent, contextUser, conversationMessages, originalMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, payload) {
1489
- try {
1490
- if (!contextUser)
1491
- return null;
1492
- if (!agent?.ID)
1493
- return null;
1494
- const rag = new AgentPreExecutionRAG();
1495
- const result = await rag.Execute({
1496
- agent,
1497
- lastUserMessage,
1498
- recentMessages: originalMessages ? originalMessages.slice(-5) : undefined,
1499
- payload,
1500
- primaryScopeRecordId,
1501
- primaryScopeEntityId,
1502
- secondaryScopes,
1503
- contextUser
1504
- });
1505
- if (!result)
1506
- return null;
1507
- if (conversationMessages && result.formattedSystemMessage) {
1508
- this._ragContext = result.formattedSystemMessage;
1509
- conversationMessages.unshift({ role: 'system', content: this._ragContext });
1510
- this.logStatus(`🔎 Injected pre-execution RAG context: ${result.combinedResults.length} result(s) from ${result.queriedScopeIDs.length} scope(s)`, true);
1511
- }
1512
- this._injectedRAG = result;
1513
- return result;
1514
- }
1515
- catch (error) {
1516
- const msg = error instanceof Error ? error.message : String(error);
1517
- this.logError(`InjectPreExecutionRAG failed — continuing without RAG context: ${msg}`, {
1518
- agent,
1519
- category: 'AgentPreExecutionRAG'
1520
- });
1521
- return null;
1522
- }
2122
+ // Delegate to the shared builder so the Realtime agent type injects pre-execution RAG
2123
+ // identically. Verbose status + non-fatal error logging are threaded through from this instance.
2124
+ const result = await new AgentMemoryContextBuilder().InjectPreExecutionRAG(lastUserMessage, agent, contextUser, conversationMessages, originalMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, payload, (message, verboseOnly) => this.logStatus(message, verboseOnly), (error, options) => this.logError(error, options));
2125
+ // Store for inclusion in result (externally observable behavior preserved)
2126
+ this._injectedRAG = result;
2127
+ return result;
1523
2128
  }
1524
2129
  /**
1525
2130
  * Converts UI markup (@{...} syntax) in user messages to plain text.
@@ -1793,7 +2398,7 @@ export class BaseAgent {
1793
2398
  const systemPrompt = config.systemPrompt;
1794
2399
  const childPrompt = config.childPrompt;
1795
2400
  // Gather context data (including runtime action changes)
1796
- const promptTemplateData = await this.gatherPromptTemplateData(params.agent, params.contextUser, params.data, params.actionChanges);
2401
+ const promptTemplateData = await this.gatherPromptTemplateData(params.agent, params.contextUser, params.data, params.actionChanges, params.subAgentChanges);
1797
2402
  // Set up the hierarchical prompt execution
1798
2403
  const promptParams = new AIPromptParams();
1799
2404
  // Handle case where systemPrompt is optional (e.g., Flow Agent Type)
@@ -1868,6 +2473,13 @@ export class BaseAgent {
1868
2473
  else if (this._artifactToolManager.HasArtifacts()) {
1869
2474
  this.logStatus(`[ArtifactTools] Artifacts present but tools disabled by agent config (includeArtifactToolsDocs=false)`, true, params);
1870
2475
  }
2476
+ // Enable the memory-writes response field + docs only for agents that opted in
2477
+ // via AllowMemoryWrite. Disabled agents never see the docs, so a well-behaved
2478
+ // LLM never emits the field (the turn loop still guards against drift).
2479
+ const memoryWritesDocsEnabled = agentTypePromptParams?.includeMemoryWritesDocs !== false;
2480
+ if (memoryWritesDocsEnabled && params.agent.AllowMemoryWrite === true) {
2481
+ promptParams.data['_MEMORY_WRITES_ENABLED'] = true;
2482
+ }
1871
2483
  // Inject pipeline tool docs when pipelines are enabled and at least one source exists.
1872
2484
  // A pipeline's first step must be a source (Action or artifact tool); with none
1873
2485
  // available pipelines are impossible, so BuildPipelineToolDocs returns '' and the
@@ -3433,6 +4045,129 @@ The context is now within limits. Please retry your request with the recovered c
3433
4045
  `Instead: page it with get_rows(start, count), or run a pipeline that filters/aggregates it ` +
3434
4046
  `server-side (where / select / groupBy → only the small final result returns to you).]`);
3435
4047
  }
4048
+ /**
4049
+ * Executes a batch of in-flight memory writes, recording each as its own
4050
+ * `Tool` AIAgentRunStep (a sibling of the Prompt step that requested them)
4051
+ * with full inputs/outcomes captured in InputData/OutputData.
4052
+ *
4053
+ * Writes run SEQUENTIALLY (not Promise.all like artifact tools) by design:
4054
+ * each persisted note is embedded and synced into the in-memory vector
4055
+ * service on Save, so write N must be visible to write N+1's near-duplicate
4056
+ * check (this is also what makes same-run supersede-own work). The per-run
4057
+ * cap bounds the cost of the serialization.
4058
+ *
4059
+ * Step naming convention: `Memory Write` for log/UI clarity.
4060
+ *
4061
+ * @protected
4062
+ */
4063
+ async executeMemoryWritesAsSteps(writes, params) {
4064
+ const results = [];
4065
+ for (const write of writes) {
4066
+ const writeStep = await this.createStepEntity({
4067
+ stepType: 'Tool',
4068
+ stepName: 'Memory Write',
4069
+ contextUser: params.contextUser,
4070
+ inputData: {
4071
+ note: write.note,
4072
+ type: write.type,
4073
+ scopeHint: write.scopeHint,
4074
+ },
4075
+ });
4076
+ const result = await this._memoryWriteManager.ExecuteWrite(write, {
4077
+ agentId: params.agent.ID,
4078
+ contextUser: params.contextUser,
4079
+ agentRunId: this._agentRun?.ID,
4080
+ conversationId: this._agentRun?.ConversationID || undefined,
4081
+ conversationDetailId: params.conversationDetailId,
4082
+ userId: params.userId || params.contextUser?.ID,
4083
+ companyId: params.companyId,
4084
+ verbose: params.verbose,
4085
+ provider: this.ProviderToUse,
4086
+ });
4087
+ const failed = result.disposition === 'error' || result.disposition === 'rejected-type';
4088
+ await this.finalizeStepEntity(writeStep, !failed, failed ? result.reason : undefined, {
4089
+ disposition: result.disposition,
4090
+ noteId: result.noteId,
4091
+ finalScope: result.finalScope,
4092
+ reason: result.reason,
4093
+ durationMs: result.durationMs,
4094
+ });
4095
+ results.push(result);
4096
+ }
4097
+ return results;
4098
+ }
4099
+ /**
4100
+ * Turn-loop entry point for in-flight memory writes, gated on the agent's
4101
+ * AllowMemoryWrite flag. When disabled but the LLM emitted writes anyway
4102
+ * (prompt drift / injection attempt), records ONE summary skip step —
4103
+ * observable without per-write noise — and tells the agent the memories
4104
+ * were NOT saved so it stops re-emitting. When enabled, executes the
4105
+ * writes as run steps and injects the results message.
4106
+ *
4107
+ * @protected
4108
+ */
4109
+ async processMemoryWritesForTurn(memoryWrites, params) {
4110
+ if (params.agent.AllowMemoryWrite !== true) {
4111
+ this.logStatus(`[MemoryWrites] LLM emitted ${memoryWrites.length} memory write(s) but AllowMemoryWrite=false — skipping`, true, params);
4112
+ const skipStep = await this.createStepEntity({
4113
+ stepType: 'Tool',
4114
+ stepName: 'Memory Writes: skipped (AllowMemoryWrite=false)',
4115
+ contextUser: params.contextUser,
4116
+ inputData: { requestedWriteCount: memoryWrites.length },
4117
+ });
4118
+ await this.finalizeStepEntity(skipStep, true, undefined, { skipped: true, reason: 'AllowMemoryWrite=false' });
4119
+ params.conversationMessages.push({
4120
+ role: 'user',
4121
+ content: 'Memory write result: this agent does not have durable memory writes enabled — the requested memories were NOT saved. Do not emit memoryWrites again.',
4122
+ metadata: {
4123
+ turnAdded: this._promptTurnCount,
4124
+ messageType: 'tool-result',
4125
+ expirationTurns: 3,
4126
+ expirationMode: 'Compact',
4127
+ compactMode: 'First N Chars',
4128
+ compactLength: 200,
4129
+ compactPromptId: '',
4130
+ },
4131
+ });
4132
+ return;
4133
+ }
4134
+ this.logStatus(`[MemoryWrites] LLM requested ${memoryWrites.length} memory write(s)`, true, params);
4135
+ const writeResults = await this.executeMemoryWritesAsSteps(memoryWrites, params);
4136
+ this.injectMemoryWriteResultsMessage(params, writeResults);
4137
+ }
4138
+ /**
4139
+ * Pushes a single user-role message containing memory-write outcomes into
4140
+ * the conversation, mirroring `injectArtifactToolResultsMessage`'s
4141
+ * inject-once-then-expire pattern. Closing the loop here is what stops the
4142
+ * LLM from re-emitting the same memory on subsequent turns.
4143
+ *
4144
+ * @protected
4145
+ */
4146
+ injectMemoryWriteResultsMessage(params, results) {
4147
+ if (results.length === 0)
4148
+ return;
4149
+ const header = results.length === 1
4150
+ ? 'Memory write result:'
4151
+ : `Memory write results (${results.length} writes):`;
4152
+ const body = results.map((r, i) => {
4153
+ const note = r.request.note.length > 120 ? `${r.request.note.slice(0, 120)}…` : r.request.note;
4154
+ return `${i + 1}. "${note}" — **${r.disposition}**${r.reason ? `: ${r.reason}` : ''}`;
4155
+ }).join('\n');
4156
+ const message = {
4157
+ role: 'user',
4158
+ content: `${header}\n${body}`,
4159
+ metadata: {
4160
+ turnAdded: this._promptTurnCount,
4161
+ messageType: 'tool-result',
4162
+ expirationTurns: 3,
4163
+ expirationMode: 'Compact',
4164
+ compactMode: 'First N Chars',
4165
+ compactLength: 300,
4166
+ compactPromptId: '',
4167
+ },
4168
+ };
4169
+ params.conversationMessages.push(message);
4170
+ }
3436
4171
  /**
3437
4172
  * Builds a per-run {@link PipelineToolRegistry} that unifies the three pipeline-able
3438
4173
  * substrates behind one namespace: built-in transforms, the agent's effective Actions, and
@@ -3583,37 +4318,70 @@ The context is now within limits. Please retry your request with the recovered c
3583
4318
  *
3584
4319
  * @private
3585
4320
  */
3586
- async gatherPromptTemplateData(agent, _contextUser, extraData, actionChanges) {
4321
+ async gatherPromptTemplateData(agent, _contextUser, extraData, actionChanges, subAgentChanges) {
3587
4322
  try {
3588
4323
  const engine = AIEngine.Instance;
3589
- // Find sub-agents using AIEngine
3590
- const activeSubAgents = engine.Agents.filter(a => UUIDsEqual(a.ParentID, agent.ID) && a.Status === 'Active')
3591
- .sort((a, b) => a.ExecutionOrder - b.ExecutionOrder);
3592
- const activeAgentRelationships = engine.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, agent.ID) && ar.Status === 'Active');
3593
- // now combine the child sub-agents from the direct parentID relationships with the agentRelationships array, distinct to not repeat
3594
- // unique ID values
3595
- const uniqueActiveSubAgentIDs = new Set();
3596
- activeSubAgents.forEach(a => uniqueActiveSubAgentIDs.add(a.ID));
3597
- activeAgentRelationships.forEach(ar => uniqueActiveSubAgentIDs.add(ar.SubAgentID));
3598
- const uniqueActiveSubAgents = Array.from(uniqueActiveSubAgentIDs).map(id => engine.Agents.find(a => UUIDsEqual(a.ID, id)));
3599
- // Load available actions from database configuration
3600
- const agentActions = engine.AgentActions.filter(aa => UUIDsEqual(aa.AgentID, agent.ID) && aa.Status === 'Active');
3601
- let actions = ActionEngineServer.Instance.Actions.filter(a => agentActions.some(aa => UUIDsEqual(aa.ActionID, a.ID)));
3602
- // Apply runtime action changes if provided
4324
+ // Build (or reuse) the agent-invariant base catalog. This is process-wide cached on
4325
+ // AIEngine and wiped on Agent/AgentAction/AgentRelationship/AgentType changes + reloads.
4326
+ // It turns the per-step rebuild (sub-agent + action resolution, markdown, JSON.parse of
4327
+ // agent-type params) into a once-per-agent cost; the common no-override step reuses it wholesale.
4328
+ let catalog = engine.GetAgentBaseCatalog(agent.ID);
4329
+ if (!catalog) {
4330
+ catalog = this.buildAgentBaseCatalog(agent, engine);
4331
+ engine.SetAgentBaseCatalog(agent.ID, catalog);
4332
+ }
4333
+ const isRoot = this._depth === 0;
4334
+ // Sub-agents: reuse cached base unless runtime subAgentChanges apply (then clone + re-format).
4335
+ let uniqueActiveSubAgents = catalog.uniqueActiveSubAgents;
4336
+ let subAgentDetails = catalog.subAgentDetails;
4337
+ let subAgentCount = catalog.subAgentCount;
4338
+ if (subAgentChanges?.length) {
4339
+ uniqueActiveSubAgents = this.applySubAgentChanges(catalog.uniqueActiveSubAgents, subAgentChanges, agent.ID, isRoot, engine);
4340
+ subAgentCount = uniqueActiveSubAgents.length;
4341
+ subAgentDetails = this.formatSubAgentDetails(uniqueActiveSubAgents);
4342
+ }
4343
+ // Actions: reuse cached active set unless runtime actionChanges apply (then clone + re-format).
4344
+ //
4345
+ // FAST-PATH SHARING CONTRACT: on the no-override path, `activeActions` (and therefore
4346
+ // `_effectiveActions`) and `uniqueActiveSubAgents` above are the SAME array references
4347
+ // held by the process-wide AIEngine catalog cache. Downstream consumers MUST treat them
4348
+ // as read-only — they are only ever read (`.find`/`.map`/`.length`/`.some`), never mutated
4349
+ // in place. On the override path a fresh array is built via filter/applyActionChanges, so
4350
+ // the cached arrays are never the mutated ones. Keeping the references (vs. copying) avoids
4351
+ // a per-step allocation; if a future consumer needs to mutate, it must `.slice()` first.
4352
+ let activeActions = catalog.activeActions;
4353
+ let actionDetails = catalog.actionDetails;
3603
4354
  if (actionChanges?.length) {
3604
- const isRoot = this._depth === 0;
3605
- const result = this.applyActionChanges(actions, actionChanges, agent.ID, isRoot);
3606
- actions = result.actions;
4355
+ const result = this.applyActionChanges([...catalog.baseActionsRaw], actionChanges, agent.ID, isRoot);
4356
+ activeActions = result.actions.filter(a => a.Status === 'Active');
3607
4357
  this._dynamicActionLimits = result.dynamicLimits;
4358
+ actionDetails = this.formatActionDetails(activeActions);
3608
4359
  }
3609
- // Filter to only active actions and store for later validation in executeActionsStep
3610
- const activeActions = actions.filter(a => a.Status === 'Active');
4360
+ else {
4361
+ // No actionChanges this step → no dynamically-added actions, hence no dynamic limits.
4362
+ // gatherPromptTemplateData runs once per step, and _dynamicActionLimits is keyed to the
4363
+ // actionChanges of the CURRENT step (read at validation time in checkActionExecutionLimits).
4364
+ // Resetting to {} is correct and required: it prevents a prior step's actionChanges limits
4365
+ // from leaking into a step that has none. It is NOT relied upon to persist across steps.
4366
+ this._dynamicActionLimits = {};
4367
+ }
4368
+ // Store for later validation in executeActionsStep
3611
4369
  this._effectiveActions = activeActions;
3612
- // Build agent type prompt params (merged from schema defaults, agent config, and runtime overrides)
3613
- const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
4370
+ // Agent type prompt params: reuse cached base merge unless a runtime override is present.
3614
4371
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
3615
- const agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
3616
- // Build client tool details for the prompt
4372
+ let agentTypePromptParams;
4373
+ if (runtimePromptParamOverrides) {
4374
+ const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
4375
+ agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
4376
+ }
4377
+ else {
4378
+ // Fast path: shallow-clone the cached base params before handing them out. The cached
4379
+ // object lives in the process-wide AIEngine catalog and is shared across every run of
4380
+ // this agent; the audit shows it is read-only downstream today, but the clone is cheap
4381
+ // and removes any cache-poisoning foot-gun should a future consumer write to it.
4382
+ agentTypePromptParams = { ...catalog.baseAgentTypePromptParams };
4383
+ }
4384
+ // Build client tool details for the prompt (per-run; depends on extraData)
3617
4385
  const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
3618
4386
  // Build app context section if provided in extraData
3619
4387
  const appContext = this.buildAppContextSection(extraData);
@@ -3621,10 +4389,10 @@ The context is now within limits. Please retry your request with the recovered c
3621
4389
  agentName: agent.Name,
3622
4390
  agentDescription: agent.Description,
3623
4391
  parentAgentName: agent.Parent ? agent.Parent.trim() : "",
3624
- subAgentCount: uniqueActiveSubAgents.length,
3625
- subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
4392
+ subAgentCount: subAgentCount,
4393
+ subAgentDetails: subAgentDetails,
3626
4394
  actionCount: activeActions.length,
3627
- actionDetails: this.formatActionDetails(activeActions),
4395
+ actionDetails: actionDetails,
3628
4396
  clientToolDetails: clientToolDetails,
3629
4397
  appContext: appContext,
3630
4398
  };
@@ -3656,6 +4424,98 @@ The context is now within limits. Please retry your request with the recovered c
3656
4424
  throw new Error(`Error gathering context data: ${error.message}`);
3657
4425
  }
3658
4426
  }
4427
+ /**
4428
+ * Builds the agent-invariant {@link AgentBaseCatalog} — the resolved sub-agents + actions and
4429
+ * their formatted markdown, plus the base agent-type prompt params. Computed once per agent and
4430
+ * cached on AIEngine (see gatherPromptTemplateData); does NOT apply any runtime overrides.
4431
+ *
4432
+ * @protected
4433
+ */
4434
+ buildAgentBaseCatalog(agent, engine) {
4435
+ // Resolve sub-agents: direct ParentID children + active relationships, de-duped, ordered.
4436
+ const activeSubAgents = engine.Agents.filter(a => UUIDsEqual(a.ParentID, agent.ID) && a.Status === 'Active')
4437
+ .sort((a, b) => a.ExecutionOrder - b.ExecutionOrder);
4438
+ const activeAgentRelationships = engine.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, agent.ID) && ar.Status === 'Active');
4439
+ const uniqueActiveSubAgentIDs = new Set();
4440
+ activeSubAgents.forEach(a => uniqueActiveSubAgentIDs.add(a.ID));
4441
+ activeAgentRelationships.forEach(ar => uniqueActiveSubAgentIDs.add(ar.SubAgentID));
4442
+ const uniqueActiveSubAgents = Array.from(uniqueActiveSubAgentIDs).map(id => engine.Agents.find(a => UUIDsEqual(a.ID, id)));
4443
+ // Resolve actions from the agent's active AIAgentAction junctions.
4444
+ const agentActions = engine.AgentActions.filter(aa => UUIDsEqual(aa.AgentID, agent.ID) && aa.Status === 'Active');
4445
+ const baseActionsRaw = ActionEngineServer.Instance.Actions.filter(a => agentActions.some(aa => UUIDsEqual(aa.ActionID, a.ID)));
4446
+ const activeActions = baseActionsRaw.filter(a => a.Status === 'Active');
4447
+ // Base agent-type prompt params (schema defaults + agent config; NO runtime overrides).
4448
+ const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
4449
+ const baseAgentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, undefined);
4450
+ return {
4451
+ uniqueActiveSubAgents,
4452
+ subAgentCount: uniqueActiveSubAgents.length,
4453
+ subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
4454
+ baseActionsRaw,
4455
+ activeActions,
4456
+ actionDetails: this.formatActionDetails(activeActions),
4457
+ baseAgentTypePromptParams,
4458
+ };
4459
+ }
4460
+ /**
4461
+ * Applies runtime {@link SubAgentChange}s to a base sub-agent set — the sub-agent counterpart of
4462
+ * {@link applyActionChanges}. Returns a NEW array (never mutates the cached base set).
4463
+ *
4464
+ * @protected
4465
+ */
4466
+ applySubAgentChanges(baseSubAgents, subAgentChanges, agentId, isRoot, engine) {
4467
+ let subAgents = [...baseSubAgents];
4468
+ for (const change of subAgentChanges) {
4469
+ if (!this.doesChangeScopeApply(change.scope, agentId, isRoot, change.agentIds)) {
4470
+ continue;
4471
+ }
4472
+ if (change.mode === 'add') {
4473
+ for (const subAgentId of change.subAgentIds) {
4474
+ if (!subAgents.some(a => UUIDsEqual(a.ID, subAgentId))) {
4475
+ const toAdd = engine.Agents.find(a => UUIDsEqual(a.ID, subAgentId));
4476
+ if (toAdd) {
4477
+ subAgents.push(toAdd);
4478
+ }
4479
+ else {
4480
+ LogStatus(`Sub-agent with ID '${subAgentId}' not found in AIEngine - skipping add`);
4481
+ }
4482
+ }
4483
+ }
4484
+ }
4485
+ else if (change.mode === 'remove') {
4486
+ subAgents = subAgents.filter(a => !change.subAgentIds.some(id => UUIDsEqual(id, a.ID)));
4487
+ }
4488
+ }
4489
+ return subAgents;
4490
+ }
4491
+ /**
4492
+ * Filters/transforms sub-agent changes for propagation to a sub-agent — the sub-agent counterpart
4493
+ * of {@link filterActionChangesForSubAgent} (same propagation rules).
4494
+ *
4495
+ * @protected
4496
+ */
4497
+ filterSubAgentChangesForSubAgent(subAgentChanges) {
4498
+ if (!subAgentChanges?.length) {
4499
+ return undefined;
4500
+ }
4501
+ const filtered = [];
4502
+ for (const change of subAgentChanges) {
4503
+ switch (change.scope) {
4504
+ case 'root':
4505
+ continue; // only applies to root — don't propagate
4506
+ case 'global':
4507
+ filtered.push(change);
4508
+ break;
4509
+ case 'all-subagents':
4510
+ filtered.push({ ...change, scope: 'global' });
4511
+ break;
4512
+ case 'specific':
4513
+ filtered.push(change);
4514
+ break;
4515
+ }
4516
+ }
4517
+ return filtered.length > 0 ? filtered : undefined;
4518
+ }
3659
4519
  /**
3660
4520
  * Builds merged agent type prompt params from schema defaults,
3661
4521
  * agent config, and runtime overrides.
@@ -3743,7 +4603,8 @@ The context is now within limits. Please retry your request with the recovered c
3743
4603
  { docsFlag: 'includeWhileDocs', responseTypeKey: 'while' },
3744
4604
  { docsFlag: 'includeScratchpadDocs', responseTypeKey: 'scratchpad' },
3745
4605
  { docsFlag: 'includeArtifactToolsDocs', responseTypeKey: 'artifactToolCalls' },
3746
- { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' }
4606
+ { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' },
4607
+ { docsFlag: 'includeMemoryWritesDocs', responseTypeKey: 'memoryWrites' }
3747
4608
  ];
3748
4609
  for (const { docsFlag, responseTypeKey } of alignmentMappings) {
3749
4610
  // Check if the user explicitly set this response type property
@@ -3985,8 +4846,9 @@ The context is now within limits. Please retry your request with the recovered c
3985
4846
  this.logStatus(`🎯 Propagating effort level ${params.effortLevel} to sub-agent '${subAgentRequest.name}'`, true, params);
3986
4847
  }
3987
4848
  const parentStepCountsToPass = [...this._parentStepCounts, stepCount + 1];
3988
- // Filter action changes for sub-agent propagation
4849
+ // Filter action / sub-agent changes for sub-agent propagation
3989
4850
  const subAgentActionChanges = this.filterActionChangesForSubAgent(params.actionChanges);
4851
+ const subAgentSubAgentChanges = this.filterSubAgentChangesForSubAgent(params.subAgentChanges);
3990
4852
  // Execute the sub-agent with cancellation and streaming support
3991
4853
  // Use subAgentRequest.context if provided, otherwise fall back to params.context
3992
4854
  // This allows Flow agents and Loop agents to propagate context through sub-agent requests
@@ -4014,12 +4876,15 @@ The context is now within limits. Please retry your request with the recovered c
4014
4876
  context: subAgentContext, // use subAgentRequest.context if provided, otherwise params.context
4015
4877
  verbose: params.verbose, // pass verbose flag to sub-agent
4016
4878
  actionChanges: subAgentActionChanges, // propagate filtered action changes to sub-agent
4879
+ subAgentChanges: subAgentSubAgentChanges, // propagate filtered sub-agent changes to sub-agent
4017
4880
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
4018
4881
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
4019
4882
  SecondaryScopes: params.SecondaryScopes,
4020
4883
  onAgentRunCreated: async (agentRunId) => {
4021
4884
  stepEntity.TargetLogID = agentRunId;
4022
- this.queueStepSave(stepEntity);
4885
+ // Re-apply post-INSERT: this callback can fire while the step's INSERT is still in flight,
4886
+ // and the INSERT's reload would otherwise revert TargetLogID back to null.
4887
+ this.queueStepSave(stepEntity, (s) => { s.TargetLogID = agentRunId; });
4023
4888
  }
4024
4889
  });
4025
4890
  // Check if execution was successful
@@ -4604,6 +5469,11 @@ The context is now within limits. Please retry your request with the recovered c
4604
5469
  if (params.data?.conversationId) {
4605
5470
  this._agentRun.ConversationID = params.data.conversationId;
4606
5471
  }
5472
+ // Stamp the realtime/long-lived session id (if any) so every run — including delegated
5473
+ // child runs that inherit this value — is groupable under the same MJ: AI Agent Session.
5474
+ if (params.agentSessionID) {
5475
+ this._agentRun.AgentSessionID = params.agentSessionID;
5476
+ }
4607
5477
  this._agentRun.Status = 'Running';
4608
5478
  this._agentRun.StartedAt = new Date();
4609
5479
  this._agentRun.UserID = params.userId || params.contextUser?.ID || null;
@@ -4761,38 +5631,42 @@ The context is now within limits. Please retry your request with the recovered c
4761
5631
  */
4762
5632
  async createStepEntity(params) {
4763
5633
  const stepEntity = await this._activeProvider.GetEntityObject('MJ: AI Agent Run Steps', params.contextUser);
4764
- stepEntity.AgentRunID = this._agentRun.ID;
5634
+ // Client-generate the PK so the step ID is valid IMMEDIATELY (before the INSERT lands) — child
5635
+ // steps link via ParentID and the post-create UPDATE-phase mutations reference this row, and the
5636
+ // create INSERT is fire-and-forget (the agent flow must not block on it).
5637
+ stepEntity.NewRecord();
4765
5638
  // Step number is based on current count of steps + 1
4766
- stepEntity.StepNumber = (this._agentRun.Steps?.length || 0) + 1;
4767
- stepEntity.StepType = params.stepType;
4768
- // Include hierarchy breadcrumb in StepName for better logging
4769
- stepEntity.StepName = this.formatHierarchicalMessage(params.stepName);
4770
- // check to see if targetId is a valid UUID
5639
+ const stepNumber = (this._agentRun.Steps?.length || 0) + 1;
5640
+ // Warn on a non-UUID targetId before delegating (initAgentRunStep silently ignores invalid ids).
4771
5641
  if (params.targetId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(params.targetId)) {
4772
- // If not valid, we can just ignore it, but console.warn
4773
5642
  console.warn(`Invalid target ID format: ${params.targetId}`);
4774
5643
  }
4775
- else {
4776
- stepEntity.TargetID = params.targetId || null;
4777
- }
4778
- stepEntity.TargetLogID = params.targetLogId || null;
4779
- stepEntity.ParentID = params.parentId || null; // Link to parent step (e.g., loop step)
4780
- stepEntity.Status = 'Running';
4781
- stepEntity.StartedAt = new Date();
4782
- stepEntity.PayloadAtStart = this.serializePayloadAtStart(params.payloadAtStart);
4783
- stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(params.payloadAtEnd);
4784
- // Populate InputData if provided
4785
- if (params.inputData) {
4786
- stepEntity.InputData = JSON.stringify({
4787
- ...params.inputData,
4788
- context: {
4789
- agentHierarchy: this._agentHierarchy,
4790
- depth: this._depth,
4791
- stepNumber: stepEntity.StepNumber
4792
- }
4793
- });
4794
- }
4795
- this.queueStepSave(stepEntity);
5644
+ // Populate the started fields via the shared single-source-of-truth helper. Instance-specific
5645
+ // concerns (hierarchy breadcrumb, InputData context, payload serialization) are computed here.
5646
+ initAgentRunStep(stepEntity, {
5647
+ AgentRunID: this._agentRun.ID,
5648
+ StepNumber: stepNumber,
5649
+ StepType: params.stepType,
5650
+ StepName: this.formatHierarchicalMessage(params.stepName), // include hierarchy breadcrumb
5651
+ TargetID: params.targetId,
5652
+ TargetLogID: params.targetLogId,
5653
+ ParentID: params.parentId, // Link to parent step (e.g., loop step)
5654
+ PayloadAtStart: this.serializePayloadAtStart(params.payloadAtStart),
5655
+ PayloadAtEnd: this.serializePayloadAtEnd(params.payloadAtEnd),
5656
+ InputData: params.inputData
5657
+ ? JSON.stringify({
5658
+ ...params.inputData,
5659
+ context: {
5660
+ agentHierarchy: this._agentHierarchy,
5661
+ depth: this._depth,
5662
+ stepNumber
5663
+ }
5664
+ })
5665
+ : undefined
5666
+ });
5667
+ // Fire-and-forget the 'started' INSERT — the agent flow never blocks on a step save. The queue
5668
+ // tracks the INSERT so every later UPDATE (queueStepSave) chains AFTER it commits.
5669
+ this._stepSaveQueue.Insert(stepEntity);
4796
5670
  // Add the step to the agent run's Steps array
4797
5671
  if (this._agentRun) {
4798
5672
  this._agentRun.Steps.push(stepEntity);
@@ -4859,57 +5733,42 @@ The context is now within limits. Please retry your request with the recovered c
4859
5733
  */
4860
5734
  async finalizeStepEntity(stepEntity, success, errorMessage, outputData) {
4861
5735
  try {
4862
- stepEntity.Status = success ? 'Completed' : 'Failed';
4863
- stepEntity.CompletedAt = new Date();
4864
- stepEntity.Success = success;
4865
- stepEntity.ErrorMessage = errorMessage || null;
4866
- // Populate OutputData if provided
4867
- if (outputData) {
4868
- stepEntity.OutputData = JSON.stringify({
4869
- ...CopyScalarsAndArrays(outputData, true),
4870
- context: {
4871
- success,
4872
- durationMs: stepEntity.CompletedAt.getTime() - stepEntity.StartedAt.getTime(),
4873
- errorMessage
4874
- }
4875
- });
4876
- }
4877
- this.queueStepSave(stepEntity);
5736
+ // Capture the completion timestamp NOW so the duration is accurate regardless of when the
5737
+ // mutation is actually applied/persisted.
5738
+ const finalizeOpts = {
5739
+ success,
5740
+ errorMessage,
5741
+ outputData: outputData ? CopyScalarsAndArrays(outputData, true) : undefined,
5742
+ completedAt: new Date(),
5743
+ // Capture any TargetLogID already stamped on the entity (e.g. a prompt-run / sub-agent-run id
5744
+ // set before finalize) so the post-INSERT re-apply restores it too — otherwise the INSERT's
5745
+ // reload could leave it null on a fast step.
5746
+ targetLogID: stepEntity.TargetLogID ?? undefined
5747
+ };
5748
+ // Apply to the in-memory entity NOW so the run's Steps array / UI see the terminal state
5749
+ // immediately. This in-memory copy can be reverted by the INSERT's post-save reload if the step
5750
+ // finished while its INSERT was still in flight, which is why we ALSO re-apply it inside the
5751
+ // post-INSERT continuation below (idempotent — same completedAt).
5752
+ finalizeAgentRunStep(stepEntity, finalizeOpts);
5753
+ // Fire-and-forget the UPDATE, but re-assert the finalize state AFTER the INSERT (and its reload)
5754
+ // lands so the force-persisted UPDATE never writes stale pre-finalize values. The agent flow
5755
+ // never blocks on this UPDATE.
5756
+ this.queueStepSave(stepEntity, (s) => finalizeAgentRunStep(s, finalizeOpts));
4878
5757
  }
4879
5758
  catch (e) {
4880
5759
  LogError(`Failed to update agent run step record: ${e?.message ?? e}`, undefined, e);
4881
5760
  }
4882
5761
  }
4883
5762
  /**
4884
- * Queues a database save for a step entity.
4885
- *
4886
- * - Saves on the same step record are chained (sequenced) to prevent an UPDATE
4887
- * from racing the original INSERT.
4888
- * - Saves on different step records run concurrently.
4889
- * - Failures are not thrown — they're logged via `LogError` (with the entity's
4890
- * `LatestResult.CompleteMessage` per the BaseEntity convention) so the
4891
- * agent loop isn't blocked by observability writes — but they ARE surfaced
4892
- * in `finalizeAgentRun` so callers see step-record drift.
4893
- *
4894
- * Exposed as `protected` so driver sub-classes (e.g. Skip) that author
4895
- * custom `AIAgentRunStep` records can fire-and-forget saves through the
4896
- * same chained/non-blocking machinery instead of awaiting `entity.Save()`
4897
- * inline and blocking the agent loop.
5763
+ * Queues a fire-and-forget UPDATE of a step entity whose fields the caller has ALREADY mutated.
5764
+ * Delegates to {@link AgentRunStepSaveQueue.QueueUpdate} — the agent flow never awaits this; the UPDATE
5765
+ * chains after the step's INSERT and force-persists (`IgnoreDirtyState`). Kept `protected` so driver
5766
+ * subclasses that finalize their own steps get the same non-blocking behavior.
4898
5767
  *
4899
5768
  * @protected
4900
5769
  */
4901
- queueStepSave(stepEntity) {
4902
- // Chain on the entity INSTANCE (stable), NOT stepEntity.ID — the ID is empty until the INSERT
4903
- // Save() assigns it, so an ID-keyed chain breaks for fast create→finalize sequences.
4904
- const previousSave = this._stepSavePromises.get(stepEntity) ?? Promise.resolve();
4905
- const currentSave = previousSave.then(() => stepEntity.Save()).then((ok) => {
4906
- if (!ok) {
4907
- LogError(`Failed to save agent run step record ${stepEntity.ID || '(unsaved)'}: ${stepEntity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
4908
- }
4909
- return ok;
4910
- });
4911
- this._stepSavePromises.set(stepEntity, currentSave);
4912
- this._pendingSaves.push(currentSave);
5770
+ queueStepSave(stepEntity, applyMutation) {
5771
+ this._stepSaveQueue.QueueUpdate(stepEntity, applyMutation);
4913
5772
  }
4914
5773
  /**
4915
5774
  * Maps an array through an async worker with bounded concurrency.
@@ -5242,10 +6101,8 @@ The context is now within limits. Please retry your request with the recovered c
5242
6101
  },
5243
6102
  displayMode: 'live' // Only show in live mode
5244
6103
  });
5245
- // Set PayloadAtStart
5246
- if (stepEntity && payload) {
5247
- stepEntity.PayloadAtStart = this.serializePayloadAtStart(payload);
5248
- }
6104
+ // PayloadAtStart was already serialized from this same `payload` by createStepEntity
6105
+ // above (payloadAtStart: payload) — no need to re-serialize the (potentially large) payload here.
5249
6106
  let downstreamPayload = payload; // Start with current payload
5250
6107
  if (params.agent.PayloadSelfReadPaths) {
5251
6108
  const downstreamPaths = JSON.parse(params.agent.PayloadSelfReadPaths);
@@ -5282,7 +6139,9 @@ The context is now within limits. Please retry your request with the recovered c
5282
6139
  } : undefined;
5283
6140
  promptParams.onPromptRunCreated = async (promptRunId) => {
5284
6141
  stepEntity.TargetLogID = promptRunId;
5285
- this.queueStepSave(stepEntity);
6142
+ // Re-apply post-INSERT: onPromptRunCreated can fire before the step's INSERT lands, and the
6143
+ // INSERT's reload would otherwise revert TargetLogID back to null.
6144
+ this.queueStepSave(stepEntity, (s) => { s.TargetLogID = promptRunId; });
5286
6145
  };
5287
6146
  // Execute the prompt
5288
6147
  const promptResult = await this.executePrompt(promptParams);
@@ -5294,7 +6153,7 @@ The context is now within limits. Please retry your request with the recovered c
5294
6153
  // Update step entity with AIPromptRun ID if available
5295
6154
  if (promptResult.promptRun?.ID) {
5296
6155
  stepEntity.TargetLogID = promptResult.promptRun.ID;
5297
- stepEntity.PromptRun = promptResult.promptRun; // Store the prompt run object
6156
+ stepEntity.PromptRun = promptResult.promptRun; // transient related object (not a persisted field)
5298
6157
  // don't save here, we save when we call finalizeStepEntity()
5299
6158
  }
5300
6159
  // Check if prompt execution failed
@@ -5411,6 +6270,11 @@ The context is now within limits. Please retry your request with the recovered c
5411
6270
  else if (this._artifactToolManager.HasArtifacts()) {
5412
6271
  this.logStatus(`[ArtifactTools] LLM did not use artifact tools this turn (artifacts available but not accessed)`, true, params);
5413
6272
  }
6273
+ // Execute in-flight memory writes if provided (zero turn cost — processed inline)
6274
+ const memoryWrites = initialNextStep.memoryWrites;
6275
+ if (memoryWrites?.length) {
6276
+ await this.processMemoryWritesForTurn(memoryWrites, params);
6277
+ }
5414
6278
  // Execute a tool pipeline if provided (zero turn cost — processed inline). Each step's
5415
6279
  // output is threaded into the next server-side; only the final step's output returns to
5416
6280
  // the LLM, so intermediate payloads never enter the context window.
@@ -5797,7 +6661,7 @@ The context is now within limits. Please retry your request with the recovered c
5797
6661
  // Update step entity with AIAgentRun ID if available
5798
6662
  if (subAgentResult.agentRun?.ID) {
5799
6663
  stepEntity.TargetLogID = subAgentResult.agentRun.ID;
5800
- // Set the SubAgentRun property for hierarchical tracking
6664
+ // Set the SubAgentRun property for hierarchical tracking (transient related object)
5801
6665
  stepEntity.SubAgentRun = subAgentResult.agentRun;
5802
6666
  stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(mergedPayload);
5803
6667
  // saving happens later by calling finalizeStepEntity()
@@ -6926,8 +7790,11 @@ The context is now within limits. Please retry your request with the recovered c
6926
7790
  actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser);
6927
7791
  // Update step entity with ActionExecutionLog ID if available
6928
7792
  if (actionResult.LogEntry?.ID) {
6929
- stepEntity.TargetLogID = actionResult.LogEntry.ID;
6930
- this.queueStepSave(stepEntity);
7793
+ const logId = actionResult.LogEntry.ID;
7794
+ stepEntity.TargetLogID = logId;
7795
+ // Re-apply post-INSERT: a fast action can finish before the step's INSERT lands, and
7796
+ // the INSERT's reload would otherwise revert TargetLogID back to null.
7797
+ this.queueStepSave(stepEntity, (s) => { s.TargetLogID = logId; });
6931
7798
  }
6932
7799
  // Prepare output data with action result
6933
7800
  const outputData = {
@@ -8203,27 +9070,15 @@ The context is now within limits. Please retry your request with the recovered c
8203
9070
  * @private
8204
9071
  */
8205
9072
  async finalizeAgentRun(finalStep, payload, contextUser) {
8206
- // Await every pending step save (success OR failure) and accumulate diagnostics.
8207
- // We use allSettled so a single failure doesn't shadow the rest, and we drain
8208
- // both queues afterwards so an instance reused for another run doesn't leak
8209
- // settled promises.
8210
- const pending = this._pendingSaves;
8211
- this._pendingSaves = [];
8212
- this._stepSavePromises.clear();
8213
- if (pending.length > 0) {
8214
- const settled = await Promise.allSettled(pending);
8215
- const rejections = settled.filter(s => s.status === 'rejected');
8216
- const falses = settled.filter(s => s.status === 'fulfilled' && s.value === false).length;
8217
- for (const r of rejections) {
8218
- LogError(`Pending step save rejected: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
8219
- }
8220
- const totalFailures = rejections.length + falses;
8221
- if (totalFailures > 0 && this._agentRun) {
8222
- const note = `${totalFailures} step record save(s) failed during this run; see logs for details.`;
8223
- this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
8224
- ? `${this._agentRun.ErrorMessage}\n${note}`
8225
- : note;
8226
- }
9073
+ // Flush every pending step save (success OR failure) via the shared queue, which allSettles so a
9074
+ // single failure doesn't shadow the rest and drains itself so a reused instance doesn't leak
9075
+ // settled promises. Surface the failure count on the run for visibility.
9076
+ const { failures } = await this._stepSaveQueue.Flush();
9077
+ if (failures > 0 && this._agentRun) {
9078
+ const note = `${failures} step record save(s) failed during this run; see logs for details.`;
9079
+ this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
9080
+ ? `${this._agentRun.ErrorMessage}\n${note}`
9081
+ : note;
8227
9082
  }
8228
9083
  // Only resolve media placeholders for ROOT agents (depth === 0)
8229
9084
  // Sub-agents keep placeholders intact so parent agents don't get huge base64 in their context
@@ -8259,12 +9114,17 @@ The context is now within limits. Please retry your request with the recovered c
8259
9114
  else {
8260
9115
  this._agentRun.Status = 'Completed';
8261
9116
  }
8262
- this._agentRun.Result = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9117
+ // Serialize the (largest-it-ever-gets) final payload ONCE and reuse for both
9118
+ // Result and FinalPayload instead of stringifying the same object three times.
9119
+ const finalPayloadJson = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9120
+ this._agentRun.Result = finalPayloadJson;
8263
9121
  this._agentRun.FinalStep = finalStep.step;
8264
9122
  this._agentRun.Message = finalStep.message;
8265
- // Set the FinalPayloadObject - this will automatically stringify for the DB
9123
+ // Set the FinalPayloadObject (populates the object cache; its setter also writes
9124
+ // FinalPayload when the value changes). We then assign FinalPayload from the
9125
+ // already-computed JSON to guarantee it's set regardless of the setter's change guard.
8266
9126
  this._agentRun.FinalPayloadObject = resolvedPayload;
8267
- this._agentRun.FinalPayload = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9127
+ this._agentRun.FinalPayload = finalPayloadJson;
8268
9128
  // Calculate total tokens from all prompts and sub-agents
8269
9129
  const tokenStats = this.calculateTokenStats();
8270
9130
  this._agentRun.TotalTokensUsed = tokenStats.totalTokens;