@memberjunction/ai-agents 5.40.2 → 5.41.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 (80) hide show
  1. package/README.md +45 -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 +365 -24
  33. package/dist/base-agent.d.ts.map +1 -1
  34. package/dist/base-agent.js +995 -175
  35. package/dist/base-agent.js.map +1 -1
  36. package/dist/index.d.ts +11 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +15 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/memory-manager-agent.d.ts +55 -2
  41. package/dist/memory-manager-agent.d.ts.map +1 -1
  42. package/dist/memory-manager-agent.js +261 -62
  43. package/dist/memory-manager-agent.js.map +1 -1
  44. package/dist/realtime/meeting-controls-channel-server.d.ts +198 -0
  45. package/dist/realtime/meeting-controls-channel-server.d.ts.map +1 -0
  46. package/dist/realtime/meeting-controls-channel-server.js +319 -0
  47. package/dist/realtime/meeting-controls-channel-server.js.map +1 -0
  48. package/dist/realtime/meeting-controls-state.d.ts +191 -0
  49. package/dist/realtime/meeting-controls-state.d.ts.map +1 -0
  50. package/dist/realtime/meeting-controls-state.js +219 -0
  51. package/dist/realtime/meeting-controls-state.js.map +1 -0
  52. package/dist/realtime/realtime-channel-server-host.d.ts +166 -0
  53. package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -0
  54. package/dist/realtime/realtime-channel-server-host.js +378 -0
  55. package/dist/realtime/realtime-channel-server-host.js.map +1 -0
  56. package/dist/realtime/realtime-client-session-service.d.ts +884 -0
  57. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -0
  58. package/dist/realtime/realtime-client-session-service.js +1401 -0
  59. package/dist/realtime/realtime-client-session-service.js.map +1 -0
  60. package/dist/realtime/realtime-coagent-config.d.ts +202 -0
  61. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -0
  62. package/dist/realtime/realtime-coagent-config.js +334 -0
  63. package/dist/realtime/realtime-coagent-config.js.map +1 -0
  64. package/dist/realtime/realtime-narration.d.ts +67 -0
  65. package/dist/realtime/realtime-narration.d.ts.map +1 -0
  66. package/dist/realtime/realtime-narration.js +127 -0
  67. package/dist/realtime/realtime-narration.js.map +1 -0
  68. package/dist/realtime/realtime-session-runner.d.ts +383 -0
  69. package/dist/realtime/realtime-session-runner.d.ts.map +1 -0
  70. package/dist/realtime/realtime-session-runner.js +532 -0
  71. package/dist/realtime/realtime-session-runner.js.map +1 -0
  72. package/dist/realtime/realtime-tool-broker.d.ts +279 -0
  73. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -0
  74. package/dist/realtime/realtime-tool-broker.js +184 -0
  75. package/dist/realtime/realtime-tool-broker.js.map +1 -0
  76. package/dist/realtime/whiteboard-channel-server.d.ts +50 -0
  77. package/dist/realtime/whiteboard-channel-server.d.ts.map +1 -0
  78. package/dist/realtime/whiteboard-channel-server.js +85 -0
  79. package/dist/realtime/whiteboard-channel-server.js.map +1 -0
  80. package/package.json +17 -17
@@ -11,22 +11,25 @@
11
11
  * @since 2.49.0
12
12
  */
13
13
  import { FileStorageEngineBase } from '@memberjunction/core-entities';
14
- import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase } from '@memberjunction/core';
14
+ import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase, EntitySaveOptions } 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 { BuildVoiceMannerSection, GetNarrationPaceMs, GetProviderVoiceSettings, ResolveEffectiveRealtimeConfig } from './realtime/realtime-coagent-config.js';
19
23
  import { AIEngine } from '@memberjunction/aiengine';
20
24
  import { ActionEngineServer } from '@memberjunction/actions';
21
25
  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';
26
+ import { AgentMemoryContextBuilder } from './agent-memory-context-builder.js';
25
27
  import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy } from '@memberjunction/ai-core-plus';
26
28
  import { AgentRunner } from './AgentRunner.js';
27
29
  import { PayloadManager } from './PayloadManager.js';
28
30
  import { ScratchpadManager } from './ScratchpadManager.js';
29
31
  import { ArtifactToolManager } from './ArtifactToolManager.js';
32
+ import { MemoryWriteManager } from './MemoryWriteManager.js';
30
33
  import { PipelineExecutor, PipelineToolRegistry, ActionInvocable, ArtifactToolInvocable, BuildPipelineToolDocs, formatFinalOutput, summarizePipelineStages, } from './pipeline/index.js';
31
34
  import { AgentDataPreloader } from './AgentDataPreloader.js';
32
35
  import { ClientToolRequestManager } from './ClientToolRequestManager.js';
@@ -103,6 +106,16 @@ export class BaseAgent {
103
106
  * INSERT on millisecond-fast steps (e.g. pipelines), which left them stuck at `Running`.
104
107
  */
105
108
  this._stepSavePromises = new Map();
109
+ /**
110
+ * Per-step 'started'-INSERT promises, keyed by the step entity instance. The create INSERT is
111
+ * fire-and-forget (the agent flow never blocks on it — the PK is client-generated by NewRecord() so
112
+ * the ID is valid immediately). Every UPDATE-phase save ({@link queueStepSave}) chains after this
113
+ * INSERT promise so the UPDATE never races ahead of the create, and force-persists with
114
+ * `IgnoreDirtyState` so a mutation absorbed by the INSERT's post-save dirty-reset (which silently
115
+ * no-op'd UPDATEs and left fast create→finalize steps stuck at Status='Running') is still written.
116
+ * A WeakMap so entries are GC'd with the entity.
117
+ */
118
+ this._stepInsertPromises = new WeakMap();
106
119
  /**
107
120
  * Active per-request metadata provider, set at the start of Execute().
108
121
  * Defaults to the global Metadata.Provider; overridden when a per-request
@@ -209,6 +222,11 @@ export class BaseAgent {
209
222
  * Allows agents to explore input artifacts on demand.
210
223
  */
211
224
  this._artifactToolManager = new ArtifactToolManager();
225
+ /**
226
+ * Manages in-flight durable memory writes for the current agent run.
227
+ * Only consulted when the agent has AllowMemoryWrite enabled.
228
+ */
229
+ this._memoryWriteManager = new MemoryWriteManager();
212
230
  /**
213
231
  * Effective actions available to this agent after applying actionChanges.
214
232
  * Populated during gatherPromptTemplateData() and used for validation in executeActionsStep().
@@ -253,21 +271,16 @@ export class BaseAgent {
253
271
  * @private
254
272
  */
255
273
  this.MAX_RECOVERY_ATTEMPTS = 1;
256
- /**
257
- * Storage for injected memory context to prepend to prompts
258
- */
259
- this._memoryContext = '';
260
274
  /**
261
275
  * Storage for injected notes and examples to include in result
262
276
  */
263
277
  this._injectedMemory = { notes: [], examples: [] };
264
278
  /**
265
279
  * 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.
280
+ * Contains the structured per-scope / combined result detail for downstream observability
281
+ * and artifact persistence. The formatted `<retrieved_context>` system-message block is
282
+ * unshifted onto `conversationMessages` by the shared {@link AgentMemoryContextBuilder}.
269
283
  */
270
- this._ragContext = '';
271
284
  this._injectedRAG = null;
272
285
  /**
273
286
  * Determines the request type ID based on the Chat step's context.
@@ -1006,6 +1019,7 @@ export class BaseAgent {
1006
1019
  // Reset scratchpad and artifact tools for each new execution (ephemeral per run)
1007
1020
  this._scratchpadManager.Clear();
1008
1021
  this._artifactToolManager.Clear();
1022
+ this._memoryWriteManager.Clear();
1009
1023
  // Initialize artifact tools with any input artifacts attached to the run.
1010
1024
  // Artifacts arrive as a typed first-class field on ExecuteAgentParams —
1011
1025
  // they are NOT routed through `data` because prompt-template rendering
@@ -1146,6 +1160,21 @@ export class BaseAgent {
1146
1160
  // Must wait for config from Phase 2 because it needs the resolved agent type and
1147
1161
  // prompt configuration to initialize the type-specific state machine.
1148
1162
  await this.initializeAgentType(wrappedParams, config);
1163
+ // =====================================================================================
1164
+ // SESSION-DRIVEN BRANCH (Realtime agent type)
1165
+ //
1166
+ // For session-driven agent types (the Realtime / Realtime Co-Agent type, marked by
1167
+ // `IsSessionDriven === true`), we do NOT enter the iterative reasoning loop. Instead we
1168
+ // hand control to a RealtimeSessionRunner that drives a long-lived duplex model session.
1169
+ //
1170
+ // This is the ONLY entry point into the realtime path. Loop and Flow agent types do not
1171
+ // expose `IsSessionDriven`, so `isSessionDrivenAgentType(...)` returns false for them and
1172
+ // their execution falls through to `executeAgentInternal` below — byte-for-byte unchanged.
1173
+ // =====================================================================================
1174
+ if (this.isSessionDrivenAgentType(this.AgentTypeInstance)) {
1175
+ this.logStatus(`🎙️ Agent '${params.agent.Name}' is session-driven — routing to RealtimeSessionRunner`, true, params);
1176
+ return await this.executeRealtimeSession(wrappedParams, config);
1177
+ }
1149
1178
  // Execute the agent's internal logic with wrapped parameters
1150
1179
  this.logStatus(`🚀 Executing agent '${params.agent.Name}' internal logic`, true, params);
1151
1180
  const executionResult = await this.executeAgentInternal(wrappedParams, config);
@@ -1201,6 +1230,580 @@ export class BaseAgent {
1201
1230
  params.cancellationToken = upstreamToken;
1202
1231
  }
1203
1232
  }
1233
+ // =====================================================================================
1234
+ // REALTIME (SESSION-DRIVEN) AGENT SUPPORT
1235
+ //
1236
+ // The methods below back the session-driven branch taken in Execute() for the Realtime
1237
+ // agent type. They are entered ONLY via that guarded branch; Loop/Flow agents never reach
1238
+ // them. The bulk of the work is building a RealtimeSessionRunnerDeps from BaseAgent's real
1239
+ // collaborators (model resolution, sub-agent delegation, tool execution, transcript
1240
+ // persistence, and usage checkpointing) and then driving RealtimeSessionRunner.Run().
1241
+ // =====================================================================================
1242
+ /**
1243
+ * Type guard for whether the resolved agent-type instance is session-driven.
1244
+ *
1245
+ * Detects the Realtime agent type without importing it (and without `instanceof`, which is
1246
+ * brittle under bundler class-duplication) by duck-typing the `IsSessionDriven` getter that
1247
+ * `RealtimeAgentType` adds. `BaseAgentType` (and Loop/Flow) do not expose this member, so the
1248
+ * guard returns `false` for them and the iterative loop runs unchanged.
1249
+ *
1250
+ * @param agentType The resolved agent-type instance for this run.
1251
+ * @returns `true` only when the type explicitly marks itself session-driven.
1252
+ */
1253
+ isSessionDrivenAgentType(agentType) {
1254
+ return agentType.IsSessionDriven === true;
1255
+ }
1256
+ /**
1257
+ * Drives a session-driven (Realtime) agent run end-to-end.
1258
+ *
1259
+ * Resolves the realtime model, assembles the session parameters (system prompt + memory/context),
1260
+ * builds the {@link RealtimeSessionRunnerDeps} from this agent's collaborators, runs the
1261
+ * {@link RealtimeSessionRunner}, and maps the result onto the finalized `AIAgentRun`.
1262
+ *
1263
+ * If no realtime model can be resolved (expected today, before the P3 drivers / P4 model
1264
+ * metadata land), it finalizes the run as a clean FAILED result with an actionable message
1265
+ * rather than throwing — a mis-provisioned environment must not crash the caller.
1266
+ *
1267
+ * @template R The caller's expected payload type (unused on the realtime path; the session
1268
+ * produces transcript/usage rather than a structured payload).
1269
+ * @param params The wrapped execution parameters.
1270
+ * @param config The loaded agent configuration (provides the system prompt, if any).
1271
+ * @returns The finalized {@link ExecuteAgentResult}.
1272
+ */
1273
+ async executeRealtimeSession(params, config) {
1274
+ // 1) Resolve the realtime model (overridable seam — tests inject a mock).
1275
+ const modelResolution = await this.resolveRealtimeModel(params);
1276
+ if (!modelResolution) {
1277
+ const message = `Agent '${params.agent.Name}' is session-driven (Realtime) but no usable Realtime model could be ` +
1278
+ `resolved. Configure a model of AIModelType 'Realtime' with an active vendor DriverClass and a ` +
1279
+ `valid API key (e.g. AI_VENDOR_API_KEY__<driver>). This is expected until the realtime drivers ` +
1280
+ `and model metadata are provisioned.`;
1281
+ this.logError(message, { agent: params.agent, category: 'RealtimeSession' });
1282
+ return await this.createFailureResult(message, params.contextUser);
1283
+ }
1284
+ // 2) Create the single long-lived AIPromptRun that usage is checkpointed onto.
1285
+ const promptRun = await this.createRealtimePromptRun(params, config, modelResolution);
1286
+ // 3) Build the injected deps and run the session.
1287
+ try {
1288
+ const deps = await this.buildRealtimeSessionDeps(params, config, modelResolution, promptRun);
1289
+ const runner = new RealtimeSessionRunner(deps);
1290
+ const sessionResult = await runner.Run();
1291
+ return await this.finalizeRealtimeRun(params, sessionResult);
1292
+ }
1293
+ catch (error) {
1294
+ const msg = error instanceof Error ? error.message : String(error);
1295
+ this.logError(`Realtime session failed for agent '${params.agent.Name}': ${msg}`, {
1296
+ agent: params.agent,
1297
+ category: 'RealtimeSession'
1298
+ });
1299
+ return await this.createFailureResult(msg, params.contextUser);
1300
+ }
1301
+ }
1302
+ /**
1303
+ * Resolves the realtime model + vendor driver + API key for a session-driven run.
1304
+ *
1305
+ * **Overridable seam.** This is the single injection point that test subclasses override to
1306
+ * return a mock {@link BaseRealtimeModel}, so {@link executeRealtimeSession} can be exercised
1307
+ * without provider SDKs or DB metadata.
1308
+ *
1309
+ * Production resolution: pick the highest-power active model of AIModelType `Realtime`; then
1310
+ * pick its highest-priority active vendor whose `DriverClass` has a resolvable API key; then
1311
+ * instantiate the driver via the `ClassFactory`. Returns `null` (never throws) if any step
1312
+ * can't be satisfied — the caller turns that into a clean FAILED result. (Per-agent realtime
1313
+ * model preference can later be wired through the agent's prompt-model config, the same path
1314
+ * loop agents use for `ModelSelectionMode`; the AI Agent entity has no direct model FK.)
1315
+ *
1316
+ * @param params The execution parameters (for the agent + context user).
1317
+ * @returns The resolved model instance plus its model/vendor identifiers, or `null`.
1318
+ */
1319
+ async resolveRealtimeModel(params) {
1320
+ const model = this.selectRealtimeModelEntity(params.agent);
1321
+ if (!model) {
1322
+ return null;
1323
+ }
1324
+ const vendor = this.selectRealtimeVendor(model.ID);
1325
+ if (!vendor) {
1326
+ return null;
1327
+ }
1328
+ const apiKey = GetAIAPIKey(vendor.driverClass);
1329
+ if (!apiKey) {
1330
+ return null;
1331
+ }
1332
+ const instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseRealtimeModel, vendor.driverClass, apiKey);
1333
+ if (!instance) {
1334
+ return null;
1335
+ }
1336
+ return { model: instance, modelID: model.ID, vendorID: vendor.vendorID, apiName: vendor.apiName, driverClass: vendor.driverClass };
1337
+ }
1338
+ /**
1339
+ * Selects the `MJ: AI Models` row to use for a realtime session: the highest-power active
1340
+ * model of AIModelType `Realtime`. Returns `null` when no `Realtime` model exists in metadata
1341
+ * (expected before P4).
1342
+ *
1343
+ * @param agent The agent being executed (reserved for future per-agent model preference).
1344
+ * @returns The chosen model entity, or `null`.
1345
+ */
1346
+ selectRealtimeModelEntity(agent) {
1347
+ const isRealtime = (m) => typeof m.AIModelType === 'string' && m.AIModelType.trim().toLowerCase() === 'realtime';
1348
+ const realtimeModels = AIEngine.Instance.Models.filter(m => m.IsActive && isRealtime(m));
1349
+ if (realtimeModels.length === 0) {
1350
+ return null;
1351
+ }
1352
+ // Effective-config model preference (realtime.modelPreference, an MJ: AI Models Name or
1353
+ // ID) participates first. METADATA preferences degrade gracefully: an unsatisfiable
1354
+ // preference logs and falls through to the default highest-PowerRank selection.
1355
+ const preference = this.resolveRealtimeEffectiveConfig(agent).realtime?.modelPreference;
1356
+ if (preference) {
1357
+ const wanted = preference.trim().toLowerCase();
1358
+ const preferred = realtimeModels.find(m => UUIDsEqual(m.ID, preference))
1359
+ ?? realtimeModels.find(m => m.Name?.trim().toLowerCase() === wanted);
1360
+ if (preferred) {
1361
+ return preferred;
1362
+ }
1363
+ this.logError(`Realtime model preference '${preference}' for agent '${agent.Name}' matches no Active Realtime ` +
1364
+ 'model — falling through to default (highest-PowerRank) selection.', { agent, category: 'RealtimeSession' });
1365
+ }
1366
+ return realtimeModels.sort((a, b) => (b.PowerRank ?? 0) - (a.PowerRank ?? 0))[0];
1367
+ }
1368
+ /**
1369
+ * Resolves the agent's EFFECTIVE realtime configuration — the agent TYPE's
1370
+ * `DefaultConfiguration` (base layer) deep-merged with the agent's `TypeConfiguration`
1371
+ * (per-agent layer; the server-bridged path has no runtime-override layer). Tolerant:
1372
+ * malformed layers contribute nothing and an unloaded type cache yields no type defaults.
1373
+ * See `realtime/realtime-coagent-config.ts` for the merge contract.
1374
+ *
1375
+ * @param agent The session-driven (Realtime) agent.
1376
+ * @returns The normalized effective configuration (possibly empty, never `null`).
1377
+ */
1378
+ resolveRealtimeEffectiveConfig(agent) {
1379
+ let typeDefault = null;
1380
+ try {
1381
+ if (agent.TypeID) {
1382
+ const type = (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, agent.TypeID));
1383
+ typeDefault = type?.DefaultConfiguration ?? null;
1384
+ }
1385
+ }
1386
+ catch {
1387
+ typeDefault = null;
1388
+ }
1389
+ return ResolveEffectiveRealtimeConfig(typeDefault, agent.TypeConfiguration ?? null, null);
1390
+ }
1391
+ /**
1392
+ * Selects the highest-priority active vendor for a model whose `DriverClass` has a resolvable
1393
+ * API key. Mirrors the vendor-selection pattern used by prompt execution.
1394
+ *
1395
+ * @param modelID The chosen model's ID.
1396
+ * @returns The vendor driver/api identifiers, or `null` when none has a usable key.
1397
+ */
1398
+ selectRealtimeVendor(modelID) {
1399
+ const vendors = AIEngine.Instance.ModelVendors
1400
+ .filter(mv => UUIDsEqual(mv.ModelID, modelID) && mv.Status === 'Active' && mv.DriverClass != null)
1401
+ .sort((a, b) => (b.Priority ?? 0) - (a.Priority ?? 0));
1402
+ for (const v of vendors) {
1403
+ if (GetAIAPIKey(v.DriverClass)) {
1404
+ return { vendorID: v.VendorID ?? '', driverClass: v.DriverClass, apiName: v.APIName ?? '' };
1405
+ }
1406
+ }
1407
+ return null;
1408
+ }
1409
+ /**
1410
+ * Creates the single long-lived `AIPromptRun` that realtime usage is checkpointed onto.
1411
+ *
1412
+ * One run is created per session (not per turn) so {@link RealtimeSessionRunnerDeps.CheckpointUsage}
1413
+ * can incrementally update the same record — crash-safe by design. Returns `null` on failure;
1414
+ * the session still runs (usage checkpoints simply become no-ops).
1415
+ *
1416
+ * @param params The execution parameters.
1417
+ * @param config The agent configuration (provides the system prompt id, if any).
1418
+ * @param modelResolution The resolved model/vendor identifiers.
1419
+ * @returns The persisted prompt run, or `null` if it could not be created.
1420
+ */
1421
+ async createRealtimePromptRun(params, config, modelResolution) {
1422
+ try {
1423
+ const md = params.provider || this._activeProvider;
1424
+ const promptRun = await md.GetEntityObject('MJ: AI Prompt Runs', params.contextUser);
1425
+ promptRun.NewRecord();
1426
+ if (config.systemPrompt) {
1427
+ promptRun.PromptID = config.systemPrompt.ID;
1428
+ }
1429
+ promptRun.ModelID = modelResolution.modelID;
1430
+ promptRun.VendorID = modelResolution.vendorID || null;
1431
+ promptRun.AgentID = params.agent.ID;
1432
+ promptRun.AgentRunID = this._agentRun?.ID ?? null;
1433
+ promptRun.Status = 'Running';
1434
+ promptRun.RunAt = new Date();
1435
+ promptRun.StreamingEnabled = true;
1436
+ promptRun.Cancelled = false;
1437
+ promptRun.CacheHit = false;
1438
+ if (!await promptRun.Save()) {
1439
+ this.logError(`Failed to create realtime AIPromptRun: ${promptRun.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1440
+ agent: params.agent,
1441
+ category: 'RealtimeSession'
1442
+ });
1443
+ return null;
1444
+ }
1445
+ return promptRun;
1446
+ }
1447
+ catch (error) {
1448
+ const msg = error instanceof Error ? error.message : String(error);
1449
+ this.logError(`Error creating realtime AIPromptRun: ${msg}`, { agent: params.agent, category: 'RealtimeSession' });
1450
+ return null;
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Builds the fully-populated {@link RealtimeSessionRunnerDeps} from this agent's collaborators.
1455
+ *
1456
+ * Each dependency is a thin closure over BaseAgent state so the runner stays decoupled from
1457
+ * metadata/DB. The closures cover: target delegation (via {@link ExecuteSubAgent}), non-target
1458
+ * tool execution, transcript persistence (as `ConversationDetail`), and usage checkpointing
1459
+ * (onto the long-lived prompt run).
1460
+ *
1461
+ * @param params The execution parameters.
1462
+ * @param config The agent configuration.
1463
+ * @param modelResolution The resolved realtime model + identifiers.
1464
+ * @param promptRun The long-lived prompt run for usage checkpoints (may be `null`).
1465
+ * @returns The assembled deps object.
1466
+ */
1467
+ async buildRealtimeSessionDeps(params, config, modelResolution, promptRun) {
1468
+ const effectiveConfig = this.resolveRealtimeEffectiveConfig(params.agent);
1469
+ const sessionParams = await this.buildRealtimeSessionParams(params, config, modelResolution.apiName, effectiveConfig, modelResolution.driverClass);
1470
+ return {
1471
+ Model: modelResolution.model,
1472
+ SessionParams: sessionParams,
1473
+ DelegateToTarget: (request) => this.delegateRealtimeToTarget(params, config, request),
1474
+ ExecuteTool: (call) => this.executeRealtimeTool(params, call),
1475
+ PersistTranscript: (transcript) => this.persistRealtimeTranscript(params, transcript),
1476
+ CheckpointUsage: (usage) => this.checkpointRealtimeUsage(promptRun, usage),
1477
+ // DB-driven spoken-progress wording (shared lookup with the client-direct path);
1478
+ // null → the runner's documented built-in first-person fallback.
1479
+ NarrationInstructionsTemplate: ResolveNarrationInstructionsTemplate(),
1480
+ // Effective-config narration pacing (realtime.narration.paceMs); null → runner default.
1481
+ NarrationPaceMs: GetNarrationPaceMs(effectiveConfig),
1482
+ LogStatus: (message, verboseOnly) => this.logStatus(message, verboseOnly ?? false, params),
1483
+ LogError: (error) => this.logError(error, { agent: params.agent, category: 'RealtimeSession' })
1484
+ };
1485
+ }
1486
+ /**
1487
+ * Assembles the {@link RealtimeSessionParams} for the session.
1488
+ *
1489
+ * The system prompt is framed as a companion "voice for the target agent". The base system
1490
+ * prompt text (when an agent-level system prompt exists) plus the same memory/context a loop
1491
+ * agent would assemble (via {@link AgentMemoryContextBuilder}) are concatenated. The
1492
+ * always-present `invoke-target-agent` tool is added by the runner itself, so it is NOT
1493
+ * populated here.
1494
+ *
1495
+ * @param params The execution parameters.
1496
+ * @param config The agent configuration.
1497
+ * @param modelApiName The vendor API name of the resolved realtime model.
1498
+ * @returns The session parameters.
1499
+ */
1500
+ async buildRealtimeSessionParams(params, config, modelApiName, effectiveConfig, driverClass) {
1501
+ const framing = `You are the real-time voice for the agent "${params.agent.Name}". Hold a natural, ` +
1502
+ `low-latency conversation with the user. When actual work is required, call the ` +
1503
+ `'invoke-target-agent' tool and narrate progress while it runs — do not attempt to do ` +
1504
+ `the work yourself. ONE EXCEPTION: besides 'invoke-target-agent' you may have been given ` +
1505
+ `interactive-surface tools (for example 'browser_*' to drive a LIVE web browser the user ` +
1506
+ `can watch, or 'Whiteboard_*' to draw on a shared board). Those surfaces are operated by ` +
1507
+ `YOU, directly — when the user asks to use one (e.g. "open/show a browser", "go to a ` +
1508
+ `site", "add to the whiteboard"), call the matching tool yourself immediately and narrate ` +
1509
+ `what you're doing. NEVER route an interactive-surface request through 'invoke-target-agent', ` +
1510
+ `and never claim you lack a session — calling the tool is all that's needed.`;
1511
+ const basePrompt = config.systemPrompt?.TemplateText ? config.systemPrompt.TemplateText : '';
1512
+ // Effective-config voice persona (realtime.voice.default) → short "Voice & manner" section.
1513
+ const voiceManner = BuildVoiceMannerSection(effectiveConfig);
1514
+ const memoryContext = await this.assembleRealtimeContext(params);
1515
+ const systemPrompt = [framing, basePrompt, voiceManner, memoryContext]
1516
+ .filter(part => part && part.trim().length > 0)
1517
+ .join('\n\n');
1518
+ // Provider-matched voice settings (realtime.voice.providers.<provider>) flow into the
1519
+ // driver's open Config bag — the same pact every other config entry rides.
1520
+ const providerVoice = GetProviderVoiceSettings(effectiveConfig, driverClass ?? null);
1521
+ return {
1522
+ Model: modelApiName,
1523
+ SystemPrompt: systemPrompt,
1524
+ InitialContext: memoryContext || undefined,
1525
+ // JSONObjectLike -> JSONObject: safe — the settings object came from JSON.parse.
1526
+ Config: providerVoice ? providerVoice : undefined
1527
+ };
1528
+ }
1529
+ /**
1530
+ * Assembles the same memory/context block a loop agent injects, reusing
1531
+ * {@link AgentMemoryContextBuilder} so there is no duplicated retrieval logic. The builder
1532
+ * unshifts a system message onto a throwaway array, which we pull back out as plain text to
1533
+ * feed the realtime model's session context.
1534
+ *
1535
+ * @param params The execution parameters.
1536
+ * @returns The concatenated context text (empty string when nothing was injected).
1537
+ */
1538
+ async assembleRealtimeContext(params) {
1539
+ const lastUserMessage = params.conversationMessages.filter(m => m.role === 'user').pop();
1540
+ const inputText = typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : '';
1541
+ const scratch = [];
1542
+ const builder = new AgentMemoryContextBuilder();
1543
+ 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));
1544
+ return scratch
1545
+ .map(m => (typeof m.content === 'string' ? m.content : ''))
1546
+ .filter(c => c.length > 0)
1547
+ .join('\n\n');
1548
+ }
1549
+ /**
1550
+ * Delegates an `invoke-target-agent` tool call to the top-level target agent.
1551
+ *
1552
+ * Threads the runner-owned {@link DelegateToTargetRequest.AbortSignal} into the child run's
1553
+ * `cancellationToken` (so barge-in cancels the delegated work), and links the child run to this
1554
+ * run via `parentRun` (→ `ParentRunID`) while propagating `agentSessionID` so both runs group
1555
+ * under the same session.
1556
+ *
1557
+ * **Target source.** The target agent id comes from `params.data.targetAgentID` when present
1558
+ * (the Realtime Co-Agent receives its target as a runtime parameter), falling back to the agent's
1559
+ * own `DefaultModelID`-style config is NOT applicable here; absent a target the delegation
1560
+ * returns a failed {@link DelegatedResult} the model can narrate.
1561
+ *
1562
+ * @param params The (parent) execution parameters.
1563
+ * @param config The agent configuration (unused today; reserved for target-from-config wiring).
1564
+ * @param request The delegation request derived from the tool call.
1565
+ * @returns The delegated result for the model's tool_response.
1566
+ */
1567
+ async delegateRealtimeToTarget(params, config, request) {
1568
+ const targetAgent = this.resolveRealtimeTargetAgent(params);
1569
+ if (!targetAgent) {
1570
+ return {
1571
+ CallID: request.CallID,
1572
+ Success: false,
1573
+ Output: 'No target agent is configured for this voice session, so the request could not be performed.'
1574
+ };
1575
+ }
1576
+ try {
1577
+ const requestText = this.parseDelegateRequestText(request.Arguments);
1578
+ const runner = new AgentRunner(params.provider || this._activeProvider);
1579
+ const result = await runner.RunAgent({
1580
+ agent: targetAgent,
1581
+ conversationMessages: [{ role: 'user', content: requestText }],
1582
+ contextUser: params.contextUser,
1583
+ cancellationToken: request.AbortSignal,
1584
+ parentRun: this._agentRun ?? undefined,
1585
+ agentSessionID: params.agentSessionID,
1586
+ parentAgentHierarchy: this._agentHierarchy,
1587
+ parentDepth: this._depth,
1588
+ configurationId: params.configurationId,
1589
+ apiKeys: params.apiKeys,
1590
+ data: params.data,
1591
+ verbose: params.verbose,
1592
+ // Progress streams BOTH to the runner's narration consumer (request.OnProgress —
1593
+ // it paces SendContextNote/RequestSpokenUpdate over the live socket) AND to any
1594
+ // host-level onProgress the parent execution carries.
1595
+ onProgress: this.combineProgressCallbacks(request.OnProgress, params.onProgress)
1596
+ });
1597
+ return {
1598
+ CallID: request.CallID,
1599
+ Success: result.success,
1600
+ Output: result.success
1601
+ ? (result.agentRun?.Message || 'The target agent completed the request.')
1602
+ : (result.agentRun?.ErrorMessage || 'The target agent failed to complete the request.')
1603
+ };
1604
+ }
1605
+ catch (error) {
1606
+ const msg = error instanceof Error ? error.message : String(error);
1607
+ return { CallID: request.CallID, Success: false, Output: `Delegation failed: ${msg}` };
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Combines the runner-supplied delegation progress callback with the host-level one so a
1612
+ * single `onProgress` fans out to both. Returns the lone callback when only one exists, and
1613
+ * `undefined` when neither does. A throw from one consumer never starves the other.
1614
+ */
1615
+ combineProgressCallbacks(first, second) {
1616
+ if (!first) {
1617
+ return second;
1618
+ }
1619
+ if (!second) {
1620
+ return first;
1621
+ }
1622
+ return (progress) => {
1623
+ try {
1624
+ first(progress);
1625
+ }
1626
+ catch {
1627
+ /* one consumer failing must not starve the other */
1628
+ }
1629
+ second(progress);
1630
+ };
1631
+ }
1632
+ /**
1633
+ * Resolves the top-level target agent for the voice session.
1634
+ *
1635
+ * The target is supplied as a runtime parameter on `params.data.targetAgentID` (the Voice
1636
+ * Co-Agent voices on behalf of a target chosen at session start). Returns `null` when no
1637
+ * resolvable target is configured.
1638
+ *
1639
+ * @param params The execution parameters.
1640
+ * @returns The target agent entity, or `null`.
1641
+ */
1642
+ resolveRealtimeTargetAgent(params) {
1643
+ const targetID = params.data?.targetAgentID;
1644
+ if (!targetID) {
1645
+ return null;
1646
+ }
1647
+ return AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, targetID)) ?? null;
1648
+ }
1649
+ /**
1650
+ * Parses the natural-language request text out of an `invoke-target-agent` call's arguments.
1651
+ * Falls back to the raw argument string when it is not the expected `{ request: string }` JSON.
1652
+ *
1653
+ * @param argumentsJson The raw arguments string emitted by the model.
1654
+ * @returns The request text to hand to the target agent.
1655
+ */
1656
+ parseDelegateRequestText(argumentsJson) {
1657
+ try {
1658
+ const parsed = JSON.parse(argumentsJson);
1659
+ if (typeof parsed.request === 'string') {
1660
+ return parsed.request;
1661
+ }
1662
+ }
1663
+ catch {
1664
+ /* not JSON — fall through to raw */
1665
+ }
1666
+ return argumentsJson;
1667
+ }
1668
+ /**
1669
+ * Executes a non-target realtime tool call by routing it through the agent's existing action
1670
+ * execution under the session context user.
1671
+ *
1672
+ * Today this maps the realtime call onto the agent's configured actions by name; unknown tools
1673
+ * return a failed {@link ToolExecutionResult} the model can narrate. (The richer client/UI tool
1674
+ * routing is wired in a later phase; this keeps server actions usable now.)
1675
+ *
1676
+ * @param params The execution parameters.
1677
+ * @param call The non-target tool call.
1678
+ * @returns The tool execution result for the model's tool_response.
1679
+ */
1680
+ async executeRealtimeTool(params, call) {
1681
+ const action = this.getEffectiveActionsForValidation(params.agent.ID).find(a => a.Name === call.ToolName);
1682
+ if (!action) {
1683
+ return {
1684
+ CallID: call.CallID,
1685
+ Success: false,
1686
+ Output: `Tool '${call.ToolName}' is not available to this agent.`
1687
+ };
1688
+ }
1689
+ try {
1690
+ const agentAction = { name: action.Name, params: this.parseRealtimeToolParams(call.Arguments) };
1691
+ const result = await this.ExecuteSingleAction(params, agentAction, action, params.contextUser);
1692
+ return {
1693
+ CallID: call.CallID,
1694
+ Success: result.Success,
1695
+ Output: result.Message || (result.Success ? 'Tool completed.' : 'Tool failed.')
1696
+ };
1697
+ }
1698
+ catch (error) {
1699
+ const msg = error instanceof Error ? error.message : String(error);
1700
+ return { CallID: call.CallID, Success: false, Output: `Tool execution failed: ${msg}` };
1701
+ }
1702
+ }
1703
+ /**
1704
+ * Parses a realtime tool call's JSON arguments into an action parameter map.
1705
+ *
1706
+ * @param argumentsJson The raw arguments string.
1707
+ * @returns A record of parameter name → value (empty when not parseable).
1708
+ */
1709
+ parseRealtimeToolParams(argumentsJson) {
1710
+ try {
1711
+ const parsed = JSON.parse(argumentsJson);
1712
+ if (parsed && typeof parsed === 'object') {
1713
+ return parsed;
1714
+ }
1715
+ }
1716
+ catch {
1717
+ /* ignore — return empty params */
1718
+ }
1719
+ return {};
1720
+ }
1721
+ /**
1722
+ * Persists a single realtime transcript turn as a `ConversationDetail` stamped with the
1723
+ * session id. User turns are written as `Role='User'`, assistant turns as `Role='AI'`. Only
1724
+ * final transcripts are persisted (interim/partial updates are skipped to avoid churn).
1725
+ *
1726
+ * @param params The execution parameters (provides conversation id + context user).
1727
+ * @param transcript The transcript turn emitted by the model.
1728
+ */
1729
+ async persistRealtimeTranscript(params, transcript) {
1730
+ if (!transcript.IsFinal || !transcript.Text?.trim()) {
1731
+ return;
1732
+ }
1733
+ const conversationID = params.data?.conversationId;
1734
+ if (!conversationID) {
1735
+ return; // Without a conversation we have nowhere to durably attach the turn.
1736
+ }
1737
+ const md = params.provider || this._activeProvider;
1738
+ const detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1739
+ detail.NewRecord();
1740
+ detail.ConversationID = conversationID;
1741
+ detail.Role = transcript.Role === 'user' ? 'User' : 'AI';
1742
+ detail.Message = transcript.Text;
1743
+ if (params.agentSessionID) {
1744
+ detail.AgentSessionID = params.agentSessionID;
1745
+ }
1746
+ if (!await detail.Save()) {
1747
+ this.logError(`Failed to persist realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1748
+ agent: params.agent,
1749
+ category: 'RealtimeSession'
1750
+ });
1751
+ }
1752
+ }
1753
+ /**
1754
+ * Checkpoints accumulated realtime usage onto the single long-lived prompt run. This is the
1755
+ * incremental, crash-safe write the runner invokes on a debounced cadence and at close.
1756
+ *
1757
+ * @param promptRun The long-lived prompt run (no-op when `null`).
1758
+ * @param usage The cumulative usage snapshot to persist.
1759
+ */
1760
+ async checkpointRealtimeUsage(promptRun, usage) {
1761
+ if (!promptRun) {
1762
+ return;
1763
+ }
1764
+ promptRun.TokensPrompt = usage.InputTokens;
1765
+ promptRun.TokensCompletion = usage.OutputTokens;
1766
+ promptRun.TokensUsed = usage.InputTokens + usage.OutputTokens;
1767
+ if (!await promptRun.Save()) {
1768
+ this.logError(`Failed to checkpoint realtime usage: ${promptRun.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1769
+ category: 'RealtimeSession'
1770
+ });
1771
+ }
1772
+ }
1773
+ /**
1774
+ * Maps a completed {@link RealtimeSessionResult} onto the finalized `AIAgentRun` and returns
1775
+ * the {@link ExecuteAgentResult}. A clean close finalizes as success; a session error finalizes
1776
+ * as failure with the error message.
1777
+ *
1778
+ * @template R The caller's payload type (unused on the realtime path).
1779
+ * @param params The execution parameters.
1780
+ * @param sessionResult The result returned by {@link RealtimeSessionRunner.Run}.
1781
+ * @returns The finalized agent result.
1782
+ */
1783
+ async finalizeRealtimeRun(params, sessionResult) {
1784
+ if (sessionResult.Success) {
1785
+ this.logStatus(`🎙️ Realtime session for '${params.agent.Name}' completed: ${sessionResult.TranscriptTurnCount} turn(s), ` +
1786
+ `${sessionResult.FinalUsage.InputTokens + sessionResult.FinalUsage.OutputTokens} token(s).`, true, params);
1787
+ const successStep = this.createSessionSuccessStep();
1788
+ return await this.finalizeAgentRun(successStep, undefined, params.contextUser);
1789
+ }
1790
+ const message = sessionResult.ErrorMessage || 'Realtime session ended with an error.';
1791
+ return await this.createFailureResult(message, params.contextUser);
1792
+ }
1793
+ /**
1794
+ * Builds a terminal `Success` step describing the completion of a realtime session, used to
1795
+ * finalize the run through the shared {@link finalizeAgentRun} path.
1796
+ *
1797
+ * @template R The caller's payload type.
1798
+ * @returns A terminal success step.
1799
+ */
1800
+ createSessionSuccessStep() {
1801
+ return {
1802
+ step: 'Success',
1803
+ terminate: true,
1804
+ message: 'Realtime session completed.'
1805
+ };
1806
+ }
1204
1807
  /**
1205
1808
  * Sub-classes can override this method to perform any specialized initialization
1206
1809
  * @param params
@@ -1393,72 +1996,16 @@ export class BaseAgent {
1393
1996
  * @returns Object containing injected notes and examples
1394
1997
  */
1395
1998
  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 };
1999
+ // Delegate the orchestration to the shared, reusable builder so both BaseAgent and the
2000
+ // Realtime agent type inject memory identically. The observability context and verbose
2001
+ // status logging are derived from this instance and passed through.
2002
+ const observability = this._agentRun
2003
+ ? { agentRunID: this._agentRun.ID, stepNumber: (this._agentRun.Steps?.length || 0) + 1 }
2004
+ : undefined;
2005
+ const result = await new AgentMemoryContextBuilder().InjectContextMemory(input, agent, userId, companyId, contextUser, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, secondaryScopeConfig, observability, (message, verboseOnly) => this.logStatus(message, verboseOnly));
2006
+ // Store for inclusion in result (externally observable behavior preserved)
2007
+ this._injectedMemory = result;
2008
+ return result;
1462
2009
  }
1463
2010
  /**
1464
2011
  * Inject pre-execution RAG context for this agent using scoped search.
@@ -1486,40 +2033,12 @@ export class BaseAgent {
1486
2033
  * @returns The structured RAG result, or `null` if no scopes produced results.
1487
2034
  */
1488
2035
  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
- }
2036
+ // Delegate to the shared builder so the Realtime agent type injects pre-execution RAG
2037
+ // identically. Verbose status + non-fatal error logging are threaded through from this instance.
2038
+ 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));
2039
+ // Store for inclusion in result (externally observable behavior preserved)
2040
+ this._injectedRAG = result;
2041
+ return result;
1523
2042
  }
1524
2043
  /**
1525
2044
  * Converts UI markup (@{...} syntax) in user messages to plain text.
@@ -1793,7 +2312,7 @@ export class BaseAgent {
1793
2312
  const systemPrompt = config.systemPrompt;
1794
2313
  const childPrompt = config.childPrompt;
1795
2314
  // Gather context data (including runtime action changes)
1796
- const promptTemplateData = await this.gatherPromptTemplateData(params.agent, params.contextUser, params.data, params.actionChanges);
2315
+ const promptTemplateData = await this.gatherPromptTemplateData(params.agent, params.contextUser, params.data, params.actionChanges, params.subAgentChanges);
1797
2316
  // Set up the hierarchical prompt execution
1798
2317
  const promptParams = new AIPromptParams();
1799
2318
  // Handle case where systemPrompt is optional (e.g., Flow Agent Type)
@@ -1868,6 +2387,13 @@ export class BaseAgent {
1868
2387
  else if (this._artifactToolManager.HasArtifacts()) {
1869
2388
  this.logStatus(`[ArtifactTools] Artifacts present but tools disabled by agent config (includeArtifactToolsDocs=false)`, true, params);
1870
2389
  }
2390
+ // Enable the memory-writes response field + docs only for agents that opted in
2391
+ // via AllowMemoryWrite. Disabled agents never see the docs, so a well-behaved
2392
+ // LLM never emits the field (the turn loop still guards against drift).
2393
+ const memoryWritesDocsEnabled = agentTypePromptParams?.includeMemoryWritesDocs !== false;
2394
+ if (memoryWritesDocsEnabled && params.agent.AllowMemoryWrite === true) {
2395
+ promptParams.data['_MEMORY_WRITES_ENABLED'] = true;
2396
+ }
1871
2397
  // Inject pipeline tool docs when pipelines are enabled and at least one source exists.
1872
2398
  // A pipeline's first step must be a source (Action or artifact tool); with none
1873
2399
  // available pipelines are impossible, so BuildPipelineToolDocs returns '' and the
@@ -3433,6 +3959,129 @@ The context is now within limits. Please retry your request with the recovered c
3433
3959
  `Instead: page it with get_rows(start, count), or run a pipeline that filters/aggregates it ` +
3434
3960
  `server-side (where / select / groupBy → only the small final result returns to you).]`);
3435
3961
  }
3962
+ /**
3963
+ * Executes a batch of in-flight memory writes, recording each as its own
3964
+ * `Tool` AIAgentRunStep (a sibling of the Prompt step that requested them)
3965
+ * with full inputs/outcomes captured in InputData/OutputData.
3966
+ *
3967
+ * Writes run SEQUENTIALLY (not Promise.all like artifact tools) by design:
3968
+ * each persisted note is embedded and synced into the in-memory vector
3969
+ * service on Save, so write N must be visible to write N+1's near-duplicate
3970
+ * check (this is also what makes same-run supersede-own work). The per-run
3971
+ * cap bounds the cost of the serialization.
3972
+ *
3973
+ * Step naming convention: `Memory Write` for log/UI clarity.
3974
+ *
3975
+ * @protected
3976
+ */
3977
+ async executeMemoryWritesAsSteps(writes, params) {
3978
+ const results = [];
3979
+ for (const write of writes) {
3980
+ const writeStep = await this.createStepEntity({
3981
+ stepType: 'Tool',
3982
+ stepName: 'Memory Write',
3983
+ contextUser: params.contextUser,
3984
+ inputData: {
3985
+ note: write.note,
3986
+ type: write.type,
3987
+ scopeHint: write.scopeHint,
3988
+ },
3989
+ });
3990
+ const result = await this._memoryWriteManager.ExecuteWrite(write, {
3991
+ agentId: params.agent.ID,
3992
+ contextUser: params.contextUser,
3993
+ agentRunId: this._agentRun?.ID,
3994
+ conversationId: this._agentRun?.ConversationID || undefined,
3995
+ conversationDetailId: params.conversationDetailId,
3996
+ userId: params.userId || params.contextUser?.ID,
3997
+ companyId: params.companyId,
3998
+ verbose: params.verbose,
3999
+ provider: this.ProviderToUse,
4000
+ });
4001
+ const failed = result.disposition === 'error' || result.disposition === 'rejected-type';
4002
+ await this.finalizeStepEntity(writeStep, !failed, failed ? result.reason : undefined, {
4003
+ disposition: result.disposition,
4004
+ noteId: result.noteId,
4005
+ finalScope: result.finalScope,
4006
+ reason: result.reason,
4007
+ durationMs: result.durationMs,
4008
+ });
4009
+ results.push(result);
4010
+ }
4011
+ return results;
4012
+ }
4013
+ /**
4014
+ * Turn-loop entry point for in-flight memory writes, gated on the agent's
4015
+ * AllowMemoryWrite flag. When disabled but the LLM emitted writes anyway
4016
+ * (prompt drift / injection attempt), records ONE summary skip step —
4017
+ * observable without per-write noise — and tells the agent the memories
4018
+ * were NOT saved so it stops re-emitting. When enabled, executes the
4019
+ * writes as run steps and injects the results message.
4020
+ *
4021
+ * @protected
4022
+ */
4023
+ async processMemoryWritesForTurn(memoryWrites, params) {
4024
+ if (params.agent.AllowMemoryWrite !== true) {
4025
+ this.logStatus(`[MemoryWrites] LLM emitted ${memoryWrites.length} memory write(s) but AllowMemoryWrite=false — skipping`, true, params);
4026
+ const skipStep = await this.createStepEntity({
4027
+ stepType: 'Tool',
4028
+ stepName: 'Memory Writes: skipped (AllowMemoryWrite=false)',
4029
+ contextUser: params.contextUser,
4030
+ inputData: { requestedWriteCount: memoryWrites.length },
4031
+ });
4032
+ await this.finalizeStepEntity(skipStep, true, undefined, { skipped: true, reason: 'AllowMemoryWrite=false' });
4033
+ params.conversationMessages.push({
4034
+ role: 'user',
4035
+ content: 'Memory write result: this agent does not have durable memory writes enabled — the requested memories were NOT saved. Do not emit memoryWrites again.',
4036
+ metadata: {
4037
+ turnAdded: this._promptTurnCount,
4038
+ messageType: 'tool-result',
4039
+ expirationTurns: 3,
4040
+ expirationMode: 'Compact',
4041
+ compactMode: 'First N Chars',
4042
+ compactLength: 200,
4043
+ compactPromptId: '',
4044
+ },
4045
+ });
4046
+ return;
4047
+ }
4048
+ this.logStatus(`[MemoryWrites] LLM requested ${memoryWrites.length} memory write(s)`, true, params);
4049
+ const writeResults = await this.executeMemoryWritesAsSteps(memoryWrites, params);
4050
+ this.injectMemoryWriteResultsMessage(params, writeResults);
4051
+ }
4052
+ /**
4053
+ * Pushes a single user-role message containing memory-write outcomes into
4054
+ * the conversation, mirroring `injectArtifactToolResultsMessage`'s
4055
+ * inject-once-then-expire pattern. Closing the loop here is what stops the
4056
+ * LLM from re-emitting the same memory on subsequent turns.
4057
+ *
4058
+ * @protected
4059
+ */
4060
+ injectMemoryWriteResultsMessage(params, results) {
4061
+ if (results.length === 0)
4062
+ return;
4063
+ const header = results.length === 1
4064
+ ? 'Memory write result:'
4065
+ : `Memory write results (${results.length} writes):`;
4066
+ const body = results.map((r, i) => {
4067
+ const note = r.request.note.length > 120 ? `${r.request.note.slice(0, 120)}…` : r.request.note;
4068
+ return `${i + 1}. "${note}" — **${r.disposition}**${r.reason ? `: ${r.reason}` : ''}`;
4069
+ }).join('\n');
4070
+ const message = {
4071
+ role: 'user',
4072
+ content: `${header}\n${body}`,
4073
+ metadata: {
4074
+ turnAdded: this._promptTurnCount,
4075
+ messageType: 'tool-result',
4076
+ expirationTurns: 3,
4077
+ expirationMode: 'Compact',
4078
+ compactMode: 'First N Chars',
4079
+ compactLength: 300,
4080
+ compactPromptId: '',
4081
+ },
4082
+ };
4083
+ params.conversationMessages.push(message);
4084
+ }
3436
4085
  /**
3437
4086
  * Builds a per-run {@link PipelineToolRegistry} that unifies the three pipeline-able
3438
4087
  * substrates behind one namespace: built-in transforms, the agent's effective Actions, and
@@ -3583,37 +4232,70 @@ The context is now within limits. Please retry your request with the recovered c
3583
4232
  *
3584
4233
  * @private
3585
4234
  */
3586
- async gatherPromptTemplateData(agent, _contextUser, extraData, actionChanges) {
4235
+ async gatherPromptTemplateData(agent, _contextUser, extraData, actionChanges, subAgentChanges) {
3587
4236
  try {
3588
4237
  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
4238
+ // Build (or reuse) the agent-invariant base catalog. This is process-wide cached on
4239
+ // AIEngine and wiped on Agent/AgentAction/AgentRelationship/AgentType changes + reloads.
4240
+ // It turns the per-step rebuild (sub-agent + action resolution, markdown, JSON.parse of
4241
+ // agent-type params) into a once-per-agent cost; the common no-override step reuses it wholesale.
4242
+ let catalog = engine.GetAgentBaseCatalog(agent.ID);
4243
+ if (!catalog) {
4244
+ catalog = this.buildAgentBaseCatalog(agent, engine);
4245
+ engine.SetAgentBaseCatalog(agent.ID, catalog);
4246
+ }
4247
+ const isRoot = this._depth === 0;
4248
+ // Sub-agents: reuse cached base unless runtime subAgentChanges apply (then clone + re-format).
4249
+ let uniqueActiveSubAgents = catalog.uniqueActiveSubAgents;
4250
+ let subAgentDetails = catalog.subAgentDetails;
4251
+ let subAgentCount = catalog.subAgentCount;
4252
+ if (subAgentChanges?.length) {
4253
+ uniqueActiveSubAgents = this.applySubAgentChanges(catalog.uniqueActiveSubAgents, subAgentChanges, agent.ID, isRoot, engine);
4254
+ subAgentCount = uniqueActiveSubAgents.length;
4255
+ subAgentDetails = this.formatSubAgentDetails(uniqueActiveSubAgents);
4256
+ }
4257
+ // Actions: reuse cached active set unless runtime actionChanges apply (then clone + re-format).
4258
+ //
4259
+ // FAST-PATH SHARING CONTRACT: on the no-override path, `activeActions` (and therefore
4260
+ // `_effectiveActions`) and `uniqueActiveSubAgents` above are the SAME array references
4261
+ // held by the process-wide AIEngine catalog cache. Downstream consumers MUST treat them
4262
+ // as read-only — they are only ever read (`.find`/`.map`/`.length`/`.some`), never mutated
4263
+ // in place. On the override path a fresh array is built via filter/applyActionChanges, so
4264
+ // the cached arrays are never the mutated ones. Keeping the references (vs. copying) avoids
4265
+ // a per-step allocation; if a future consumer needs to mutate, it must `.slice()` first.
4266
+ let activeActions = catalog.activeActions;
4267
+ let actionDetails = catalog.actionDetails;
3603
4268
  if (actionChanges?.length) {
3604
- const isRoot = this._depth === 0;
3605
- const result = this.applyActionChanges(actions, actionChanges, agent.ID, isRoot);
3606
- actions = result.actions;
4269
+ const result = this.applyActionChanges([...catalog.baseActionsRaw], actionChanges, agent.ID, isRoot);
4270
+ activeActions = result.actions.filter(a => a.Status === 'Active');
3607
4271
  this._dynamicActionLimits = result.dynamicLimits;
4272
+ actionDetails = this.formatActionDetails(activeActions);
3608
4273
  }
3609
- // Filter to only active actions and store for later validation in executeActionsStep
3610
- const activeActions = actions.filter(a => a.Status === 'Active');
4274
+ else {
4275
+ // No actionChanges this step → no dynamically-added actions, hence no dynamic limits.
4276
+ // gatherPromptTemplateData runs once per step, and _dynamicActionLimits is keyed to the
4277
+ // actionChanges of the CURRENT step (read at validation time in checkActionExecutionLimits).
4278
+ // Resetting to {} is correct and required: it prevents a prior step's actionChanges limits
4279
+ // from leaking into a step that has none. It is NOT relied upon to persist across steps.
4280
+ this._dynamicActionLimits = {};
4281
+ }
4282
+ // Store for later validation in executeActionsStep
3611
4283
  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));
4284
+ // Agent type prompt params: reuse cached base merge unless a runtime override is present.
3614
4285
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
3615
- const agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
3616
- // Build client tool details for the prompt
4286
+ let agentTypePromptParams;
4287
+ if (runtimePromptParamOverrides) {
4288
+ const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
4289
+ agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
4290
+ }
4291
+ else {
4292
+ // Fast path: shallow-clone the cached base params before handing them out. The cached
4293
+ // object lives in the process-wide AIEngine catalog and is shared across every run of
4294
+ // this agent; the audit shows it is read-only downstream today, but the clone is cheap
4295
+ // and removes any cache-poisoning foot-gun should a future consumer write to it.
4296
+ agentTypePromptParams = { ...catalog.baseAgentTypePromptParams };
4297
+ }
4298
+ // Build client tool details for the prompt (per-run; depends on extraData)
3617
4299
  const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
3618
4300
  // Build app context section if provided in extraData
3619
4301
  const appContext = this.buildAppContextSection(extraData);
@@ -3621,10 +4303,10 @@ The context is now within limits. Please retry your request with the recovered c
3621
4303
  agentName: agent.Name,
3622
4304
  agentDescription: agent.Description,
3623
4305
  parentAgentName: agent.Parent ? agent.Parent.trim() : "",
3624
- subAgentCount: uniqueActiveSubAgents.length,
3625
- subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
4306
+ subAgentCount: subAgentCount,
4307
+ subAgentDetails: subAgentDetails,
3626
4308
  actionCount: activeActions.length,
3627
- actionDetails: this.formatActionDetails(activeActions),
4309
+ actionDetails: actionDetails,
3628
4310
  clientToolDetails: clientToolDetails,
3629
4311
  appContext: appContext,
3630
4312
  };
@@ -3656,6 +4338,98 @@ The context is now within limits. Please retry your request with the recovered c
3656
4338
  throw new Error(`Error gathering context data: ${error.message}`);
3657
4339
  }
3658
4340
  }
4341
+ /**
4342
+ * Builds the agent-invariant {@link AgentBaseCatalog} — the resolved sub-agents + actions and
4343
+ * their formatted markdown, plus the base agent-type prompt params. Computed once per agent and
4344
+ * cached on AIEngine (see gatherPromptTemplateData); does NOT apply any runtime overrides.
4345
+ *
4346
+ * @protected
4347
+ */
4348
+ buildAgentBaseCatalog(agent, engine) {
4349
+ // Resolve sub-agents: direct ParentID children + active relationships, de-duped, ordered.
4350
+ const activeSubAgents = engine.Agents.filter(a => UUIDsEqual(a.ParentID, agent.ID) && a.Status === 'Active')
4351
+ .sort((a, b) => a.ExecutionOrder - b.ExecutionOrder);
4352
+ const activeAgentRelationships = engine.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, agent.ID) && ar.Status === 'Active');
4353
+ const uniqueActiveSubAgentIDs = new Set();
4354
+ activeSubAgents.forEach(a => uniqueActiveSubAgentIDs.add(a.ID));
4355
+ activeAgentRelationships.forEach(ar => uniqueActiveSubAgentIDs.add(ar.SubAgentID));
4356
+ const uniqueActiveSubAgents = Array.from(uniqueActiveSubAgentIDs).map(id => engine.Agents.find(a => UUIDsEqual(a.ID, id)));
4357
+ // Resolve actions from the agent's active AIAgentAction junctions.
4358
+ const agentActions = engine.AgentActions.filter(aa => UUIDsEqual(aa.AgentID, agent.ID) && aa.Status === 'Active');
4359
+ const baseActionsRaw = ActionEngineServer.Instance.Actions.filter(a => agentActions.some(aa => UUIDsEqual(aa.ActionID, a.ID)));
4360
+ const activeActions = baseActionsRaw.filter(a => a.Status === 'Active');
4361
+ // Base agent-type prompt params (schema defaults + agent config; NO runtime overrides).
4362
+ const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
4363
+ const baseAgentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, undefined);
4364
+ return {
4365
+ uniqueActiveSubAgents,
4366
+ subAgentCount: uniqueActiveSubAgents.length,
4367
+ subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
4368
+ baseActionsRaw,
4369
+ activeActions,
4370
+ actionDetails: this.formatActionDetails(activeActions),
4371
+ baseAgentTypePromptParams,
4372
+ };
4373
+ }
4374
+ /**
4375
+ * Applies runtime {@link SubAgentChange}s to a base sub-agent set — the sub-agent counterpart of
4376
+ * {@link applyActionChanges}. Returns a NEW array (never mutates the cached base set).
4377
+ *
4378
+ * @protected
4379
+ */
4380
+ applySubAgentChanges(baseSubAgents, subAgentChanges, agentId, isRoot, engine) {
4381
+ let subAgents = [...baseSubAgents];
4382
+ for (const change of subAgentChanges) {
4383
+ if (!this.doesChangeScopeApply(change.scope, agentId, isRoot, change.agentIds)) {
4384
+ continue;
4385
+ }
4386
+ if (change.mode === 'add') {
4387
+ for (const subAgentId of change.subAgentIds) {
4388
+ if (!subAgents.some(a => UUIDsEqual(a.ID, subAgentId))) {
4389
+ const toAdd = engine.Agents.find(a => UUIDsEqual(a.ID, subAgentId));
4390
+ if (toAdd) {
4391
+ subAgents.push(toAdd);
4392
+ }
4393
+ else {
4394
+ LogStatus(`Sub-agent with ID '${subAgentId}' not found in AIEngine - skipping add`);
4395
+ }
4396
+ }
4397
+ }
4398
+ }
4399
+ else if (change.mode === 'remove') {
4400
+ subAgents = subAgents.filter(a => !change.subAgentIds.some(id => UUIDsEqual(id, a.ID)));
4401
+ }
4402
+ }
4403
+ return subAgents;
4404
+ }
4405
+ /**
4406
+ * Filters/transforms sub-agent changes for propagation to a sub-agent — the sub-agent counterpart
4407
+ * of {@link filterActionChangesForSubAgent} (same propagation rules).
4408
+ *
4409
+ * @protected
4410
+ */
4411
+ filterSubAgentChangesForSubAgent(subAgentChanges) {
4412
+ if (!subAgentChanges?.length) {
4413
+ return undefined;
4414
+ }
4415
+ const filtered = [];
4416
+ for (const change of subAgentChanges) {
4417
+ switch (change.scope) {
4418
+ case 'root':
4419
+ continue; // only applies to root — don't propagate
4420
+ case 'global':
4421
+ filtered.push(change);
4422
+ break;
4423
+ case 'all-subagents':
4424
+ filtered.push({ ...change, scope: 'global' });
4425
+ break;
4426
+ case 'specific':
4427
+ filtered.push(change);
4428
+ break;
4429
+ }
4430
+ }
4431
+ return filtered.length > 0 ? filtered : undefined;
4432
+ }
3659
4433
  /**
3660
4434
  * Builds merged agent type prompt params from schema defaults,
3661
4435
  * agent config, and runtime overrides.
@@ -3743,7 +4517,8 @@ The context is now within limits. Please retry your request with the recovered c
3743
4517
  { docsFlag: 'includeWhileDocs', responseTypeKey: 'while' },
3744
4518
  { docsFlag: 'includeScratchpadDocs', responseTypeKey: 'scratchpad' },
3745
4519
  { docsFlag: 'includeArtifactToolsDocs', responseTypeKey: 'artifactToolCalls' },
3746
- { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' }
4520
+ { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' },
4521
+ { docsFlag: 'includeMemoryWritesDocs', responseTypeKey: 'memoryWrites' }
3747
4522
  ];
3748
4523
  for (const { docsFlag, responseTypeKey } of alignmentMappings) {
3749
4524
  // Check if the user explicitly set this response type property
@@ -3985,8 +4760,9 @@ The context is now within limits. Please retry your request with the recovered c
3985
4760
  this.logStatus(`🎯 Propagating effort level ${params.effortLevel} to sub-agent '${subAgentRequest.name}'`, true, params);
3986
4761
  }
3987
4762
  const parentStepCountsToPass = [...this._parentStepCounts, stepCount + 1];
3988
- // Filter action changes for sub-agent propagation
4763
+ // Filter action / sub-agent changes for sub-agent propagation
3989
4764
  const subAgentActionChanges = this.filterActionChangesForSubAgent(params.actionChanges);
4765
+ const subAgentSubAgentChanges = this.filterSubAgentChangesForSubAgent(params.subAgentChanges);
3990
4766
  // Execute the sub-agent with cancellation and streaming support
3991
4767
  // Use subAgentRequest.context if provided, otherwise fall back to params.context
3992
4768
  // This allows Flow agents and Loop agents to propagate context through sub-agent requests
@@ -4014,6 +4790,7 @@ The context is now within limits. Please retry your request with the recovered c
4014
4790
  context: subAgentContext, // use subAgentRequest.context if provided, otherwise params.context
4015
4791
  verbose: params.verbose, // pass verbose flag to sub-agent
4016
4792
  actionChanges: subAgentActionChanges, // propagate filtered action changes to sub-agent
4793
+ subAgentChanges: subAgentSubAgentChanges, // propagate filtered sub-agent changes to sub-agent
4017
4794
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
4018
4795
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
4019
4796
  SecondaryScopes: params.SecondaryScopes,
@@ -4604,6 +5381,11 @@ The context is now within limits. Please retry your request with the recovered c
4604
5381
  if (params.data?.conversationId) {
4605
5382
  this._agentRun.ConversationID = params.data.conversationId;
4606
5383
  }
5384
+ // Stamp the realtime/long-lived session id (if any) so every run — including delegated
5385
+ // child runs that inherit this value — is groupable under the same MJ: AI Agent Session.
5386
+ if (params.agentSessionID) {
5387
+ this._agentRun.AgentSessionID = params.agentSessionID;
5388
+ }
4607
5389
  this._agentRun.Status = 'Running';
4608
5390
  this._agentRun.StartedAt = new Date();
4609
5391
  this._agentRun.UserID = params.userId || params.contextUser?.ID || null;
@@ -4761,6 +5543,10 @@ The context is now within limits. Please retry your request with the recovered c
4761
5543
  */
4762
5544
  async createStepEntity(params) {
4763
5545
  const stepEntity = await this._activeProvider.GetEntityObject('MJ: AI Agent Run Steps', params.contextUser);
5546
+ // Client-generate the PK so the step ID is valid IMMEDIATELY (before the INSERT lands) — child
5547
+ // steps link via ParentID and the post-create UPDATE-phase mutations reference this row, and the
5548
+ // create INSERT is fire-and-forget (the agent flow must not block on it).
5549
+ stepEntity.NewRecord();
4764
5550
  stepEntity.AgentRunID = this._agentRun.ID;
4765
5551
  // Step number is based on current count of steps + 1
4766
5552
  stepEntity.StepNumber = (this._agentRun.Steps?.length || 0) + 1;
@@ -4792,7 +5578,12 @@ The context is now within limits. Please retry your request with the recovered c
4792
5578
  }
4793
5579
  });
4794
5580
  }
4795
- this.queueStepSave(stepEntity);
5581
+ // Fire-and-forget the 'started' INSERT — the agent flow never blocks on a step save. Store the
5582
+ // promise per-step so every later UPDATE (queueStepSave) runs only AFTER this INSERT commits
5583
+ // (see _stepInsertPromises).
5584
+ const insertPromise = this.saveStepRecord(stepEntity, 'insert');
5585
+ this._stepInsertPromises.set(stepEntity, insertPromise);
5586
+ this._pendingSaves.push(insertPromise);
4796
5587
  // Add the step to the agent run's Steps array
4797
5588
  if (this._agentRun) {
4798
5589
  this._agentRun.Steps.push(stepEntity);
@@ -4859,6 +5650,9 @@ The context is now within limits. Please retry your request with the recovered c
4859
5650
  */
4860
5651
  async finalizeStepEntity(stepEntity, success, errorMessage, outputData) {
4861
5652
  try {
5653
+ // Apply the completion state to the in-memory entity NOW (so the run's Steps array / UI see
5654
+ // Completed immediately), then fire-and-forget the UPDATE via queueStepSave — which chains after
5655
+ // the INSERT and force-persists (IgnoreDirtyState). The agent flow never blocks on this UPDATE.
4862
5656
  stepEntity.Status = success ? 'Completed' : 'Failed';
4863
5657
  stepEntity.CompletedAt = new Date();
4864
5658
  stepEntity.Success = success;
@@ -4881,33 +5675,51 @@ The context is now within limits. Please retry your request with the recovered c
4881
5675
  }
4882
5676
  }
4883
5677
  /**
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.
5678
+ * Saves one step record and ALWAYS logs (never swallows, never verbose-gated) a failure via `LogError`
5679
+ * with `LatestResult.CompleteMessage` — the log is observability, so a failure must surface but must
5680
+ * not throw into the agent loop. The `update` phase force-saves with `IgnoreDirtyState` because a
5681
+ * finalize/TargetLogID mutation applied while the INSERT was still in flight gets absorbed by the
5682
+ * INSERT's post-save dirty-reset, leaving the new values only in memory (the "step stuck at Running"
5683
+ * bug); forcing the UPDATE re-persists them. `insert` saves normally.
5684
+ * @returns whether the row persisted.
5685
+ */
5686
+ async saveStepRecord(stepEntity, phase) {
5687
+ try {
5688
+ let options;
5689
+ if (phase === 'update') {
5690
+ options = new EntitySaveOptions();
5691
+ options.IgnoreDirtyState = true;
5692
+ }
5693
+ const ok = await stepEntity.Save(options);
5694
+ if (!ok) {
5695
+ LogError(`Failed to ${phase} agent run step record ${stepEntity.ID || '(unsaved)'}: ${stepEntity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
5696
+ }
5697
+ return ok;
5698
+ }
5699
+ catch (e) {
5700
+ LogError(`Error on ${phase} of agent run step record ${stepEntity.ID || '(unsaved)'}: ${e?.message ?? e}`);
5701
+ return false;
5702
+ }
5703
+ }
5704
+ /**
5705
+ * Queues a fire-and-forget UPDATE of a step entity whose fields the caller has ALREADY mutated.
4893
5706
  *
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.
5707
+ * - The agent flow never awaits this (logging is fire-and-forget).
5708
+ * - Chains after the step's 'started' INSERT and any prior queued save, so the UPDATE never races
5709
+ * ahead of the INSERT; updates to DIFFERENT steps run concurrently.
5710
+ * - Force-persists (IgnoreDirtyState) because a mutation applied while the INSERT was in flight can be
5711
+ * absorbed by the INSERT's post-save dirty-reset, leaving the entity "clean" with the new values
5712
+ * only in memory — without the force, the UPDATE would silently no-op and the row would stay stuck
5713
+ * at Status='Running' / null TargetLogID.
5714
+ * - Failures are logged (never thrown) and surfaced via `_pendingSaves` at run finalize.
4898
5715
  *
4899
5716
  * @protected
4900
5717
  */
4901
5718
  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
- });
5719
+ // Chain on the entity INSTANCE (stable), NOT stepEntity.ID. Fall back to the INSERT promise so an
5720
+ // UPDATE queued before the create lands still runs after it.
5721
+ const previousSave = this._stepSavePromises.get(stepEntity) ?? this._stepInsertPromises.get(stepEntity) ?? Promise.resolve();
5722
+ const currentSave = previousSave.then(() => this.saveStepRecord(stepEntity, 'update'));
4911
5723
  this._stepSavePromises.set(stepEntity, currentSave);
4912
5724
  this._pendingSaves.push(currentSave);
4913
5725
  }
@@ -5242,10 +6054,8 @@ The context is now within limits. Please retry your request with the recovered c
5242
6054
  },
5243
6055
  displayMode: 'live' // Only show in live mode
5244
6056
  });
5245
- // Set PayloadAtStart
5246
- if (stepEntity && payload) {
5247
- stepEntity.PayloadAtStart = this.serializePayloadAtStart(payload);
5248
- }
6057
+ // PayloadAtStart was already serialized from this same `payload` by createStepEntity
6058
+ // above (payloadAtStart: payload) — no need to re-serialize the (potentially large) payload here.
5249
6059
  let downstreamPayload = payload; // Start with current payload
5250
6060
  if (params.agent.PayloadSelfReadPaths) {
5251
6061
  const downstreamPaths = JSON.parse(params.agent.PayloadSelfReadPaths);
@@ -5294,7 +6104,7 @@ The context is now within limits. Please retry your request with the recovered c
5294
6104
  // Update step entity with AIPromptRun ID if available
5295
6105
  if (promptResult.promptRun?.ID) {
5296
6106
  stepEntity.TargetLogID = promptResult.promptRun.ID;
5297
- stepEntity.PromptRun = promptResult.promptRun; // Store the prompt run object
6107
+ stepEntity.PromptRun = promptResult.promptRun; // transient related object (not a persisted field)
5298
6108
  // don't save here, we save when we call finalizeStepEntity()
5299
6109
  }
5300
6110
  // Check if prompt execution failed
@@ -5411,6 +6221,11 @@ The context is now within limits. Please retry your request with the recovered c
5411
6221
  else if (this._artifactToolManager.HasArtifacts()) {
5412
6222
  this.logStatus(`[ArtifactTools] LLM did not use artifact tools this turn (artifacts available but not accessed)`, true, params);
5413
6223
  }
6224
+ // Execute in-flight memory writes if provided (zero turn cost — processed inline)
6225
+ const memoryWrites = initialNextStep.memoryWrites;
6226
+ if (memoryWrites?.length) {
6227
+ await this.processMemoryWritesForTurn(memoryWrites, params);
6228
+ }
5414
6229
  // Execute a tool pipeline if provided (zero turn cost — processed inline). Each step's
5415
6230
  // output is threaded into the next server-side; only the final step's output returns to
5416
6231
  // the LLM, so intermediate payloads never enter the context window.
@@ -5797,7 +6612,7 @@ The context is now within limits. Please retry your request with the recovered c
5797
6612
  // Update step entity with AIAgentRun ID if available
5798
6613
  if (subAgentResult.agentRun?.ID) {
5799
6614
  stepEntity.TargetLogID = subAgentResult.agentRun.ID;
5800
- // Set the SubAgentRun property for hierarchical tracking
6615
+ // Set the SubAgentRun property for hierarchical tracking (transient related object)
5801
6616
  stepEntity.SubAgentRun = subAgentResult.agentRun;
5802
6617
  stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(mergedPayload);
5803
6618
  // saving happens later by calling finalizeStepEntity()
@@ -8259,12 +9074,17 @@ The context is now within limits. Please retry your request with the recovered c
8259
9074
  else {
8260
9075
  this._agentRun.Status = 'Completed';
8261
9076
  }
8262
- this._agentRun.Result = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9077
+ // Serialize the (largest-it-ever-gets) final payload ONCE and reuse for both
9078
+ // Result and FinalPayload instead of stringifying the same object three times.
9079
+ const finalPayloadJson = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9080
+ this._agentRun.Result = finalPayloadJson;
8263
9081
  this._agentRun.FinalStep = finalStep.step;
8264
9082
  this._agentRun.Message = finalStep.message;
8265
- // Set the FinalPayloadObject - this will automatically stringify for the DB
9083
+ // Set the FinalPayloadObject (populates the object cache; its setter also writes
9084
+ // FinalPayload when the value changes). We then assign FinalPayload from the
9085
+ // already-computed JSON to guarantee it's set regardless of the setter's change guard.
8266
9086
  this._agentRun.FinalPayloadObject = resolvedPayload;
8267
- this._agentRun.FinalPayload = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
9087
+ this._agentRun.FinalPayload = finalPayloadJson;
8268
9088
  // Calculate total tokens from all prompts and sub-agents
8269
9089
  const tokenStats = this.calculateTokenStats();
8270
9090
  this._agentRun.TotalTokensUsed = tokenStats.totalTokens;