@memberjunction/ai-agents 5.41.0 → 5.43.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 (41) hide show
  1. package/README.md +10 -0
  2. package/dist/agent-types/flow-agent-type.d.ts.map +1 -1
  3. package/dist/agent-types/flow-agent-type.js +10 -2
  4. package/dist/agent-types/flow-agent-type.js.map +1 -1
  5. package/dist/base-agent.d.ts +57 -51
  6. package/dist/base-agent.d.ts.map +1 -1
  7. package/dist/base-agent.js +239 -187
  8. package/dist/base-agent.js.map +1 -1
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/memory-manager-agent.d.ts +44 -2
  14. package/dist/memory-manager-agent.d.ts.map +1 -1
  15. package/dist/memory-manager-agent.js +133 -100
  16. package/dist/memory-manager-agent.js.map +1 -1
  17. package/dist/realtime/bridge-realtime-session-factory.d.ts +111 -0
  18. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -0
  19. package/dist/realtime/bridge-realtime-session-factory.js +163 -0
  20. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -0
  21. package/dist/realtime/bridge-room-transcript-sink.d.ts +63 -0
  22. package/dist/realtime/bridge-room-transcript-sink.d.ts.map +1 -0
  23. package/dist/realtime/bridge-room-transcript-sink.js +151 -0
  24. package/dist/realtime/bridge-room-transcript-sink.js.map +1 -0
  25. package/dist/realtime/realtime-client-session-service.d.ts +148 -6
  26. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  27. package/dist/realtime/realtime-client-session-service.js +241 -35
  28. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  29. package/dist/realtime/realtime-coagent-config.d.ts +142 -7
  30. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  31. package/dist/realtime/realtime-coagent-config.js +176 -8
  32. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  33. package/dist/realtime/realtime-tool-broker.d.ts +15 -0
  34. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -1
  35. package/dist/realtime/realtime-tool-broker.js +22 -0
  36. package/dist/realtime/realtime-tool-broker.js.map +1 -1
  37. package/dist/realtime/realtime-turn-moderator.d.ts +103 -0
  38. package/dist/realtime/realtime-turn-moderator.d.ts.map +1 -0
  39. package/dist/realtime/realtime-turn-moderator.js +266 -0
  40. package/dist/realtime/realtime-turn-moderator.js.map +1 -0
  41. package/package.json +17 -17
@@ -11,7 +11,7 @@
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, EntitySaveOptions } from '@memberjunction/core';
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
17
  import { BaseRealtimeModel, GetAIAPIKey } from '@memberjunction/ai';
@@ -19,12 +19,14 @@ import { BaseAgentType } from './agent-types/base-agent-type.js';
19
19
  import { CopyScalarsAndArrays, JSONValidator, MJGlobal, SafeExpressionEvaluator, UUIDsEqual } from '@memberjunction/global';
20
20
  import { RealtimeSessionRunner } from './realtime/realtime-session-runner.js';
21
21
  import { ResolveNarrationInstructionsTemplate } from './realtime/realtime-narration.js';
22
- import { BuildVoiceMannerSection, GetNarrationPaceMs, GetProviderVoiceSettings, ResolveEffectiveRealtimeConfig } from './realtime/realtime-coagent-config.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';
23
25
  import { AIEngine } from '@memberjunction/aiengine';
24
26
  import { ActionEngineServer } from '@memberjunction/actions';
25
27
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
26
28
  import { AgentMemoryContextBuilder } from './agent-memory-context-builder.js';
27
- import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy } from '@memberjunction/ai-core-plus';
29
+ import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue } from '@memberjunction/ai-core-plus';
28
30
  import { AgentRunner } from './AgentRunner.js';
29
31
  import { PayloadManager } from './PayloadManager.js';
30
32
  import { ScratchpadManager } from './ScratchpadManager.js';
@@ -92,30 +94,13 @@ export class BaseAgent {
92
94
  */
93
95
  this._promptRunner = new AIPromptRunner();
94
96
  /**
95
- * List of pending database save Promises for observability step records.
96
- * 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).
97
102
  */
98
- this._pendingSaves = [];
99
- /**
100
- * Queue map to chain database saves sequentially per step entity.
101
- * Prevents UPDATE queries running before INSERT queries on quick steps.
102
- *
103
- * Keyed by the step ENTITY INSTANCE, not its `ID`: a new step's `ID` is empty at create time and
104
- * only gets populated during its INSERT `Save()`, so keying by `ID` would file the create and the
105
- * finalize under different buckets and defeat the chain — letting the UPDATE race ahead of the
106
- * INSERT on millisecond-fast steps (e.g. pipelines), which left them stuck at `Running`.
107
- */
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();
103
+ this._stepSaveQueue = new AgentRunStepSaveQueue();
119
104
  /**
120
105
  * Active per-request metadata provider, set at the start of Execute().
121
106
  * Defaults to the global Metadata.Provider; overridden when a per-request
@@ -1299,6 +1284,93 @@ export class BaseAgent {
1299
1284
  return await this.createFailureResult(msg, params.contextUser);
1300
1285
  }
1301
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
+ }
1302
1374
  /**
1303
1375
  * Resolves the realtime model + vendor driver + API key for a session-driven run.
1304
1376
  *
@@ -1316,54 +1388,68 @@ export class BaseAgent {
1316
1388
  * @param params The execution parameters (for the agent + context user).
1317
1389
  * @returns The resolved model instance plus its model/vendor identifiers, or `null`.
1318
1390
  */
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;
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 };
1335
1412
  }
1336
- return { model: instance, modelID: model.ID, vendorID: vendor.vendorID, apiName: vendor.apiName, driverClass: vendor.driverClass };
1413
+ return null;
1337
1414
  }
1338
1415
  /**
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).
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.
1342
1424
  *
1343
- * @param agent The agent being executed (reserved for future per-agent model preference).
1344
- * @returns The chosen model entity, or `null`.
1425
+ * @param agent The agent being executed.
1426
+ * @returns The candidate models in resolution order (empty when none are active).
1345
1427
  */
1346
- selectRealtimeModelEntity(agent) {
1428
+ selectRealtimeModelCandidates(agent, overrideModelID) {
1347
1429
  const isRealtime = (m) => typeof m.AIModelType === 'string' && m.AIModelType.trim().toLowerCase() === 'realtime';
1348
1430
  const realtimeModels = AIEngine.Instance.Models.filter(m => m.IsActive && isRealtime(m));
1349
1431
  if (realtimeModels.length === 0) {
1350
- return null;
1432
+ return [];
1351
1433
  }
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;
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;
1356
1440
  if (preference) {
1357
1441
  const wanted = preference.trim().toLowerCase();
1358
1442
  const preferred = realtimeModels.find(m => UUIDsEqual(m.ID, preference))
1359
1443
  ?? realtimeModels.find(m => m.Name?.trim().toLowerCase() === wanted);
1360
1444
  if (preferred) {
1361
- return 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))];
1362
1448
  }
1363
1449
  this.logError(`Realtime model preference '${preference}' for agent '${agent.Name}' matches no Active Realtime ` +
1364
1450
  'model — falling through to default (highest-PowerRank) selection.', { agent, category: 'RealtimeSession' });
1365
1451
  }
1366
- return realtimeModels.sort((a, b) => (b.PowerRank ?? 0) - (a.PowerRank ?? 0))[0];
1452
+ return byPower;
1367
1453
  }
1368
1454
  /**
1369
1455
  * Resolves the agent's EFFECTIVE realtime configuration — the agent TYPE's
@@ -1498,16 +1584,11 @@ export class BaseAgent {
1498
1584
  * @returns The session parameters.
1499
1585
  */
1500
1586
  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.`;
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');
1511
1592
  const basePrompt = config.systemPrompt?.TemplateText ? config.systemPrompt.TemplateText : '';
1512
1593
  // Effective-config voice persona (realtime.voice.default) → short "Voice & manner" section.
1513
1594
  const voiceManner = BuildVoiceMannerSection(effectiveConfig);
@@ -1941,8 +2022,13 @@ export class BaseAgent {
1941
2022
  * @protected
1942
2023
  */
1943
2024
  async initializeEngines(contextUser) {
1944
- 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.
1945
2030
  await ActionEngineServer.Instance.Config(false, contextUser);
2031
+ await AIEngine.Instance.Config(false, contextUser);
1946
2032
  }
1947
2033
  /**
1948
2034
  * Determine the scope label for a note based on its scope fields.
@@ -4796,7 +4882,9 @@ The context is now within limits. Please retry your request with the recovered c
4796
4882
  SecondaryScopes: params.SecondaryScopes,
4797
4883
  onAgentRunCreated: async (agentRunId) => {
4798
4884
  stepEntity.TargetLogID = agentRunId;
4799
- 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; });
4800
4888
  }
4801
4889
  });
4802
4890
  // Check if execution was successful
@@ -5547,43 +5635,38 @@ The context is now within limits. Please retry your request with the recovered c
5547
5635
  // steps link via ParentID and the post-create UPDATE-phase mutations reference this row, and the
5548
5636
  // create INSERT is fire-and-forget (the agent flow must not block on it).
5549
5637
  stepEntity.NewRecord();
5550
- stepEntity.AgentRunID = this._agentRun.ID;
5551
5638
  // Step number is based on current count of steps + 1
5552
- stepEntity.StepNumber = (this._agentRun.Steps?.length || 0) + 1;
5553
- stepEntity.StepType = params.stepType;
5554
- // Include hierarchy breadcrumb in StepName for better logging
5555
- stepEntity.StepName = this.formatHierarchicalMessage(params.stepName);
5556
- // 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).
5557
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)) {
5558
- // If not valid, we can just ignore it, but console.warn
5559
5642
  console.warn(`Invalid target ID format: ${params.targetId}`);
5560
5643
  }
5561
- else {
5562
- stepEntity.TargetID = params.targetId || null;
5563
- }
5564
- stepEntity.TargetLogID = params.targetLogId || null;
5565
- stepEntity.ParentID = params.parentId || null; // Link to parent step (e.g., loop step)
5566
- stepEntity.Status = 'Running';
5567
- stepEntity.StartedAt = new Date();
5568
- stepEntity.PayloadAtStart = this.serializePayloadAtStart(params.payloadAtStart);
5569
- stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(params.payloadAtEnd);
5570
- // Populate InputData if provided
5571
- if (params.inputData) {
5572
- stepEntity.InputData = JSON.stringify({
5573
- ...params.inputData,
5574
- context: {
5575
- agentHierarchy: this._agentHierarchy,
5576
- depth: this._depth,
5577
- stepNumber: stepEntity.StepNumber
5578
- }
5579
- });
5580
- }
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);
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);
5587
5670
  // Add the step to the agent run's Steps array
5588
5671
  if (this._agentRun) {
5589
5672
  this._agentRun.Steps.push(stepEntity);
@@ -5650,78 +5733,42 @@ The context is now within limits. Please retry your request with the recovered c
5650
5733
  */
5651
5734
  async finalizeStepEntity(stepEntity, success, errorMessage, outputData) {
5652
5735
  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.
5656
- stepEntity.Status = success ? 'Completed' : 'Failed';
5657
- stepEntity.CompletedAt = new Date();
5658
- stepEntity.Success = success;
5659
- stepEntity.ErrorMessage = errorMessage || null;
5660
- // Populate OutputData if provided
5661
- if (outputData) {
5662
- stepEntity.OutputData = JSON.stringify({
5663
- ...CopyScalarsAndArrays(outputData, true),
5664
- context: {
5665
- success,
5666
- durationMs: stepEntity.CompletedAt.getTime() - stepEntity.StartedAt.getTime(),
5667
- errorMessage
5668
- }
5669
- });
5670
- }
5671
- 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));
5672
5757
  }
5673
5758
  catch (e) {
5674
5759
  LogError(`Failed to update agent run step record: ${e?.message ?? e}`, undefined, e);
5675
5760
  }
5676
5761
  }
5677
- /**
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
5762
  /**
5705
5763
  * Queues a fire-and-forget UPDATE of a step entity whose fields the caller has ALREADY mutated.
5706
- *
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.
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.
5715
5767
  *
5716
5768
  * @protected
5717
5769
  */
5718
- queueStepSave(stepEntity) {
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'));
5723
- this._stepSavePromises.set(stepEntity, currentSave);
5724
- this._pendingSaves.push(currentSave);
5770
+ queueStepSave(stepEntity, applyMutation) {
5771
+ this._stepSaveQueue.QueueUpdate(stepEntity, applyMutation);
5725
5772
  }
5726
5773
  /**
5727
5774
  * Maps an array through an async worker with bounded concurrency.
@@ -6092,7 +6139,9 @@ The context is now within limits. Please retry your request with the recovered c
6092
6139
  } : undefined;
6093
6140
  promptParams.onPromptRunCreated = async (promptRunId) => {
6094
6141
  stepEntity.TargetLogID = promptRunId;
6095
- 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; });
6096
6145
  };
6097
6146
  // Execute the prompt
6098
6147
  const promptResult = await this.executePrompt(promptParams);
@@ -7741,8 +7790,11 @@ The context is now within limits. Please retry your request with the recovered c
7741
7790
  actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser);
7742
7791
  // Update step entity with ActionExecutionLog ID if available
7743
7792
  if (actionResult.LogEntry?.ID) {
7744
- stepEntity.TargetLogID = actionResult.LogEntry.ID;
7745
- 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; });
7746
7798
  }
7747
7799
  // Prepare output data with action result
7748
7800
  const outputData = {
@@ -8468,6 +8520,18 @@ The context is now within limits. Please retry your request with the recovered c
8468
8520
  * Strips "payload." prefix if present (for LLM convenience)
8469
8521
  */
8470
8522
  getCollectionFromPayload(payload, path) {
8523
+ // Support a literal static collection: "static:[1,2,3,4,5]". This lets a ForEach iterate a
8524
+ // fixed list/range without a prior step having to build the array in the payload first.
8525
+ const trimmed = path.trim();
8526
+ if (trimmed.toLowerCase().startsWith('static:')) {
8527
+ try {
8528
+ const parsed = JSON.parse(trimmed.substring(trimmed.indexOf(':') + 1).trim());
8529
+ return Array.isArray(parsed) ? parsed : null;
8530
+ }
8531
+ catch {
8532
+ return null;
8533
+ }
8534
+ }
8471
8535
  // Remove "payload." prefix if present
8472
8536
  const cleanPath = path.toLowerCase().startsWith('payload.')
8473
8537
  ? path.substring(8)
@@ -9018,27 +9082,15 @@ The context is now within limits. Please retry your request with the recovered c
9018
9082
  * @private
9019
9083
  */
9020
9084
  async finalizeAgentRun(finalStep, payload, contextUser) {
9021
- // Await every pending step save (success OR failure) and accumulate diagnostics.
9022
- // We use allSettled so a single failure doesn't shadow the rest, and we drain
9023
- // both queues afterwards so an instance reused for another run doesn't leak
9024
- // settled promises.
9025
- const pending = this._pendingSaves;
9026
- this._pendingSaves = [];
9027
- this._stepSavePromises.clear();
9028
- if (pending.length > 0) {
9029
- const settled = await Promise.allSettled(pending);
9030
- const rejections = settled.filter(s => s.status === 'rejected');
9031
- const falses = settled.filter(s => s.status === 'fulfilled' && s.value === false).length;
9032
- for (const r of rejections) {
9033
- LogError(`Pending step save rejected: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
9034
- }
9035
- const totalFailures = rejections.length + falses;
9036
- if (totalFailures > 0 && this._agentRun) {
9037
- const note = `${totalFailures} step record save(s) failed during this run; see logs for details.`;
9038
- this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
9039
- ? `${this._agentRun.ErrorMessage}\n${note}`
9040
- : note;
9041
- }
9085
+ // Flush every pending step save (success OR failure) via the shared queue, which allSettles so a
9086
+ // single failure doesn't shadow the rest and drains itself so a reused instance doesn't leak
9087
+ // settled promises. Surface the failure count on the run for visibility.
9088
+ const { failures } = await this._stepSaveQueue.Flush();
9089
+ if (failures > 0 && this._agentRun) {
9090
+ const note = `${failures} step record save(s) failed during this run; see logs for details.`;
9091
+ this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
9092
+ ? `${this._agentRun.ErrorMessage}\n${note}`
9093
+ : note;
9042
9094
  }
9043
9095
  // Only resolve media placeholders for ROOT agents (depth === 0)
9044
9096
  // Sub-agents keep placeholders intact so parent agents don't get huge base64 in their context