@memberjunction/ai-agents 6.1.4 → 6.2.0-edge.1

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 (147) hide show
  1. package/README.md +25 -0
  2. package/dist/AgentDataPreloader.d.ts +4 -0
  3. package/dist/AgentDataPreloader.d.ts.map +1 -1
  4. package/dist/AgentDataPreloader.js +10 -2
  5. package/dist/AgentDataPreloader.js.map +1 -1
  6. package/dist/AgentRunner.d.ts +9 -4
  7. package/dist/AgentRunner.d.ts.map +1 -1
  8. package/dist/AgentRunner.js +39 -27
  9. package/dist/AgentRunner.js.map +1 -1
  10. package/dist/ArtifactToolManager.d.ts +4 -4
  11. package/dist/ArtifactToolManager.js +10 -10
  12. package/dist/ConversationToolManager.d.ts +16 -3
  13. package/dist/ConversationToolManager.d.ts.map +1 -1
  14. package/dist/ConversationToolManager.js +28 -5
  15. package/dist/ConversationToolManager.js.map +1 -1
  16. package/dist/PayloadChangeAnalyzer.d.ts +4 -0
  17. package/dist/PayloadChangeAnalyzer.d.ts.map +1 -1
  18. package/dist/PayloadChangeAnalyzer.js +10 -2
  19. package/dist/PayloadChangeAnalyzer.js.map +1 -1
  20. package/dist/PayloadFeedbackManager.d.ts +8 -0
  21. package/dist/PayloadFeedbackManager.d.ts.map +1 -1
  22. package/dist/PayloadFeedbackManager.js +20 -4
  23. package/dist/PayloadFeedbackManager.js.map +1 -1
  24. package/dist/PayloadManager.d.ts +27 -0
  25. package/dist/PayloadManager.d.ts.map +1 -1
  26. package/dist/PayloadManager.js +47 -15
  27. package/dist/PayloadManager.js.map +1 -1
  28. package/dist/agent-types/base-agent-type.d.ts +16 -0
  29. package/dist/agent-types/base-agent-type.d.ts.map +1 -1
  30. package/dist/agent-types/base-agent-type.js +20 -1
  31. package/dist/agent-types/base-agent-type.js.map +1 -1
  32. package/dist/agent-types/flow-agent-type.d.ts +9 -0
  33. package/dist/agent-types/flow-agent-type.d.ts.map +1 -1
  34. package/dist/agent-types/flow-agent-type.js +18 -5
  35. package/dist/agent-types/flow-agent-type.js.map +1 -1
  36. package/dist/agent-types/loop-agent-prompt-params.d.ts +74 -0
  37. package/dist/agent-types/loop-agent-prompt-params.d.ts.map +1 -1
  38. package/dist/agent-types/loop-agent-prompt-params.js +2 -0
  39. package/dist/agent-types/loop-agent-prompt-params.js.map +1 -1
  40. package/dist/artifact-target-plan.d.ts +2 -0
  41. package/dist/artifact-target-plan.d.ts.map +1 -1
  42. package/dist/artifact-target-plan.js +5 -1
  43. package/dist/artifact-target-plan.js.map +1 -1
  44. package/dist/base-agent.d.ts +408 -3
  45. package/dist/base-agent.d.ts.map +1 -1
  46. package/dist/base-agent.js +924 -78
  47. package/dist/base-agent.js.map +1 -1
  48. package/dist/constants.d.ts +57 -0
  49. package/dist/constants.d.ts.map +1 -0
  50. package/dist/constants.js +70 -0
  51. package/dist/constants.js.map +1 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +3 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/memory-manager-agent.d.ts +12 -12
  57. package/dist/memory-manager-agent.js +65 -65
  58. package/dist/native-tools/action-tool-builder.d.ts +8 -0
  59. package/dist/native-tools/action-tool-builder.d.ts.map +1 -1
  60. package/dist/native-tools/action-tool-builder.js +22 -6
  61. package/dist/native-tools/action-tool-builder.js.map +1 -1
  62. package/dist/native-tools/control-tools.d.ts +8 -0
  63. package/dist/native-tools/control-tools.d.ts.map +1 -1
  64. package/dist/native-tools/control-tools.js +25 -9
  65. package/dist/native-tools/control-tools.js.map +1 -1
  66. package/dist/native-tools/dual-channel.d.ts +2 -0
  67. package/dist/native-tools/dual-channel.d.ts.map +1 -1
  68. package/dist/native-tools/dual-channel.js +5 -1
  69. package/dist/native-tools/dual-channel.js.map +1 -1
  70. package/dist/native-tools/tool-result-turns.d.ts +9 -0
  71. package/dist/native-tools/tool-result-turns.d.ts.map +1 -1
  72. package/dist/native-tools/tool-result-turns.js +15 -3
  73. package/dist/native-tools/tool-result-turns.js.map +1 -1
  74. package/dist/pipeline/coerce.d.ts +14 -0
  75. package/dist/pipeline/coerce.d.ts.map +1 -1
  76. package/dist/pipeline/coerce.js +37 -9
  77. package/dist/pipeline/coerce.js.map +1 -1
  78. package/dist/pipeline/jsonpath-eval.d.ts +4 -0
  79. package/dist/pipeline/jsonpath-eval.d.ts.map +1 -1
  80. package/dist/pipeline/jsonpath-eval.js +10 -2
  81. package/dist/pipeline/jsonpath-eval.js.map +1 -1
  82. package/dist/pipeline/operators.js +16 -16
  83. package/dist/pipeline/path.d.ts +6 -0
  84. package/dist/pipeline/path.d.ts.map +1 -1
  85. package/dist/pipeline/path.js +17 -5
  86. package/dist/pipeline/path.js.map +1 -1
  87. package/dist/pipeline/pipeline-executor.js +10 -10
  88. package/dist/pipeline/predicate.d.ts +4 -0
  89. package/dist/pipeline/predicate.d.ts.map +1 -1
  90. package/dist/pipeline/predicate.js +15 -7
  91. package/dist/pipeline/predicate.js.map +1 -1
  92. package/dist/pipeline/providers/action-provider.js +2 -2
  93. package/dist/pipeline/providers/artifact-tool-provider.js +2 -2
  94. package/dist/pipeline/providers/serialize.d.ts +4 -0
  95. package/dist/pipeline/providers/serialize.d.ts.map +1 -1
  96. package/dist/pipeline/providers/serialize.js +10 -2
  97. package/dist/pipeline/providers/serialize.js.map +1 -1
  98. package/dist/pipeline/template.d.ts +4 -0
  99. package/dist/pipeline/template.d.ts.map +1 -1
  100. package/dist/pipeline/template.js +15 -7
  101. package/dist/pipeline/template.js.map +1 -1
  102. package/dist/realtime/agent-media-library.d.ts +10 -0
  103. package/dist/realtime/agent-media-library.d.ts.map +1 -1
  104. package/dist/realtime/agent-media-library.js +29 -9
  105. package/dist/realtime/agent-media-library.js.map +1 -1
  106. package/dist/realtime/media-channel-server.js +2 -2
  107. package/dist/realtime/meeting-controls-state.d.ts +2 -0
  108. package/dist/realtime/meeting-controls-state.d.ts.map +1 -1
  109. package/dist/realtime/meeting-controls-state.js +6 -2
  110. package/dist/realtime/meeting-controls-state.js.map +1 -1
  111. package/dist/realtime/realtime-channel-server-data-context.d.ts +2 -0
  112. package/dist/realtime/realtime-channel-server-data-context.d.ts.map +1 -1
  113. package/dist/realtime/realtime-channel-server-data-context.js +5 -1
  114. package/dist/realtime/realtime-channel-server-data-context.js.map +1 -1
  115. package/dist/realtime/realtime-channel-server-host.js +2 -2
  116. package/dist/realtime/realtime-client-session-service.d.ts +4 -0
  117. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  118. package/dist/realtime/realtime-client-session-service.js +15 -6
  119. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  120. package/dist/realtime/realtime-coagent-config.d.ts +2 -0
  121. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  122. package/dist/realtime/realtime-coagent-config.js +6 -2
  123. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  124. package/dist/realtime/realtime-recording-store.d.ts +15 -0
  125. package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
  126. package/dist/realtime/realtime-recording-store.js +31 -7
  127. package/dist/realtime/realtime-recording-store.js.map +1 -1
  128. package/dist/realtime/realtime-session-runner.d.ts +4 -4
  129. package/dist/realtime/realtime-session-runner.js +9 -9
  130. package/dist/realtime/realtime-session-runner.js.map +1 -1
  131. package/dist/runtime-state-fragment.d.ts +101 -0
  132. package/dist/runtime-state-fragment.d.ts.map +1 -0
  133. package/dist/runtime-state-fragment.js +173 -0
  134. package/dist/runtime-state-fragment.js.map +1 -0
  135. package/dist/types/payload-operations.d.ts +8 -0
  136. package/dist/types/payload-operations.d.ts.map +1 -1
  137. package/dist/types/payload-operations.js +21 -5
  138. package/dist/types/payload-operations.js.map +1 -1
  139. package/dist/utils/ConversationMessageResolver.d.ts +4 -0
  140. package/dist/utils/ConversationMessageResolver.d.ts.map +1 -1
  141. package/dist/utils/ConversationMessageResolver.js +10 -2
  142. package/dist/utils/ConversationMessageResolver.js.map +1 -1
  143. package/dist/volatile-child-prompt.d.ts +33 -0
  144. package/dist/volatile-child-prompt.d.ts.map +1 -0
  145. package/dist/volatile-child-prompt.js +76 -0
  146. package/dist/volatile-child-prompt.js.map +1 -0
  147. package/package.json +20 -18
@@ -11,16 +11,17 @@
11
11
  * @since 2.49.0
12
12
  */
13
13
  import { FileStorageEngineBase, MJEnvironmentEntityExtended } from '@memberjunction/core-entities';
14
- import { buildActionToolSet, filterDeclarableActions, sanitizeToolName } from './native-tools/action-tool-builder.js';
15
- import { buildNativeToolSet, SUB_AGENT_TOOL_PREFIX } from './native-tools/control-tools.js';
16
- import { buildAssistantToolCallTurn, buildToolResultTurn, compactToolResultContent } from './native-tools/tool-result-turns.js';
17
- import { looksLikeLoopEnvelope } from './native-tools/dual-channel.js';
14
+ import { BuildActionToolSet, FilterDeclarableActions, SanitizeToolName } from './native-tools/action-tool-builder.js';
15
+ import { BuildNativeToolSet, SUB_AGENT_TOOL_PREFIX } from './native-tools/control-tools.js';
16
+ import { BuildAssistantToolCallTurn, BuildToolResultTurn, CompactToolResultContent } from './native-tools/tool-result-turns.js';
17
+ import { LooksLikeLoopEnvelope } from './native-tools/dual-channel.js';
18
+ import { ResolvePromptRunUserID } from "@memberjunction/ai-core-plus";
18
19
  import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase } from '@memberjunction/core';
19
20
  import { AgentRunWatchdog } from './agent-run-watchdog.js';
20
21
  import { AIPromptRunner, GetToolCallingDecision } from '@memberjunction/ai-prompts';
21
- import { BaseRealtimeModel, GetAIAPIKey } from '@memberjunction/ai';
22
+ import { BaseRealtimeModel, GetAIAPIKey, IsPrefixPromptCache } from '@memberjunction/ai';
22
23
  import { BaseAgentType } from './agent-types/base-agent-type.js';
23
- import { CopyScalarsAndArrays, JSONValidator, MJGlobal, SafeExpressionEvaluator, UUIDsEqual, EscapeSQLString } from '@memberjunction/global';
24
+ import { CopyScalarsAndArrays, JSONValidator, MJGlobal, NormalizeUUID, SafeExpressionEvaluator, UUIDsEqual, EscapeSQLString } from '@memberjunction/global';
24
25
  // token optimization via @memberjunction/context-crush (SmartCrusher/CacheAligner-inspired)
25
26
  import { CrushJSON, DescribeCrush, PartitionStablePrefix } from '@memberjunction/context-crush';
26
27
  // AST-aware code reduction (CodeCompressor-inspired) — opt-in per agent type
@@ -32,7 +33,7 @@ import { SelectRealtimeVendorForModel } from './realtime/realtime-vendor-resolut
32
33
  import { RealtimeClientSessionService, WarnOnUnmatchedProviderVoice } from './realtime/realtime-client-session-service.js';
33
34
  import { BuildRealtimeAgentFraming } from './realtime/realtime-tool-broker.js';
34
35
  import { RealtimeRecordingController } from './realtime/realtime-recording-capture.js';
35
- import { resolveRecordingStorageAccountID, storeRealtimeRecording } from './realtime/realtime-recording-store.js';
36
+ import { ResolveRecordingStorageAccountID, StoreRealtimeRecording } from './realtime/realtime-recording-store.js';
36
37
  import { AIEngine } from '@memberjunction/aiengine';
37
38
  import { ActionEngineServer } from '@memberjunction/actions';
38
39
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
@@ -43,7 +44,12 @@ import { FormatToolResultSection, FormatToolErrorSection, RenderToolResultData,
43
44
  import { PriorTurnToolResultCache } from './prior-turn-tool-result-cache.js';
44
45
  import { PromptComponentResolver, InjectScopedPromptParts } from './prompt-component-resolver.js';
45
46
  import { ScopedPromptConfigResolver, ApplyScopedPromptConfig } from './scoped-prompt-config-resolver.js';
46
- import { StringifyForPersistence, AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, ResolveClientTools, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue, ExtractPromptResultText, GetTaskGraphSubmitter } from '@memberjunction/ai-core-plus';
47
+ import { StringifyForPersistence, AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, ResolveClientTools, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue, ExtractPromptResultText, GetTaskGraphSubmitter, SystemPlaceholderManager } from '@memberjunction/ai-core-plus';
48
+ import { ActionResult, RunActionParams } from '@memberjunction/actions-base';
49
+ import { TemplateEngineServer } from '@memberjunction/templates';
50
+ import { RuntimeStateFragmentBuilder, EscapeRuntimeStateTagsInMessage } from './runtime-state-fragment.js';
51
+ import { ResolveSpecializationPlacement } from './volatile-child-prompt.js';
52
+ import { CURRENT_DATE_PLACEHOLDER, CURRENT_DAY_OF_WEEK_PLACEHOLDER, CURRENT_TIME_PLACEHOLDER, RUNTIME_STATE_TAG, SCRATCHPAD_NOTES_PLACEHOLDER, SCRATCHPAD_TASK_SUMMARY_PLACEHOLDER, SCRATCHPAD_TASKS_PLACEHOLDER, VOLATILE_TEMPLATE_MARKERS, } from './constants.js';
47
53
  import { AgentRunner } from './AgentRunner.js';
48
54
  import { PayloadManager } from './PayloadManager.js';
49
55
  import { ScratchpadManager } from './ScratchpadManager.js';
@@ -106,6 +112,21 @@ import _ from 'lodash';
106
112
  * one) from saturating the model API / DB pool with N concurrent runs.
107
113
  */
108
114
  const PARALLEL_SUBAGENT_CONCURRENCY_LIMIT = 5;
115
+ /** Identical-arguments rule: this many failures with the same arguments block further identical calls. */
116
+ export const IDENTICAL_FAILURE_THRESHOLD = 2;
117
+ /** Attempt budget: this many consecutive failures, across any arguments, disable the action for the run. */
118
+ export const ACTION_FAILURE_BUDGET = 5;
119
+ /**
120
+ * The {@link ActionResult} returned when the run-scoped circuit breaker blocks a call before it
121
+ * reaches the action engine. Carries the rule that fired so the failure directive can name it
122
+ * directly instead of re-deriving it from the failure history, which a blocked call never updates.
123
+ */
124
+ export class CircuitBreakerActionResult extends ActionResult {
125
+ constructor(Reason) {
126
+ super();
127
+ this.Reason = Reason;
128
+ }
129
+ }
109
130
  export class BaseAgent {
110
131
  constructor() {
111
132
  /**
@@ -128,6 +149,24 @@ export class BaseAgent {
128
149
  * @private
129
150
  */
130
151
  this._activeProvider = Metadata.Provider; // global-provider-ok: default until Execute() captures per-request provider
152
+ /**
153
+ * Index in conversationMessages where this agent run began, used to accurately restore
154
+ * turn 1's trailing state message in append-only mode without corrupting prior chat turns.
155
+ * @private
156
+ */
157
+ this._turn1InsertionIndex = -1;
158
+ /**
159
+ * Actions that have failed fatally (e.g., missing API key, unauthorized, or repeated unrecoverable errors)
160
+ * during the current agent run. Subsequent attempts to execute these actions are short-circuited in 0ms.
161
+ * @private
162
+ */
163
+ this._fatalActionFailures = new Set();
164
+ /**
165
+ * Parameter-aware failure tracking per action name for the current agent run.
166
+ * Differentiates identical retries (which trip quickly) from parameter modifications (which allow self-correction).
167
+ * @private
168
+ */
169
+ this._actionFailureHistory = new Map();
131
170
  /**
132
171
  * This is state information that is specific to the agent type. BaseAgent doesn't know what
133
172
  * this contains or care, it is just responsible for keeping this, giving the Agent Type the
@@ -460,6 +499,242 @@ export class BaseAgent {
460
499
  * @private
461
500
  */
462
501
  static { this.MAX_CONSECUTIVE_UNPRODUCTIVE_RETRIES = 10; }
502
+ /**
503
+ * The identity of one action call's arguments, for the circuit breaker's identical-arguments
504
+ * rule: two calls with the same normalized string are "the same call", whatever order the model
505
+ * wrote the keys in.
506
+ *
507
+ * Algorithm, top to bottom:
508
+ * 1. Null, undefined or a non-object yields `''` (a call with no arguments).
509
+ * 2. The top-level keys are sorted, and each `(key, value)` pair passes through
510
+ * {@link normalizeActionParamEntry}, which may rename it, rewrite its value, or drop it.
511
+ * 3. Every value passes through {@link normalizeActionParamValue}: plain objects are rebuilt with
512
+ * sorted keys at EVERY depth, arrays keep their order but normalize each element, and
513
+ * anything else (strings, numbers, booleans, null, Dates, entity instances) is kept as is.
514
+ * 4. The result is serialized with `JSON.stringify`. Should that throw (a circular reference,
515
+ * a BigInt), the fallback is a sorted list of the top-level keys — still deterministic, still
516
+ * distinguishes differently-shaped calls, and never throws.
517
+ *
518
+ * Three protected layers so a subclass can change one part without re-implementing the rest:
519
+ * override {@link normalizeActionParamEntry} to ignore a key (a trace id, a timestamp the model
520
+ * regenerates on every call), or {@link normalizeActionParamValue} to canonicalize values
521
+ * (case-fold a search query, trim whitespace) so near-identical retries count as identical.
522
+ */
523
+ normalizeActionParams(params) {
524
+ if (!params || typeof params !== 'object') {
525
+ return '';
526
+ }
527
+ try {
528
+ const normalized = {};
529
+ for (const key of Object.keys(params).sort()) {
530
+ const entry = this.normalizeActionParamEntry(key, params[key]);
531
+ if (entry) {
532
+ normalized[entry.key] = entry.value;
533
+ }
534
+ }
535
+ return JSON.stringify(normalized);
536
+ }
537
+ catch {
538
+ return `[unserializable:${Object.keys(params).sort().join(',')}]`;
539
+ }
540
+ }
541
+ /**
542
+ * Normalizes one top-level `(key, value)` pair of an action's arguments. The default keeps the
543
+ * key and normalizes the value through {@link normalizeActionParamValue}. Return `null` to drop
544
+ * the pair from the call's identity — the seam for ignoring arguments that legitimately differ
545
+ * between otherwise identical retries.
546
+ */
547
+ normalizeActionParamEntry(key, value) {
548
+ return { key, value: this.normalizeActionParamValue(value) };
549
+ }
550
+ /**
551
+ * Normalizes one value, recursively: a plain object is rebuilt with its keys sorted, an array
552
+ * keeps its order with each element normalized, and any other value is returned unchanged. The
553
+ * seam for canonicalizing values before they are compared.
554
+ *
555
+ * "Plain" here is by prototype (`Object.prototype` or none), not the structural
556
+ * `IsPlainObject` from `@memberjunction/global`: a Date, Map or entity instance must pass through
557
+ * as an opaque leaf and serialize as itself, not be rebuilt as an empty bag of sorted keys.
558
+ */
559
+ normalizeActionParamValue(value) {
560
+ if (Array.isArray(value)) {
561
+ return value.map(item => this.normalizeActionParamValue(item));
562
+ }
563
+ if (value !== null && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
564
+ const source = value;
565
+ const sorted = {};
566
+ for (const key of Object.keys(source).sort()) {
567
+ sorted[key] = this.normalizeActionParamValue(source[key]);
568
+ }
569
+ return sorted;
570
+ }
571
+ return value;
572
+ }
573
+ /**
574
+ * Detects whether an action error message represents a fatal configuration or credential
575
+ * problem: the tool cannot work in this environment no matter what arguments it is given,
576
+ * so retrying is pointless and the action is locked out for the rest of the run.
577
+ *
578
+ * Deliberately NOT fatal: HTTP 401/403, "unauthorized" and "forbidden". Those are usually
579
+ * per-resource (one site blocking a fetch, one record the user cannot read) or transient
580
+ * (a search provider using 403 as a rate limit), so they fall through to the parameter-aware
581
+ * failure history where the identical-arguments rule and the consecutive-attempt budget
582
+ * bound them without disabling the tool for every other resource.
583
+ *
584
+ * Also NOT fatal, for the same reason: a failure the model can fix by changing its arguments.
585
+ * A message that names a parameter is treated as an argument problem whatever else it says,
586
+ * and a call that itself carried credential-shaped arguments (password, API key, token) is
587
+ * never fatal even on "authentication failed", because the credential came from the model,
588
+ * not the environment. Demoting a message from fatal costs at most the attempt budget.
589
+ *
590
+ * @param message The action's failure message.
591
+ * @param actionParams The arguments the call was made with, when known.
592
+ */
593
+ isFatalActionError(message, actionParams) {
594
+ if (!message) {
595
+ return false;
596
+ }
597
+ // 1. Parameter/argument/input problems are recoverable (the agent can adjust inputs), so they
598
+ // never trip the fatal breaker, however the rest of the message is phrased.
599
+ const isParamError = /\b(?:parameter|argument|param|input|field|option|property|value|column|filter|header)\b/i.test(message);
600
+ if (isParamError || this.hasCredentialShapedArguments(actionParams)) {
601
+ return false;
602
+ }
603
+ // 2. Missing or invalid credentials, API keys, or authentication failures are unrecoverable in this run
604
+ const fatalCredentialPattern = /(?:api[\s_-]?key\s+(?:is\s+)?(?:not\s+found|missing|required|invalid)|(?:missing|invalid)\s+api[\s_-]?key|no\s+api[\s_-]?key|credentials?\s+(?:not\s+found|missing)|authentication\s+failed)/i;
605
+ if (fatalCredentialPattern.test(message)) {
606
+ return true;
607
+ }
608
+ // 3. Action/service/provider-level configuration problems where the tool itself cannot execute in this environment
609
+ const fatalConfigPattern = /(?:(?:action|tool|service|provider|integration|driver|client|api|extension|engine|server)\s+(?:is\s+)?not\s+configured|not\s+configured\s+(?:for\s+(?:this\s+)?tenant|in\s+(?:this\s+)?environment|on\s+this\s+server|in\s+(?:config|mj\.config))|^\s*(?:action\s+)?(?:is\s+)?not\s+configured[.!]*\s*$)/i;
610
+ return fatalConfigPattern.test(message);
611
+ }
612
+ /**
613
+ * True when any top-level argument name looks like a credential the model supplied itself
614
+ * (password, secret, credential, API key, or an access / auth / bearer / refresh / ID token).
615
+ * `maxTokens`-style names are deliberately not matched.
616
+ */
617
+ hasCredentialShapedArguments(actionParams) {
618
+ if (!actionParams || typeof actionParams !== 'object') {
619
+ return false;
620
+ }
621
+ const credentialKey = /password|passwd|secret|credential|api[_-]?key|(?:access|auth|bearer|refresh|id)[_-]?token|^token$/i;
622
+ return Object.keys(actionParams).some(key => credentialKey.test(key));
623
+ }
624
+ /**
625
+ * Records a non-successful outcome for the run-scoped action circuit breaker. A fatal
626
+ * configuration error locks the action out for the rest of the run; any other failure
627
+ * updates the parameter-aware history behind the identical-arguments rule and the
628
+ * consecutive-attempt budget.
629
+ */
630
+ recordActionFailure(action, actionEntity, message, normalizedParams) {
631
+ if (this.isFatalActionError(message, action.params)) {
632
+ this._fatalActionFailures.add(action.name);
633
+ if (actionEntity?.Name) {
634
+ this._fatalActionFailures.add(actionEntity.Name);
635
+ }
636
+ return;
637
+ }
638
+ const existing = this._actionFailureHistory.get(action.name) || (actionEntity?.Name ? this._actionFailureHistory.get(actionEntity.Name) : undefined);
639
+ const isIdentical = existing !== undefined && existing.lastParamsString === normalizedParams;
640
+ const record = {
641
+ lastParamsString: normalizedParams,
642
+ identicalFailures: isIdentical && existing ? existing.identicalFailures + 1 : 1,
643
+ totalConsecutiveFailures: (existing?.totalConsecutiveFailures ?? 0) + 1
644
+ };
645
+ this._actionFailureHistory.set(action.name, record);
646
+ if (actionEntity?.Name) {
647
+ this._actionFailureHistory.set(actionEntity.Name, record);
648
+ }
649
+ }
650
+ /**
651
+ * Clears the parameter-aware failure history for an action after it succeeds, so the
652
+ * identical-arguments rule and the consecutive-attempt budget start over.
653
+ */
654
+ clearActionFailureRecord(action, actionEntity) {
655
+ this._actionFailureHistory.delete(action.name);
656
+ if (actionEntity?.Name) {
657
+ this._actionFailureHistory.delete(actionEntity.Name);
658
+ }
659
+ }
660
+ /**
661
+ * Applies the three circuit-breaker rules to a call that is about to be dispatched. Returns a
662
+ * blocked result, with the rule that fired, when the call must not go to the action engine;
663
+ * null when it may proceed. Rules are checked fatal → identical-arguments → budget.
664
+ */
665
+ checkActionCircuitBreaker(params, action, actionEntity, normalizedParams) {
666
+ const entityKey = actionEntity?.Name;
667
+ if (this._fatalActionFailures.has(action.name) || (entityKey && this._fatalActionFailures.has(entityKey))) {
668
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited (0ms): fatal configuration or credential error earlier in this run`, false, params);
669
+ return this.buildBlockedActionResult(actionEntity, 'fatal', `Action '${action.name}' is disabled for this run because it previously failed with an unrecoverable configuration or credential error. You must select an alternative action.`);
670
+ }
671
+ const record = this._actionFailureHistory.get(action.name) || (entityKey ? this._actionFailureHistory.get(entityKey) : undefined);
672
+ if (!record) {
673
+ return null;
674
+ }
675
+ if (record.lastParamsString === normalizedParams && record.identicalFailures >= IDENTICAL_FAILURE_THRESHOLD) {
676
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited on identical retry loop (0ms)`, false, params);
677
+ return this.buildBlockedActionResult(actionEntity, 'identical-arguments', `Action '${action.name}' is disabled for these inputs because it already failed ${record.identicalFailures} times with identical arguments. You must modify your parameters or select an alternative tool.`);
678
+ }
679
+ if (record.totalConsecutiveFailures >= ACTION_FAILURE_BUDGET) {
680
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited on max retry attempts (0ms)`, false, params);
681
+ return this.buildBlockedActionResult(actionEntity, 'attempts-exhausted', `Action '${action.name}' is disabled for this run after ${ACTION_FAILURE_BUDGET} consecutive failures across parameter attempts. You must select an alternative tool or proceed with available data.`);
682
+ }
683
+ return null;
684
+ }
685
+ /** The failed {@link ActionResult} a blocked call returns in place of dispatching. */
686
+ buildBlockedActionResult(actionEntity, reason, message) {
687
+ const blocked = new CircuitBreakerActionResult(reason);
688
+ blocked.Success = false;
689
+ blocked.Message = message;
690
+ blocked.Params = [];
691
+ blocked.RunParams = new RunActionParams();
692
+ blocked.RunParams.Action = actionEntity;
693
+ return blocked;
694
+ }
695
+ /**
696
+ * Names the rule behind a failed action summary so the directive matches what actually
697
+ * happened. A blocked call reports the rule that blocked it. A dispatched failure is fatal if
698
+ * `recordActionFailure` locked the action out (the summary has no access to the call's
699
+ * arguments, so the decision is read back rather than re-derived from the message); otherwise
700
+ * the budget is checked BEFORE the identical-arguments rule, because once the budget is spent
701
+ * the action is blocked whatever the arguments are, and telling the model to change them
702
+ * would send it in circles.
703
+ */
704
+ classifyActionFailure(summary) {
705
+ if (summary.breakerReason) {
706
+ return summary.breakerReason;
707
+ }
708
+ if (this._fatalActionFailures.has(summary.actionName)) {
709
+ return 'fatal';
710
+ }
711
+ const record = this._actionFailureHistory.get(summary.actionName);
712
+ if (!record) {
713
+ return 'warning';
714
+ }
715
+ if (record.totalConsecutiveFailures >= ACTION_FAILURE_BUDGET) {
716
+ return 'attempts-exhausted';
717
+ }
718
+ if (record.identicalFailures >= IDENTICAL_FAILURE_THRESHOLD) {
719
+ return 'identical-arguments';
720
+ }
721
+ return 'warning';
722
+ }
723
+ /** The guidance line appended to the history for one failed action. */
724
+ formatActionFailureDirective(summary) {
725
+ const name = summary.actionName;
726
+ const record = this._actionFailureHistory.get(name);
727
+ switch (this.classifyActionFailure(summary)) {
728
+ case 'fatal':
729
+ return `[CRITICAL/ACTION_UNAVAILABLE] Action '${name}' failed with an unrecoverable configuration or credential error: "${summary.message}". This action cannot execute in this environment. DO NOT call '${name}' again during this run. You MUST select an alternative tool or proceed with available data.`;
730
+ case 'attempts-exhausted':
731
+ return `[CRITICAL/ATTEMPTS_EXHAUSTED] Action '${name}' has failed ${record?.totalConsecutiveFailures ?? ACTION_FAILURE_BUDGET} consecutive times: "${summary.message}". Retries for this action are exhausted. You MUST pivot to an alternative tool or continue with available data.`;
732
+ case 'identical-arguments':
733
+ return `[CRITICAL/REPEATED_IDENTICAL_CALL] Action '${name}' failed again with the EXACT SAME arguments: "${summary.message}". Calling '${name}' with these parameters will not work. You MUST either adjust your parameters or pivot to an alternative tool.`;
734
+ default:
735
+ return `[WARNING/ACTION_FAILURE] Action '${name}' failed: "${summary.message}". Review the error and adjust your input parameters (attempt ${record?.totalConsecutiveFailures ?? 1} of ${ACTION_FAILURE_BUDGET}). DO NOT retry calling '${name}' with identical arguments.`;
736
+ }
737
+ }
463
738
  /**
464
739
  * Returns the active metadata provider for this agent run. Subclasses MUST
465
740
  * use this getter (rather than `new Metadata()` or `Metadata.Provider`) so
@@ -575,12 +850,16 @@ export class BaseAgent {
575
850
  * }]);
576
851
  * ```
577
852
  */
578
- promoteMediaOutputs(mediaOutputs) {
853
+ PromoteMediaOutputs(mediaOutputs) {
579
854
  if (mediaOutputs && mediaOutputs.length > 0) {
580
855
  this._mediaOutputs.push(...mediaOutputs);
581
856
  this.logStatus(`📎 Promoted ${mediaOutputs.length} media output(s) to agent results`, true);
582
857
  }
583
858
  }
859
+ /** @deprecated Use {@link PromoteMediaOutputs}. */
860
+ promoteMediaOutputs(mediaOutputs) {
861
+ return this.PromoteMediaOutputs(mediaOutputs);
862
+ }
584
863
  /**
585
864
  * Gets the currently accumulated media outputs for this agent run.
586
865
  * @returns Array of promoted media outputs
@@ -1196,6 +1475,11 @@ export class BaseAgent {
1196
1475
  this._executeParams = wrappedParams;
1197
1476
  this._agentConfig = undefined;
1198
1477
  this._lastModelSelectionInfo = undefined;
1478
+ this._lastVolatileStateMessage = undefined;
1479
+ this._resolvedTrailingStateMode = undefined;
1480
+ this._turn1InsertionIndex = -1;
1481
+ this._fatalActionFailures.clear();
1482
+ this._actionFailureHistory.clear();
1199
1483
  // Convert UI markup in conversation messages to plain text if requested (default: true)
1200
1484
  if (params.convertUIMarkupToPlainText !== false) {
1201
1485
  this.convertUIMarkupInMessages(wrappedParams.conversationMessages);
@@ -1206,7 +1490,8 @@ export class BaseAgent {
1206
1490
  this._memoryWriteManager.Clear();
1207
1491
  // Arm conversation-history retrieval tools — available only when the run has a
1208
1492
  // conversation to page against (the same gate as all cross-turn context features).
1209
- this._conversationToolManager.Initialize(wrappedParams.conversationId || null, params.contextUser);
1493
+ // A history floor holds here too: the tools page only rows from it onward.
1494
+ this._conversationToolManager.Initialize(wrappedParams.conversationId || null, params.contextUser, wrappedParams.ConversationHistoryFrom ?? null);
1210
1495
  this._conversationToolManager.SetSummaryHost(this.buildConversationSummaryHost(wrappedParams));
1211
1496
  // Initialize artifact tools with any input artifacts attached to the run.
1212
1497
  // Artifacts arrive as a typed first-class field on ExecuteAgentParams —
@@ -1753,6 +2038,11 @@ export class BaseAgent {
1753
2038
  promptRun.ModelID = modelResolution.modelID;
1754
2039
  promptRun.VendorID = modelResolution.vendorID || null;
1755
2040
  promptRun.AgentID = params.agent.ID;
2041
+ promptRun.UserID = ResolvePromptRunUserID({
2042
+ UserID: params.userId,
2043
+ AgentRun: this._agentRun,
2044
+ ContextUser: params.contextUser,
2045
+ });
1756
2046
  promptRun.Status = 'Running';
1757
2047
  promptRun.RunAt = new Date();
1758
2048
  promptRun.StreamingEnabled = true;
@@ -2260,7 +2550,7 @@ export class BaseAgent {
2260
2550
  }
2261
2551
  // Storage: recording provider, else attachment provider; then that provider's first account.
2262
2552
  const storageAccountId = params.contextUser
2263
- ? await resolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2553
+ ? await ResolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2264
2554
  : null;
2265
2555
  if (!storageAccountId) {
2266
2556
  this.logStatus('🔴 Realtime recording on but no resolvable storage account (RecordingStorageProviderID/AttachmentStorageProviderID) — recording disabled.', false, params);
@@ -2311,7 +2601,7 @@ export class BaseAgent {
2311
2601
  // same mixed PCM as the WAV — persisted as a peaks.json sidecar so the player renders the
2312
2602
  // real waveform without re-decoding the audio. Best-effort: an empty array writes no sidecar.
2313
2603
  const peaks = controller.GetPeaks();
2314
- const stored = await storeRealtimeRecording({
2604
+ const stored = await StoreRealtimeRecording({
2315
2605
  Audio: encoded.Buffer,
2316
2606
  MimeType: 'audio/wav',
2317
2607
  Media: controller.Media,
@@ -2451,7 +2741,7 @@ export class BaseAgent {
2451
2741
  stepCount++;
2452
2742
  // Promote any media outputs from this step to the agent's outputs
2453
2743
  if (nextStep.promoteMediaOutputs && nextStep.promoteMediaOutputs.length > 0) {
2454
- this.promoteMediaOutputs(nextStep.promoteMediaOutputs);
2744
+ this.PromoteMediaOutputs(nextStep.promoteMediaOutputs);
2455
2745
  }
2456
2746
  // Track consecutive failed steps to prevent infinite retry loops.
2457
2747
  // Any non-Failed step resets the counter.
@@ -2976,7 +3266,7 @@ export class BaseAgent {
2976
3266
  stepEntity.NativeToolCallCount = callCount;
2977
3267
  // A tool call wins, but a turn that ALSO carried a valid
2978
3268
  // envelope gave two answers, and the one we discard has to be counted somewhere.
2979
- stepEntity.NativeDualChannel = callCount > 0 ? looksLikeLoopEnvelope(message?.content) : null;
3269
+ stepEntity.NativeDualChannel = callCount > 0 ? LooksLikeLoopEnvelope(message?.content) : null;
2980
3270
  // whether this step's results went back as native tool-result turns.
2981
3271
  stepEntity.NativeToolResultsSent = GetToolCallingDecision(promptResult?.chatResult)?.toolResults === true;
2982
3272
  if (stepEntity.NativeDualChannel) {
@@ -3006,7 +3296,7 @@ export class BaseAgent {
3006
3296
  if (!turn?.sendResultsNatively || this._lastNativeTurnAppended === turn) {
3007
3297
  return;
3008
3298
  }
3009
- params.conversationMessages.push(buildAssistantToolCallTurn(turn));
3299
+ params.conversationMessages.push(BuildAssistantToolCallTurn(turn));
3010
3300
  this._lastNativeTurnAppended = turn;
3011
3301
  }
3012
3302
  /**
@@ -3051,13 +3341,13 @@ export class BaseAgent {
3051
3341
  }
3052
3342
  results.push({
3053
3343
  toolCallId: action.toolCallId,
3054
- toolName: sanitizeToolName(summary.actionName),
3344
+ toolName: SanitizeToolName(summary.actionName),
3055
3345
  content: this.formatActionResultsAsMarkdown([summary]),
3056
3346
  isError: !summary.success
3057
3347
  });
3058
3348
  }
3059
3349
  if (results.length > 0) {
3060
- params.conversationMessages.push(buildToolResultTurn(results, metadata));
3350
+ params.conversationMessages.push(BuildToolResultTurn(results, metadata));
3061
3351
  }
3062
3352
  if (orphans.length > 0) {
3063
3353
  params.conversationMessages.push({ role: 'user', content: `Action results:\n${this.formatActionResultsAsMarkdown(orphans)}`, metadata });
@@ -3117,7 +3407,7 @@ export class BaseAgent {
3117
3407
  if (unanswered.length === 0) {
3118
3408
  return;
3119
3409
  }
3120
- params.conversationMessages.push(buildToolResultTurn(unanswered.map((call) => ({
3410
+ params.conversationMessages.push(BuildToolResultTurn(unanswered.map((call) => ({
3121
3411
  toolCallId: call.id,
3122
3412
  toolName: call.name,
3123
3413
  content: 'Not executed — the agent did not run this call on this turn. See the message that follows.',
@@ -3129,7 +3419,7 @@ export class BaseAgent {
3129
3419
  * answers, which every provider rejects. Expiry and recovery stub its blocks instead.
3130
3420
  */
3131
3421
  stubToolTurn(message, note) {
3132
- return compactToolResultContent(message, () => note);
3422
+ return CompactToolResultContent(message, () => note);
3133
3423
  }
3134
3424
  applyNativeTools(promptParams, params) {
3135
3425
  this._nativeToolBindings = undefined;
@@ -3147,18 +3437,18 @@ export class BaseAgent {
3147
3437
  return;
3148
3438
  }
3149
3439
  // ...and a per-agent-ACTION gate: rows that opt out are removed before the tool set is built.
3150
- const actions = filterDeclarableActions(this.getEffectiveActionsForValidation(params.agent.ID), AIEngine.Instance.AgentActions.filter((aa) => UUIDsEqual(aa.AgentID, params.agent.ID)));
3440
+ const actions = FilterDeclarableActions(this.getEffectiveActionsForValidation(params.agent.ID), AIEngine.Instance.AgentActions.filter((aa) => UUIDsEqual(aa.AgentID, params.agent.ID)));
3151
3441
  const subAgents = this.getEffectiveSubAgentsForValidation(params.agent.ID);
3152
3442
  if (actions.length === 0 && subAgents.length === 0) {
3153
3443
  return;
3154
3444
  }
3155
3445
  try {
3156
- const actionSet = buildActionToolSet(actions, new Map(actions.map((a) => [a.ID, a.Params.Items])));
3446
+ const actionSet = BuildActionToolSet(actions, new Map(actions.map((a) => [a.ID, a.Params.Items])));
3157
3447
  // Under implicit control flow the agent cannot know which model will answer, so it declares the full
3158
3448
  // set — Actions plus the control-flow tools (one per sub-agent, payload_change_request,
3159
3449
  // ask_user) — and NAMES the control ones. The runner keeps them only when the selected
3160
3450
  // model's LLM.NativeControlFlow resolves to 'implicit'; a hybrid model never sees them.
3161
- const toolSet = buildNativeToolSet(actionSet, subAgents);
3451
+ const toolSet = BuildNativeToolSet(actionSet, subAgents);
3162
3452
  promptParams.tools = toolSet.tools;
3163
3453
  promptParams.controlFlowToolNames = toolSet.controlToolNames;
3164
3454
  promptParams.toolChoice = this.resolveToolChoiceForTurn(params);
@@ -3239,6 +3529,11 @@ export class BaseAgent {
3239
3529
  // Attribute the resulting AIPromptRun to this agent. Agents share agent-type-level system
3240
3530
  // prompts, so without this a parent's inference and its sub-agent's are indistinguishable.
3241
3531
  promptParams.agentId = params.agent.ID;
3532
+ promptParams.UserID = ResolvePromptRunUserID({
3533
+ UserID: params.userId,
3534
+ AgentRun: this._agentRun,
3535
+ ContextUser: params.contextUser,
3536
+ }) ?? undefined;
3242
3537
  // Handle case where systemPrompt is optional (e.g., Flow Agent Type)
3243
3538
  if (systemPrompt) {
3244
3539
  promptParams.prompt = systemPrompt;
@@ -3294,9 +3589,9 @@ export class BaseAgent {
3294
3589
  const agentTypePromptParams = promptParams.data.__agentTypePromptParams;
3295
3590
  const scratchpadEnabled = agentTypePromptParams?.includeScratchpadDocs !== false;
3296
3591
  if (scratchpadEnabled && this._scratchpadManager) {
3297
- promptParams.data['_SCRATCHPAD_NOTES'] = this._scratchpadManager.GetNotes() || '_(no notes yet)_';
3298
- promptParams.data['_SCRATCHPAD_TASKS'] = this._scratchpadManager.ToPromptString();
3299
- promptParams.data['_SCRATCHPAD_TASK_SUMMARY'] = this._scratchpadManager.GetTaskSummary();
3592
+ promptParams.data[SCRATCHPAD_NOTES_PLACEHOLDER] = this._scratchpadManager.GetNotes() || '_(no notes yet)_';
3593
+ promptParams.data[SCRATCHPAD_TASKS_PLACEHOLDER] = this._scratchpadManager.ToPromptString();
3594
+ promptParams.data[SCRATCHPAD_TASK_SUMMARY_PLACEHOLDER] = this._scratchpadManager.GetTaskSummary();
3300
3595
  }
3301
3596
  // Inject artifact tools template variables if enabled and artifacts are present.
3302
3597
  // Note: prior tool results are NO LONGER injected via a per-turn template var.
@@ -3432,8 +3727,334 @@ export class BaseAgent {
3432
3727
  params.data?.SecondaryScopes,
3433
3728
  }, promptParams);
3434
3729
  }
3730
+ // Prompt-cache layout. The per-iteration state (and, when the child prompt is volatile, the
3731
+ // specialization) never lives in the system prompt; it rides as the FINAL message of THIS request.
3732
+ // In append-only mode (OpenAI prompt caching), prior runtime-state fragments are retained in history
3733
+ // so each turn extends the exact byte prefix of the previous request, maintaining ~93% cache hits.
3734
+ // In replace-in-place mode (Gemini/Cerebras), only the latest fragment is attached, keeping history lean.
3735
+ const volatileStateMessage = await this.buildVolatileStateMessage(params, promptParams, payload, childPrompt, agentType, systemPrompt);
3736
+ if (volatileStateMessage) {
3737
+ const isAppendOnly = this.shouldUseAppendOnlyTrailingState(promptParams);
3738
+ this.restoreTurn1VolatileStateIfNeeded(params, isAppendOnly);
3739
+ promptParams.conversationMessages = this.assembleOutgoingMessages(params.conversationMessages, volatileStateMessage, isAppendOnly);
3740
+ if (isAppendOnly) {
3741
+ params.conversationMessages.push(volatileStateMessage);
3742
+ }
3743
+ this._lastVolatileStateMessage = volatileStateMessage;
3744
+ }
3435
3745
  return promptParams;
3436
3746
  }
3747
+ /**
3748
+ * In append-only mode, restores turn 1's volatile state fragment if mode resolution
3749
+ * was deferred until after turn 1 (e.g. dynamic model selection).
3750
+ *
3751
+ * Restores the fragment at the exact message boundary where turn 1 executed, ensuring
3752
+ * earlier turns in multi-turn conversations are not corrupted.
3753
+ *
3754
+ * This is only correct for a replace → append-only flip between turn 1 and turn 2, when
3755
+ * `_lastVolatileStateMessage` still holds turn 1's fragment. `shouldUseAppendOnlyTrailingState`
3756
+ * freezes the mode at the first model selection precisely so that no later flip can occur.
3757
+ */
3758
+ restoreTurn1VolatileStateIfNeeded(params, isAppendOnly) {
3759
+ if (this._turn1InsertionIndex < 0) {
3760
+ // Record the message boundary at Turn 1 before any loop messages are added
3761
+ this._turn1InsertionIndex = params.conversationMessages.length;
3762
+ }
3763
+ else if (isAppendOnly && this._lastVolatileStateMessage && !this.runHasVolatileStateMessage(params)) {
3764
+ // If append-only was resolved after turn 1 (via _lastModelSelectionInfo),
3765
+ // restore turn 1's fragment at the exact position where turn 1 executed it (the turn 1 boundary)
3766
+ // to ensure exact prefix match without corrupting pre-existing conversation history.
3767
+ const insertIdx = Math.min(this._turn1InsertionIndex, params.conversationMessages.length);
3768
+ params.conversationMessages.splice(insertIdx, 0, this._lastVolatileStateMessage);
3769
+ }
3770
+ }
3771
+ /**
3772
+ * Whether THIS run has already placed a volatile-state fragment in the history, i.e. at or after
3773
+ * the turn-1 boundary. Fragments before the boundary belong to an earlier run whose history the
3774
+ * caller reused; they must not suppress this run's turn-1 restore.
3775
+ */
3776
+ runHasVolatileStateMessage(params) {
3777
+ const start = Math.max(0, this._turn1InsertionIndex);
3778
+ return params.conversationMessages.slice(start).some(m => m.metadata?.volatileState === true);
3779
+ }
3780
+ /**
3781
+ * The message array sent for ONE request under `'trailingMessage'` placement: a copy of the history
3782
+ * with every non-system message's fragment tag literals escaped, then the real fragment last.
3783
+ *
3784
+ * Escaping at send time (rather than where text enters the history) covers every source at once —
3785
+ * user turns, action results, sub-agent results, skill activations — without rewriting stored data,
3786
+ * and it is deterministic, so the cached prefix stays byte-stable across iterations. System messages
3787
+ * and framework-authored volatile state messages are left alone.
3788
+ */
3789
+ assembleOutgoingMessages(history, fragment, isAppendOnly = false) {
3790
+ const source = isAppendOnly ? history : history.filter(m => !m.metadata?.volatileState);
3791
+ const sanitized = source.map(m => (m.role === 'system' || m.metadata?.volatileState ? m : EscapeRuntimeStateTagsInMessage(m)));
3792
+ return [...sanitized, fragment];
3793
+ }
3794
+ /**
3795
+ * Determines whether the current prompt execution should use append-only trailing state retention.
3796
+ *
3797
+ * Why: OpenAI and xAI prompt caching operate on an exact byte prefix match from token 0. Replacing the
3798
+ * trailing runtime-state fragment turn-over-turn breaks the byte prefix after the system prompt,
3799
+ * dropping cache hit rates significantly. In append-only mode, prior runtime state messages are
3800
+ * retained in the message history so each turn is an exact prefix extension of the prior turn,
3801
+ * achieving ~93% cache hit rate. Providers with block-level or sliding caching (Gemini, Cerebras)
3802
+ * use replace-in-place to keep context compact.
3803
+ *
3804
+ * Which providers are which is METADATA, not code: the `PrefixPromptCache` flag in the model
3805
+ * catalog's `ModelConfiguration` cascade (Model Types < Models < Vendors' `Configuration.ModelDefaults`
3806
+ * < Model Vendors), read through {@link resolvePrefixPromptCache}. `true` means append-only;
3807
+ * anything else means replace.
3808
+ *
3809
+ * Decided ONCE per run. An explicit `trailingStateMode` or a runtime model override answers
3810
+ * immediately. Otherwise the answer is frozen at the first model selection and reused for every
3811
+ * later turn, so a failover to another vendor cannot flip the layout mid-run — a flip after turn 2
3812
+ * would leave stale fragments in the history or, worse, restore the wrong turn's fragment. On turn 1,
3813
+ * before any selection is known, the answer is replace-in-place: turn 1's fragment is kept and, if
3814
+ * turn 2 resolves to append-only, spliced back at the turn-1 boundary, which reproduces exactly the
3815
+ * bytes an append-only turn 1 would have sent. Nothing is lost by deferring, so the prompt's bound
3816
+ * models are deliberately NOT consulted — prompts commonly bind several vendors for failover, and
3817
+ * guessing from them mis-pins runs that end up selecting another vendor.
3818
+ */
3819
+ shouldUseAppendOnlyTrailingState(promptParams) {
3820
+ const data = promptParams.data ?? {};
3821
+ // An explicit trailingStateMode wins; 'auto' (the default) or an absent key falls through to
3822
+ // vendor/model detection below. See TrailingStateMode for when to force either mode.
3823
+ const agentTypePromptParams = data.__agentTypePromptParams;
3824
+ if (agentTypePromptParams?.trailingStateMode === 'appendOnly') {
3825
+ return true;
3826
+ }
3827
+ if (agentTypePromptParams?.trailingStateMode === 'replace') {
3828
+ return false;
3829
+ }
3830
+ // A model override pins the serving path for the whole run, so it answers now. A vendor-only
3831
+ // override cannot: the strategy lives on the model-vendor row, which needs the model too, so
3832
+ // that case is decided at the first selection like any other run.
3833
+ if (promptParams.override?.modelId) {
3834
+ const model = AIEngine.Instance?.ModelsByID?.get(NormalizeUUID(promptParams.override.modelId));
3835
+ const vendor = promptParams.override.vendorId ? AIEngine.Instance?.VendorsByID?.get(NormalizeUUID(promptParams.override.vendorId)) : undefined;
3836
+ return this.resolvePrefixPromptCache(model, vendor);
3837
+ }
3838
+ if (this._resolvedTrailingStateMode !== undefined) {
3839
+ return this._resolvedTrailingStateMode;
3840
+ }
3841
+ if (this._lastModelSelectionInfo) {
3842
+ const model = this._lastModelSelectionInfo.ModelSelected;
3843
+ const vendor = this._lastModelSelectionInfo.vendorSelected;
3844
+ this._resolvedTrailingStateMode = this.resolvePrefixPromptCache(model, vendor);
3845
+ return this._resolvedTrailingStateMode;
3846
+ }
3847
+ // Turn 1, nothing known yet: replace-in-place, resolved for good on turn 2 (see above).
3848
+ return false;
3849
+ }
3850
+ /**
3851
+ * Whether a model, as served by a vendor, sits behind a byte-prefix prompt cache
3852
+ * (`LLM.PrefixPromptCache`), read from the model catalog's `ModelConfiguration` cascade —
3853
+ * `AIModelType < AIModel < AIVendor.Configuration.ModelDefaults < AIModelVendor` — via
3854
+ * `AIEngine.GetEffectiveModelConfiguration`. The most specific layer is the INFERENCE-PROVIDER
3855
+ * model-vendor row for `vendor`, whose vendor row supplies the host-wide default that beats the
3856
+ * model's own bag; when the vendor is unknown, or has no inference row for this model, the model
3857
+ * and type layers still answer. False when no layer declares it, which callers
3858
+ * treat as a block cache (replace-in-place).
3859
+ *
3860
+ * Extension point: a subclass with out-of-catalog knowledge (an OpenAI-compatible gateway whose
3861
+ * rows carry no flag, say) can override this rather than the mode decision above.
3862
+ */
3863
+ resolvePrefixPromptCache(model, vendor) {
3864
+ if (!model) {
3865
+ return false;
3866
+ }
3867
+ const engine = AIEngine.Instance;
3868
+ const modelVendor = vendor
3869
+ ? (engine.ModelVendorsByModelID?.get(NormalizeUUID(model.ID)) ?? []).find(mv => UUIDsEqual(mv.VendorID, vendor.ID) && engine.IsInferenceProvider(mv))
3870
+ : undefined;
3871
+ return IsPrefixPromptCache(engine.GetEffectiveModelConfiguration(model.ID, modelVendor?.ID));
3872
+ }
3873
+ /**
3874
+ * Builds the framework-authored `user` message that carries the loop agent's volatile state as the
3875
+ * final message of the request; returns null only when every block is turned off, or when the
3876
+ * system prompt template in this database has not yet synced and still embeds the state itself
3877
+ * (see the guard below). The blocks mirror the sections the template used to render (see
3878
+ * {@link RuntimeStateFragmentBuilder}), and each honors the same include flag the template did.
3879
+ *
3880
+ * Why this exists: provider prompt caching is a prefix match over tools → system → messages, so
3881
+ * state that changes every iteration INSIDE the system prompt invalidates the entire history each
3882
+ * call. Measured on Sage: 36% → 83% cached on Gemini 2.5 Flash, 12% → 96% on Claude Opus 5 (with the
3883
+ * Anthropic adapter placing its breakpoint before this message), output quality unchanged.
3884
+ *
3885
+ * The message carries STATE only — never rules. It is marked `metadata.volatileState` so adapters
3886
+ * can recognize it without depending on this package's tag names.
3887
+ */
3888
+ async buildVolatileStateMessage(params, promptParams, payload, childPrompt, agentType, systemPrompt) {
3889
+ const data = promptParams.data ?? {};
3890
+ const agentTypePromptParams = data.__agentTypePromptParams;
3891
+ // Delivery gate: the fragment is emitted only for a system prompt whose template points the model
3892
+ // at it. See resolveRuntimeStateDelivery for the two ways a template can fail that test.
3893
+ const effectiveSystemPrompt = systemPrompt ?? promptParams.prompt;
3894
+ const delivery = await this.resolveRuntimeStateDelivery(effectiveSystemPrompt, params.contextUser);
3895
+ if (delivery === 'embedded') {
3896
+ this.logStatus('⚠️ System prompt template still contains volatile blocks (database template unsynced); skipping trailing runtime-state fragment to avoid duplicate state.', true, params);
3897
+ return null;
3898
+ }
3899
+ if (delivery === 'unsupported') {
3900
+ this.logStatus(`System prompt template has no <${RUNTIME_STATE_TAG}> pointer (not a Loop agent system prompt); skipping trailing runtime-state fragment.`, true, params);
3901
+ return null;
3902
+ }
3903
+ const includeDateTime = agentTypePromptParams?.includeDateTimeInPrompt !== false;
3904
+ const includeScratchpad = agentTypePromptParams?.includeScratchpadDocs !== false;
3905
+ const includePayload = agentTypePromptParams?.includePayloadInPrompt !== false;
3906
+ const specialization = await this.resolveRelocatedSpecialization(promptParams, childPrompt, agentType, params.contextUser);
3907
+ const fragment = new RuntimeStateFragmentBuilder().Build({
3908
+ DateTime: includeDateTime ? await this.resolveFragmentDateTime(promptParams) : null,
3909
+ Scratchpad: includeScratchpad ? this.readScratchpadFromTemplateData(data) : null,
3910
+ // Same value the agent type injects for the template (`payload || {}`), so both placements agree.
3911
+ Payload: includePayload ? { Value: payload || {} } : null,
3912
+ Specialization: specialization,
3913
+ });
3914
+ if (!fragment) {
3915
+ return null;
3916
+ }
3917
+ this.logStatus(`📦 Volatile state → trailing message (${fragment.length} chars${specialization ? ', specialization relocated' : ''})`, true, params);
3918
+ return { role: 'user', content: fragment, metadata: { volatileState: true, turnAdded: this._promptTurnCount } };
3919
+ }
3920
+ /**
3921
+ * Decides whether this run's specialization (child prompt) rides in the trailing message, and if so
3922
+ * pre-renders it and flags the template to render a stub in its place. Decided from the child
3923
+ * template's UNRENDERED text via {@link ResolveSpecializationPlacement}, so the answer is the same on
3924
+ * every iteration and the layout never flips mid-run. Returns the rendered specialization, or null
3925
+ * when it stays in the system prompt.
3926
+ */
3927
+ async resolveRelocatedSpecialization(promptParams, childPrompt, agentType, contextUser) {
3928
+ const placeholder = agentType.AgentPromptPlaceholder;
3929
+ if (!childPrompt || !placeholder || !promptParams.childPrompts || promptParams.childPrompts.length === 0) {
3930
+ return null;
3931
+ }
3932
+ const templateText = await this.loadChildPromptTemplateText(childPrompt, contextUser);
3933
+ const agentTypePromptParams = promptParams.data?.__agentTypePromptParams;
3934
+ if (ResolveSpecializationPlacement(agentTypePromptParams, templateText) !== 'trailingMessage') {
3935
+ return null;
3936
+ }
3937
+ const rendered = await this._promptRunner.RenderChildPromptTemplates(promptParams.childPrompts, promptParams);
3938
+ const text = rendered.renderedTemplates[placeholder];
3939
+ if (!text || text.trim().length === 0) {
3940
+ return null;
3941
+ }
3942
+ // Cache pre-rendered child templates so AIPromptRunner.ExecutePrompt does not re-render them
3943
+ promptParams.PreRenderedChildTemplates = rendered.renderedTemplates;
3944
+ // The same data object the parent template renders against — this switches the `## Specialization`
3945
+ // block to its stub and extends the Runtime State pointer.
3946
+ if (promptParams.data) {
3947
+ promptParams.data._SPECIALIZATION_RELOCATED = true;
3948
+ }
3949
+ return text;
3950
+ }
3951
+ /**
3952
+ * Decides, from the system prompt's UNRENDERED template text, whether the trailing runtime-state
3953
+ * fragment belongs on this request:
3954
+ *
3955
+ * - `'trailing'` — the template carries the `<mj-runtime-state>` pointer, so the model is told where
3956
+ * the state lives. The Loop agent system prompt.
3957
+ * - `'embedded'` — the template still renders the state blocks itself (an environment whose
3958
+ * TemplateContent has not synced the new Loop template, or the Flow template, which embeds the
3959
+ * payload). Emitting the fragment would deliver the same state twice.
3960
+ * - `'unsupported'` — the template has neither. The Harness system prompt, or a custom prompt run
3961
+ * without the Loop system prompt. The model would receive an unexplained block.
3962
+ * - `'unknown'` — no template text to inspect (no TemplateID, or the lookup failed). The caller
3963
+ * fails OPEN here: a Loop agent losing its payload from the model's view is far worse than a
3964
+ * non-Loop agent receiving an unexplained fragment, and in practice every agent type's system
3965
+ * prompt has a template, so this arises only from a lookup failure.
3966
+ */
3967
+ async resolveRuntimeStateDelivery(systemPrompt, contextUser) {
3968
+ const templateText = await this.loadPromptTemplateText(systemPrompt, contextUser);
3969
+ if (templateText === null) {
3970
+ return 'unknown';
3971
+ }
3972
+ if (this.templateTextEmbedsVolatileState(templateText)) {
3973
+ return 'embedded';
3974
+ }
3975
+ return templateText.includes(`<${RUNTIME_STATE_TAG}>`) ? 'trailing' : 'unsupported';
3976
+ }
3977
+ /**
3978
+ * The strings whose presence in a system prompt's unrendered template text means the template
3979
+ * still renders the volatile state itself, so the trailing fragment must be suppressed. Defaults
3980
+ * to {@link VOLATILE_TEMPLATE_MARKERS}: the three block headings plus the date and payload
3981
+ * placeholders. Extension point — an agent type whose template lays the state out under other
3982
+ * headings overrides this to return its own markers.
3983
+ */
3984
+ get volatileTemplateMarkers() {
3985
+ return VOLATILE_TEMPLATE_MARKERS;
3986
+ }
3987
+ /**
3988
+ * True when unrendered template text contains any of {@link volatileTemplateMarkers} — the legacy
3989
+ * Loop layout, or any template that embeds the payload.
3990
+ */
3991
+ templateTextEmbedsVolatileState(templateText) {
3992
+ return this.volatileTemplateMarkers.some(marker => templateText.includes(marker));
3993
+ }
3994
+ /**
3995
+ * Raw template text (placeholders intact) for an AI prompt from the cached template engine.
3996
+ * Null when the prompt has no template or the lookup fails.
3997
+ */
3998
+ async loadPromptTemplateText(prompt, contextUser) {
3999
+ if (!prompt?.TemplateID) {
4000
+ return null;
4001
+ }
4002
+ try {
4003
+ await TemplateEngineServer.Instance.Config(false, contextUser);
4004
+ const template = TemplateEngineServer.Instance.Templates?.find(t => UUIDsEqual(t.ID, prompt.TemplateID));
4005
+ return template?.GetHighestPriorityContent()?.TemplateText ?? null;
4006
+ }
4007
+ catch (e) {
4008
+ this.logError(e instanceof Error ? e : String(e), { category: 'RuntimeStateFragment', severity: 'warning' });
4009
+ return null;
4010
+ }
4011
+ }
4012
+ /**
4013
+ * The child prompt's raw template text (placeholders intact), from the cached template engine.
4014
+ * Null when the prompt has no template or the lookup fails — which fails CLOSED: with no text to
4015
+ * inspect, {@link ResolveSpecializationPlacement} keeps the specialization in the system prompt.
4016
+ */
4017
+ async loadChildPromptTemplateText(childPrompt, contextUser) {
4018
+ return this.loadPromptTemplateText(childPrompt, contextUser);
4019
+ }
4020
+ /**
4021
+ * The date/time strings exactly as the system placeholders would render them into the template.
4022
+ * Resolves only the three temporal placeholders (by name, through the same registry the template
4023
+ * uses, so a registered override applies here too) rather than every system placeholder — this runs
4024
+ * on every loop iteration.
4025
+ */
4026
+ async resolveFragmentDateTime(promptParams) {
4027
+ const resolve = async (name) => {
4028
+ const placeholder = SystemPlaceholderManager.getPlaceholders().find(p => p.name === name);
4029
+ if (!placeholder) {
4030
+ return null;
4031
+ }
4032
+ try {
4033
+ const value = await placeholder.getValue(promptParams);
4034
+ return value == null ? null : String(value);
4035
+ }
4036
+ catch (e) {
4037
+ this.logError(e instanceof Error ? e : String(e), { category: 'RuntimeStateFragment', severity: 'warning', metadata: { placeholder: name } });
4038
+ return null;
4039
+ }
4040
+ };
4041
+ const [date, dayOfWeek, time] = await Promise.all([resolve(CURRENT_DATE_PLACEHOLDER), resolve(CURRENT_DAY_OF_WEEK_PLACEHOLDER), resolve(CURRENT_TIME_PLACEHOLDER)]);
4042
+ if (!date || !dayOfWeek || !time) {
4043
+ return null;
4044
+ }
4045
+ return { Date: date, DayOfWeek: dayOfWeek, Time: time };
4046
+ }
4047
+ /**
4048
+ * The scratchpad strings already placed in the template data by the prep step (so the fragment shows
4049
+ * exactly what the template would have). Null when the scratchpad is disabled or absent.
4050
+ */
4051
+ readScratchpadFromTemplateData(data) {
4052
+ const notes = data[SCRATCHPAD_NOTES_PLACEHOLDER], tasks = data[SCRATCHPAD_TASKS_PLACEHOLDER], summary = data[SCRATCHPAD_TASK_SUMMARY_PLACEHOLDER];
4053
+ if (typeof notes !== 'string' || typeof tasks !== 'string' || typeof summary !== 'string') {
4054
+ return null;
4055
+ }
4056
+ return { Notes: notes, Tasks: tasks, TaskSummary: summary };
4057
+ }
3437
4058
  /**
3438
4059
  * Executes the configured prompt. Always uses the attemptJSONRepair option to try to fix LLM
3439
4060
  * JSON syntax issues if they arise.
@@ -4211,7 +4832,7 @@ export class BaseAgent {
4211
4832
  }
4212
4833
  // Check absolute maximum iterations (safety net to prevent infinite loops)
4213
4834
  const absoluteMaxIterations = params.absoluteMaxIterations ?? BaseAgent.DEFAULT_ABSOLUTE_MAX_ITERATIONS;
4214
- if (agentRun.TotalPromptIterations && agentRun.TotalPromptIterations >= absoluteMaxIterations) {
4835
+ if (agentRun.TotalPromptIterations != null && agentRun.TotalPromptIterations >= absoluteMaxIterations) {
4215
4836
  return {
4216
4837
  exceeded: true,
4217
4838
  type: 'iterations',
@@ -4221,7 +4842,7 @@ export class BaseAgent {
4221
4842
  };
4222
4843
  }
4223
4844
  // Check cost limit
4224
- if (agent.MaxCostPerRun && agentRun.TotalCost) {
4845
+ if (agent.MaxCostPerRun != null && agentRun.TotalCost != null) {
4225
4846
  if (agentRun.TotalCost >= agent.MaxCostPerRun) {
4226
4847
  return {
4227
4848
  exceeded: true,
@@ -4233,7 +4854,7 @@ export class BaseAgent {
4233
4854
  }
4234
4855
  }
4235
4856
  // Check token limit
4236
- if (agent.MaxTokensPerRun && agentRun.TotalTokensUsed) {
4857
+ if (agent.MaxTokensPerRun != null && agentRun.TotalTokensUsed != null) {
4237
4858
  if (agentRun.TotalTokensUsed >= agent.MaxTokensPerRun) {
4238
4859
  return {
4239
4860
  exceeded: true,
@@ -4245,7 +4866,7 @@ export class BaseAgent {
4245
4866
  }
4246
4867
  }
4247
4868
  // Check iteration limit
4248
- if (agent.MaxIterationsPerRun && agentRun.TotalPromptIterations) {
4869
+ if (agent.MaxIterationsPerRun != null && agentRun.TotalPromptIterations != null) {
4249
4870
  if (agentRun.TotalPromptIterations >= agent.MaxIterationsPerRun) {
4250
4871
  return {
4251
4872
  exceeded: true,
@@ -4257,7 +4878,7 @@ export class BaseAgent {
4257
4878
  }
4258
4879
  }
4259
4880
  // Check time limit
4260
- if (agent.MaxTimePerRun && agentRun.StartedAt) {
4881
+ if (agent.MaxTimePerRun != null && agentRun.StartedAt) {
4261
4882
  const elapsedSeconds = Math.floor((Date.now() - new Date(agentRun.StartedAt).getTime()) / 1000);
4262
4883
  if (elapsedSeconds >= agent.MaxTimePerRun) {
4263
4884
  return {
@@ -4625,6 +5246,60 @@ export class BaseAgent {
4625
5246
  serializePayloadAtEnd(payload) {
4626
5247
  return payload ? JSON.stringify(payload) : null;
4627
5248
  }
5249
+ /**
5250
+ * Recovery Strategy 0: drop stale runtime-state fragments.
5251
+ *
5252
+ * Under append-only trailing-state retention (prefix-cache providers: OpenAI, xAI) every
5253
+ * iteration leaves its `<mj-runtime-state>` message in the history so the next request is an
5254
+ * exact prefix extension of the last. Those copies are cached tokens on the wire, but they are
5255
+ * context all the same, and they carry nothing the model needs: the CURRENT state always rides
5256
+ * as the fresh fragment appended to the outgoing request. So when the context overflows they
5257
+ * are the first thing to go, oldest first, all but the most recent. Keeping the newest one
5258
+ * matters for two reasons: it is the history's only fragment after this pass, so
5259
+ * {@link restoreTurn1VolatileStateIfNeeded} does not splice a turn-1 copy back in, and it
5260
+ * keeps the prefix intact from that point forward. The cost is one cache miss on the next call;
5261
+ * the alternative was a failed run.
5262
+ *
5263
+ * A no-op under replace-in-place retention, where the history never holds a fragment.
5264
+ *
5265
+ * @param params - Agent execution parameters
5266
+ * @param tokensToSave - Target number of tokens to free
5267
+ * @param currentStepCount - Current turn number, for the lifecycle event
5268
+ * @returns Result with tokens saved and strategy description
5269
+ * @protected
5270
+ */
5271
+ recoveryStrategy_DropStaleVolatileState(params, tokensToSave, currentStepCount) {
5272
+ const fragmentIndices = params.conversationMessages
5273
+ .map((msg, index) => (msg.metadata?.volatileState === true ? index : -1))
5274
+ .filter(index => index >= 0);
5275
+ // All but the most recent, oldest first.
5276
+ const stale = fragmentIndices.slice(0, -1);
5277
+ if (stale.length === 0) {
5278
+ return { tokensSaved: 0, strategyName: 'No stale runtime-state fragments to drop' };
5279
+ }
5280
+ let tokensSaved = 0;
5281
+ const removedIndices = [];
5282
+ for (const index of stale) {
5283
+ if (tokensSaved >= tokensToSave)
5284
+ break;
5285
+ removedIndices.push(index);
5286
+ tokensSaved += this.estimateTokens(params.conversationMessages[index].content);
5287
+ }
5288
+ // Remove in reverse order to keep the remaining indices valid.
5289
+ removedIndices.sort((a, b) => b - a).forEach(index => {
5290
+ const removed = params.conversationMessages.splice(index, 1)[0];
5291
+ this.emitMessageLifecycleEvent({
5292
+ type: 'message-removed',
5293
+ turn: currentStepCount,
5294
+ messageIndex: index,
5295
+ message: removed,
5296
+ reason: 'Context recovery - stale runtime-state fragment (append-only retention)',
5297
+ tokensSaved: this.estimateTokens(removed.content)
5298
+ });
5299
+ });
5300
+ this.logStatus(`Dropped ${removedIndices.length} stale runtime-state fragment(s) (${tokensSaved} tokens); ${fragmentIndices.length - removedIndices.length} retained`, true, params);
5301
+ return { tokensSaved, strategyName: `Dropped ${removedIndices.length} stale runtime-state fragment(s) retained for prefix caching` };
5302
+ }
4628
5303
  /**
4629
5304
  * Recovery Strategy 1: Remove oldest tool-result messages.
4630
5305
  * Targets messages older than minAge turns for removal.
@@ -4730,7 +5405,7 @@ export class BaseAgent {
4730
5405
  : JSON.stringify(originalMessage.content);
4731
5406
  if (originalMessage.role === 'tool') {
4732
5407
  // compact each tool_result block's text; the block structure is what the provider needs.
4733
- const compacted = compactToolResultContent(originalMessage, (t) => (t.length > 500 ? `${t.slice(0, 500)}… [compacted from ${t.length} chars]` : t));
5408
+ const compacted = CompactToolResultContent(originalMessage, (t) => (t.length > 500 ? `${t.slice(0, 500)}… [compacted from ${t.length} chars]` : t));
4734
5409
  const saved = originalTokens - this.estimateTokens(compacted.content);
4735
5410
  if (saved > 0) {
4736
5411
  params.conversationMessages[candidate.index] = {
@@ -4845,10 +5520,13 @@ export class BaseAgent {
4845
5520
  * @protected
4846
5521
  */
4847
5522
  recoveryStrategy_TrimLastUserMessage(params, tokensToSave) {
4848
- // Find the last user message (reverse search for compatibility)
5523
+ // Find the last user message (reverse search for compatibility). A retained runtime-state
5524
+ // fragment is user-role but is framework state, not the user's request: trimming it would
5525
+ // leave a damaged copy in the history and spare the message this strategy is meant to trim.
4849
5526
  let lastUserMessageIndex = -1;
4850
5527
  for (let i = params.conversationMessages.length - 1; i >= 0; i--) {
4851
- if (params.conversationMessages[i].role === 'user') {
5528
+ const candidate = params.conversationMessages[i];
5529
+ if (candidate.role === 'user' && candidate.metadata?.volatileState !== true) {
4852
5530
  lastUserMessageIndex = i;
4853
5531
  break;
4854
5532
  }
@@ -4922,6 +5600,9 @@ export class BaseAgent {
4922
5600
  const currentPromptTurn = this._promptTurnCount;
4923
5601
  // Try multiple recovery strategies in order
4924
5602
  const strategies = [
5603
+ // Retained runtime-state fragments (append-only mode) are pure cache filler: stale copies of
5604
+ // state the next request re-sends anyway. Freeing them costs one cache miss, never content.
5605
+ () => this.recoveryStrategy_DropStaleVolatileState(params, tokensToSave, currentPromptTurn),
4925
5606
  () => this.recoveryStrategy_RemoveOldestToolResults(params, tokensToSave, currentPromptTurn, 5),
4926
5607
  () => this.recoveryStrategy_CompactOldToolResults(params, tokensToSave, currentPromptTurn, 3),
4927
5608
  () => this.recoveryStrategy_RemoveOldestToolResults(params, tokensToSave, currentPromptTurn, 2),
@@ -4988,7 +5669,7 @@ The context is now within limits. Please retry your request with the recovered c
4988
5669
  // if we need to retry make sure we add the retry message to the conversation messages
4989
5670
  if (guardrailCheckedStep.step === 'Retry' && guardrailCheckedStep.payloadToolCallId && guardrailCheckedStep.nativeTurn?.sendResultsNatively) {
4990
5671
  // the payload-only turn is answered as a tool result for the payload_change_request call.
4991
- params.conversationMessages.push(buildToolResultTurn([{
5672
+ params.conversationMessages.push(BuildToolResultTurn([{
4992
5673
  toolCallId: guardrailCheckedStep.payloadToolCallId,
4993
5674
  toolName: 'payload_change_request',
4994
5675
  content: guardrailCheckedStep.retryInstructions || 'Payload change applied.',
@@ -5059,10 +5740,12 @@ The context is now within limits. Please retry your request with the recovered c
5059
5740
  * its direct predecessor's results, so context never compounds.
5060
5741
  *
5061
5742
  * Gated on conversationId + root depth — programmatic runs and sub-agents skip it.
5743
+ * Skipped under a history floor (`ConversationHistoryFrom`): the previous run's tool
5744
+ * results can quote messages from before the floor.
5062
5745
  * @protected
5063
5746
  */
5064
5747
  async injectPriorTurnToolResults(params) {
5065
- if (!params.conversationId || this._depth !== 0) {
5748
+ if (!params.conversationId || this._depth !== 0 || params.ConversationHistoryFrom) {
5066
5749
  return;
5067
5750
  }
5068
5751
  try {
@@ -5301,6 +5984,11 @@ The context is now within limits. Please retry your request with the recovered c
5301
5984
  promptParams.data = { lens, messages: rangeText };
5302
5985
  promptParams.contextUser = params.contextUser;
5303
5986
  promptParams.agentId = params.agent.ID;
5987
+ promptParams.UserID = ResolvePromptRunUserID({
5988
+ UserID: params.userId,
5989
+ AgentRun: this._agentRun,
5990
+ ContextUser: params.contextUser,
5991
+ }) ?? undefined;
5304
5992
  const result = await this._promptRunner.ExecutePrompt(promptParams);
5305
5993
  const text = ExtractPromptResultText(result);
5306
5994
  if (!result.success || text.length === 0) {
@@ -5625,8 +6313,11 @@ The context is now within limits. Please retry your request with the recovered c
5625
6313
  };
5626
6314
  // Operators (where/select/map/…) are pure code-defined verbs, not registry tools — only
5627
6315
  // capabilities (Actions + artifact tools) live here as pipeline sources/stages.
5628
- // Actions — each wrapped to run via the existing single-action execution path.
5629
- this.getEffectiveActionsForValidation(params.agent.ID).forEach((actionEntity) => register(new ActionInvocable(actionEntity.Name, (p) => this.ExecuteSingleAction(params, { name: actionEntity.Name, params: p }, actionEntity, params.contextUser))));
6316
+ // Actions — each wrapped to run via the existing single-action execution path. The
6317
+ // run-scoped circuit breaker is bypassed here: the pipeline executor's `map` stage does
6318
+ // its own per-element failure accounting and expects elements to be independent, and
6319
+ // there is no model in that loop to act on the breaker's guidance.
6320
+ this.getEffectiveActionsForValidation(params.agent.ID).forEach((actionEntity) => register(new ActionInvocable(actionEntity.Name, (p) => this.ExecuteSingleAction(params, { name: actionEntity.Name, params: p }, actionEntity, params.contextUser, { skipCircuitBreaker: true }))));
5630
6321
  // Artifact tools — one invocable per distinct tool name; `artifactId` is supplied as a
5631
6322
  // call-time param so the same `{ tool, params }` step shape works across all substrates.
5632
6323
  this._artifactToolManager.GetAvailableToolNames().forEach((toolName) => register(new ArtifactToolInvocable(toolName, async (tool, p) => {
@@ -6120,6 +6811,33 @@ The context is now within limits. Please retry your request with the recovered c
6120
6811
  return {};
6121
6812
  }
6122
6813
  }
6814
+ /**
6815
+ * Whether THIS action may use the run's runtime API key for THIS driver class. The default is
6816
+ * yes: the run was started on those keys, and an action that calls a vendor on the user's behalf
6817
+ * (Generate Image) is doing what the prompts do. Override to narrow it — an agent that knows
6818
+ * which of its actions talk to which vendor can refuse everything else, and a refusal costs the
6819
+ * action nothing but the customer's key: it falls back to the platform key as if the run had none.
6820
+ */
6821
+ actionMayUseRuntimeAPIKey(action, driverClass, params) {
6822
+ return true;
6823
+ }
6824
+ /**
6825
+ * The {@link RuntimeAPIKeyResolver} handed to one action dispatch: one driver class in, one key
6826
+ * out, the list itself never leaves this closure. Every answer is logged by action and driver
6827
+ * class (never the key), so a run's log shows which action drew which credential.
6828
+ */
6829
+ buildRuntimeAPIKeyResolver(params, actionEntity) {
6830
+ const runKeys = params.apiKeys;
6831
+ return (driverClass) => {
6832
+ if (!this.actionMayUseRuntimeAPIKey(actionEntity, driverClass, params)) {
6833
+ this.logStatus(`🔑 Runtime API key for '${driverClass}' refused to action '${actionEntity.Name}' by policy — platform key applies`, true, params);
6834
+ return undefined;
6835
+ }
6836
+ const key = GetAIAPIKey(driverClass, runKeys);
6837
+ this.logStatus(`🔑 Action '${actionEntity.Name}' resolved an API key for '${driverClass}' (${runKeys?.some((k) => k.driverClass === driverClass) ? 'run' : 'platform'})`, true, params);
6838
+ return key || undefined;
6839
+ };
6840
+ }
6123
6841
  /**
6124
6842
  * This method executes one action using the MemberJunction Actions framework.
6125
6843
  * The full ActionResult objects are returned, allowing the caller to access result codes, output parameters,
@@ -6128,12 +6846,24 @@ The context is now within limits. Please retry your request with the recovered c
6128
6846
  * @param {ExecuteAgentParams} params - Parameters from agent execution for context passing
6129
6847
  * @param {AgentAction} action - Action to execute
6130
6848
  * @param {UserInfo} [contextUser] - Optional user context for permissions
6849
+ * @param {ExecuteSingleActionOptions} [options] - `skipCircuitBreaker` bypasses the run-scoped
6850
+ * circuit breaker for callers that do their own failure accounting (the pipeline executor)
6131
6851
  *
6132
6852
  * @returns {Promise<ActionResult>} ActionResult object from the action execution
6133
6853
  *
6134
6854
  * @throws {Error} If the action fails to execute
6135
6855
  */
6136
- async ExecuteSingleAction(params, action, actionEntity, contextUser) {
6856
+ async ExecuteSingleAction(params, action, actionEntity, contextUser, options) {
6857
+ const skipBreaker = options?.skipCircuitBreaker === true;
6858
+ const normalizedParams = this.normalizeActionParams(action.params);
6859
+ // Run-scoped circuit breaker: each rule short-circuits in 0ms with a result that carries the
6860
+ // rule that fired, so the failure directive can name it without consulting the history.
6861
+ if (!skipBreaker) {
6862
+ const blocked = this.checkActionCircuitBreaker(params, action, actionEntity, normalizedParams);
6863
+ if (blocked) {
6864
+ return blocked;
6865
+ }
6866
+ }
6137
6867
  try {
6138
6868
  const actionEngine = ActionEngineServer.Instance;
6139
6869
  // Convert params object to ActionParam array
@@ -6165,17 +6895,34 @@ The context is now within limits. Please retry your request with the recovered c
6165
6895
  ContextUser: contextUser,
6166
6896
  Filters: [],
6167
6897
  SkipActionLog: false,
6168
- Context: actionContext
6898
+ Context: actionContext,
6899
+ // The run's RUNTIME API KEYS, as a RESOLVER bound to this one action — see
6900
+ // buildRuntimeAPIKeyResolver(). Per dispatch on purpose: actionContext IS params.context,
6901
+ // shared by every action in the run (parallel ones included) and copied into sub-agent
6902
+ // runs, so anything stamped there would name the wrong action under parallel dispatch
6903
+ // and travel further than the action it was meant for. Absent when the run has no keys,
6904
+ // so the action uses GetAIAPIKey(driverClass) exactly as before.
6905
+ RuntimeAPIKeyResolver: params.apiKeys && params.apiKeys.length > 0 ? this.buildRuntimeAPIKeyResolver(params, actionEntity) : undefined,
6169
6906
  });
6170
6907
  if (result.Success) {
6171
6908
  this.logStatus(` ✅ Action '${action.name}' completed successfully`, true, params);
6909
+ if (!skipBreaker) {
6910
+ this.clearActionFailureRecord(action, actionEntity);
6911
+ }
6172
6912
  }
6173
6913
  else {
6174
6914
  this.logStatus(` ❌ Action '${action.name}' failed: ${result.Message || 'Unknown error'}`, false, params);
6915
+ if (!skipBreaker) {
6916
+ this.recordActionFailure(action, actionEntity, result.Message, normalizedParams);
6917
+ }
6175
6918
  }
6176
6919
  return result;
6177
6920
  }
6178
6921
  catch (error) {
6922
+ const errorMsg = error instanceof Error ? error.message : String(error);
6923
+ if (!skipBreaker) {
6924
+ this.recordActionFailure(action, actionEntity, errorMsg, normalizedParams);
6925
+ }
6179
6926
  this.logError(error, {
6180
6927
  category: 'ActionExecution',
6181
6928
  metadata: {
@@ -6256,6 +7003,13 @@ The context is now within limits. Please retry your request with the recovered c
6256
7003
  * **Priority:** AIAgentRelationship.MessageMode takes precedence over AIAgent.MessageMode
6257
7004
  * to allow different parent agents to pass messages differently to the same sub-agent.
6258
7005
  *
7006
+ * **Runtime state never crosses the boundary.** The parent's trailing runtime-state messages
7007
+ * (`metadata.volatileState` — its payload, scratchpad and, when relocated, its specialization;
7008
+ * retained in the history under append-only mode) are dropped BEFORE any mode slices the
7009
+ * history, so a sub-agent never sees the parent's state, never has it counted against
7010
+ * `MaxMessages`, and never receives it unescaped when it builds no fragment of its own. The
7011
+ * sub-agent builds its own fragment at its prompt step.
7012
+ *
6259
7013
  * Subclasses can override this method to implement custom message preparation logic
6260
7014
  * specific to their domain (e.g., Skip agents adding special context).
6261
7015
  *
@@ -6275,6 +7029,9 @@ The context is now within limits. Please retry your request with the recovered c
6275
7029
  // Get MessageMode and MaxMessages from either relationship or child agent
6276
7030
  let messageMode = relationship?.MessageMode || subAgent.MessageMode || 'None';
6277
7031
  let maxMessages = relationship?.MaxMessages || subAgent.MaxMessages || null;
7032
+ // The parent's history minus its runtime-state messages (see the doc comment): every mode
7033
+ // slices THIS, so no fragment reaches the sub-agent and none spends a MaxMessages slot.
7034
+ const history = params.conversationMessages.filter(m => m.metadata?.volatileState !== true);
6278
7035
  // Apply message mode
6279
7036
  switch (messageMode) {
6280
7037
  case 'None':
@@ -6283,23 +7040,23 @@ The context is now within limits. Please retry your request with the recovered c
6283
7040
  break;
6284
7041
  case 'All':
6285
7042
  // Pass all parent conversation history
6286
- messages = [...params.conversationMessages];
7043
+ messages = [...history];
6287
7044
  break;
6288
7045
  case 'Latest':
6289
7046
  // Pass most recent N messages
6290
7047
  if (maxMessages && maxMessages > 0) {
6291
- messages = this.makeToolTurnsSelfConsistent(params.conversationMessages.slice(-maxMessages));
7048
+ messages = this.makeToolTurnsSelfConsistent(history.slice(-maxMessages));
6292
7049
  }
6293
7050
  else {
6294
- messages = [...params.conversationMessages];
7051
+ messages = [...history];
6295
7052
  }
6296
7053
  break;
6297
7054
  case 'Bookend':
6298
7055
  // Pass first 2 + most recent (N-2) with indicator message between
6299
- if (maxMessages && maxMessages > 2 && params.conversationMessages.length > maxMessages) {
6300
- const firstTwo = params.conversationMessages.slice(0, 2);
6301
- const remaining = params.conversationMessages.slice(-(maxMessages - 2));
6302
- const omittedCount = params.conversationMessages.length - maxMessages;
7056
+ if (maxMessages && maxMessages > 2 && history.length > maxMessages) {
7057
+ const firstTwo = history.slice(0, 2);
7058
+ const remaining = history.slice(-(maxMessages - 2));
7059
+ const omittedCount = history.length - maxMessages;
6303
7060
  messages = this.makeToolTurnsSelfConsistent([
6304
7061
  ...firstTwo,
6305
7062
  {
@@ -6310,7 +7067,7 @@ The context is now within limits. Please retry your request with the recovered c
6310
7067
  ]);
6311
7068
  }
6312
7069
  else {
6313
- messages = [...params.conversationMessages];
7070
+ messages = [...history];
6314
7071
  }
6315
7072
  break;
6316
7073
  default:
@@ -6407,6 +7164,7 @@ The context is now within limits. Please retry your request with the recovered c
6407
7164
  subAgentChanges: subAgentSubAgentChanges, // propagate filtered sub-agent changes to sub-agent
6408
7165
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
6409
7166
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
7167
+ companyId: params.companyId,
6410
7168
  SecondaryScopes: params.SecondaryScopes,
6411
7169
  onAgentRunCreated: async (agentRunId) => {
6412
7170
  stepEntity.TargetLogID = agentRunId;
@@ -7759,7 +8517,7 @@ The context is now within limits. Please retry your request with the recovered c
7759
8517
  await this.recordFoldedTaskGraph(params, previousDecision);
7760
8518
  return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
7761
8519
  case 'Actions':
7762
- return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
8520
+ return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount, this.actionOptionsForAgentType());
7763
8521
  // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
7764
8522
  // union (non-terminal, like 'ClientTools') — LoopAgentType.DetermineNextStep() emits it
7765
8523
  // when the LLM chooses to activate a skill.
@@ -8650,9 +9408,9 @@ The context is now within limits. Please retry your request with the recovered c
8650
9408
  }
8651
9409
  if (previousDecision?.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
8652
9410
  // the delegate_to_* call is answered as a tool result.
8653
- params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
9411
+ params.conversationMessages.push(BuildToolResultTurn([...this.payloadToolResult(previousDecision), {
8654
9412
  toolCallId: subAgentRequest.toolCallId,
8655
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
9413
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(subAgentRequest.name)}`,
8656
9414
  content: resultMessage,
8657
9415
  isError: !subAgentResult.success
8658
9416
  }], subAgentMetadata));
@@ -9144,11 +9902,11 @@ The context is now within limits. Please retry your request with the recovered c
9144
9902
  const pairable = nativeResults ? allExecutions.filter((e) => !!e.request.toolCallId) : [];
9145
9903
  const unpairable = allExecutions.filter((e) => !pairable.includes(e));
9146
9904
  if (pairable.length > 0) {
9147
- params.conversationMessages.push(buildToolResultTurn([
9905
+ params.conversationMessages.push(BuildToolResultTurn([
9148
9906
  ...this.payloadToolResult(previousDecision),
9149
9907
  ...pairable.map((e) => ({
9150
9908
  toolCallId: e.request.toolCallId,
9151
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(e.request.name)}`,
9909
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(e.request.name)}`,
9152
9910
  content: this.buildParallelSubAgentSummary([e]),
9153
9911
  isError: !e.result.success
9154
9912
  }))
@@ -9372,9 +10130,9 @@ The context is now within limits. Please retry your request with the recovered c
9372
10130
  }
9373
10131
  if (previousDecision.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
9374
10132
  // the delegate_to_* call is answered as a tool result (same as the child path).
9375
- params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
10133
+ params.conversationMessages.push(BuildToolResultTurn([...this.payloadToolResult(previousDecision), {
9376
10134
  toolCallId: subAgentRequest.toolCallId,
9377
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
10135
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(subAgentRequest.name)}`,
9378
10136
  content: relatedResultMessage,
9379
10137
  isError: !subAgentResult.success
9380
10138
  }], relatedMetadata));
@@ -9623,12 +10381,21 @@ The context is now within limits. Please retry your request with the recovered c
9623
10381
  * Supports both dot notation (obj.prop) and array indexing (arr[0]).
9624
10382
  *
9625
10383
 
10384
+ /**
10385
+ * The {@link ExecuteSingleActionOptions} the main loop passes for this run's agent type: the
10386
+ * circuit-breaker exemption when the type has opted out (`BaseAgentType.UsesActionCircuitBreaker`
10387
+ * is false — Flow), otherwise none. Kept as a seam so a subclass can widen or narrow the
10388
+ * exemption without touching the loop.
10389
+ */
10390
+ actionOptionsForAgentType() {
10391
+ return this.AgentTypeInstance?.UsesActionCircuitBreaker === false ? { skipCircuitBreaker: true } : undefined;
10392
+ }
9626
10393
  /**
9627
10394
  * Executes actions step and tracks it.
9628
10395
  *
9629
10396
  * @private
9630
10397
  */
9631
- async executeActionsStep(params, previousDecision, parentStepId, addConversationMessage = true, stepCount = 0) {
10398
+ async executeActionsStep(params, previousDecision, parentStepId, addConversationMessage = true, stepCount = 0, actionOptions) {
9632
10399
  try {
9633
10400
  const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
9634
10401
  const actions = previousDecision.actions || [];
@@ -9788,7 +10555,7 @@ The context is now within limits. Please retry your request with the recovered c
9788
10555
  let actionResult;
9789
10556
  try {
9790
10557
  // Execute the action
9791
- actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser);
10558
+ actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser, actionOptions);
9792
10559
  // Update step entity with ActionExecutionLog ID if available
9793
10560
  if (actionResult.LogEntry?.ID) {
9794
10561
  const logId = actionResult.LogEntry.ID;
@@ -9808,7 +10575,7 @@ The context is now within limits. Please retry your request with the recovered c
9808
10575
  };
9809
10576
  // Finalize step entity with output data
9810
10577
  await this.finalizeStepEntity(stepEntity, actionResult.Success, actionResult.Success ? undefined : actionResult.Message, outputData);
9811
- return { success: true, result: actionResult, action: aa, actionEntity, stepEntity };
10578
+ return { success: actionResult.Success, result: actionResult, action: aa, actionEntity, stepEntity, error: actionResult.Success ? undefined : actionResult.Message };
9812
10579
  }
9813
10580
  catch (error) {
9814
10581
  await this.finalizeStepEntity(stepEntity, false, error.message);
@@ -9824,9 +10591,10 @@ The context is now within limits. Please retry your request with the recovered c
9824
10591
  // Build a clean summary of action results
9825
10592
  // Apply large binary content interception to prevent context overflow
9826
10593
  const actionSummaries = actionResults.map(result => {
9827
- const actionResult = result.success ? result.result : null;
10594
+ const actionResult = result.result;
10595
+ const isActionSuccess = Boolean(result.success && (actionResult ? actionResult.Success : true));
9828
10596
  // Filter to output params only
9829
- const outputParams = result.result?.Params?.filter(p => p.Type === 'Both' || p.Type === 'Output') || [];
10597
+ const outputParams = actionResult?.Params?.filter(p => p.Type === 'Both' || p.Type === 'Output') || [];
9830
10598
  // Intercept large media content (images, audio, video) and replace with placeholders
9831
10599
  // This prevents context overflow from base64 data (~700K tokens per 1024x1024 image)
9832
10600
  // Pass actionEntity for generic ValueType=MediaOutput detection from metadata
@@ -9836,11 +10604,12 @@ The context is now within limits. Please retry your request with the recovered c
9836
10604
  this._fileOutputs.push(...fileOutputs);
9837
10605
  return {
9838
10606
  actionName: result.action.name,
9839
- success: result.success,
10607
+ success: isActionSuccess,
9840
10608
  params: sanitizedParams,
9841
- resultCode: actionResult?.Result?.ResultCode || (result.success ? 'SUCCESS' : 'ERROR'),
9842
- message: result.success ? actionResult?.Message || 'Action completed' : result.error || 'Unknown error',
9843
- aiDirectives: result.success ? actionResult?.AIDirectives : undefined
10609
+ resultCode: actionResult?.Result?.ResultCode || (isActionSuccess ? 'SUCCESS' : 'ERROR'),
10610
+ message: actionResult?.Message || (isActionSuccess ? 'Action completed' : result.error || 'Unknown error'),
10611
+ aiDirectives: isActionSuccess ? actionResult?.AIDirectives : undefined,
10612
+ breakerReason: actionResult instanceof CircuitBreakerActionResult ? actionResult.Reason : undefined
9844
10613
  };
9845
10614
  });
9846
10615
  // Check if any actions failed
@@ -9906,6 +10675,16 @@ The context is now within limits. Please retry your request with the recovered c
9906
10675
  content: `IMPORTANT — Follow these directives from the action results:\n\n${directiveText}`
9907
10676
  });
9908
10677
  }
10678
+ // Surface failure guidance for failed actions so the model does not repeatedly loop on broken
10679
+ // tools. Not when the breaker is bypassed for this step: there is then no model in the loop to
10680
+ // act on it (Flow, ForEach, While, pipeline), and the directive would only pollute the history.
10681
+ if (failedActions.length > 0 && actionOptions?.skipCircuitBreaker !== true) {
10682
+ const failureText = failedActions.map(f => this.formatActionFailureDirective(f)).join('\n\n');
10683
+ params.conversationMessages.push({
10684
+ role: 'user',
10685
+ content: `IMPORTANT — Action Execution Failure Guidance:\n\n${failureText}`
10686
+ });
10687
+ }
9909
10688
  }
9910
10689
  // Call agent type's post-processing for actions
9911
10690
  let finalPayload = currentPayload;
@@ -11174,16 +11953,18 @@ The context is now within limits. Please retry your request with the recovered c
11174
11953
  async executeForEachLoop(params, config, previousDecision) {
11175
11954
  const forEach = previousDecision.forEach;
11176
11955
  if (!forEach) {
11956
+ // Not reported to the model: a Loop agent never gets here, because LoopAgentType turns a
11957
+ // ForEach step with no details into a Retry whose errorMessage the model is shown.
11177
11958
  return this.createFailedStep('ForEach configuration missing', previousDecision);
11178
11959
  }
11179
11960
  const validationMessage = this.validateForEachOperation(forEach);
11180
11961
  if (validationMessage) {
11181
- return this.createFailedStep(`ForEach configuration invalid: ${validationMessage}`, previousDecision);
11962
+ return this.failLoopBeforeFirstIteration('ForEach', forEach.collectionPath, `ForEach configuration invalid: ${validationMessage}`, previousDecision, params, forEach.action?.name);
11182
11963
  }
11183
11964
  const currentPayload = previousDecision.newPayload || previousDecision.previousPayload;
11184
11965
  const collection = this.getCollectionFromPayload(currentPayload, forEach.collectionPath);
11185
11966
  if (!collection) {
11186
- return this.createFailedStep(`Collection path "${forEach.collectionPath}" not an array`, previousDecision);
11967
+ return this.failLoopBeforeFirstIteration('ForEach', forEach.collectionPath, `Collection path "${forEach.collectionPath}" not an array`, previousDecision, params, forEach.action?.name);
11187
11968
  }
11188
11969
  const loopStepEntity = await this.createForEachLoopStep(forEach, collection, currentPayload, params);
11189
11970
  const loopResults = await this.executeForEachIterations(forEach, collection, currentPayload, loopStepEntity.ID, params, config);
@@ -11461,7 +12242,9 @@ The context is now within limits. Please retry your request with the recovered c
11461
12242
  params: resolvedParams
11462
12243
  };
11463
12244
  const actionStep = { step: 'Actions', actions: [resolvedAction], newPayload: currentPayload, previousPayload: currentPayload, terminate: false };
11464
- result = await this.executeActionsStep(params, actionStep, parentStepId, false);
12245
+ // Loop iterations bypass the circuit breaker: the loop does its own per-item accounting and
12246
+ // there is no model between items to act on the breaker's guidance.
12247
+ result = await this.executeActionsStep(params, actionStep, parentStepId, false, 0, { skipCircuitBreaker: true });
11465
12248
  }
11466
12249
  else if (forEach.subAgent) {
11467
12250
  const subAgentStep = { step: 'Sub-Agent', subAgent: forEach.subAgent, newPayload: currentPayload, previousPayload: currentPayload };
@@ -11487,7 +12270,7 @@ The context is now within limits. Please retry your request with the recovered c
11487
12270
  async completeForEachLoop(forEach, loopStepEntity, loopResults, previousDecision, params) {
11488
12271
  // Finalize the loop step now that loop is complete
11489
12272
  loopStepEntity.PayloadAtEnd = this.serializePayloadAtEnd(loopResults.finalPayload);
11490
- await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, loopResults.errors.join('\n\n'), loopResults);
12273
+ await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, this.formatLoopErrors(loopResults.errors), loopResults);
11491
12274
  if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
11492
12275
  this.injectLoopResultsMessage('ForEach', forEach.collectionPath, loopResults.results, loopResults.errors, params, forEach.action?.name);
11493
12276
  }
@@ -11517,6 +12300,32 @@ The context is now within limits. Please retry your request with the recovered c
11517
12300
  metadata
11518
12301
  });
11519
12302
  }
12303
+ /**
12304
+ * Fails a loop that never ran an iteration, and tells the model why.
12305
+ *
12306
+ * A Loop agent answers a Failed step by prompting again (`HandleStepFallback` returns null), and
12307
+ * nothing on that path reads the step's `errorMessage`. Without the injected message the model
12308
+ * gets another turn with no idea its loop failed, and is likely to emit the same loop again.
12309
+ * Flow agents don't inject loop results, so for them this is just the Failed step.
12310
+ */
12311
+ failLoopBeforeFirstIteration(loopType, collectionOrCondition, errorMessage, previousDecision, params, actionName) {
12312
+ if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
12313
+ this.injectLoopResultsMessage(loopType, collectionOrCondition, [], [{ index: 0, message: errorMessage }], params, actionName);
12314
+ }
12315
+ return this.createFailedStep(errorMessage, previousDecision);
12316
+ }
12317
+ /**
12318
+ * One loop error as readable text. Joining the error objects directly wrote "[object Object]"
12319
+ * into the step's ErrorMessage. Falls back to JSON when an iteration threw something with no
12320
+ * message (a thrown non-Error).
12321
+ */
12322
+ describeLoopError(err) {
12323
+ return err.message ? err.message : JSON.stringify(err);
12324
+ }
12325
+ /** All loop errors as text for a step's ErrorMessage. */
12326
+ formatLoopErrors(errors) {
12327
+ return errors.map(err => this.describeLoopError(err)).join('\n\n');
12328
+ }
11520
12329
  /**
11521
12330
  * Formats loop iteration results as markdown. Handles two distinct result shapes
11522
12331
  * depending on whether the loop body executed actions or sub-agents:
@@ -11557,8 +12366,7 @@ The context is now within limits. Please retry your request with the recovered c
11557
12366
  if (errors.length > 0) {
11558
12367
  lines.push(`### Errors`);
11559
12368
  for (const err of errors) {
11560
- const errMsg = typeof err === 'string' ? err : err?.message || JSON.stringify(err);
11561
- lines.push(`• ✗ ${errMsg}`);
12369
+ lines.push(`• ✗ ${this.describeLoopError(err)}`);
11562
12370
  }
11563
12371
  }
11564
12372
  return lines.join('\n');
@@ -11664,11 +12472,13 @@ The context is now within limits. Please retry your request with the recovered c
11664
12472
  async executeWhileLoop(params, config, previousDecision) {
11665
12473
  const whileOp = previousDecision.while;
11666
12474
  if (!whileOp) {
12475
+ // Not reported to the model: a Loop agent never gets here, because LoopAgentType turns a
12476
+ // While step with no details into a Retry whose errorMessage the model is shown.
11667
12477
  return this.createFailedStep('While configuration missing', previousDecision);
11668
12478
  }
11669
12479
  const validationMessage = this.validateWhileOperation(whileOp);
11670
12480
  if (validationMessage) {
11671
- return this.createFailedStep(`While configuration invalid: ${validationMessage}`, previousDecision);
12481
+ return this.failLoopBeforeFirstIteration('While', whileOp.condition, `While configuration invalid: ${validationMessage}`, previousDecision, params, whileOp.action?.name);
11672
12482
  }
11673
12483
  const currentPayload = previousDecision.newPayload || previousDecision.previousPayload;
11674
12484
  const loopStepEntity = await this.createWhileLoopStep(whileOp, currentPayload, params);
@@ -11697,6 +12507,7 @@ The context is now within limits. Please retry your request with the recovered c
11697
12507
  const results = [];
11698
12508
  const errors = [];
11699
12509
  let iterationCount = 0;
12510
+ let conditionError;
11700
12511
  const evaluator = new SafeExpressionEvaluator();
11701
12512
  // ACTUAL WHILE LOOP - simple and clear!
11702
12513
  while (iterationCount < maxIterations) {
@@ -11704,7 +12515,15 @@ The context is now within limits. Please retry your request with the recovered c
11704
12515
  await new Promise(resolve => setTimeout(resolve, whileOp.delayBetweenIterationsMs));
11705
12516
  }
11706
12517
  const evalResult = evaluator.evaluate(whileOp.condition, { payload: currentPayload, results, errors });
11707
- if (!evalResult.success || !evalResult.value) {
12518
+ if (!evalResult.success) {
12519
+ // "Could not evaluate" is not "evaluated false". Treating it as false used to end the
12520
+ // loop silently and finalize it as a success — a malformed condition produced a green,
12521
+ // zero-iteration loop with the evaluator's error discarded.
12522
+ conditionError = `While condition '${whileOp.condition}' could not be evaluated: ${evalResult.error ?? 'unknown error'}`;
12523
+ errors.push({ index: iterationCount, message: conditionError });
12524
+ break;
12525
+ }
12526
+ if (!evalResult.value) {
11708
12527
  break;
11709
12528
  }
11710
12529
  const attemptContext = { attemptNumber: iterationCount + 1, totalAttempts: iterationCount };
@@ -11720,7 +12539,7 @@ The context is now within limits. Please retry your request with the recovered c
11720
12539
  }
11721
12540
  iterationCount++;
11722
12541
  }
11723
- return { results, errors, finalPayload: currentPayload, iterations: iterationCount };
12542
+ return { results, errors, finalPayload: currentPayload, iterations: iterationCount, conditionError };
11724
12543
  }
11725
12544
  /**
11726
12545
  * Helper: Execute single While iteration
@@ -11749,7 +12568,8 @@ The context is now within limits. Please retry your request with the recovered c
11749
12568
  params: resolvedParams
11750
12569
  };
11751
12570
  const actionStep = { step: 'Actions', actions: [resolvedAction], newPayload: currentPayload, previousPayload: currentPayload, terminate: false };
11752
- result = await this.executeActionsStep(params, actionStep, parentStepId, false);
12571
+ // Same exemption as ForEach: the loop owns per-iteration accounting.
12572
+ result = await this.executeActionsStep(params, actionStep, parentStepId, false, 0, { skipCircuitBreaker: true });
11753
12573
  }
11754
12574
  else if (whileOp.subAgent) {
11755
12575
  const subAgentStep = { step: 'Sub-Agent', subAgent: whileOp.subAgent, newPayload: currentPayload, previousPayload: currentPayload };
@@ -11774,13 +12594,23 @@ The context is now within limits. Please retry your request with the recovered c
11774
12594
  */
11775
12595
  async completeWhileLoop(whileOp, loopStepEntity, loopResults, previousDecision, params) {
11776
12596
  loopStepEntity.PayloadAtEnd = this.serializePayloadAtEnd(loopResults.finalPayload);
11777
- await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, loopResults.errors.join('\n\n'), loopResults);
12597
+ await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, this.formatLoopErrors(loopResults.errors), loopResults);
12598
+ // Inject before the early return below: a Loop agent re-prompts after a Failed step, and the
12599
+ // loop-results message (the condition error, under Errors) is the only way the model learns why.
11778
12600
  if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
11779
12601
  this.injectLoopResultsMessage('While', whileOp.condition, loopResults.results, loopResults.errors, params, whileOp.action?.name);
11780
12602
  }
12603
+ // A condition that never evaluated means the loop never ran: fail the step rather than
12604
+ // report a completed zero-iteration loop.
12605
+ if (loopResults.conditionError && loopResults.iterations === 0) {
12606
+ return this.createFailedStep(loopResults.conditionError, previousDecision);
12607
+ }
12608
+ const retryInstructions = loopResults.conditionError
12609
+ ? `While loop request using condition '${whileOp.condition}' stopped after ${loopResults.iterations} iteration(s): ${loopResults.conditionError}`
12610
+ : `Completed While loop request using condition '${whileOp.condition}' after ${loopResults.iterations} iteration(s)`;
11781
12611
  return {
11782
12612
  step: 'Retry',
11783
- retryInstructions: `Completed While loop request using condition '${whileOp.condition}' after ${loopResults.iterations} iteration(s)`,
12613
+ retryInstructions,
11784
12614
  terminate: false,
11785
12615
  newPayload: loopResults.finalPayload,
11786
12616
  previousPayload: previousDecision.previousPayload
@@ -11927,7 +12757,7 @@ The context is now within limits. Please retry your request with the recovered c
11927
12757
  }
11928
12758
  // Also promote any media from the final step's promoteMediaOutputs
11929
12759
  if (finalStep.promoteMediaOutputs && finalStep.promoteMediaOutputs.length > 0) {
11930
- this.promoteMediaOutputs(finalStep.promoteMediaOutputs);
12760
+ this.PromoteMediaOutputs(finalStep.promoteMediaOutputs);
11931
12761
  }
11932
12762
  // Return unified media outputs — all items are persisted by AgentRunner.
11933
12763
  // Sub-agents pass their mediaOutputs to parent for merging and placeholder resolution.
@@ -12170,7 +13000,7 @@ The context is now within limits. Please retry your request with the recovered c
12170
13000
  if (item.message.role === 'tool') {
12171
13001
  // compact per block so the tool turn keeps answering its call.
12172
13002
  const limit = item.metadata.compactLength || 500;
12173
- const compactedTurn = compactToolResultContent(item.message, (t) => (t.length > limit ? `${t.slice(0, limit)}… [compacted from ${t.length} chars]` : t));
13003
+ const compactedTurn = CompactToolResultContent(item.message, (t) => (t.length > limit ? `${t.slice(0, limit)}… [compacted from ${t.length} chars]` : t));
12174
13004
  const saved = this.estimateTokens(originalContent) - this.estimateTokens(compactedTurn.content);
12175
13005
  params.conversationMessages[item.index] = {
12176
13006
  ...compactedTurn,
@@ -12270,10 +13100,12 @@ The context is now within limits. Please retry your request with the recovered c
12270
13100
  * (agent or type ContextWindowMaxTokens) — before the first prompt the model is
12271
13101
  * unknown, and compacting against the conservative default would over-trigger on
12272
13102
  * large-context models. The post-turn hook (real model known) covers those.
13103
+ * Skipped under a history floor (`ConversationHistoryFrom`): a summary folds in the
13104
+ * conversation from its first message, which is what the floor excludes.
12273
13105
  * @protected
12274
13106
  */
12275
13107
  async checkPreTurnCompaction(params, config) {
12276
- if (!params.conversationId || this._depth !== 0) {
13108
+ if (!params.conversationId || this._depth !== 0 || params.ConversationHistoryFrom) {
12277
13109
  return;
12278
13110
  }
12279
13111
  const budget = this.resolveCompactionBudget(params, config);
@@ -12298,10 +13130,19 @@ The context is now within limits. Please retry your request with the recovered c
12298
13130
  * final step (→ AwaitingFeedback) is the NORMAL ending of a conversational turn;
12299
13131
  * gating on 'Completed' alone silently disabled post-turn compaction for exactly
12300
13132
  * the long-chat scenario this feature targets.
13133
+ *
13134
+ * Skipped under a history floor (`ConversationHistoryFrom`). A run with a floor must not
13135
+ * write the conversation's summary: the summary covers every row below its boundary, and
13136
+ * a run that may not read the rows before the floor can't produce that — nor, once reads
13137
+ * are narrowed to what the asker can see, can it tell which rows it was not shown.
12301
13138
  * @protected
12302
13139
  */
12303
13140
  startPostTurnCompaction() {
12304
13141
  const params = this._executeParams;
13142
+ if (params?.ConversationHistoryFrom) {
13143
+ this.logStatus('Post-turn compaction skipped — the run has a history floor', true, params);
13144
+ return;
13145
+ }
12305
13146
  if (!params?.conversationId || this._depth !== 0 || !this._agentRun
12306
13147
  || !BaseAgent.settledRunStatuses.includes(this._agentRun.Status)) {
12307
13148
  // A quiet return here is indistinguishable from "the pass ran and found nothing to do":
@@ -12570,6 +13411,11 @@ The context is now within limits. Please retry your request with the recovered c
12570
13411
  };
12571
13412
  promptParams.contextUser = params.contextUser;
12572
13413
  promptParams.agentId = params.agent.ID;
13414
+ promptParams.UserID = ResolvePromptRunUserID({
13415
+ UserID: params.userId,
13416
+ AgentRun: this._agentRun,
13417
+ ContextUser: params.contextUser,
13418
+ }) ?? undefined;
12573
13419
  const runner = new AIPromptRunner();
12574
13420
  const result = await runner.ExecutePrompt(promptParams);
12575
13421
  // Update step with result