@memberjunction/ai-agents 5.37.0 → 5.38.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.
@@ -73,6 +73,12 @@ import _ from 'lodash';
73
73
  * const result = await agent.Execute(params);
74
74
  * ```
75
75
  */
76
+ /**
77
+ * Maximum number of sub-agents to dispatch concurrently when a Loop agent
78
+ * returns a `subAgents` array. Prevents a misbehaving LLM (or an over-eager
79
+ * one) from saturating the model API / DB pool with N concurrent runs.
80
+ */
81
+ const PARALLEL_SUBAGENT_CONCURRENCY_LIMIT = 5;
76
82
  export class BaseAgent {
77
83
  constructor() {
78
84
  /**
@@ -80,6 +86,16 @@ export class BaseAgent {
80
86
  * @private
81
87
  */
82
88
  this._promptRunner = new AIPromptRunner();
89
+ /**
90
+ * List of pending database save Promises for observability step records.
91
+ * Awaited concurrently in finalizeAgentRun() to prevent blocking.
92
+ */
93
+ this._pendingSaves = [];
94
+ /**
95
+ * Queue map to chain database saves sequentially per step entity.
96
+ * Prevents UPDATE queries running before INSERT queries on quick steps.
97
+ */
98
+ this._stepSavePromises = new Map();
83
99
  /**
84
100
  * Active per-request metadata provider, set at the start of Execute().
85
101
  * Defaults to the global Metadata.Provider; overridden when a per-request
@@ -418,7 +434,12 @@ export class BaseAgent {
418
434
  * This prevents context overflow when action results contain large base64 data (images, audio, video).
419
435
  *
420
436
  * Uses generic ValueType=MediaOutput detection from action metadata to identify media output params.
421
- * Intercepted media is stored in _mediaOutputs with refId and persist=false (not saved unless used).
437
+ *
438
+ * Intercepted media is stored in `_mediaOutputs` with a generated `refId` and is **always
439
+ * persisted** by `AgentRunner` — all media outputs are saved to `AIAgentRunMedia` +
440
+ * `ConversationDetailAttachment` (which auto-pairs to an artifact via the server hook).
441
+ * The `${media:<refId>}` placeholder injected into the action result keeps the LLM's
442
+ * context window small; the LLM is told the media will be displayed automatically.
422
443
  *
423
444
  * @param actionParams - The output parameters from an action result
424
445
  * @param actionEntity - Optional action entity metadata for ValueType checking
@@ -454,11 +475,10 @@ export class BaseAgent {
454
475
  if (media.data && media.data.length > BaseAgent.LARGE_BINARY_THRESHOLD) {
455
476
  // Generate unique reference ID
456
477
  const refId = `media-${Date.now().toString(36)}-${i}-${Math.random().toString(36).substring(2, 8)}`;
457
- // Store in unified media outputs with persist=false (won't be saved unless placeholder is used)
478
+ // Store in unified media outputs — always persisted by AgentRunner.
458
479
  this._mediaOutputs.push({
459
480
  ...media,
460
481
  refId,
461
- persist: false // Not persisted unless placeholder is resolved in final output
462
482
  });
463
483
  references.push(`\${media:${refId}}`);
464
484
  extractedCount++;
@@ -472,7 +492,7 @@ export class BaseAgent {
472
492
  Value: {
473
493
  mediaReferences: references,
474
494
  count: mediaItems.length,
475
- note: `${extractedCount} media item(s) extracted. Use placeholder syntax in your response: <img src="${references[0]}" alt="description" />`
495
+ note: `${extractedCount} media item(s) extracted and will be displayed to the user automatically.`
476
496
  }
477
497
  });
478
498
  this.logStatus(`📦 Extracted ${extractedCount} ${param.Name} item(s) to media references`, true);
@@ -485,14 +505,13 @@ export class BaseAgent {
485
505
  const base64Pattern = /^[A-Za-z0-9+/]+=*$/;
486
506
  if (isMediaOutputParam || base64Pattern.test(param.Value.substring(0, 1000))) {
487
507
  const refId = `data-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
488
- // Store in unified media outputs with persist=false
508
+ // Store in unified media outputs — always persisted by AgentRunner.
489
509
  this._mediaOutputs.push({
490
510
  modality: 'Image', // Default to image, could be enhanced with mime detection
491
511
  mimeType: 'application/octet-stream',
492
512
  data: param.Value,
493
513
  label: `Media data from ${param.Name}`,
494
514
  refId,
495
- persist: false
496
515
  });
497
516
  sanitizedParams.push({
498
517
  Name: param.Name,
@@ -509,40 +528,50 @@ export class BaseAgent {
509
528
  return sanitizedParams;
510
529
  }
511
530
  /**
512
- * Resolves media placeholders in a string.
513
- * Replaces ${media:ref-id} with actual data URIs (data:mime;base64,...).
514
- * Sets persist=true on resolved media so it will be saved to AIAgentRunMedia.
531
+ * Substitutes `${media:<refId>}` placeholders in a string with the actual
532
+ * data URI (`data:<mime>;base64,<bytes>`) of the matching intercepted media item.
533
+ *
534
+ * Used for payload / actionable-command resolution at the terminal step, where the
535
+ * LLM wants to embed image (or other media) data inline at a specific position in
536
+ * its structured output rather than as a trailing attachment card. The string
537
+ * variant of placeholder resolution; recursive walker lives in
538
+ * {@link resolveMediaPlaceholdersInPayload}.
539
+ *
540
+ * This function only does substitution — it has no persistence side effects.
515
541
  *
516
542
  * @param text - The string that may contain media placeholders
517
- * @returns String with placeholders resolved to actual data URIs
543
+ * @returns String with placeholders resolved to actual data URIs (or the original
544
+ * placeholder if the refId is unknown — defensive, shouldn't happen)
518
545
  * @private
519
546
  * @since 3.1.0
520
547
  */
521
548
  resolveMediaPlaceholdersInString(text) {
522
- // Check if any media has a refId (meaning we have intercepted media to resolve)
549
+ // Fast path: nothing to resolve if there are no intercepted media items.
523
550
  const hasRefIds = this._mediaOutputs.some(m => m.refId);
524
551
  if (!text || !hasRefIds) {
525
552
  return text;
526
553
  }
527
- // Match ${media:ref-id} pattern
554
+ // Match ${media:ref-id} pattern (lowercase letters, digits, dashes only —
555
+ // matches the IDs generated by interceptLargeBinaryContent).
528
556
  const placeholderRegex = /\$\{media:([a-z0-9-]+)\}/g;
529
557
  return text.replace(placeholderRegex, (match, refId) => {
530
558
  const media = this._mediaOutputs.find(m => m.refId === refId);
531
559
  if (media?.data) {
532
- // Mark for persistence since it's being used in final output
533
- media.persist = true;
534
560
  return `data:${media.mimeType};base64,${media.data}`;
535
561
  }
536
- // Keep placeholder if not found (shouldn't happen in normal flow)
562
+ // Unknown refId — leave the placeholder in place rather than emit a broken
563
+ // data URI. Defensive; this branch should not fire in normal flow.
537
564
  this.logStatus(`⚠️ Media reference '${refId}' not found in registry`, true);
538
565
  return match;
539
566
  });
540
567
  }
541
568
  /**
542
- * Resolves media placeholders in a payload of any type.
543
- * - For strings: resolves placeholders directly
544
- * - For objects: recursively processes all string properties
545
- * - For arrays: recursively processes all elements
569
+ * Resolves `${media:<refId>}` placeholders anywhere inside an arbitrary payload.
570
+ * - Strings: resolves placeholders directly
571
+ * - Objects: recursively processes every string property
572
+ * - Arrays: recursively processes every element
573
+ *
574
+ * Pure resolution — no persistence side effects.
546
575
  *
547
576
  * @param payload - The payload that may contain media placeholders in string values
548
577
  * @returns Payload with all placeholders resolved to actual data URIs
@@ -550,21 +579,12 @@ export class BaseAgent {
550
579
  * @since 3.1.0
551
580
  */
552
581
  resolveMediaPlaceholdersInPayload(payload) {
553
- // Check if any media has a refId (meaning we have intercepted media to resolve)
582
+ // Fast path: nothing to resolve if no intercepted media exists.
554
583
  const hasRefIds = this._mediaOutputs.some(m => m.refId);
555
584
  if (!hasRefIds) {
556
585
  return payload;
557
586
  }
558
- // Count how many media items have persist=false before resolution
559
- const unpersisted = this._mediaOutputs.filter(m => m.refId && m.persist === false).length;
560
- const resolved = this.resolveMediaPlaceholdersRecursive(payload);
561
- // Count how many were marked for persistence (persist changed from false to true)
562
- const persistedAfter = this._mediaOutputs.filter(m => m.refId && m.persist === true).length;
563
- const resolvedCount = persistedAfter - (unpersisted - this._mediaOutputs.filter(m => m.refId && m.persist === false).length);
564
- if (resolvedCount > 0) {
565
- this.logStatus(`✅ Resolved ${resolvedCount} media placeholder(s) in final payload`, true);
566
- }
567
- return resolved;
587
+ return this.resolveMediaPlaceholdersRecursive(payload);
568
588
  }
569
589
  /**
570
590
  * Recursively resolves media placeholders in any value.
@@ -593,54 +613,26 @@ export class BaseAgent {
593
613
  // Return primitives (numbers, booleans) as-is
594
614
  return value;
595
615
  }
596
- /**
597
- * Processes media placeholders in agent messages for conversational agents.
598
- *
599
- * Unlike artifact-based agents (which embed images in HTML payload), conversational agents
600
- * should display images via ConversationDetailAttachment. This method:
601
- * 1. Detects ${media:xxx} placeholders in the message
602
- * 2. Sets persist=true on referenced media (triggers save to AIAgentRunMedia)
603
- * 3. Strips media HTML tags from the message (images display via attachment instead)
604
- *
605
- * @param message - The message that may contain media placeholders
606
- * @returns Cleaned message with media tags stripped
607
- * @private
608
- * @since 3.1.0
616
+ // ───────────────────────── Sub-class state accessors ──────────────────────────
617
+ // Read-only `protected` getters so driver sub-classes (e.g. Skip) can inspect
618
+ // the current run's state without being able to corrupt internal invariants.
619
+ // Mutations still flow through the framework's own methods (createStepEntity,
620
+ // queueStepSave, incrementExecutionCount, etc.).
621
+ // (`AgentRun` and `MediaOutputs` are already public getters above; the
622
+ // accessors below cover state that previously had no external surface.)
623
+ /** Depth of this agent in the execution hierarchy (0 = root). @protected */
624
+ get Depth() { return this._depth; }
625
+ /** Agent name hierarchy from root to current (e.g. `['Sage', 'Skip', 'Researcher']`). @protected */
626
+ get AgentHierarchy() { return this._agentHierarchy; }
627
+ /** Parent step counts used to build the `2.1.3` hierarchical step label. @protected */
628
+ get ParentStepCounts() { return this._parentStepCounts; }
629
+ /**
630
+ * Accumulated file outputs (PDF, Excel, Word, etc.) produced this run.
631
+ * Mirrors the existing `MediaOutputs` accessor pattern but is scoped to
632
+ * driver sub-classes since it's a more internal collection.
633
+ * @protected
609
634
  */
610
- processMessageMediaPlaceholders(message) {
611
- if (!message) {
612
- return message;
613
- }
614
- // Check if any media has a refId (meaning we have intercepted media)
615
- const hasRefIds = this._mediaOutputs.some(m => m.refId);
616
- if (!hasRefIds) {
617
- return message;
618
- }
619
- // Find all ${media:xxx} placeholders and mark referenced media for persistence
620
- const placeholderRegex = /\$\{media:([a-zA-Z0-9_-]+)\}/g;
621
- let match;
622
- let promotedCount = 0;
623
- while ((match = placeholderRegex.exec(message)) !== null) {
624
- const refId = match[1];
625
- const media = this._mediaOutputs.find(m => m.refId === refId);
626
- if (media && media.persist !== true) {
627
- media.persist = true; // Triggers save to AIAgentRunMedia
628
- promotedCount++;
629
- }
630
- }
631
- if (promotedCount > 0) {
632
- this.logStatus(`📎 Auto-promoted ${promotedCount} media output(s) from message placeholders`, true);
633
- }
634
- // Strip <img>, <audio>, <video> tags containing media placeholders
635
- // The media will display via ConversationDetailAttachment instead
636
- let cleanedMessage = message
637
- .replace(/<img[^>]*src=["']\$\{media:[^}]+\}["'][^>]*\/?>/gi, '')
638
- .replace(/<audio[^>]*src=["']\$\{media:[^}]+\}["'][^>]*>.*?<\/audio>/gi, '')
639
- .replace(/<video[^>]*src=["']\$\{media:[^}]+\}["'][^>]*>.*?<\/video>/gi, '')
640
- .replace(/\n\s*\n\s*\n/g, '\n\n') // Clean up excessive newlines
641
- .trim();
642
- return cleanedMessage;
643
- }
635
+ get FileOutputs() { return this._fileOutputs; }
644
636
  /**
645
637
  * Gets the current validation retry count for the agent run.
646
638
  * This count tracks how many times the agent has retried validation
@@ -1977,51 +1969,85 @@ export class BaseAgent {
1977
1969
  * @param nextStep
1978
1970
  * @returns
1979
1971
  */
1972
+ /**
1973
+ * Returns the list of sub-agent requests on a next-step decision, normalizing
1974
+ * the singular (`subAgent`) and plural (`subAgents`) forms. Plural takes
1975
+ * precedence when both are present (parallel fan-out); otherwise the singular
1976
+ * form is wrapped into a single-element array. Empty if neither is set.
1977
+ *
1978
+ * Use this anywhere code needs to enumerate the sub-agents an LLM requested
1979
+ * — keeps validation and execution paths consistent and avoids the regression
1980
+ * where one path read `.subAgent?.name` and missed parallel requests.
1981
+ */
1982
+ getRequestedSubAgents(nextStep) {
1983
+ if (!nextStep)
1984
+ return [];
1985
+ if (nextStep.subAgents && nextStep.subAgents.length > 0) {
1986
+ return nextStep.subAgents;
1987
+ }
1988
+ return nextStep.subAgent ? [nextStep.subAgent] : [];
1989
+ }
1980
1990
  async validateSubAgentNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
1981
- // check to make sure the current agent can execute the specified sub-agent
1982
- const name = nextStep.subAgent?.name;
1983
1991
  const curAgentSubAgents = AIEngine.Instance.GetSubAgents(params.agent.ID, 'Active');
1984
- const subAgent = curAgentSubAgents.find(a => a.Name.trim().toLowerCase() === name?.trim().toLowerCase());
1985
- if (!name || !subAgent) {
1986
- this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
1992
+ // Collect requested sub-agents. Prefer plural `subAgents` (parallel fan-out);
1993
+ // fall back to singular `subAgent` for the classic single-sub-agent next step.
1994
+ const requested = this.getRequestedSubAgents(nextStep);
1995
+ if (requested.length === 0) {
1996
+ this.logError(`Sub-agent 'undefined' not found or not active for agent '${params.agent.Name}'`, {
1987
1997
  agent: params.agent,
1988
1998
  category: 'SubAgentExecution'
1989
1999
  });
1990
- // Increment validation retry count since we're changing to Retry
1991
2000
  if (nextStep.step !== 'Retry') {
1992
2001
  this._generalValidationRetryCount++;
1993
2002
  }
1994
2003
  return {
1995
2004
  step: 'Retry',
1996
- terminate: false, // this will kick it back to the prompt to run again
1997
- errorMessage: `Sub-agent '${name}' not found or not active`
2005
+ terminate: false,
2006
+ errorMessage: `Sub-agent 'undefined' not found or not active`
1998
2007
  };
1999
2008
  }
2000
- // Check MaxExecutionsPerRun limit
2001
- if (subAgent.MaxExecutionsPerRun != null) {
2002
- const executionCount = await this.getSubAgentExecutionCount(agentRun.ID, subAgent.ID);
2003
- if (executionCount >= subAgent.MaxExecutionsPerRun) {
2004
- this.logError(`Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`, {
2009
+ // Validate each requested sub-agent: existence + MaxExecutionsPerRun
2010
+ for (const req of requested) {
2011
+ const name = req?.name;
2012
+ const subAgent = curAgentSubAgents.find(a => a.Name.trim().toLowerCase() === name?.trim().toLowerCase());
2013
+ if (!name || !subAgent) {
2014
+ this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
2005
2015
  agent: params.agent,
2006
- category: 'SubAgentExecution',
2007
- metadata: {
2008
- subAgentName: name,
2009
- executionCount,
2010
- maxExecutions: subAgent.MaxExecutionsPerRun
2011
- }
2016
+ category: 'SubAgentExecution'
2012
2017
  });
2013
- // Increment validation retry count since we're changing to Retry
2014
2018
  if (nextStep.step !== 'Retry') {
2015
2019
  this._generalValidationRetryCount++;
2016
2020
  }
2017
2021
  return {
2018
2022
  step: 'Retry',
2019
2023
  terminate: false,
2020
- errorMessage: `Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`
2024
+ errorMessage: `Sub-agent '${name}' not found or not active`
2021
2025
  };
2022
2026
  }
2027
+ if (subAgent.MaxExecutionsPerRun != null) {
2028
+ const executionCount = await this.getSubAgentExecutionCount(agentRun.ID, subAgent.ID);
2029
+ if (executionCount >= subAgent.MaxExecutionsPerRun) {
2030
+ this.logError(`Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`, {
2031
+ agent: params.agent,
2032
+ category: 'SubAgentExecution',
2033
+ metadata: {
2034
+ subAgentName: name,
2035
+ executionCount,
2036
+ maxExecutions: subAgent.MaxExecutionsPerRun
2037
+ }
2038
+ });
2039
+ if (nextStep.step !== 'Retry') {
2040
+ this._generalValidationRetryCount++;
2041
+ }
2042
+ return {
2043
+ step: 'Retry',
2044
+ terminate: false,
2045
+ errorMessage: `Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`
2046
+ };
2047
+ }
2048
+ }
2023
2049
  }
2024
- // if we get here, the next step is valid and we can return it
2050
+ // All requested sub-agents are valid
2025
2051
  return nextStep;
2026
2052
  }
2027
2053
  /**
@@ -3760,6 +3786,7 @@ The context is now within limits. Please retry your request with the recovered c
3760
3786
  configurationId: params.configurationId, // propagate configuration ID to sub-agent
3761
3787
  effortLevel: params.effortLevel, // propagate effort level to sub-agent
3762
3788
  apiKeys: params.apiKeys, // propagate API keys to sub-agent
3789
+ inputArtifacts: params.inputArtifacts, // propagate input artifacts so sub-agents inherit the parent's artifact manifest + tools (e.g. a Codesmith delegate can read a Data Snapshot the parent references)
3763
3790
  data: {
3764
3791
  ...params.data,
3765
3792
  ...subAgentRequest.templateParameters,
@@ -3770,10 +3797,9 @@ The context is now within limits. Please retry your request with the recovered c
3770
3797
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
3771
3798
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
3772
3799
  SecondaryScopes: params.SecondaryScopes,
3773
- // Add callback to link AgentRun ID immediately when created
3774
3800
  onAgentRunCreated: async (agentRunId) => {
3775
3801
  stepEntity.TargetLogID = agentRunId;
3776
- await stepEntity.Save();
3802
+ this.queueStepSave(stepEntity);
3777
3803
  }
3778
3804
  });
3779
3805
  // Check if execution was successful
@@ -4497,7 +4523,12 @@ The context is now within limits. Please retry your request with the recovered c
4497
4523
  /**
4498
4524
  * Creates a step entity for tracking.
4499
4525
  *
4500
- * @private
4526
+ * Exposed as `protected` so driver sub-classes (e.g. Skip) can author custom
4527
+ * `AIAgentRunStep` records with the same setup correctness — `StepNumber`,
4528
+ * hierarchy breadcrumb, UUID validation, payload serialization, and the
4529
+ * `queueStepSave` coupling that keeps INSERT-then-UPDATE ordering safe.
4530
+ *
4531
+ * @protected
4501
4532
  * @param params - Step creation parameters
4502
4533
  * @returns {Promise<MJAIAgentRunStepEntityExtended>} - The created step entity
4503
4534
  */
@@ -4534,9 +4565,7 @@ The context is now within limits. Please retry your request with the recovered c
4534
4565
  }
4535
4566
  });
4536
4567
  }
4537
- if (!await stepEntity.Save()) {
4538
- throw new Error(`Failed to create agent run step record: ${JSON.stringify(stepEntity.LatestResult)}`);
4539
- }
4568
+ this.queueStepSave(stepEntity);
4540
4569
  // Add the step to the agent run's Steps array
4541
4570
  if (this._agentRun) {
4542
4571
  this._agentRun.Steps.push(stepEntity);
@@ -4592,6 +4621,15 @@ The context is now within limits. Please retry your request with the recovered c
4592
4621
  } : undefined
4593
4622
  };
4594
4623
  }
4624
+ /**
4625
+ * Finalizes a step entity with completion status. Pairs with `createStepEntity`
4626
+ * — drivers that create custom steps should also finalize them through this
4627
+ * method so `Status`/`CompletedAt`/`Success`/`ErrorMessage`/`OutputData` are
4628
+ * populated consistently and the UPDATE is sequenced behind the INSERT via
4629
+ * `queueStepSave`.
4630
+ *
4631
+ * @protected
4632
+ */
4595
4633
  async finalizeStepEntity(stepEntity, success, errorMessage, outputData) {
4596
4634
  try {
4597
4635
  stepEntity.Status = success ? 'Completed' : 'Failed';
@@ -4609,14 +4647,73 @@ The context is now within limits. Please retry your request with the recovered c
4609
4647
  }
4610
4648
  });
4611
4649
  }
4612
- if (!await stepEntity.Save()) {
4613
- console.error('Failed to update agent run step record');
4614
- }
4650
+ this.queueStepSave(stepEntity);
4615
4651
  }
4616
4652
  catch (e) {
4617
- console.error('Failed to update agent run step record', e);
4653
+ LogError(`Failed to update agent run step record: ${e?.message ?? e}`, undefined, e);
4618
4654
  }
4619
4655
  }
4656
+ /**
4657
+ * Queues a database save for a step entity.
4658
+ *
4659
+ * - Saves on the same step record are chained (sequenced) to prevent an UPDATE
4660
+ * from racing the original INSERT.
4661
+ * - Saves on different step records run concurrently.
4662
+ * - Failures are not thrown — they're logged via `LogError` (with the entity's
4663
+ * `LatestResult.CompleteMessage` per the BaseEntity convention) so the
4664
+ * agent loop isn't blocked by observability writes — but they ARE surfaced
4665
+ * in `finalizeAgentRun` so callers see step-record drift.
4666
+ *
4667
+ * Exposed as `protected` so driver sub-classes (e.g. Skip) that author
4668
+ * custom `AIAgentRunStep` records can fire-and-forget saves through the
4669
+ * same chained/non-blocking machinery instead of awaiting `entity.Save()`
4670
+ * inline and blocking the agent loop.
4671
+ *
4672
+ * @protected
4673
+ */
4674
+ queueStepSave(stepEntity) {
4675
+ const id = stepEntity.ID;
4676
+ const previousSave = this._stepSavePromises.get(id) ?? Promise.resolve();
4677
+ const currentSave = previousSave.then(() => stepEntity.Save()).then((ok) => {
4678
+ if (!ok) {
4679
+ LogError(`Failed to save agent run step record ${id}: ${stepEntity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
4680
+ }
4681
+ return ok;
4682
+ });
4683
+ this._stepSavePromises.set(id, currentSave);
4684
+ this._pendingSaves.push(currentSave);
4685
+ }
4686
+ /**
4687
+ * Maps an array through an async worker with bounded concurrency.
4688
+ * Preserves input order in the output. Used to cap parallel sub-agent and
4689
+ * preload dispatches so a misbehaving LLM (or a runaway data source) can't
4690
+ * exhaust the model API or DB pool.
4691
+ *
4692
+ * Exposed as `protected` so driver sub-classes performing custom parallel
4693
+ * work get the same bounded-fan-out + ordered-results contract for free.
4694
+ *
4695
+ * @protected
4696
+ */
4697
+ async mapWithConcurrency(items, limit, worker) {
4698
+ if (items.length === 0)
4699
+ return [];
4700
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length));
4701
+ const results = new Array(items.length);
4702
+ let next = 0;
4703
+ const runners = [];
4704
+ for (let i = 0; i < effectiveLimit; i++) {
4705
+ runners.push((async () => {
4706
+ while (true) {
4707
+ const idx = next++;
4708
+ if (idx >= items.length)
4709
+ return;
4710
+ results[idx] = await worker(items[idx], idx);
4711
+ }
4712
+ })());
4713
+ }
4714
+ await Promise.all(runners);
4715
+ return results;
4716
+ }
4620
4717
  /**
4621
4718
  * Default parameter resolution for loop body parameters (used by Flow agents)
4622
4719
  * Resolves item.field, payload.field, item, index, or static values
@@ -4651,7 +4748,11 @@ The context is now within limits. Please retry your request with the recovered c
4651
4748
  /**
4652
4749
  * Formats a message with agent hierarchy for streaming/progress updates.
4653
4750
  *
4654
- * @private
4751
+ * Exposed as `protected` so driver sub-classes emit progress events whose
4752
+ * breadcrumbs line up with the framework's own — keeps the Explorer tree
4753
+ * view consistent across custom and built-in dispatch.
4754
+ *
4755
+ * @protected
4655
4756
  * @param {string} baseMessage - The base message to format
4656
4757
  * @returns {string} - The formatted message with hierarchy breadcrumb
4657
4758
  */
@@ -4674,10 +4775,13 @@ The context is now within limits. Please retry your request with the recovered c
4674
4775
  * - Nested sub-agent step 3: buildHierarchicalStep(3, [2, 1]) => "2.1.3"
4675
4776
  * - Deep nesting: buildHierarchicalStep(5, [1, 2, 3, 4]) => "1.2.3.4.5"
4676
4777
  *
4778
+ * Exposed as `protected` so driver sub-classes can emit step labels that
4779
+ * match the framework's `2.1.3` nesting convention.
4780
+ *
4677
4781
  * @param currentStep - Current agent's step number (1-based)
4678
4782
  * @param parentSteps - Array of parent step counts from root to immediate parent
4679
4783
  * @returns Formatted hierarchical step string, or undefined if currentStep is undefined/null
4680
- * @private
4784
+ * @protected
4681
4785
  */
4682
4786
  buildHierarchicalStep(currentStep, parentSteps) {
4683
4787
  if (currentStep == null)
@@ -4948,10 +5052,9 @@ The context is now within limits. Please retry your request with the recovered c
4948
5052
  stepEntityId: stepEntity.ID
4949
5053
  });
4950
5054
  } : undefined;
4951
- // Add callback to link PromptRun ID immediately when created
4952
5055
  promptParams.onPromptRunCreated = async (promptRunId) => {
4953
5056
  stepEntity.TargetLogID = promptRunId;
4954
- await stepEntity.Save();
5057
+ this.queueStepSave(stepEntity);
4955
5058
  };
4956
5059
  // Execute the prompt
4957
5060
  const promptResult = await this.executePrompt(promptParams);
@@ -5596,37 +5699,31 @@ The context is now within limits. Please retry your request with the recovered c
5596
5699
  * @param subAgentPayloadOverride - Optional payload override for sub-agent execution, if provided the normal payload computation is skipped
5597
5700
  */
5598
5701
  async processSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
5599
- const subAgentRequest = previousDecision.subAgent;
5702
+ // Multiple sub-agents → parallel fan-out
5703
+ if (previousDecision.subAgents && previousDecision.subAgents.length > 0) {
5704
+ return await this.executeParallelSubAgents(params, previousDecision.subAgents, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
5705
+ }
5706
+ // Single sub-agent path. Use the helper so callers that populated `subAgents`
5707
+ // with a single entry (instead of `subAgent`) still resolve correctly.
5708
+ const requested = this.getRequestedSubAgents(previousDecision);
5709
+ const subAgentRequest = (requested[0] ?? previousDecision.subAgent);
5600
5710
  const name = subAgentRequest?.name;
5601
5711
  if (!name) {
5602
5712
  return {
5603
5713
  step: 'Failed',
5604
5714
  terminate: false,
5605
5715
  errorMessage: 'Sub-agent name is required',
5606
- previousPayload: previousDecision?.newPayload,
5607
- newPayload: previousDecision?.newPayload
5716
+ previousPayload: previousDecision.newPayload,
5717
+ newPayload: previousDecision.newPayload
5608
5718
  };
5609
5719
  }
5610
- // Find the sub-agent - check both child and related agents
5611
- const childAgents = AIEngine.Instance.Agents.filter(a => UUIDsEqual(a.ParentID, params.agent.ID) &&
5612
- a.Status === 'Active');
5613
- const childAgent = childAgents.find(a => a.Name.trim().toLowerCase() === name.trim().toLowerCase());
5614
- if (childAgent) {
5615
- // This is a child agent - use direct payload coupling
5616
- return await this.executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
5720
+ const resolved = this.resolveSubAgentByName(params, name);
5721
+ if (resolved?.relationship) {
5722
+ return await this.executeRelatedSubAgentStep(params, previousDecision, resolved.subAgentEntity, resolved.relationship, parentStepId, subAgentPayloadOverride, stepCount);
5617
5723
  }
5618
- // Check for related agent
5619
- const activeRelationships = AIEngine.Instance.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, params.agent.ID) &&
5620
- ar.Status === 'Active');
5621
- for (const relationship of activeRelationships) {
5622
- const relatedAgent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, relationship.SubAgentID) &&
5623
- a.Status === 'Active');
5624
- if (relatedAgent && relatedAgent.Name.trim().toLowerCase() === name.trim().toLowerCase()) {
5625
- // This is a related agent - use message-based coupling
5626
- return await this.executeRelatedSubAgentStep(params, previousDecision, relatedAgent, relationship, parentStepId, subAgentPayloadOverride, stepCount);
5627
- }
5724
+ if (resolved) {
5725
+ return await this.executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
5628
5726
  }
5629
- // Sub-agent not found
5630
5727
  this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
5631
5728
  agent: params.agent,
5632
5729
  category: 'SubAgentExecution'
@@ -5635,8 +5732,381 @@ The context is now within limits. Please retry your request with the recovered c
5635
5732
  step: 'Retry',
5636
5733
  terminate: false,
5637
5734
  errorMessage: `Sub-agent '${name}' not found or not active`,
5638
- previousPayload: previousDecision?.newPayload,
5639
- newPayload: previousDecision?.newPayload
5735
+ previousPayload: previousDecision.newPayload,
5736
+ newPayload: previousDecision.newPayload
5737
+ };
5738
+ }
5739
+ /**
5740
+ * Finds a sub-agent by name, checking child agents (ParentID) first, then
5741
+ * related agents (AgentRelationships). Returns `undefined` when the name
5742
+ * doesn't resolve to an active agent reachable from `params.agent`.
5743
+ *
5744
+ * Used by both the single and parallel sub-agent dispatch paths so name
5745
+ * resolution is consistent and there's one place to fix lookup bugs.
5746
+ *
5747
+ * Exposed as `protected` so driver sub-classes with custom routing logic
5748
+ * still resolve names through the same case-insensitive child-then-related
5749
+ * lookup the framework uses internally.
5750
+ *
5751
+ * @protected
5752
+ */
5753
+ resolveSubAgentByName(params, name) {
5754
+ const normalized = name.trim().toLowerCase();
5755
+ const childAgent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ParentID, params.agent.ID) &&
5756
+ a.Status === 'Active' &&
5757
+ a.Name.trim().toLowerCase() === normalized);
5758
+ if (childAgent) {
5759
+ return { subAgentEntity: childAgent };
5760
+ }
5761
+ const activeRelationships = AIEngine.Instance.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, params.agent.ID) && ar.Status === 'Active');
5762
+ for (const rel of activeRelationships) {
5763
+ const relatedAgent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, rel.SubAgentID) &&
5764
+ a.Status === 'Active' &&
5765
+ a.Name.trim().toLowerCase() === normalized);
5766
+ if (relatedAgent) {
5767
+ return { subAgentEntity: relatedAgent, relationship: rel };
5768
+ }
5769
+ }
5770
+ return undefined;
5771
+ }
5772
+ /**
5773
+ * Best-effort deep clone for sub-agent payloads. We need this so two parallel
5774
+ * sub-agents can each receive their own working copy — without it, mutations
5775
+ * by one in-flight sub-agent would race the others' reads.
5776
+ *
5777
+ * Uses `structuredClone` (Node 17+) where available; falls back to a JSON
5778
+ * round-trip for environments without it. Returns the original value on
5779
+ * non-cloneable inputs.
5780
+ *
5781
+ * **JSON fallback caveats** — the round-trip is *not* shape-preserving:
5782
+ * - `Date` → ISO string
5783
+ * - `Map`, `Set`, `RegExp`, typed arrays → `{}`
5784
+ * - `undefined` values and function-valued properties → dropped
5785
+ * - `BigInt` → throws (caught by the outer try/catch, returns original)
5786
+ * - circular refs → throws (returns original)
5787
+ * If payloads ever carry those shapes, behavior diverges between the
5788
+ * structuredClone path (Node 17+) and the JSON path. Keep sub-agent
5789
+ * payloads to plain JSON-safe shapes to avoid this skew.
5790
+ *
5791
+ * Exposed as `protected` so driver sub-classes performing their own parallel
5792
+ * dispatch get the same payload-isolation guarantee.
5793
+ *
5794
+ * @protected
5795
+ */
5796
+ cloneSubAgentPayload(payload) {
5797
+ if (payload === null || payload === undefined)
5798
+ return payload;
5799
+ if (typeof payload !== 'object')
5800
+ return payload;
5801
+ try {
5802
+ if (typeof globalThis.structuredClone === 'function') {
5803
+ return globalThis.structuredClone(payload);
5804
+ }
5805
+ return JSON.parse(JSON.stringify(payload));
5806
+ }
5807
+ catch {
5808
+ return payload;
5809
+ }
5810
+ }
5811
+ /**
5812
+ * Pre-flight for one parallel sub-agent: resolve the entity, push the
5813
+ * delegation message, emit progress. Runs synchronously (no awaits) so the
5814
+ * conversation transcript order matches the `subAgents` array's source order
5815
+ * regardless of which dispatch races to the front.
5816
+ *
5817
+ * Returns `undefined` (and pushes a sentinel message) when the sub-agent
5818
+ * can't be resolved — the dispatch loop later records this as a failed
5819
+ * execution rather than throwing inside `Promise.all`.
5820
+ *
5821
+ * @private
5822
+ */
5823
+ prepareParallelSubAgentDispatch(params, request, stepCount) {
5824
+ const resolved = this.resolveSubAgentByName(params, request.name);
5825
+ if (!resolved) {
5826
+ this.logError(`Sub-agent '${request.name}' not found or not active for agent '${params.agent.Name}'`, {
5827
+ agent: params.agent,
5828
+ category: 'SubAgentExecution'
5829
+ });
5830
+ return undefined;
5831
+ }
5832
+ const { subAgentEntity, relationship } = resolved;
5833
+ params.onProgress?.({
5834
+ step: 'subagent_execution',
5835
+ message: this.formatHierarchicalMessage(`Delegating to parallel sub-agent ${request.name}`),
5836
+ metadata: {
5837
+ agentName: params.agent.Name,
5838
+ subAgentName: request.name,
5839
+ reason: request.message,
5840
+ relationshipType: relationship ? 'related' : 'child',
5841
+ stepCount: stepCount + 1,
5842
+ hierarchicalStep: this.buildHierarchicalStep(stepCount + 1, this._parentStepCounts)
5843
+ }
5844
+ });
5845
+ params.conversationMessages.push({
5846
+ role: 'assistant',
5847
+ content: `I'm delegating this task to the parallel sub-agent "${request.name}".\n\nReason: ${request.message}`
5848
+ });
5849
+ return { request: request, subAgentEntity, relationship };
5850
+ }
5851
+ /**
5852
+ * Computes the per-sub-agent input payload + context message based on whether
5853
+ * this is a child (PayloadScope / paths) or related (input/output mapping)
5854
+ * sub-agent. The parent payload is deep-cloned for child agents so two
5855
+ * parallel sub-agents can't see each other's in-flight mutations.
5856
+ *
5857
+ * @private
5858
+ */
5859
+ async buildSubAgentInputs(params, dispatch, previousDecision, subAgentPayloadOverride) {
5860
+ const { subAgentEntity, relationship, request } = dispatch;
5861
+ const parentPayload = previousDecision.newPayload;
5862
+ if (relationship) {
5863
+ // Related agent path — input/output mapping handles the structural transform.
5864
+ let initialPayload = subAgentPayloadOverride;
5865
+ if (!initialPayload && relationship.SubAgentInputMapping) {
5866
+ initialPayload = this.applySubAgentInputMapping(parentPayload, relationship.SubAgentInputMapping);
5867
+ }
5868
+ const contextPaths = this.parseSubAgentContextPaths(relationship, request.name);
5869
+ const contextMessage = this.prepareRelatedSubAgentContextMessage(parentPayload, contextPaths, params);
5870
+ return { initialPayload, contextMessage, upstreamPaths: [] };
5871
+ }
5872
+ // Child agent path — scoping + downstream/upstream paths, with deep clone
5873
+ // so siblings can't mutate each other's input.
5874
+ const { downstreamPaths, upstreamPaths } = this.computeUpstreamDownstreamPaths(params, subAgentEntity, request);
5875
+ let initialPayload = subAgentPayloadOverride;
5876
+ if (!initialPayload) {
5877
+ initialPayload = await this.computeChildSubAgentPayload(params, subAgentEntity, downstreamPaths, request, previousDecision);
5878
+ }
5879
+ return { initialPayload: this.cloneSubAgentPayload(initialPayload), contextMessage: null, upstreamPaths };
5880
+ }
5881
+ /**
5882
+ * Safely parses the `SubAgentContextPaths` JSON field on a relationship.
5883
+ * @private
5884
+ */
5885
+ parseSubAgentContextPaths(relationship, subAgentName) {
5886
+ if (!relationship.SubAgentContextPaths)
5887
+ return [];
5888
+ try {
5889
+ return JSON.parse(relationship.SubAgentContextPaths);
5890
+ }
5891
+ catch (parseError) {
5892
+ LogError(`Failed to parse SubAgentContextPaths for sub-agent ${subAgentName}: ${parseError.message}`);
5893
+ return [];
5894
+ }
5895
+ }
5896
+ /**
5897
+ * Merges one parallel sub-agent's result back into the running parent payload.
5898
+ * Returns the new payload AND the payload that should be persisted on this
5899
+ * specific sub-agent's step record (the *delta* applied for this sub-agent,
5900
+ * not the cumulative state, so audit logs can distinguish each sibling's
5901
+ * contribution).
5902
+ *
5903
+ * @private
5904
+ */
5905
+ mergeParallelSubAgentResult(params, execution, runningPayload) {
5906
+ if (!execution.result.success) {
5907
+ // Failures don't contribute to the merged payload, but we still record
5908
+ // the sub-agent's own result on its step for forensic visibility.
5909
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
5910
+ }
5911
+ if (execution.relationship) {
5912
+ if (!execution.relationship.SubAgentOutputMapping) {
5913
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
5914
+ }
5915
+ const payloadChange = this.applySubAgentOutputMapping(execution.result.payload, runningPayload, execution.relationship.SubAgentOutputMapping);
5916
+ if (!payloadChange || !payloadChange.updateElements) {
5917
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
5918
+ }
5919
+ const mergeResult = this._payloadManager.applyAgentChangeRequest(runningPayload, payloadChange, {
5920
+ validateChanges: true,
5921
+ logChanges: true,
5922
+ analyzeChanges: true,
5923
+ generateDiff: true,
5924
+ agentName: `${execution.request.name} (related agent mapping)`,
5925
+ verbose: params.verbose === true || IsVerboseLoggingEnabled()
5926
+ });
5927
+ return { mergedPayload: mergeResult.result, stepPayloadAtEnd: execution.result.payload };
5928
+ }
5929
+ // Child agent merge — reverse-scope then merge along upstream paths.
5930
+ let resultPayloadForMerge = execution.result.payload;
5931
+ if (execution.subAgentEntity.PayloadScope) {
5932
+ resultPayloadForMerge = this._payloadManager.reversePayloadScope(execution.result.payload, execution.subAgentEntity.PayloadScope);
5933
+ }
5934
+ const mergeResult = this._payloadManager.mergeUpstreamPayload(execution.request.name, runningPayload, resultPayloadForMerge, execution.upstreamPaths, params.verbose === true || IsVerboseLoggingEnabled());
5935
+ return { mergedPayload: mergeResult.result, stepPayloadAtEnd: resultPayloadForMerge };
5936
+ }
5937
+ /**
5938
+ * Builds the aggregated markdown summary of parallel sub-agent results that
5939
+ * gets appended to the parent's conversation as a `user` message — gives the
5940
+ * Loop agent a single deterministic record of what fanned out and what came
5941
+ * back, regardless of completion order.
5942
+ *
5943
+ * @private
5944
+ */
5945
+ buildParallelSubAgentSummary(executions) {
5946
+ return executions
5947
+ .map(execution => {
5948
+ const statusEmoji = execution.result.success ? '✅' : '❌';
5949
+ const baseInfo = `${statusEmoji} **Sub-Agent: ${execution.request.name}**\n` +
5950
+ `* Message: "${execution.request.message}"\n` +
5951
+ `* Status: ${execution.result.agentRun?.FinalStep || 'Failed'}`;
5952
+ if (execution.result.agentRun?.ErrorMessage) {
5953
+ return `${baseInfo}\n* Error: ${execution.result.agentRun.ErrorMessage}`;
5954
+ }
5955
+ return baseInfo;
5956
+ })
5957
+ .join('\n\n---\n\n');
5958
+ }
5959
+ /**
5960
+ * Executes multiple sub-agents in parallel (with a concurrency cap) and
5961
+ * merges their output payloads back into the parent sequentially.
5962
+ *
5963
+ * Pipeline:
5964
+ * 1. **Synchronously** prepare each dispatch (resolve entity, push delegation
5965
+ * message, emit progress) so conversation order is deterministic.
5966
+ * 2. Create step entities and run sub-agents with bounded concurrency.
5967
+ * 3. Merge each result into the parent payload sequentially in source order.
5968
+ * 4. Finalize each step entity with its own contribution recorded.
5969
+ * 5. Append an aggregated `user` summary message to the parent conversation.
5970
+ *
5971
+ * Termination semantics: matches the single sub-agent path — if any
5972
+ * dispatched child requested `terminateAfter: true`, the parent terminates
5973
+ * regardless of whether that child succeeded. The parent's reported step is
5974
+ * `Failed` when any child failed, `Success` when terminating cleanly, and
5975
+ * `Retry` otherwise.
5976
+ *
5977
+ * @private
5978
+ */
5979
+ /**
5980
+ * Worker for one parallel sub-agent dispatch: creates the step entity, builds
5981
+ * the (isolated) input payload, and invokes `ExecuteSubAgent`. Returns
5982
+ * `undefined` for an empty dispatch slot (unresolved sub-agent name) so the
5983
+ * caller can record a synthetic failure in source order.
5984
+ *
5985
+ * @private
5986
+ */
5987
+ async runSingleParallelSubAgent(params, dispatch, previousDecision, currentPayload, parentStepId, subAgentPayloadOverride, stepCount) {
5988
+ if (!dispatch)
5989
+ return undefined;
5990
+ const { request, subAgentEntity, relationship } = dispatch;
5991
+ const stepEntity = await this.createStepEntity({
5992
+ stepType: 'Sub-Agent',
5993
+ stepName: `Execute Parallel Sub-Agent: ${request.name}`,
5994
+ contextUser: params.contextUser,
5995
+ targetId: subAgentEntity.ID,
5996
+ inputData: {
5997
+ agentName: params.agent.Name,
5998
+ subAgentName: request.name,
5999
+ message: request.message,
6000
+ terminateAfter: request.terminateAfter,
6001
+ conversationMessages: params.conversationMessages,
6002
+ parentAgentHierarchy: this._agentHierarchy,
6003
+ relationshipType: relationship ? 'related' : 'child'
6004
+ },
6005
+ payloadAtStart: currentPayload,
6006
+ parentId: parentStepId
6007
+ });
6008
+ this.incrementExecutionCount(subAgentEntity.ID);
6009
+ const { initialPayload, contextMessage, upstreamPaths } = await this.buildSubAgentInputs(params, dispatch, previousDecision, subAgentPayloadOverride);
6010
+ const result = await this.ExecuteSubAgent(params, request, subAgentEntity, stepEntity, initialPayload, contextMessage, stepCount);
6011
+ return { request, result, subAgentEntity, relationship, stepEntity, upstreamPaths };
6012
+ }
6013
+ /**
6014
+ * Builds a synthetic execution record for an unresolved sub-agent so we can
6015
+ * keep source-order alignment between `subAgentRequests` and `executions`
6016
+ * without throwing inside `Promise.all`.
6017
+ *
6018
+ * @private
6019
+ */
6020
+ synthesizeUnresolvedSubAgentExecution(request, runningPayload) {
6021
+ return {
6022
+ request,
6023
+ result: {
6024
+ success: false,
6025
+ payload: runningPayload,
6026
+ agentRun: {
6027
+ ErrorMessage: `Sub-agent '${request.name}' not found or not active`,
6028
+ FinalStep: 'Failed'
6029
+ }
6030
+ },
6031
+ subAgentEntity: { ID: '', Name: request.name },
6032
+ upstreamPaths: []
6033
+ };
6034
+ }
6035
+ /**
6036
+ * Sequentially merges each sub-agent's result into the parent payload and
6037
+ * finalizes its step entity with its own contribution recorded.
6038
+ *
6039
+ * @private
6040
+ */
6041
+ async mergeParallelExecutionsIntoParent(params, subAgentRequests, executions, startingPayload) {
6042
+ let mergedPayload = startingPayload;
6043
+ let anyFailure = false;
6044
+ const allExecutions = [];
6045
+ for (let idx = 0; idx < subAgentRequests.length; idx++) {
6046
+ const execution = executions[idx];
6047
+ if (!execution) {
6048
+ anyFailure = true;
6049
+ allExecutions.push(this.synthesizeUnresolvedSubAgentExecution(subAgentRequests[idx], mergedPayload));
6050
+ continue;
6051
+ }
6052
+ if (!execution.result.success)
6053
+ anyFailure = true;
6054
+ if (execution.result.mediaOutputs?.length)
6055
+ this._mediaOutputs.push(...execution.result.mediaOutputs);
6056
+ if (execution.result.fileOutputs?.length)
6057
+ this._fileOutputs.push(...execution.result.fileOutputs);
6058
+ const { mergedPayload: newMerged, stepPayloadAtEnd } = this.mergeParallelSubAgentResult(params, execution, mergedPayload);
6059
+ mergedPayload = newMerged;
6060
+ allExecutions.push(execution);
6061
+ await this.recordParallelStepCompletion(execution, stepPayloadAtEnd);
6062
+ }
6063
+ return { mergedPayload, anyFailure, allExecutions };
6064
+ }
6065
+ /**
6066
+ * Persists per-sibling step state (`PayloadAtEnd` is THIS sub-agent's
6067
+ * contribution, not the cumulative parent state) and finalizes its step
6068
+ * entity.
6069
+ *
6070
+ * @private
6071
+ */
6072
+ async recordParallelStepCompletion(execution, stepPayloadAtEnd) {
6073
+ if (!execution.stepEntity)
6074
+ return;
6075
+ execution.stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(stepPayloadAtEnd);
6076
+ await this.finalizeStepEntity(execution.stepEntity, execution.result.success, execution.result.agentRun?.ErrorMessage, {
6077
+ subAgentResult: {
6078
+ success: execution.result.success,
6079
+ finalStep: execution.result.agentRun?.FinalStep,
6080
+ errorMessage: execution.result.agentRun?.ErrorMessage,
6081
+ stepCount: execution.result.agentRun?.Steps?.length || 0,
6082
+ },
6083
+ shouldTerminate: execution.request.terminateAfter === true,
6084
+ nextStep: execution.request.terminateAfter === true ? 'success' : 'retry'
6085
+ });
6086
+ }
6087
+ async executeParallelSubAgents(params, subAgentRequests, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
6088
+ const currentPayload = previousDecision.newPayload;
6089
+ // Synchronous pre-flight — order-stable transcript + progress events.
6090
+ const dispatches = subAgentRequests.map(req => this.prepareParallelSubAgentDispatch(params, req, stepCount));
6091
+ // Bounded parallel dispatch.
6092
+ const executions = await this.mapWithConcurrency(dispatches, PARALLEL_SUBAGENT_CONCURRENCY_LIMIT, (dispatch) => this.runSingleParallelSubAgent(params, dispatch, previousDecision, currentPayload, parentStepId, subAgentPayloadOverride, stepCount));
6093
+ // Sequential merge + per-sibling step finalization.
6094
+ const { mergedPayload, anyFailure, allExecutions } = await this.mergeParallelExecutionsIntoParent(params, subAgentRequests, executions, currentPayload);
6095
+ // Aggregated summary appended to the parent transcript.
6096
+ params.conversationMessages.push({
6097
+ role: 'user',
6098
+ content: `Parallel Sub-Agents Completed:\n\n${this.buildParallelSubAgentSummary(allExecutions)}`
6099
+ });
6100
+ // Termination semantics: matches the single sub-agent path —
6101
+ // `terminateAfter` triggers parent termination regardless of the child's
6102
+ // success/failure. The parent's step reflects whether any child failed:
6103
+ // Failed if any did, Success if terminating cleanly, otherwise Retry.
6104
+ const shouldTerminateParent = allExecutions.some(e => e.request.terminateAfter === true);
6105
+ return {
6106
+ step: anyFailure ? 'Failed' : (shouldTerminateParent ? 'Success' : 'Retry'),
6107
+ terminate: shouldTerminateParent,
6108
+ newPayload: mergedPayload,
6109
+ previousPayload: previousDecision.newPayload
5640
6110
  };
5641
6111
  }
5642
6112
  /**
@@ -6199,7 +6669,7 @@ The context is now within limits. Please retry your request with the recovered c
6199
6669
  // Update step entity with ActionExecutionLog ID if available
6200
6670
  if (actionResult.LogEntry?.ID) {
6201
6671
  stepEntity.TargetLogID = actionResult.LogEntry.ID;
6202
- await stepEntity.Save();
6672
+ this.queueStepSave(stepEntity);
6203
6673
  }
6204
6674
  // Prepare output data with action result
6205
6675
  const outputData = {
@@ -7471,6 +7941,28 @@ The context is now within limits. Please retry your request with the recovered c
7471
7941
  * @private
7472
7942
  */
7473
7943
  async finalizeAgentRun(finalStep, payload, contextUser) {
7944
+ // Await every pending step save (success OR failure) and accumulate diagnostics.
7945
+ // We use allSettled so a single failure doesn't shadow the rest, and we drain
7946
+ // both queues afterwards so an instance reused for another run doesn't leak
7947
+ // settled promises.
7948
+ const pending = this._pendingSaves;
7949
+ this._pendingSaves = [];
7950
+ this._stepSavePromises.clear();
7951
+ if (pending.length > 0) {
7952
+ const settled = await Promise.allSettled(pending);
7953
+ const rejections = settled.filter(s => s.status === 'rejected');
7954
+ const falses = settled.filter(s => s.status === 'fulfilled' && s.value === false).length;
7955
+ for (const r of rejections) {
7956
+ LogError(`Pending step save rejected: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
7957
+ }
7958
+ const totalFailures = rejections.length + falses;
7959
+ if (totalFailures > 0 && this._agentRun) {
7960
+ const note = `${totalFailures} step record save(s) failed during this run; see logs for details.`;
7961
+ this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
7962
+ ? `${this._agentRun.ErrorMessage}\n${note}`
7963
+ : note;
7964
+ }
7965
+ }
7474
7966
  // Only resolve media placeholders for ROOT agents (depth === 0)
7475
7967
  // Sub-agents keep placeholders intact so parent agents don't get huge base64 in their context
7476
7968
  // The root agent resolves all placeholders when returning the final result to the UI
@@ -7483,12 +7975,6 @@ The context is now within limits. Please retry your request with the recovered c
7483
7975
  const resolvedActionableCommands = (finalStep.actionableCommands && isRootAgent)
7484
7976
  ? this.resolveMediaPlaceholdersInPayload(finalStep.actionableCommands)
7485
7977
  : finalStep.actionableCommands;
7486
- // For root agents: process message for media placeholders
7487
- // This promotes referenced media (sets persist=true) and strips media HTML tags
7488
- // so images display via ConversationDetailAttachment instead of embedded in message
7489
- const processedMessage = (finalStep.message && isRootAgent)
7490
- ? this.processMessageMediaPlaceholders(finalStep.message)
7491
- : finalStep.message;
7492
7978
  if (this._agentRun) {
7493
7979
  this._agentRun.CompletedAt = new Date();
7494
7980
  this._agentRun.Success = finalStep.step === 'Success' || finalStep.step === 'Chat';
@@ -7513,7 +7999,7 @@ The context is now within limits. Please retry your request with the recovered c
7513
7999
  }
7514
8000
  this._agentRun.Result = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
7515
8001
  this._agentRun.FinalStep = finalStep.step;
7516
- this._agentRun.Message = processedMessage;
8002
+ this._agentRun.Message = finalStep.message;
7517
8003
  // Set the FinalPayloadObject - this will automatically stringify for the DB
7518
8004
  this._agentRun.FinalPayloadObject = resolvedPayload;
7519
8005
  this._agentRun.FinalPayload = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
@@ -7532,10 +8018,8 @@ The context is now within limits. Please retry your request with the recovered c
7532
8018
  if (finalStep.promoteMediaOutputs && finalStep.promoteMediaOutputs.length > 0) {
7533
8019
  this.promoteMediaOutputs(finalStep.promoteMediaOutputs);
7534
8020
  }
7535
- // Return unified media outputs array which includes:
7536
- // - Explicitly promoted media (persist defaults to true)
7537
- // - Intercepted binary with refIds (persist=false unless placeholder was resolved)
7538
- // Sub-agents pass their full mediaOutputs to parent for merging and placeholder resolution.
8021
+ // Return unified media outputs — all items are persisted by AgentRunner.
8022
+ // Sub-agents pass their mediaOutputs to parent for merging and placeholder resolution.
7539
8023
  return {
7540
8024
  success: finalStep.step === 'Success' || finalStep.step === 'Chat',
7541
8025
  payload: resolvedPayload,
@@ -7607,8 +8091,12 @@ The context is now within limits. Please retry your request with the recovered c
7607
8091
  /**
7608
8092
  * Increments the execution count for an item (action or sub-agent).
7609
8093
  *
8094
+ * Exposed as `protected` so driver sub-classes performing custom dispatch
8095
+ * bump the same per-item counter the framework checks against execution
8096
+ * guardrails — without this, custom dispatch silently bypasses limits.
8097
+ *
7610
8098
  * @param itemId - The item ID to increment (action ID or sub-agent ID)
7611
- * @private
8099
+ * @protected
7612
8100
  */
7613
8101
  incrementExecutionCount(itemId) {
7614
8102
  const currentCount = this._executionCounts.get(itemId) || 0;
@@ -7619,7 +8107,7 @@ The context is now within limits. Please retry your request with the recovered c
7619
8107
  *
7620
8108
  * @param itemId - The item ID to get count for
7621
8109
  * @returns The execution count (0 if never executed)
7622
- * @private
8110
+ * @protected
7623
8111
  */
7624
8112
  getExecutionCount(itemId) {
7625
8113
  return this._executionCounts.get(itemId) || 0;