@memberjunction/ai-agents 5.37.0 → 5.39.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 (99) hide show
  1. package/README.md +47 -0
  2. package/dist/AgentDataPreloader.d.ts +8 -17
  3. package/dist/AgentDataPreloader.d.ts.map +1 -1
  4. package/dist/AgentDataPreloader.js +53 -8
  5. package/dist/AgentDataPreloader.js.map +1 -1
  6. package/dist/AgentRunner.d.ts +19 -23
  7. package/dist/AgentRunner.d.ts.map +1 -1
  8. package/dist/AgentRunner.js +73 -167
  9. package/dist/AgentRunner.js.map +1 -1
  10. package/dist/ArtifactToolManager.d.ts +13 -0
  11. package/dist/ArtifactToolManager.d.ts.map +1 -1
  12. package/dist/ArtifactToolManager.js +60 -1
  13. package/dist/ArtifactToolManager.js.map +1 -1
  14. package/dist/agent-run-watchdog.d.ts +109 -0
  15. package/dist/agent-run-watchdog.d.ts.map +1 -0
  16. package/dist/agent-run-watchdog.js +278 -0
  17. package/dist/agent-run-watchdog.js.map +1 -0
  18. package/dist/agent-types/loop-agent-prompt-params.d.ts +13 -0
  19. package/dist/agent-types/loop-agent-prompt-params.d.ts.map +1 -1
  20. package/dist/agent-types/loop-agent-prompt-params.js +3 -1
  21. package/dist/agent-types/loop-agent-prompt-params.js.map +1 -1
  22. package/dist/agent-types/loop-agent-response-type.d.ts +21 -3
  23. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  24. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  25. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  26. package/dist/agent-types/loop-agent-type.js +51 -13
  27. package/dist/agent-types/loop-agent-type.js.map +1 -1
  28. package/dist/artifact-tools/DataSnapshotToolLibrary.d.ts +4 -0
  29. package/dist/artifact-tools/DataSnapshotToolLibrary.d.ts.map +1 -1
  30. package/dist/artifact-tools/DataSnapshotToolLibrary.js +507 -12
  31. package/dist/artifact-tools/DataSnapshotToolLibrary.js.map +1 -1
  32. package/dist/artifact-tools/JSONToolLibrary.d.ts.map +1 -1
  33. package/dist/artifact-tools/JSONToolLibrary.js +7 -1
  34. package/dist/artifact-tools/JSONToolLibrary.js.map +1 -1
  35. package/dist/base-agent.d.ts +331 -80
  36. package/dist/base-agent.d.ts.map +1 -1
  37. package/dist/base-agent.js +843 -155
  38. package/dist/base-agent.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/pipeline/coerce.d.ts +43 -0
  44. package/dist/pipeline/coerce.d.ts.map +1 -0
  45. package/dist/pipeline/coerce.js +92 -0
  46. package/dist/pipeline/coerce.js.map +1 -0
  47. package/dist/pipeline/index.d.ts +20 -0
  48. package/dist/pipeline/index.d.ts.map +1 -0
  49. package/dist/pipeline/index.js +20 -0
  50. package/dist/pipeline/index.js.map +1 -0
  51. package/dist/pipeline/jsonpath-eval.d.ts +29 -0
  52. package/dist/pipeline/jsonpath-eval.d.ts.map +1 -0
  53. package/dist/pipeline/jsonpath-eval.js +144 -0
  54. package/dist/pipeline/jsonpath-eval.js.map +1 -0
  55. package/dist/pipeline/operators.d.ts +15 -0
  56. package/dist/pipeline/operators.d.ts.map +1 -0
  57. package/dist/pipeline/operators.js +332 -0
  58. package/dist/pipeline/operators.js.map +1 -0
  59. package/dist/pipeline/path.d.ts +16 -0
  60. package/dist/pipeline/path.d.ts.map +1 -0
  61. package/dist/pipeline/path.js +34 -0
  62. package/dist/pipeline/path.js.map +1 -0
  63. package/dist/pipeline/pipeline-docs.d.ts +6 -0
  64. package/dist/pipeline/pipeline-docs.d.ts.map +1 -0
  65. package/dist/pipeline/pipeline-docs.js +137 -0
  66. package/dist/pipeline/pipeline-docs.js.map +1 -0
  67. package/dist/pipeline/pipeline-executor.d.ts +72 -0
  68. package/dist/pipeline/pipeline-executor.d.ts.map +1 -0
  69. package/dist/pipeline/pipeline-executor.js +326 -0
  70. package/dist/pipeline/pipeline-executor.js.map +1 -0
  71. package/dist/pipeline/pipeline-registry.d.ts +24 -0
  72. package/dist/pipeline/pipeline-registry.d.ts.map +1 -0
  73. package/dist/pipeline/pipeline-registry.js +28 -0
  74. package/dist/pipeline/pipeline-registry.js.map +1 -0
  75. package/dist/pipeline/pipeline.types.d.ts +89 -0
  76. package/dist/pipeline/pipeline.types.d.ts.map +1 -0
  77. package/dist/pipeline/pipeline.types.js +13 -0
  78. package/dist/pipeline/pipeline.types.js.map +1 -0
  79. package/dist/pipeline/predicate.d.ts +23 -0
  80. package/dist/pipeline/predicate.d.ts.map +1 -0
  81. package/dist/pipeline/predicate.js +306 -0
  82. package/dist/pipeline/predicate.js.map +1 -0
  83. package/dist/pipeline/providers/action-provider.d.ts +21 -0
  84. package/dist/pipeline/providers/action-provider.d.ts.map +1 -0
  85. package/dist/pipeline/providers/action-provider.js +24 -0
  86. package/dist/pipeline/providers/action-provider.js.map +1 -0
  87. package/dist/pipeline/providers/artifact-tool-provider.d.ts +20 -0
  88. package/dist/pipeline/providers/artifact-tool-provider.d.ts.map +1 -0
  89. package/dist/pipeline/providers/artifact-tool-provider.js +27 -0
  90. package/dist/pipeline/providers/artifact-tool-provider.js.map +1 -0
  91. package/dist/pipeline/providers/serialize.d.ts +23 -0
  92. package/dist/pipeline/providers/serialize.d.ts.map +1 -0
  93. package/dist/pipeline/providers/serialize.js +99 -0
  94. package/dist/pipeline/providers/serialize.js.map +1 -0
  95. package/dist/pipeline/template.d.ts +18 -0
  96. package/dist/pipeline/template.d.ts.map +1 -0
  97. package/dist/pipeline/template.js +47 -0
  98. package/dist/pipeline/template.js.map +1 -0
  99. package/package.json +19 -17
@@ -11,7 +11,8 @@
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 } from '@memberjunction/core';
14
+ import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase } from '@memberjunction/core';
15
+ import { AgentRunWatchdog } from './agent-run-watchdog.js';
15
16
  import { AIPromptRunner } from '@memberjunction/ai-prompts';
16
17
  import { BaseAgentType } from './agent-types/base-agent-type.js';
17
18
  import { CopyScalarsAndArrays, JSONValidator, SafeExpressionEvaluator, UUIDsEqual } from '@memberjunction/global';
@@ -26,6 +27,7 @@ import { AgentRunner } from './AgentRunner.js';
26
27
  import { PayloadManager } from './PayloadManager.js';
27
28
  import { ScratchpadManager } from './ScratchpadManager.js';
28
29
  import { ArtifactToolManager } from './ArtifactToolManager.js';
30
+ import { PipelineExecutor, PipelineToolRegistry, ActionInvocable, ArtifactToolInvocable, BuildPipelineToolDocs, formatFinalOutput, summarizePipelineStages, } from './pipeline/index.js';
29
31
  import { AgentDataPreloader } from './AgentDataPreloader.js';
30
32
  import { ClientToolRequestManager } from './ClientToolRequestManager.js';
31
33
  import { ConversationMessageResolver } from './utils/ConversationMessageResolver.js';
@@ -73,6 +75,12 @@ import _ from 'lodash';
73
75
  * const result = await agent.Execute(params);
74
76
  * ```
75
77
  */
78
+ /**
79
+ * Maximum number of sub-agents to dispatch concurrently when a Loop agent
80
+ * returns a `subAgents` array. Prevents a misbehaving LLM (or an over-eager
81
+ * one) from saturating the model API / DB pool with N concurrent runs.
82
+ */
83
+ const PARALLEL_SUBAGENT_CONCURRENCY_LIMIT = 5;
76
84
  export class BaseAgent {
77
85
  constructor() {
78
86
  /**
@@ -80,6 +88,21 @@ export class BaseAgent {
80
88
  * @private
81
89
  */
82
90
  this._promptRunner = new AIPromptRunner();
91
+ /**
92
+ * List of pending database save Promises for observability step records.
93
+ * Awaited concurrently in finalizeAgentRun() to prevent blocking.
94
+ */
95
+ this._pendingSaves = [];
96
+ /**
97
+ * Queue map to chain database saves sequentially per step entity.
98
+ * Prevents UPDATE queries running before INSERT queries on quick steps.
99
+ *
100
+ * Keyed by the step ENTITY INSTANCE, not its `ID`: a new step's `ID` is empty at create time and
101
+ * only gets populated during its INSERT `Save()`, so keying by `ID` would file the create and the
102
+ * finalize under different buckets and defeat the chain — letting the UPDATE race ahead of the
103
+ * INSERT on millisecond-fast steps (e.g. pipelines), which left them stuck at `Running`.
104
+ */
105
+ this._stepSavePromises = new Map();
83
106
  /**
84
107
  * Active per-request metadata provider, set at the start of Execute().
85
108
  * Defaults to the global Metadata.Provider; overridden when a per-request
@@ -418,7 +441,12 @@ export class BaseAgent {
418
441
  * This prevents context overflow when action results contain large base64 data (images, audio, video).
419
442
  *
420
443
  * 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).
444
+ *
445
+ * Intercepted media is stored in `_mediaOutputs` with a generated `refId` and is **always
446
+ * persisted** by `AgentRunner` — all media outputs are saved to `AIAgentRunMedia` +
447
+ * `ConversationDetailAttachment` (which auto-pairs to an artifact via the server hook).
448
+ * The `${media:<refId>}` placeholder injected into the action result keeps the LLM's
449
+ * context window small; the LLM is told the media will be displayed automatically.
422
450
  *
423
451
  * @param actionParams - The output parameters from an action result
424
452
  * @param actionEntity - Optional action entity metadata for ValueType checking
@@ -454,11 +482,10 @@ export class BaseAgent {
454
482
  if (media.data && media.data.length > BaseAgent.LARGE_BINARY_THRESHOLD) {
455
483
  // Generate unique reference ID
456
484
  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)
485
+ // Store in unified media outputs — always persisted by AgentRunner.
458
486
  this._mediaOutputs.push({
459
487
  ...media,
460
488
  refId,
461
- persist: false // Not persisted unless placeholder is resolved in final output
462
489
  });
463
490
  references.push(`\${media:${refId}}`);
464
491
  extractedCount++;
@@ -472,7 +499,7 @@ export class BaseAgent {
472
499
  Value: {
473
500
  mediaReferences: references,
474
501
  count: mediaItems.length,
475
- note: `${extractedCount} media item(s) extracted. Use placeholder syntax in your response: <img src="${references[0]}" alt="description" />`
502
+ note: `${extractedCount} media item(s) extracted and will be displayed to the user automatically.`
476
503
  }
477
504
  });
478
505
  this.logStatus(`📦 Extracted ${extractedCount} ${param.Name} item(s) to media references`, true);
@@ -485,14 +512,13 @@ export class BaseAgent {
485
512
  const base64Pattern = /^[A-Za-z0-9+/]+=*$/;
486
513
  if (isMediaOutputParam || base64Pattern.test(param.Value.substring(0, 1000))) {
487
514
  const refId = `data-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
488
- // Store in unified media outputs with persist=false
515
+ // Store in unified media outputs — always persisted by AgentRunner.
489
516
  this._mediaOutputs.push({
490
517
  modality: 'Image', // Default to image, could be enhanced with mime detection
491
518
  mimeType: 'application/octet-stream',
492
519
  data: param.Value,
493
520
  label: `Media data from ${param.Name}`,
494
521
  refId,
495
- persist: false
496
522
  });
497
523
  sanitizedParams.push({
498
524
  Name: param.Name,
@@ -509,40 +535,50 @@ export class BaseAgent {
509
535
  return sanitizedParams;
510
536
  }
511
537
  /**
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.
538
+ * Substitutes `${media:<refId>}` placeholders in a string with the actual
539
+ * data URI (`data:<mime>;base64,<bytes>`) of the matching intercepted media item.
540
+ *
541
+ * Used for payload / actionable-command resolution at the terminal step, where the
542
+ * LLM wants to embed image (or other media) data inline at a specific position in
543
+ * its structured output rather than as a trailing attachment card. The string
544
+ * variant of placeholder resolution; recursive walker lives in
545
+ * {@link resolveMediaPlaceholdersInPayload}.
546
+ *
547
+ * This function only does substitution — it has no persistence side effects.
515
548
  *
516
549
  * @param text - The string that may contain media placeholders
517
- * @returns String with placeholders resolved to actual data URIs
550
+ * @returns String with placeholders resolved to actual data URIs (or the original
551
+ * placeholder if the refId is unknown — defensive, shouldn't happen)
518
552
  * @private
519
553
  * @since 3.1.0
520
554
  */
521
555
  resolveMediaPlaceholdersInString(text) {
522
- // Check if any media has a refId (meaning we have intercepted media to resolve)
556
+ // Fast path: nothing to resolve if there are no intercepted media items.
523
557
  const hasRefIds = this._mediaOutputs.some(m => m.refId);
524
558
  if (!text || !hasRefIds) {
525
559
  return text;
526
560
  }
527
- // Match ${media:ref-id} pattern
561
+ // Match ${media:ref-id} pattern (lowercase letters, digits, dashes only —
562
+ // matches the IDs generated by interceptLargeBinaryContent).
528
563
  const placeholderRegex = /\$\{media:([a-z0-9-]+)\}/g;
529
564
  return text.replace(placeholderRegex, (match, refId) => {
530
565
  const media = this._mediaOutputs.find(m => m.refId === refId);
531
566
  if (media?.data) {
532
- // Mark for persistence since it's being used in final output
533
- media.persist = true;
534
567
  return `data:${media.mimeType};base64,${media.data}`;
535
568
  }
536
- // Keep placeholder if not found (shouldn't happen in normal flow)
569
+ // Unknown refId — leave the placeholder in place rather than emit a broken
570
+ // data URI. Defensive; this branch should not fire in normal flow.
537
571
  this.logStatus(`⚠️ Media reference '${refId}' not found in registry`, true);
538
572
  return match;
539
573
  });
540
574
  }
541
575
  /**
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
576
+ * Resolves `${media:<refId>}` placeholders anywhere inside an arbitrary payload.
577
+ * - Strings: resolves placeholders directly
578
+ * - Objects: recursively processes every string property
579
+ * - Arrays: recursively processes every element
580
+ *
581
+ * Pure resolution — no persistence side effects.
546
582
  *
547
583
  * @param payload - The payload that may contain media placeholders in string values
548
584
  * @returns Payload with all placeholders resolved to actual data URIs
@@ -550,21 +586,12 @@ export class BaseAgent {
550
586
  * @since 3.1.0
551
587
  */
552
588
  resolveMediaPlaceholdersInPayload(payload) {
553
- // Check if any media has a refId (meaning we have intercepted media to resolve)
589
+ // Fast path: nothing to resolve if no intercepted media exists.
554
590
  const hasRefIds = this._mediaOutputs.some(m => m.refId);
555
591
  if (!hasRefIds) {
556
592
  return payload;
557
593
  }
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;
594
+ return this.resolveMediaPlaceholdersRecursive(payload);
568
595
  }
569
596
  /**
570
597
  * Recursively resolves media placeholders in any value.
@@ -593,54 +620,26 @@ export class BaseAgent {
593
620
  // Return primitives (numbers, booleans) as-is
594
621
  return value;
595
622
  }
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
623
+ // ───────────────────────── Sub-class state accessors ──────────────────────────
624
+ // Read-only `protected` getters so driver sub-classes (e.g. Skip) can inspect
625
+ // the current run's state without being able to corrupt internal invariants.
626
+ // Mutations still flow through the framework's own methods (createStepEntity,
627
+ // queueStepSave, incrementExecutionCount, etc.).
628
+ // (`AgentRun` and `MediaOutputs` are already public getters above; the
629
+ // accessors below cover state that previously had no external surface.)
630
+ /** Depth of this agent in the execution hierarchy (0 = root). @protected */
631
+ get Depth() { return this._depth; }
632
+ /** Agent name hierarchy from root to current (e.g. `['Sage', 'Skip', 'Researcher']`). @protected */
633
+ get AgentHierarchy() { return this._agentHierarchy; }
634
+ /** Parent step counts used to build the `2.1.3` hierarchical step label. @protected */
635
+ get ParentStepCounts() { return this._parentStepCounts; }
636
+ /**
637
+ * Accumulated file outputs (PDF, Excel, Word, etc.) produced this run.
638
+ * Mirrors the existing `MediaOutputs` accessor pattern but is scoped to
639
+ * driver sub-classes since it's a more internal collection.
640
+ * @protected
609
641
  */
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
- }
642
+ get FileOutputs() { return this._fileOutputs; }
644
643
  /**
645
644
  * Gets the current validation retry count for the agent run.
646
645
  * This count tracks how many times the agent has retried validation
@@ -1821,6 +1820,21 @@ export class BaseAgent {
1821
1820
  else if (this._artifactToolManager.HasArtifacts()) {
1822
1821
  this.logStatus(`[ArtifactTools] Artifacts present but tools disabled by agent config (includeArtifactToolsDocs=false)`, true, params);
1823
1822
  }
1823
+ // Inject pipeline tool docs when pipelines are enabled and at least one source exists.
1824
+ // A pipeline's first step must be a source (Action or artifact tool); with none
1825
+ // available pipelines are impossible, so BuildPipelineToolDocs returns '' and the
1826
+ // template's `{{ _PIPELINE_TOOLS }}` block stays empty.
1827
+ const pipelineDocsEnabled = agentTypePromptParams?.includePipelineDocs !== false;
1828
+ if (pipelineDocsEnabled) {
1829
+ const sourceNames = [
1830
+ ...this.getEffectiveActionsForValidation(params.agent.ID).map((a) => a.Name),
1831
+ ...this._artifactToolManager.GetAvailableToolNames(),
1832
+ ];
1833
+ const pipelineDocs = BuildPipelineToolDocs(sourceNames);
1834
+ if (pipelineDocs) {
1835
+ promptParams.data['_PIPELINE_TOOLS'] = pipelineDocs;
1836
+ }
1837
+ }
1824
1838
  // Pass file artifacts as candidate native file inputs.
1825
1839
  // The AIPromptRunner will check these against the resolved driver's
1826
1840
  // FileCapabilities and attach qualifying files as native content blocks.
@@ -1977,51 +1991,85 @@ export class BaseAgent {
1977
1991
  * @param nextStep
1978
1992
  * @returns
1979
1993
  */
1994
+ /**
1995
+ * Returns the list of sub-agent requests on a next-step decision, normalizing
1996
+ * the singular (`subAgent`) and plural (`subAgents`) forms. Plural takes
1997
+ * precedence when both are present (parallel fan-out); otherwise the singular
1998
+ * form is wrapped into a single-element array. Empty if neither is set.
1999
+ *
2000
+ * Use this anywhere code needs to enumerate the sub-agents an LLM requested
2001
+ * — keeps validation and execution paths consistent and avoids the regression
2002
+ * where one path read `.subAgent?.name` and missed parallel requests.
2003
+ */
2004
+ getRequestedSubAgents(nextStep) {
2005
+ if (!nextStep)
2006
+ return [];
2007
+ if (nextStep.subAgents && nextStep.subAgents.length > 0) {
2008
+ return nextStep.subAgents;
2009
+ }
2010
+ return nextStep.subAgent ? [nextStep.subAgent] : [];
2011
+ }
1980
2012
  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
2013
  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}'`, {
2014
+ // Collect requested sub-agents. Prefer plural `subAgents` (parallel fan-out);
2015
+ // fall back to singular `subAgent` for the classic single-sub-agent next step.
2016
+ const requested = this.getRequestedSubAgents(nextStep);
2017
+ if (requested.length === 0) {
2018
+ this.logError(`Sub-agent 'undefined' not found or not active for agent '${params.agent.Name}'`, {
1987
2019
  agent: params.agent,
1988
2020
  category: 'SubAgentExecution'
1989
2021
  });
1990
- // Increment validation retry count since we're changing to Retry
1991
2022
  if (nextStep.step !== 'Retry') {
1992
2023
  this._generalValidationRetryCount++;
1993
2024
  }
1994
2025
  return {
1995
2026
  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`
2027
+ terminate: false,
2028
+ errorMessage: `Sub-agent 'undefined' not found or not active`
1998
2029
  };
1999
2030
  }
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}`, {
2031
+ // Validate each requested sub-agent: existence + MaxExecutionsPerRun
2032
+ for (const req of requested) {
2033
+ const name = req?.name;
2034
+ const subAgent = curAgentSubAgents.find(a => a.Name.trim().toLowerCase() === name?.trim().toLowerCase());
2035
+ if (!name || !subAgent) {
2036
+ this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
2005
2037
  agent: params.agent,
2006
- category: 'SubAgentExecution',
2007
- metadata: {
2008
- subAgentName: name,
2009
- executionCount,
2010
- maxExecutions: subAgent.MaxExecutionsPerRun
2011
- }
2038
+ category: 'SubAgentExecution'
2012
2039
  });
2013
- // Increment validation retry count since we're changing to Retry
2014
2040
  if (nextStep.step !== 'Retry') {
2015
2041
  this._generalValidationRetryCount++;
2016
2042
  }
2017
2043
  return {
2018
2044
  step: 'Retry',
2019
2045
  terminate: false,
2020
- errorMessage: `Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`
2046
+ errorMessage: `Sub-agent '${name}' not found or not active`
2021
2047
  };
2022
2048
  }
2049
+ if (subAgent.MaxExecutionsPerRun != null) {
2050
+ const executionCount = await this.getSubAgentExecutionCount(agentRun.ID, subAgent.ID);
2051
+ if (executionCount >= subAgent.MaxExecutionsPerRun) {
2052
+ this.logError(`Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`, {
2053
+ agent: params.agent,
2054
+ category: 'SubAgentExecution',
2055
+ metadata: {
2056
+ subAgentName: name,
2057
+ executionCount,
2058
+ maxExecutions: subAgent.MaxExecutionsPerRun
2059
+ }
2060
+ });
2061
+ if (nextStep.step !== 'Retry') {
2062
+ this._generalValidationRetryCount++;
2063
+ }
2064
+ return {
2065
+ step: 'Retry',
2066
+ terminate: false,
2067
+ errorMessage: `Sub-agent '${name}' has reached its maximum execution limit of ${subAgent.MaxExecutionsPerRun}`
2068
+ };
2069
+ }
2070
+ }
2023
2071
  }
2024
- // if we get here, the next step is valid and we can return it
2072
+ // All requested sub-agents are valid
2025
2073
  return nextStep;
2026
2074
  }
2027
2075
  /**
@@ -3279,9 +3327,10 @@ The context is now within limits. Please retry your request with the recovered c
3279
3327
  const body = toolResults.map((r, i) => {
3280
3328
  const heading = `### ${i + 1}. ${r.artifactId}.${r.tool}(${JSON.stringify(r.input)})`;
3281
3329
  if (r.result.success) {
3282
- const data = typeof r.result.data === 'string'
3330
+ const raw = typeof r.result.data === 'string'
3283
3331
  ? r.result.data
3284
3332
  : JSON.stringify(r.result.data, null, 2);
3333
+ const data = this.capStandaloneToolResultText(raw);
3285
3334
  return `${heading}\n\`\`\`json\n${data}\n\`\`\``;
3286
3335
  }
3287
3336
  return `${heading}\n**Error:** ${r.result.errorMessage}`;
@@ -3305,6 +3354,154 @@ The context is now within limits. Please retry your request with the recovered c
3305
3354
  };
3306
3355
  params.conversationMessages.push(message);
3307
3356
  }
3357
+ /**
3358
+ * Character budget (~4 chars/token) for a SINGLE standalone artifact-tool result injected into
3359
+ * the conversation. A `get_full` on a large artifact can otherwise dump the whole thing into
3360
+ * context and overflow the model's window — the exact failure pipelines exist to avoid. Override
3361
+ * in a subclass to tune. Pipelines are unaffected: their intermediate results never flow through
3362
+ * here, and the executor already caps a pipeline's final output.
3363
+ *
3364
+ * @protected
3365
+ */
3366
+ get maxStandaloneToolResultChars() {
3367
+ return 100_000; // ~25k tokens
3368
+ }
3369
+ /**
3370
+ * Bound a standalone tool result to {@link maxStandaloneToolResultChars}: return a head slice
3371
+ * plus a redirect that teaches the agent to page (`get_rows`) or reduce (`pipeline`) instead of
3372
+ * reading a whole large artifact. Mirrors how read/search tools cap output at the tool boundary.
3373
+ *
3374
+ * @protected
3375
+ */
3376
+ capStandaloneToolResultText(text) {
3377
+ const budget = this.maxStandaloneToolResultChars;
3378
+ if (text.length <= budget) {
3379
+ return text;
3380
+ }
3381
+ const omitted = text.length - budget;
3382
+ return (text.slice(0, budget) +
3383
+ `\n\n…[truncated ${omitted.toLocaleString()} chars. This artifact is too large to read whole ` +
3384
+ `(~${Math.round(text.length / 4000)}k tokens) — reading it in full overflows the context window. ` +
3385
+ `Instead: page it with get_rows(start, count), or run a pipeline that filters/aggregates it ` +
3386
+ `server-side (where / select / groupBy → only the small final result returns to you).]`);
3387
+ }
3388
+ /**
3389
+ * Builds a per-run {@link PipelineToolRegistry} that unifies the three pipeline-able
3390
+ * substrates behind one namespace: built-in transforms, the agent's effective Actions, and
3391
+ * the run's artifact tools. Transforms register first so their reserved names win; a source
3392
+ * whose name collides with a transform is skipped for pipeline use (still callable normally)
3393
+ * and logged, rather than aborting the whole pipeline.
3394
+ *
3395
+ * @protected
3396
+ */
3397
+ buildPipelineRegistry(params) {
3398
+ const registry = new PipelineToolRegistry();
3399
+ const register = (invocable) => {
3400
+ try {
3401
+ registry.Register(invocable);
3402
+ }
3403
+ catch (e) {
3404
+ this.logStatus(`[Pipeline] Skipped tool "${invocable.toolName}": ${e.message}`, true, params);
3405
+ }
3406
+ };
3407
+ // Operators (where/select/map/…) are pure code-defined verbs, not registry tools — only
3408
+ // capabilities (Actions + artifact tools) live here as pipeline sources/stages.
3409
+ // Actions — each wrapped to run via the existing single-action execution path.
3410
+ this.getEffectiveActionsForValidation(params.agent.ID).forEach((actionEntity) => register(new ActionInvocable(actionEntity.Name, (p) => this.ExecuteSingleAction(params, { name: actionEntity.Name, params: p }, actionEntity, params.contextUser))));
3411
+ // Artifact tools — one invocable per distinct tool name; `artifactId` is supplied as a
3412
+ // call-time param so the same `{ tool, params }` step shape works across all substrates.
3413
+ this._artifactToolManager.GetAvailableToolNames().forEach((toolName) => register(new ArtifactToolInvocable(toolName, async (tool, p) => {
3414
+ const stored = await this._artifactToolManager.ExecuteSingleToolCall({
3415
+ artifactId: String(p.artifactId ?? ''),
3416
+ tool,
3417
+ input: p,
3418
+ });
3419
+ return stored.result;
3420
+ })));
3421
+ return registry;
3422
+ }
3423
+ /**
3424
+ * Runs a tool pipeline as a single `Tool` step in the run tree (sibling of the prompt step that
3425
+ * requested it, matching artifact-tool steps). ALL pipeline observability lives in this step's
3426
+ * `OutputData` — the per-stage breakdown, totals, bytes saved, and the tool chain — so there are
3427
+ * no dedicated pipeline entities and no extra SQL I/O; the run tree alone carries everything a
3428
+ * debug UI needs.
3429
+ *
3430
+ * @protected
3431
+ */
3432
+ async executePipelineAsStep(pipeline, params) {
3433
+ const stepEntity = await this.createStepEntity({
3434
+ stepType: 'Tool',
3435
+ stepName: `Pipeline: ${pipeline.steps.length} step(s)`,
3436
+ contextUser: params.contextUser,
3437
+ inputData: { steps: pipeline.steps },
3438
+ });
3439
+ const registry = this.buildPipelineRegistry(params);
3440
+ // The executor converts stage-level errors into a failed RESULT (it doesn't throw for those),
3441
+ // but an unexpected throw — e.g. a tool returning a non-serializable value (BigInt/circular)
3442
+ // that trips JSON.stringify in the executor's byte-accounting — must NEVER leave this step
3443
+ // stuck on 'Running'. Catch it and materialize a failed result so finalize always runs and the
3444
+ // failure surfaces as a 'Failed' step (answering "do pipeline errors show as errors?": yes).
3445
+ let result;
3446
+ try {
3447
+ result = await new PipelineExecutor(registry).Execute(pipeline.steps);
3448
+ }
3449
+ catch (e) {
3450
+ result = {
3451
+ success: false,
3452
+ finalOutput: null,
3453
+ steps: [],
3454
+ error: `Pipeline crashed: ${e?.message ?? String(e)}`,
3455
+ contextBytesSaved: 0,
3456
+ };
3457
+ }
3458
+ // A pipeline is ONE run-step — not a parent + a child step per stage. It runs server-side in a
3459
+ // single fast pass, so the full per-stage breakdown + totals live in this step's OutputData for
3460
+ // a debug UI to visualize — no separate entities, no extra DB writes.
3461
+ await this.finalizeStepEntity(stepEntity, result.success, result.success ? undefined : result.error, {
3462
+ success: result.success,
3463
+ toolChain: summarizePipelineStages(result.steps),
3464
+ steps: result.steps,
3465
+ contextBytesSaved: result.contextBytesSaved,
3466
+ totalBytesStreamed: result.steps.reduce((sum, s) => sum + s.outputSize, 0),
3467
+ totalDurationMs: result.steps.reduce((sum, s) => sum + s.durationMs, 0),
3468
+ failedStepIndex: result.failedStepIndex,
3469
+ });
3470
+ return result;
3471
+ }
3472
+ /**
3473
+ * Pushes the pipeline's final output (or its failure message) into the conversation for the
3474
+ * LLM's next turn, mirroring the artifact-tool "inject once, then expire" pattern. Only the
3475
+ * final output is surfaced — intermediate step outputs never enter the context window.
3476
+ *
3477
+ * @protected
3478
+ */
3479
+ injectPipelineResultMessage(params, result) {
3480
+ const diagnostic = result.success && result.diagnostic
3481
+ ? `\n⚠ Empty result — ${result.diagnostic}`
3482
+ : '';
3483
+ // Identify which pipeline this result belongs to (stage chain, e.g. `get_rows → where →
3484
+ // select`). Without it, multiple pipeline results across turns are indistinguishable once
3485
+ // compacted — mirrors how artifact-tool results name their tool/artifact.
3486
+ const label = summarizePipelineStages(result.steps);
3487
+ const content = result.success
3488
+ ? `Pipeline result [${label}] (final stage value — intermediate stages stayed out of context, ~${result.contextBytesSaved} bytes saved):\n\`\`\`\n${formatFinalOutput(result.finalOutput)}\n\`\`\`${diagnostic}`
3489
+ : `Pipeline failed [${label}].\n${result.error}`;
3490
+ const message = {
3491
+ role: 'user',
3492
+ content,
3493
+ metadata: {
3494
+ turnAdded: this._promptTurnCount,
3495
+ messageType: 'tool-result',
3496
+ expirationTurns: 3,
3497
+ expirationMode: 'Compact',
3498
+ compactMode: 'First N Chars',
3499
+ compactLength: 500,
3500
+ compactPromptId: '',
3501
+ },
3502
+ };
3503
+ params.conversationMessages.push(message);
3504
+ }
3308
3505
  /**
3309
3506
  * Creates a chat message containing sub-agent execution results.
3310
3507
  *
@@ -3497,7 +3694,8 @@ The context is now within limits. Please retry your request with the recovered c
3497
3694
  { docsFlag: 'includeForEachDocs', responseTypeKey: 'forEach' },
3498
3695
  { docsFlag: 'includeWhileDocs', responseTypeKey: 'while' },
3499
3696
  { docsFlag: 'includeScratchpadDocs', responseTypeKey: 'scratchpad' },
3500
- { docsFlag: 'includeArtifactToolsDocs', responseTypeKey: 'artifactToolCalls' }
3697
+ { docsFlag: 'includeArtifactToolsDocs', responseTypeKey: 'artifactToolCalls' },
3698
+ { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' }
3501
3699
  ];
3502
3700
  for (const { docsFlag, responseTypeKey } of alignmentMappings) {
3503
3701
  // Check if the user explicitly set this response type property
@@ -3760,6 +3958,7 @@ The context is now within limits. Please retry your request with the recovered c
3760
3958
  configurationId: params.configurationId, // propagate configuration ID to sub-agent
3761
3959
  effortLevel: params.effortLevel, // propagate effort level to sub-agent
3762
3960
  apiKeys: params.apiKeys, // propagate API keys to sub-agent
3961
+ 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
3962
  data: {
3764
3963
  ...params.data,
3765
3964
  ...subAgentRequest.templateParameters,
@@ -3770,10 +3969,9 @@ The context is now within limits. Please retry your request with the recovered c
3770
3969
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
3771
3970
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
3772
3971
  SecondaryScopes: params.SecondaryScopes,
3773
- // Add callback to link AgentRun ID immediately when created
3774
3972
  onAgentRunCreated: async (agentRunId) => {
3775
3973
  stepEntity.TargetLogID = agentRunId;
3776
- await stepEntity.Save();
3974
+ this.queueStepSave(stepEntity);
3777
3975
  }
3778
3976
  });
3779
3977
  // Check if execution was successful
@@ -4442,6 +4640,13 @@ The context is now within limits. Please retry your request with the recovered c
4442
4640
  const errorMessage = JSON.stringify(CopyScalarsAndArrays(this._agentRun.LatestResult));
4443
4641
  throw new Error(`Failed to create agent run record: Details: ${errorMessage}`);
4444
4642
  }
4643
+ // Hand the now-persisted run (it has a stable ID) to the watchdog so a process restart,
4644
+ // crash, or failed terminal-state write can't leave it stuck 'Running' forever. Only the
4645
+ // server-side DB provider can heartbeat via SQL; client/non-DB providers simply opt out.
4646
+ const runProvider = params.provider || this._activeProvider;
4647
+ if (runProvider instanceof DatabaseProviderBase && params.contextUser) {
4648
+ AgentRunWatchdog.Instance.Track(this._agentRun.ID, runProvider, params.contextUser);
4649
+ }
4445
4650
  // Invoke callback if provided
4446
4651
  if (modifiedParams.onAgentRunCreated) {
4447
4652
  try {
@@ -4497,7 +4702,12 @@ The context is now within limits. Please retry your request with the recovered c
4497
4702
  /**
4498
4703
  * Creates a step entity for tracking.
4499
4704
  *
4500
- * @private
4705
+ * Exposed as `protected` so driver sub-classes (e.g. Skip) can author custom
4706
+ * `AIAgentRunStep` records with the same setup correctness — `StepNumber`,
4707
+ * hierarchy breadcrumb, UUID validation, payload serialization, and the
4708
+ * `queueStepSave` coupling that keeps INSERT-then-UPDATE ordering safe.
4709
+ *
4710
+ * @protected
4501
4711
  * @param params - Step creation parameters
4502
4712
  * @returns {Promise<MJAIAgentRunStepEntityExtended>} - The created step entity
4503
4713
  */
@@ -4534,9 +4744,7 @@ The context is now within limits. Please retry your request with the recovered c
4534
4744
  }
4535
4745
  });
4536
4746
  }
4537
- if (!await stepEntity.Save()) {
4538
- throw new Error(`Failed to create agent run step record: ${JSON.stringify(stepEntity.LatestResult)}`);
4539
- }
4747
+ this.queueStepSave(stepEntity);
4540
4748
  // Add the step to the agent run's Steps array
4541
4749
  if (this._agentRun) {
4542
4750
  this._agentRun.Steps.push(stepEntity);
@@ -4592,6 +4800,15 @@ The context is now within limits. Please retry your request with the recovered c
4592
4800
  } : undefined
4593
4801
  };
4594
4802
  }
4803
+ /**
4804
+ * Finalizes a step entity with completion status. Pairs with `createStepEntity`
4805
+ * — drivers that create custom steps should also finalize them through this
4806
+ * method so `Status`/`CompletedAt`/`Success`/`ErrorMessage`/`OutputData` are
4807
+ * populated consistently and the UPDATE is sequenced behind the INSERT via
4808
+ * `queueStepSave`.
4809
+ *
4810
+ * @protected
4811
+ */
4595
4812
  async finalizeStepEntity(stepEntity, success, errorMessage, outputData) {
4596
4813
  try {
4597
4814
  stepEntity.Status = success ? 'Completed' : 'Failed';
@@ -4609,14 +4826,74 @@ The context is now within limits. Please retry your request with the recovered c
4609
4826
  }
4610
4827
  });
4611
4828
  }
4612
- if (!await stepEntity.Save()) {
4613
- console.error('Failed to update agent run step record');
4614
- }
4829
+ this.queueStepSave(stepEntity);
4615
4830
  }
4616
4831
  catch (e) {
4617
- console.error('Failed to update agent run step record', e);
4832
+ LogError(`Failed to update agent run step record: ${e?.message ?? e}`, undefined, e);
4618
4833
  }
4619
4834
  }
4835
+ /**
4836
+ * Queues a database save for a step entity.
4837
+ *
4838
+ * - Saves on the same step record are chained (sequenced) to prevent an UPDATE
4839
+ * from racing the original INSERT.
4840
+ * - Saves on different step records run concurrently.
4841
+ * - Failures are not thrown — they're logged via `LogError` (with the entity's
4842
+ * `LatestResult.CompleteMessage` per the BaseEntity convention) so the
4843
+ * agent loop isn't blocked by observability writes — but they ARE surfaced
4844
+ * in `finalizeAgentRun` so callers see step-record drift.
4845
+ *
4846
+ * Exposed as `protected` so driver sub-classes (e.g. Skip) that author
4847
+ * custom `AIAgentRunStep` records can fire-and-forget saves through the
4848
+ * same chained/non-blocking machinery instead of awaiting `entity.Save()`
4849
+ * inline and blocking the agent loop.
4850
+ *
4851
+ * @protected
4852
+ */
4853
+ queueStepSave(stepEntity) {
4854
+ // Chain on the entity INSTANCE (stable), NOT stepEntity.ID — the ID is empty until the INSERT
4855
+ // Save() assigns it, so an ID-keyed chain breaks for fast create→finalize sequences.
4856
+ const previousSave = this._stepSavePromises.get(stepEntity) ?? Promise.resolve();
4857
+ const currentSave = previousSave.then(() => stepEntity.Save()).then((ok) => {
4858
+ if (!ok) {
4859
+ LogError(`Failed to save agent run step record ${stepEntity.ID || '(unsaved)'}: ${stepEntity.LatestResult?.CompleteMessage ?? 'unknown error'}`);
4860
+ }
4861
+ return ok;
4862
+ });
4863
+ this._stepSavePromises.set(stepEntity, currentSave);
4864
+ this._pendingSaves.push(currentSave);
4865
+ }
4866
+ /**
4867
+ * Maps an array through an async worker with bounded concurrency.
4868
+ * Preserves input order in the output. Used to cap parallel sub-agent and
4869
+ * preload dispatches so a misbehaving LLM (or a runaway data source) can't
4870
+ * exhaust the model API or DB pool.
4871
+ *
4872
+ * Exposed as `protected` so driver sub-classes performing custom parallel
4873
+ * work get the same bounded-fan-out + ordered-results contract for free.
4874
+ *
4875
+ * @protected
4876
+ */
4877
+ async mapWithConcurrency(items, limit, worker) {
4878
+ if (items.length === 0)
4879
+ return [];
4880
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length));
4881
+ const results = new Array(items.length);
4882
+ let next = 0;
4883
+ const runners = [];
4884
+ for (let i = 0; i < effectiveLimit; i++) {
4885
+ runners.push((async () => {
4886
+ while (true) {
4887
+ const idx = next++;
4888
+ if (idx >= items.length)
4889
+ return;
4890
+ results[idx] = await worker(items[idx], idx);
4891
+ }
4892
+ })());
4893
+ }
4894
+ await Promise.all(runners);
4895
+ return results;
4896
+ }
4620
4897
  /**
4621
4898
  * Default parameter resolution for loop body parameters (used by Flow agents)
4622
4899
  * Resolves item.field, payload.field, item, index, or static values
@@ -4651,7 +4928,11 @@ The context is now within limits. Please retry your request with the recovered c
4651
4928
  /**
4652
4929
  * Formats a message with agent hierarchy for streaming/progress updates.
4653
4930
  *
4654
- * @private
4931
+ * Exposed as `protected` so driver sub-classes emit progress events whose
4932
+ * breadcrumbs line up with the framework's own — keeps the Explorer tree
4933
+ * view consistent across custom and built-in dispatch.
4934
+ *
4935
+ * @protected
4655
4936
  * @param {string} baseMessage - The base message to format
4656
4937
  * @returns {string} - The formatted message with hierarchy breadcrumb
4657
4938
  */
@@ -4674,10 +4955,13 @@ The context is now within limits. Please retry your request with the recovered c
4674
4955
  * - Nested sub-agent step 3: buildHierarchicalStep(3, [2, 1]) => "2.1.3"
4675
4956
  * - Deep nesting: buildHierarchicalStep(5, [1, 2, 3, 4]) => "1.2.3.4.5"
4676
4957
  *
4958
+ * Exposed as `protected` so driver sub-classes can emit step labels that
4959
+ * match the framework's `2.1.3` nesting convention.
4960
+ *
4677
4961
  * @param currentStep - Current agent's step number (1-based)
4678
4962
  * @param parentSteps - Array of parent step counts from root to immediate parent
4679
4963
  * @returns Formatted hierarchical step string, or undefined if currentStep is undefined/null
4680
- * @private
4964
+ * @protected
4681
4965
  */
4682
4966
  buildHierarchicalStep(currentStep, parentSteps) {
4683
4967
  if (currentStep == null)
@@ -4948,10 +5232,9 @@ The context is now within limits. Please retry your request with the recovered c
4948
5232
  stepEntityId: stepEntity.ID
4949
5233
  });
4950
5234
  } : undefined;
4951
- // Add callback to link PromptRun ID immediately when created
4952
5235
  promptParams.onPromptRunCreated = async (promptRunId) => {
4953
5236
  stepEntity.TargetLogID = promptRunId;
4954
- await stepEntity.Save();
5237
+ this.queueStepSave(stepEntity);
4955
5238
  };
4956
5239
  // Execute the prompt
4957
5240
  const promptResult = await this.executePrompt(promptParams);
@@ -5080,6 +5363,14 @@ The context is now within limits. Please retry your request with the recovered c
5080
5363
  else if (this._artifactToolManager.HasArtifacts()) {
5081
5364
  this.logStatus(`[ArtifactTools] LLM did not use artifact tools this turn (artifacts available but not accessed)`, true, params);
5082
5365
  }
5366
+ // Execute a tool pipeline if provided (zero turn cost — processed inline). Each step's
5367
+ // output is threaded into the next server-side; only the final step's output returns to
5368
+ // the LLM, so intermediate payloads never enter the context window.
5369
+ if (initialNextStep.pipeline?.steps?.length) {
5370
+ this.logStatus(`[Pipeline] LLM requested a ${initialNextStep.pipeline.steps.length}-stage pipeline: ${initialNextStep.pipeline.steps.map(s => s.tool ?? Object.keys(s)[0]).join(' | ')}`, true, params);
5371
+ const pipelineResult = await this.executePipelineAsStep(initialNextStep.pipeline, params);
5372
+ this.injectPipelineResultMessage(params, pipelineResult);
5373
+ }
5083
5374
  // now that we have processed the payload, we can process the next step which does validation and changes the next step if
5084
5375
  // validation fails
5085
5376
  const updatedNextStep = await this.processNextStep(initialNextStep, params, config.agentType, promptResult, finalPayload, stepEntity);
@@ -5596,37 +5887,31 @@ The context is now within limits. Please retry your request with the recovered c
5596
5887
  * @param subAgentPayloadOverride - Optional payload override for sub-agent execution, if provided the normal payload computation is skipped
5597
5888
  */
5598
5889
  async processSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
5599
- const subAgentRequest = previousDecision.subAgent;
5890
+ // Multiple sub-agents → parallel fan-out
5891
+ if (previousDecision.subAgents && previousDecision.subAgents.length > 0) {
5892
+ return await this.executeParallelSubAgents(params, previousDecision.subAgents, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
5893
+ }
5894
+ // Single sub-agent path. Use the helper so callers that populated `subAgents`
5895
+ // with a single entry (instead of `subAgent`) still resolve correctly.
5896
+ const requested = this.getRequestedSubAgents(previousDecision);
5897
+ const subAgentRequest = (requested[0] ?? previousDecision.subAgent);
5600
5898
  const name = subAgentRequest?.name;
5601
5899
  if (!name) {
5602
5900
  return {
5603
5901
  step: 'Failed',
5604
5902
  terminate: false,
5605
5903
  errorMessage: 'Sub-agent name is required',
5606
- previousPayload: previousDecision?.newPayload,
5607
- newPayload: previousDecision?.newPayload
5904
+ previousPayload: previousDecision.newPayload,
5905
+ newPayload: previousDecision.newPayload
5608
5906
  };
5609
5907
  }
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);
5908
+ const resolved = this.resolveSubAgentByName(params, name);
5909
+ if (resolved?.relationship) {
5910
+ return await this.executeRelatedSubAgentStep(params, previousDecision, resolved.subAgentEntity, resolved.relationship, parentStepId, subAgentPayloadOverride, stepCount);
5617
5911
  }
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
- }
5912
+ if (resolved) {
5913
+ return await this.executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
5628
5914
  }
5629
- // Sub-agent not found
5630
5915
  this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
5631
5916
  agent: params.agent,
5632
5917
  category: 'SubAgentExecution'
@@ -5635,8 +5920,381 @@ The context is now within limits. Please retry your request with the recovered c
5635
5920
  step: 'Retry',
5636
5921
  terminate: false,
5637
5922
  errorMessage: `Sub-agent '${name}' not found or not active`,
5638
- previousPayload: previousDecision?.newPayload,
5639
- newPayload: previousDecision?.newPayload
5923
+ previousPayload: previousDecision.newPayload,
5924
+ newPayload: previousDecision.newPayload
5925
+ };
5926
+ }
5927
+ /**
5928
+ * Finds a sub-agent by name, checking child agents (ParentID) first, then
5929
+ * related agents (AgentRelationships). Returns `undefined` when the name
5930
+ * doesn't resolve to an active agent reachable from `params.agent`.
5931
+ *
5932
+ * Used by both the single and parallel sub-agent dispatch paths so name
5933
+ * resolution is consistent and there's one place to fix lookup bugs.
5934
+ *
5935
+ * Exposed as `protected` so driver sub-classes with custom routing logic
5936
+ * still resolve names through the same case-insensitive child-then-related
5937
+ * lookup the framework uses internally.
5938
+ *
5939
+ * @protected
5940
+ */
5941
+ resolveSubAgentByName(params, name) {
5942
+ const normalized = name.trim().toLowerCase();
5943
+ const childAgent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ParentID, params.agent.ID) &&
5944
+ a.Status === 'Active' &&
5945
+ a.Name.trim().toLowerCase() === normalized);
5946
+ if (childAgent) {
5947
+ return { subAgentEntity: childAgent };
5948
+ }
5949
+ const activeRelationships = AIEngine.Instance.AgentRelationships.filter(ar => UUIDsEqual(ar.AgentID, params.agent.ID) && ar.Status === 'Active');
5950
+ for (const rel of activeRelationships) {
5951
+ const relatedAgent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, rel.SubAgentID) &&
5952
+ a.Status === 'Active' &&
5953
+ a.Name.trim().toLowerCase() === normalized);
5954
+ if (relatedAgent) {
5955
+ return { subAgentEntity: relatedAgent, relationship: rel };
5956
+ }
5957
+ }
5958
+ return undefined;
5959
+ }
5960
+ /**
5961
+ * Best-effort deep clone for sub-agent payloads. We need this so two parallel
5962
+ * sub-agents can each receive their own working copy — without it, mutations
5963
+ * by one in-flight sub-agent would race the others' reads.
5964
+ *
5965
+ * Uses `structuredClone` (Node 17+) where available; falls back to a JSON
5966
+ * round-trip for environments without it. Returns the original value on
5967
+ * non-cloneable inputs.
5968
+ *
5969
+ * **JSON fallback caveats** — the round-trip is *not* shape-preserving:
5970
+ * - `Date` → ISO string
5971
+ * - `Map`, `Set`, `RegExp`, typed arrays → `{}`
5972
+ * - `undefined` values and function-valued properties → dropped
5973
+ * - `BigInt` → throws (caught by the outer try/catch, returns original)
5974
+ * - circular refs → throws (returns original)
5975
+ * If payloads ever carry those shapes, behavior diverges between the
5976
+ * structuredClone path (Node 17+) and the JSON path. Keep sub-agent
5977
+ * payloads to plain JSON-safe shapes to avoid this skew.
5978
+ *
5979
+ * Exposed as `protected` so driver sub-classes performing their own parallel
5980
+ * dispatch get the same payload-isolation guarantee.
5981
+ *
5982
+ * @protected
5983
+ */
5984
+ cloneSubAgentPayload(payload) {
5985
+ if (payload === null || payload === undefined)
5986
+ return payload;
5987
+ if (typeof payload !== 'object')
5988
+ return payload;
5989
+ try {
5990
+ if (typeof globalThis.structuredClone === 'function') {
5991
+ return globalThis.structuredClone(payload);
5992
+ }
5993
+ return JSON.parse(JSON.stringify(payload));
5994
+ }
5995
+ catch {
5996
+ return payload;
5997
+ }
5998
+ }
5999
+ /**
6000
+ * Pre-flight for one parallel sub-agent: resolve the entity, push the
6001
+ * delegation message, emit progress. Runs synchronously (no awaits) so the
6002
+ * conversation transcript order matches the `subAgents` array's source order
6003
+ * regardless of which dispatch races to the front.
6004
+ *
6005
+ * Returns `undefined` (and pushes a sentinel message) when the sub-agent
6006
+ * can't be resolved — the dispatch loop later records this as a failed
6007
+ * execution rather than throwing inside `Promise.all`.
6008
+ *
6009
+ * @private
6010
+ */
6011
+ prepareParallelSubAgentDispatch(params, request, stepCount) {
6012
+ const resolved = this.resolveSubAgentByName(params, request.name);
6013
+ if (!resolved) {
6014
+ this.logError(`Sub-agent '${request.name}' not found or not active for agent '${params.agent.Name}'`, {
6015
+ agent: params.agent,
6016
+ category: 'SubAgentExecution'
6017
+ });
6018
+ return undefined;
6019
+ }
6020
+ const { subAgentEntity, relationship } = resolved;
6021
+ params.onProgress?.({
6022
+ step: 'subagent_execution',
6023
+ message: this.formatHierarchicalMessage(`Delegating to parallel sub-agent ${request.name}`),
6024
+ metadata: {
6025
+ agentName: params.agent.Name,
6026
+ subAgentName: request.name,
6027
+ reason: request.message,
6028
+ relationshipType: relationship ? 'related' : 'child',
6029
+ stepCount: stepCount + 1,
6030
+ hierarchicalStep: this.buildHierarchicalStep(stepCount + 1, this._parentStepCounts)
6031
+ }
6032
+ });
6033
+ params.conversationMessages.push({
6034
+ role: 'assistant',
6035
+ content: `I'm delegating this task to the parallel sub-agent "${request.name}".\n\nReason: ${request.message}`
6036
+ });
6037
+ return { request: request, subAgentEntity, relationship };
6038
+ }
6039
+ /**
6040
+ * Computes the per-sub-agent input payload + context message based on whether
6041
+ * this is a child (PayloadScope / paths) or related (input/output mapping)
6042
+ * sub-agent. The parent payload is deep-cloned for child agents so two
6043
+ * parallel sub-agents can't see each other's in-flight mutations.
6044
+ *
6045
+ * @private
6046
+ */
6047
+ async buildSubAgentInputs(params, dispatch, previousDecision, subAgentPayloadOverride) {
6048
+ const { subAgentEntity, relationship, request } = dispatch;
6049
+ const parentPayload = previousDecision.newPayload;
6050
+ if (relationship) {
6051
+ // Related agent path — input/output mapping handles the structural transform.
6052
+ let initialPayload = subAgentPayloadOverride;
6053
+ if (!initialPayload && relationship.SubAgentInputMapping) {
6054
+ initialPayload = this.applySubAgentInputMapping(parentPayload, relationship.SubAgentInputMapping);
6055
+ }
6056
+ const contextPaths = this.parseSubAgentContextPaths(relationship, request.name);
6057
+ const contextMessage = this.prepareRelatedSubAgentContextMessage(parentPayload, contextPaths, params);
6058
+ return { initialPayload, contextMessage, upstreamPaths: [] };
6059
+ }
6060
+ // Child agent path — scoping + downstream/upstream paths, with deep clone
6061
+ // so siblings can't mutate each other's input.
6062
+ const { downstreamPaths, upstreamPaths } = this.computeUpstreamDownstreamPaths(params, subAgentEntity, request);
6063
+ let initialPayload = subAgentPayloadOverride;
6064
+ if (!initialPayload) {
6065
+ initialPayload = await this.computeChildSubAgentPayload(params, subAgentEntity, downstreamPaths, request, previousDecision);
6066
+ }
6067
+ return { initialPayload: this.cloneSubAgentPayload(initialPayload), contextMessage: null, upstreamPaths };
6068
+ }
6069
+ /**
6070
+ * Safely parses the `SubAgentContextPaths` JSON field on a relationship.
6071
+ * @private
6072
+ */
6073
+ parseSubAgentContextPaths(relationship, subAgentName) {
6074
+ if (!relationship.SubAgentContextPaths)
6075
+ return [];
6076
+ try {
6077
+ return JSON.parse(relationship.SubAgentContextPaths);
6078
+ }
6079
+ catch (parseError) {
6080
+ LogError(`Failed to parse SubAgentContextPaths for sub-agent ${subAgentName}: ${parseError.message}`);
6081
+ return [];
6082
+ }
6083
+ }
6084
+ /**
6085
+ * Merges one parallel sub-agent's result back into the running parent payload.
6086
+ * Returns the new payload AND the payload that should be persisted on this
6087
+ * specific sub-agent's step record (the *delta* applied for this sub-agent,
6088
+ * not the cumulative state, so audit logs can distinguish each sibling's
6089
+ * contribution).
6090
+ *
6091
+ * @private
6092
+ */
6093
+ mergeParallelSubAgentResult(params, execution, runningPayload) {
6094
+ if (!execution.result.success) {
6095
+ // Failures don't contribute to the merged payload, but we still record
6096
+ // the sub-agent's own result on its step for forensic visibility.
6097
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
6098
+ }
6099
+ if (execution.relationship) {
6100
+ if (!execution.relationship.SubAgentOutputMapping) {
6101
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
6102
+ }
6103
+ const payloadChange = this.applySubAgentOutputMapping(execution.result.payload, runningPayload, execution.relationship.SubAgentOutputMapping);
6104
+ if (!payloadChange || !payloadChange.updateElements) {
6105
+ return { mergedPayload: runningPayload, stepPayloadAtEnd: execution.result.payload };
6106
+ }
6107
+ const mergeResult = this._payloadManager.applyAgentChangeRequest(runningPayload, payloadChange, {
6108
+ validateChanges: true,
6109
+ logChanges: true,
6110
+ analyzeChanges: true,
6111
+ generateDiff: true,
6112
+ agentName: `${execution.request.name} (related agent mapping)`,
6113
+ verbose: params.verbose === true || IsVerboseLoggingEnabled()
6114
+ });
6115
+ return { mergedPayload: mergeResult.result, stepPayloadAtEnd: execution.result.payload };
6116
+ }
6117
+ // Child agent merge — reverse-scope then merge along upstream paths.
6118
+ let resultPayloadForMerge = execution.result.payload;
6119
+ if (execution.subAgentEntity.PayloadScope) {
6120
+ resultPayloadForMerge = this._payloadManager.reversePayloadScope(execution.result.payload, execution.subAgentEntity.PayloadScope);
6121
+ }
6122
+ const mergeResult = this._payloadManager.mergeUpstreamPayload(execution.request.name, runningPayload, resultPayloadForMerge, execution.upstreamPaths, params.verbose === true || IsVerboseLoggingEnabled());
6123
+ return { mergedPayload: mergeResult.result, stepPayloadAtEnd: resultPayloadForMerge };
6124
+ }
6125
+ /**
6126
+ * Builds the aggregated markdown summary of parallel sub-agent results that
6127
+ * gets appended to the parent's conversation as a `user` message — gives the
6128
+ * Loop agent a single deterministic record of what fanned out and what came
6129
+ * back, regardless of completion order.
6130
+ *
6131
+ * @private
6132
+ */
6133
+ buildParallelSubAgentSummary(executions) {
6134
+ return executions
6135
+ .map(execution => {
6136
+ const statusEmoji = execution.result.success ? '✅' : '❌';
6137
+ const baseInfo = `${statusEmoji} **Sub-Agent: ${execution.request.name}**\n` +
6138
+ `* Message: "${execution.request.message}"\n` +
6139
+ `* Status: ${execution.result.agentRun?.FinalStep || 'Failed'}`;
6140
+ if (execution.result.agentRun?.ErrorMessage) {
6141
+ return `${baseInfo}\n* Error: ${execution.result.agentRun.ErrorMessage}`;
6142
+ }
6143
+ return baseInfo;
6144
+ })
6145
+ .join('\n\n---\n\n');
6146
+ }
6147
+ /**
6148
+ * Executes multiple sub-agents in parallel (with a concurrency cap) and
6149
+ * merges their output payloads back into the parent sequentially.
6150
+ *
6151
+ * Pipeline:
6152
+ * 1. **Synchronously** prepare each dispatch (resolve entity, push delegation
6153
+ * message, emit progress) so conversation order is deterministic.
6154
+ * 2. Create step entities and run sub-agents with bounded concurrency.
6155
+ * 3. Merge each result into the parent payload sequentially in source order.
6156
+ * 4. Finalize each step entity with its own contribution recorded.
6157
+ * 5. Append an aggregated `user` summary message to the parent conversation.
6158
+ *
6159
+ * Termination semantics: matches the single sub-agent path — if any
6160
+ * dispatched child requested `terminateAfter: true`, the parent terminates
6161
+ * regardless of whether that child succeeded. The parent's reported step is
6162
+ * `Failed` when any child failed, `Success` when terminating cleanly, and
6163
+ * `Retry` otherwise.
6164
+ *
6165
+ * @private
6166
+ */
6167
+ /**
6168
+ * Worker for one parallel sub-agent dispatch: creates the step entity, builds
6169
+ * the (isolated) input payload, and invokes `ExecuteSubAgent`. Returns
6170
+ * `undefined` for an empty dispatch slot (unresolved sub-agent name) so the
6171
+ * caller can record a synthetic failure in source order.
6172
+ *
6173
+ * @private
6174
+ */
6175
+ async runSingleParallelSubAgent(params, dispatch, previousDecision, currentPayload, parentStepId, subAgentPayloadOverride, stepCount) {
6176
+ if (!dispatch)
6177
+ return undefined;
6178
+ const { request, subAgentEntity, relationship } = dispatch;
6179
+ const stepEntity = await this.createStepEntity({
6180
+ stepType: 'Sub-Agent',
6181
+ stepName: `Execute Parallel Sub-Agent: ${request.name}`,
6182
+ contextUser: params.contextUser,
6183
+ targetId: subAgentEntity.ID,
6184
+ inputData: {
6185
+ agentName: params.agent.Name,
6186
+ subAgentName: request.name,
6187
+ message: request.message,
6188
+ terminateAfter: request.terminateAfter,
6189
+ conversationMessages: params.conversationMessages,
6190
+ parentAgentHierarchy: this._agentHierarchy,
6191
+ relationshipType: relationship ? 'related' : 'child'
6192
+ },
6193
+ payloadAtStart: currentPayload,
6194
+ parentId: parentStepId
6195
+ });
6196
+ this.incrementExecutionCount(subAgentEntity.ID);
6197
+ const { initialPayload, contextMessage, upstreamPaths } = await this.buildSubAgentInputs(params, dispatch, previousDecision, subAgentPayloadOverride);
6198
+ const result = await this.ExecuteSubAgent(params, request, subAgentEntity, stepEntity, initialPayload, contextMessage, stepCount);
6199
+ return { request, result, subAgentEntity, relationship, stepEntity, upstreamPaths };
6200
+ }
6201
+ /**
6202
+ * Builds a synthetic execution record for an unresolved sub-agent so we can
6203
+ * keep source-order alignment between `subAgentRequests` and `executions`
6204
+ * without throwing inside `Promise.all`.
6205
+ *
6206
+ * @private
6207
+ */
6208
+ synthesizeUnresolvedSubAgentExecution(request, runningPayload) {
6209
+ return {
6210
+ request,
6211
+ result: {
6212
+ success: false,
6213
+ payload: runningPayload,
6214
+ agentRun: {
6215
+ ErrorMessage: `Sub-agent '${request.name}' not found or not active`,
6216
+ FinalStep: 'Failed'
6217
+ }
6218
+ },
6219
+ subAgentEntity: { ID: '', Name: request.name },
6220
+ upstreamPaths: []
6221
+ };
6222
+ }
6223
+ /**
6224
+ * Sequentially merges each sub-agent's result into the parent payload and
6225
+ * finalizes its step entity with its own contribution recorded.
6226
+ *
6227
+ * @private
6228
+ */
6229
+ async mergeParallelExecutionsIntoParent(params, subAgentRequests, executions, startingPayload) {
6230
+ let mergedPayload = startingPayload;
6231
+ let anyFailure = false;
6232
+ const allExecutions = [];
6233
+ for (let idx = 0; idx < subAgentRequests.length; idx++) {
6234
+ const execution = executions[idx];
6235
+ if (!execution) {
6236
+ anyFailure = true;
6237
+ allExecutions.push(this.synthesizeUnresolvedSubAgentExecution(subAgentRequests[idx], mergedPayload));
6238
+ continue;
6239
+ }
6240
+ if (!execution.result.success)
6241
+ anyFailure = true;
6242
+ if (execution.result.mediaOutputs?.length)
6243
+ this._mediaOutputs.push(...execution.result.mediaOutputs);
6244
+ if (execution.result.fileOutputs?.length)
6245
+ this._fileOutputs.push(...execution.result.fileOutputs);
6246
+ const { mergedPayload: newMerged, stepPayloadAtEnd } = this.mergeParallelSubAgentResult(params, execution, mergedPayload);
6247
+ mergedPayload = newMerged;
6248
+ allExecutions.push(execution);
6249
+ await this.recordParallelStepCompletion(execution, stepPayloadAtEnd);
6250
+ }
6251
+ return { mergedPayload, anyFailure, allExecutions };
6252
+ }
6253
+ /**
6254
+ * Persists per-sibling step state (`PayloadAtEnd` is THIS sub-agent's
6255
+ * contribution, not the cumulative parent state) and finalizes its step
6256
+ * entity.
6257
+ *
6258
+ * @private
6259
+ */
6260
+ async recordParallelStepCompletion(execution, stepPayloadAtEnd) {
6261
+ if (!execution.stepEntity)
6262
+ return;
6263
+ execution.stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(stepPayloadAtEnd);
6264
+ await this.finalizeStepEntity(execution.stepEntity, execution.result.success, execution.result.agentRun?.ErrorMessage, {
6265
+ subAgentResult: {
6266
+ success: execution.result.success,
6267
+ finalStep: execution.result.agentRun?.FinalStep,
6268
+ errorMessage: execution.result.agentRun?.ErrorMessage,
6269
+ stepCount: execution.result.agentRun?.Steps?.length || 0,
6270
+ },
6271
+ shouldTerminate: execution.request.terminateAfter === true,
6272
+ nextStep: execution.request.terminateAfter === true ? 'success' : 'retry'
6273
+ });
6274
+ }
6275
+ async executeParallelSubAgents(params, subAgentRequests, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
6276
+ const currentPayload = previousDecision.newPayload;
6277
+ // Synchronous pre-flight — order-stable transcript + progress events.
6278
+ const dispatches = subAgentRequests.map(req => this.prepareParallelSubAgentDispatch(params, req, stepCount));
6279
+ // Bounded parallel dispatch.
6280
+ const executions = await this.mapWithConcurrency(dispatches, PARALLEL_SUBAGENT_CONCURRENCY_LIMIT, (dispatch) => this.runSingleParallelSubAgent(params, dispatch, previousDecision, currentPayload, parentStepId, subAgentPayloadOverride, stepCount));
6281
+ // Sequential merge + per-sibling step finalization.
6282
+ const { mergedPayload, anyFailure, allExecutions } = await this.mergeParallelExecutionsIntoParent(params, subAgentRequests, executions, currentPayload);
6283
+ // Aggregated summary appended to the parent transcript.
6284
+ params.conversationMessages.push({
6285
+ role: 'user',
6286
+ content: `Parallel Sub-Agents Completed:\n\n${this.buildParallelSubAgentSummary(allExecutions)}`
6287
+ });
6288
+ // Termination semantics: matches the single sub-agent path —
6289
+ // `terminateAfter` triggers parent termination regardless of the child's
6290
+ // success/failure. The parent's step reflects whether any child failed:
6291
+ // Failed if any did, Success if terminating cleanly, otherwise Retry.
6292
+ const shouldTerminateParent = allExecutions.some(e => e.request.terminateAfter === true);
6293
+ return {
6294
+ step: anyFailure ? 'Failed' : (shouldTerminateParent ? 'Success' : 'Retry'),
6295
+ terminate: shouldTerminateParent,
6296
+ newPayload: mergedPayload,
6297
+ previousPayload: previousDecision.newPayload
5640
6298
  };
5641
6299
  }
5642
6300
  /**
@@ -6199,7 +6857,7 @@ The context is now within limits. Please retry your request with the recovered c
6199
6857
  // Update step entity with ActionExecutionLog ID if available
6200
6858
  if (actionResult.LogEntry?.ID) {
6201
6859
  stepEntity.TargetLogID = actionResult.LogEntry.ID;
6202
- await stepEntity.Save();
6860
+ this.queueStepSave(stepEntity);
6203
6861
  }
6204
6862
  // Prepare output data with action result
6205
6863
  const outputData = {
@@ -7431,6 +8089,8 @@ The context is now within limits. Please retry your request with the recovered c
7431
8089
  this._agentRun.TotalTokensUsed = tokenStats.totalTokens;
7432
8090
  this._agentRun.TotalPromptTokensUsed = tokenStats.promptTokens;
7433
8091
  this._agentRun.TotalCompletionTokensUsed = tokenStats.completionTokens;
8092
+ this._agentRun.TotalCacheReadTokensUsed = tokenStats.cacheReadTokens;
8093
+ this._agentRun.TotalCacheWriteTokensUsed = tokenStats.cacheWriteTokens;
7434
8094
  this._agentRun.TotalCost = tokenStats.totalCost;
7435
8095
  await this._agentRun.Save();
7436
8096
  }
@@ -7457,6 +8117,8 @@ The context is now within limits. Please retry your request with the recovered c
7457
8117
  this._agentRun.TotalTokensUsed = tokenStats.totalTokens;
7458
8118
  this._agentRun.TotalPromptTokensUsed = tokenStats.promptTokens;
7459
8119
  this._agentRun.TotalCompletionTokensUsed = tokenStats.completionTokens;
8120
+ this._agentRun.TotalCacheReadTokensUsed = tokenStats.cacheReadTokens;
8121
+ this._agentRun.TotalCacheWriteTokensUsed = tokenStats.cacheWriteTokens;
7460
8122
  this._agentRun.TotalCost = tokenStats.totalCost;
7461
8123
  await this._agentRun.Save();
7462
8124
  }
@@ -7471,6 +8133,28 @@ The context is now within limits. Please retry your request with the recovered c
7471
8133
  * @private
7472
8134
  */
7473
8135
  async finalizeAgentRun(finalStep, payload, contextUser) {
8136
+ // Await every pending step save (success OR failure) and accumulate diagnostics.
8137
+ // We use allSettled so a single failure doesn't shadow the rest, and we drain
8138
+ // both queues afterwards so an instance reused for another run doesn't leak
8139
+ // settled promises.
8140
+ const pending = this._pendingSaves;
8141
+ this._pendingSaves = [];
8142
+ this._stepSavePromises.clear();
8143
+ if (pending.length > 0) {
8144
+ const settled = await Promise.allSettled(pending);
8145
+ const rejections = settled.filter(s => s.status === 'rejected');
8146
+ const falses = settled.filter(s => s.status === 'fulfilled' && s.value === false).length;
8147
+ for (const r of rejections) {
8148
+ LogError(`Pending step save rejected: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
8149
+ }
8150
+ const totalFailures = rejections.length + falses;
8151
+ if (totalFailures > 0 && this._agentRun) {
8152
+ const note = `${totalFailures} step record save(s) failed during this run; see logs for details.`;
8153
+ this._agentRun.ErrorMessage = this._agentRun.ErrorMessage
8154
+ ? `${this._agentRun.ErrorMessage}\n${note}`
8155
+ : note;
8156
+ }
8157
+ }
7474
8158
  // Only resolve media placeholders for ROOT agents (depth === 0)
7475
8159
  // Sub-agents keep placeholders intact so parent agents don't get huge base64 in their context
7476
8160
  // The root agent resolves all placeholders when returning the final result to the UI
@@ -7483,12 +8167,6 @@ The context is now within limits. Please retry your request with the recovered c
7483
8167
  const resolvedActionableCommands = (finalStep.actionableCommands && isRootAgent)
7484
8168
  ? this.resolveMediaPlaceholdersInPayload(finalStep.actionableCommands)
7485
8169
  : 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
8170
  if (this._agentRun) {
7493
8171
  this._agentRun.CompletedAt = new Date();
7494
8172
  this._agentRun.Success = finalStep.step === 'Success' || finalStep.step === 'Chat';
@@ -7513,7 +8191,7 @@ The context is now within limits. Please retry your request with the recovered c
7513
8191
  }
7514
8192
  this._agentRun.Result = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
7515
8193
  this._agentRun.FinalStep = finalStep.step;
7516
- this._agentRun.Message = processedMessage;
8194
+ this._agentRun.Message = finalStep.message;
7517
8195
  // Set the FinalPayloadObject - this will automatically stringify for the DB
7518
8196
  this._agentRun.FinalPayloadObject = resolvedPayload;
7519
8197
  this._agentRun.FinalPayload = resolvedPayload ? JSON.stringify(resolvedPayload) : null;
@@ -7522,6 +8200,8 @@ The context is now within limits. Please retry your request with the recovered c
7522
8200
  this._agentRun.TotalTokensUsed = tokenStats.totalTokens;
7523
8201
  this._agentRun.TotalPromptTokensUsed = tokenStats.promptTokens;
7524
8202
  this._agentRun.TotalCompletionTokensUsed = tokenStats.completionTokens;
8203
+ this._agentRun.TotalCacheReadTokensUsed = tokenStats.cacheReadTokens;
8204
+ this._agentRun.TotalCacheWriteTokensUsed = tokenStats.cacheWriteTokens;
7525
8205
  this._agentRun.TotalCost = tokenStats.totalCost;
7526
8206
  const ok = await this._agentRun.Save();
7527
8207
  if (!ok) {
@@ -7532,10 +8212,8 @@ The context is now within limits. Please retry your request with the recovered c
7532
8212
  if (finalStep.promoteMediaOutputs && finalStep.promoteMediaOutputs.length > 0) {
7533
8213
  this.promoteMediaOutputs(finalStep.promoteMediaOutputs);
7534
8214
  }
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.
8215
+ // Return unified media outputs — all items are persisted by AgentRunner.
8216
+ // Sub-agents pass their mediaOutputs to parent for merging and placeholder resolution.
7539
8217
  return {
7540
8218
  success: finalStep.step === 'Success' || finalStep.step === 'Chat',
7541
8219
  payload: resolvedPayload,
@@ -7562,15 +8240,19 @@ The context is now within limits. Please retry your request with the recovered c
7562
8240
  let totalTokens = 0;
7563
8241
  let promptTokens = 0;
7564
8242
  let completionTokens = 0;
8243
+ let cacheReadTokens = 0;
8244
+ let cacheWriteTokens = 0;
7565
8245
  let totalCost = 0;
7566
8246
  // Iterate through the agent run's steps to sum up tokens
7567
8247
  if (this._agentRun?.Steps) {
7568
8248
  for (const step of this._agentRun.Steps) {
7569
8249
  if (step.StepType === 'Prompt' && step.PromptRun) {
7570
- // Add tokens from prompt runs
8250
+ // Add tokens from prompt runs (rollup fields include any nested child prompt runs)
7571
8251
  totalTokens += step.PromptRun.TokensUsedRollup || 0;
7572
8252
  promptTokens += step.PromptRun.TokensPromptRollup || 0;
7573
8253
  completionTokens += step.PromptRun.TokensCompletionRollup || 0;
8254
+ cacheReadTokens += step.PromptRun.TokensCacheReadRollup || 0;
8255
+ cacheWriteTokens += step.PromptRun.TokensCacheWriteRollup || 0;
7574
8256
  totalCost += step.PromptRun.TotalCost || 0;
7575
8257
  }
7576
8258
  else if (step.StepType === 'Sub-Agent' && step.SubAgentRun) {
@@ -7578,11 +8260,13 @@ The context is now within limits. Please retry your request with the recovered c
7578
8260
  totalTokens += step.SubAgentRun.TotalTokensUsed || 0;
7579
8261
  promptTokens += step.SubAgentRun.TotalPromptTokensUsed || 0;
7580
8262
  completionTokens += step.SubAgentRun.TotalCompletionTokensUsed || 0;
8263
+ cacheReadTokens += step.SubAgentRun.TotalCacheReadTokensUsed || 0;
8264
+ cacheWriteTokens += step.SubAgentRun.TotalCacheWriteTokensUsed || 0;
7581
8265
  totalCost += step.SubAgentRun.TotalCost || 0;
7582
8266
  }
7583
8267
  }
7584
8268
  }
7585
- return { totalTokens, promptTokens, completionTokens, totalCost };
8269
+ return { totalTokens, promptTokens, completionTokens, cacheReadTokens, cacheWriteTokens, totalCost };
7586
8270
  }
7587
8271
  /**
7588
8272
  * Gets the count of how many times a specific action has been executed in this agent run.
@@ -7607,8 +8291,12 @@ The context is now within limits. Please retry your request with the recovered c
7607
8291
  /**
7608
8292
  * Increments the execution count for an item (action or sub-agent).
7609
8293
  *
8294
+ * Exposed as `protected` so driver sub-classes performing custom dispatch
8295
+ * bump the same per-item counter the framework checks against execution
8296
+ * guardrails — without this, custom dispatch silently bypasses limits.
8297
+ *
7610
8298
  * @param itemId - The item ID to increment (action ID or sub-agent ID)
7611
- * @private
8299
+ * @protected
7612
8300
  */
7613
8301
  incrementExecutionCount(itemId) {
7614
8302
  const currentCount = this._executionCounts.get(itemId) || 0;
@@ -7619,7 +8307,7 @@ The context is now within limits. Please retry your request with the recovered c
7619
8307
  *
7620
8308
  * @param itemId - The item ID to get count for
7621
8309
  * @returns The execution count (0 if never executed)
7622
- * @private
8310
+ * @protected
7623
8311
  */
7624
8312
  getExecutionCount(itemId) {
7625
8313
  return this._executionCounts.get(itemId) || 0;