@memberjunction/ai-agents 6.2.0-edge.0 → 6.2.0-edge.2

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 (214) hide show
  1. package/README.md +43 -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/AgentDecisionService.d.ts +83 -0
  7. package/dist/AgentDecisionService.d.ts.map +1 -0
  8. package/dist/AgentDecisionService.js +155 -0
  9. package/dist/AgentDecisionService.js.map +1 -0
  10. package/dist/AgentRunner.d.ts +16 -4
  11. package/dist/AgentRunner.d.ts.map +1 -1
  12. package/dist/AgentRunner.js +55 -32
  13. package/dist/AgentRunner.js.map +1 -1
  14. package/dist/ArtifactToolManager.d.ts +4 -4
  15. package/dist/ArtifactToolManager.js +10 -10
  16. package/dist/ConversationToolManager.d.ts +16 -3
  17. package/dist/ConversationToolManager.d.ts.map +1 -1
  18. package/dist/ConversationToolManager.js +28 -5
  19. package/dist/ConversationToolManager.js.map +1 -1
  20. package/dist/KnowledgeAgent.d.ts.map +1 -1
  21. package/dist/KnowledgeAgent.js +6 -6
  22. package/dist/KnowledgeAgent.js.map +1 -1
  23. package/dist/PayloadChangeAnalyzer.d.ts +4 -0
  24. package/dist/PayloadChangeAnalyzer.d.ts.map +1 -1
  25. package/dist/PayloadChangeAnalyzer.js +10 -2
  26. package/dist/PayloadChangeAnalyzer.js.map +1 -1
  27. package/dist/PayloadFeedbackManager.d.ts +120 -13
  28. package/dist/PayloadFeedbackManager.d.ts.map +1 -1
  29. package/dist/PayloadFeedbackManager.js +244 -88
  30. package/dist/PayloadFeedbackManager.js.map +1 -1
  31. package/dist/PayloadManager.d.ts +27 -0
  32. package/dist/PayloadManager.d.ts.map +1 -1
  33. package/dist/PayloadManager.js +47 -15
  34. package/dist/PayloadManager.js.map +1 -1
  35. package/dist/agent-context-injector.d.ts +47 -3
  36. package/dist/agent-context-injector.d.ts.map +1 -1
  37. package/dist/agent-context-injector.js +77 -10
  38. package/dist/agent-context-injector.js.map +1 -1
  39. package/dist/agent-memory-context-builder.d.ts +13 -0
  40. package/dist/agent-memory-context-builder.d.ts.map +1 -1
  41. package/dist/agent-memory-context-builder.js +24 -5
  42. package/dist/agent-memory-context-builder.js.map +1 -1
  43. package/dist/agent-types/base-agent-type.d.ts +16 -0
  44. package/dist/agent-types/base-agent-type.d.ts.map +1 -1
  45. package/dist/agent-types/base-agent-type.js +20 -1
  46. package/dist/agent-types/base-agent-type.js.map +1 -1
  47. package/dist/agent-types/flow-agent-type.d.ts +106 -0
  48. package/dist/agent-types/flow-agent-type.d.ts.map +1 -1
  49. package/dist/agent-types/flow-agent-type.js +299 -29
  50. package/dist/agent-types/flow-agent-type.js.map +1 -1
  51. package/dist/agent-types/flow-graph-executor.d.ts +12 -1
  52. package/dist/agent-types/flow-graph-executor.d.ts.map +1 -1
  53. package/dist/agent-types/flow-graph-executor.js +25 -1
  54. package/dist/agent-types/flow-graph-executor.js.map +1 -1
  55. package/dist/agent-types/loop-agent-prompt-params.d.ts +258 -11
  56. package/dist/agent-types/loop-agent-prompt-params.d.ts.map +1 -1
  57. package/dist/agent-types/loop-agent-prompt-params.js +49 -2
  58. package/dist/agent-types/loop-agent-prompt-params.js.map +1 -1
  59. package/dist/agent-types/loop-agent-response-type.d.ts +12 -1
  60. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  61. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  62. package/dist/agent-types/loop-agent-type.d.ts +6 -0
  63. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  64. package/dist/agent-types/loop-agent-type.js +42 -3
  65. package/dist/agent-types/loop-agent-type.js.map +1 -1
  66. package/dist/artifact-target-plan.d.ts +2 -0
  67. package/dist/artifact-target-plan.d.ts.map +1 -1
  68. package/dist/artifact-target-plan.js +5 -1
  69. package/dist/artifact-target-plan.js.map +1 -1
  70. package/dist/base-agent.d.ts +886 -5
  71. package/dist/base-agent.d.ts.map +1 -1
  72. package/dist/base-agent.js +2567 -164
  73. package/dist/base-agent.js.map +1 -1
  74. package/dist/catalog-narrowing.d.ts +136 -0
  75. package/dist/catalog-narrowing.d.ts.map +1 -0
  76. package/dist/catalog-narrowing.js +212 -0
  77. package/dist/catalog-narrowing.js.map +1 -0
  78. package/dist/constants.d.ts +57 -0
  79. package/dist/constants.d.ts.map +1 -0
  80. package/dist/constants.js +70 -0
  81. package/dist/constants.js.map +1 -0
  82. package/dist/decision-discovery.d.ts +401 -0
  83. package/dist/decision-discovery.d.ts.map +1 -0
  84. package/dist/decision-discovery.js +511 -0
  85. package/dist/decision-discovery.js.map +1 -0
  86. package/dist/finish-if-mode-warnings.d.ts +39 -0
  87. package/dist/finish-if-mode-warnings.d.ts.map +1 -0
  88. package/dist/finish-if-mode-warnings.js +78 -0
  89. package/dist/finish-if-mode-warnings.js.map +1 -0
  90. package/dist/finish-if-state.d.ts +72 -0
  91. package/dist/finish-if-state.d.ts.map +1 -0
  92. package/dist/finish-if-state.js +109 -0
  93. package/dist/finish-if-state.js.map +1 -0
  94. package/dist/index.d.ts +9 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +10 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/memory-manager-agent.d.ts +119 -13
  99. package/dist/memory-manager-agent.d.ts.map +1 -1
  100. package/dist/memory-manager-agent.js +237 -74
  101. package/dist/memory-manager-agent.js.map +1 -1
  102. package/dist/memory-note-gate.d.ts +208 -0
  103. package/dist/memory-note-gate.d.ts.map +1 -0
  104. package/dist/memory-note-gate.js +294 -0
  105. package/dist/memory-note-gate.js.map +1 -0
  106. package/dist/native-tools/action-tool-builder.d.ts +8 -0
  107. package/dist/native-tools/action-tool-builder.d.ts.map +1 -1
  108. package/dist/native-tools/action-tool-builder.js +22 -6
  109. package/dist/native-tools/action-tool-builder.js.map +1 -1
  110. package/dist/native-tools/control-tools.d.ts +8 -0
  111. package/dist/native-tools/control-tools.d.ts.map +1 -1
  112. package/dist/native-tools/control-tools.js +25 -9
  113. package/dist/native-tools/control-tools.js.map +1 -1
  114. package/dist/native-tools/dual-channel.d.ts +2 -0
  115. package/dist/native-tools/dual-channel.d.ts.map +1 -1
  116. package/dist/native-tools/dual-channel.js +5 -1
  117. package/dist/native-tools/dual-channel.js.map +1 -1
  118. package/dist/native-tools/tool-result-turns.d.ts +9 -0
  119. package/dist/native-tools/tool-result-turns.d.ts.map +1 -1
  120. package/dist/native-tools/tool-result-turns.js +15 -3
  121. package/dist/native-tools/tool-result-turns.js.map +1 -1
  122. package/dist/pipeline/coerce.d.ts +14 -0
  123. package/dist/pipeline/coerce.d.ts.map +1 -1
  124. package/dist/pipeline/coerce.js +37 -9
  125. package/dist/pipeline/coerce.js.map +1 -1
  126. package/dist/pipeline/jsonpath-eval.d.ts +4 -0
  127. package/dist/pipeline/jsonpath-eval.d.ts.map +1 -1
  128. package/dist/pipeline/jsonpath-eval.js +10 -2
  129. package/dist/pipeline/jsonpath-eval.js.map +1 -1
  130. package/dist/pipeline/operators.js +16 -16
  131. package/dist/pipeline/path.d.ts +6 -0
  132. package/dist/pipeline/path.d.ts.map +1 -1
  133. package/dist/pipeline/path.js +17 -5
  134. package/dist/pipeline/path.js.map +1 -1
  135. package/dist/pipeline/pipeline-executor.js +10 -10
  136. package/dist/pipeline/predicate.d.ts +4 -0
  137. package/dist/pipeline/predicate.d.ts.map +1 -1
  138. package/dist/pipeline/predicate.js +15 -7
  139. package/dist/pipeline/predicate.js.map +1 -1
  140. package/dist/pipeline/providers/action-provider.js +2 -2
  141. package/dist/pipeline/providers/artifact-tool-provider.js +2 -2
  142. package/dist/pipeline/providers/serialize.d.ts +4 -0
  143. package/dist/pipeline/providers/serialize.d.ts.map +1 -1
  144. package/dist/pipeline/providers/serialize.js +10 -2
  145. package/dist/pipeline/providers/serialize.js.map +1 -1
  146. package/dist/pipeline/template.d.ts +4 -0
  147. package/dist/pipeline/template.d.ts.map +1 -1
  148. package/dist/pipeline/template.js +15 -7
  149. package/dist/pipeline/template.js.map +1 -1
  150. package/dist/realtime/agent-media-library.d.ts +10 -0
  151. package/dist/realtime/agent-media-library.d.ts.map +1 -1
  152. package/dist/realtime/agent-media-library.js +29 -9
  153. package/dist/realtime/agent-media-library.js.map +1 -1
  154. package/dist/realtime/bridge-realtime-session-factory.d.ts +5 -2
  155. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
  156. package/dist/realtime/bridge-realtime-session-factory.js +7 -4
  157. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
  158. package/dist/realtime/bridge-room-transcript-sink.d.ts.map +1 -1
  159. package/dist/realtime/bridge-room-transcript-sink.js +15 -9
  160. package/dist/realtime/bridge-room-transcript-sink.js.map +1 -1
  161. package/dist/realtime/media-channel-server.js +2 -2
  162. package/dist/realtime/meeting-controls-state.d.ts +2 -0
  163. package/dist/realtime/meeting-controls-state.d.ts.map +1 -1
  164. package/dist/realtime/meeting-controls-state.js +6 -2
  165. package/dist/realtime/meeting-controls-state.js.map +1 -1
  166. package/dist/realtime/realtime-channel-server-data-context.d.ts +2 -0
  167. package/dist/realtime/realtime-channel-server-data-context.d.ts.map +1 -1
  168. package/dist/realtime/realtime-channel-server-data-context.js +5 -1
  169. package/dist/realtime/realtime-channel-server-data-context.js.map +1 -1
  170. package/dist/realtime/realtime-channel-server-host.js +2 -2
  171. package/dist/realtime/realtime-client-session-service.d.ts +58 -12
  172. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  173. package/dist/realtime/realtime-client-session-service.js +91 -31
  174. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  175. package/dist/realtime/realtime-coagent-config.d.ts +2 -0
  176. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  177. package/dist/realtime/realtime-coagent-config.js +6 -2
  178. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  179. package/dist/realtime/realtime-recording-store.d.ts +15 -0
  180. package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
  181. package/dist/realtime/realtime-recording-store.js +31 -7
  182. package/dist/realtime/realtime-recording-store.js.map +1 -1
  183. package/dist/realtime/realtime-session-runner.d.ts +4 -4
  184. package/dist/realtime/realtime-session-runner.js +9 -9
  185. package/dist/realtime/realtime-session-runner.js.map +1 -1
  186. package/dist/realtime/realtime-vendor-resolution.d.ts +8 -3
  187. package/dist/realtime/realtime-vendor-resolution.d.ts.map +1 -1
  188. package/dist/realtime/realtime-vendor-resolution.js +3 -3
  189. package/dist/realtime/realtime-vendor-resolution.js.map +1 -1
  190. package/dist/rubric-evaluation-agent-runner.d.ts +2 -0
  191. package/dist/rubric-evaluation-agent-runner.d.ts.map +1 -0
  192. package/dist/rubric-evaluation-agent-runner.js +38 -0
  193. package/dist/rubric-evaluation-agent-runner.js.map +1 -0
  194. package/dist/runtime-state-fragment.d.ts +101 -0
  195. package/dist/runtime-state-fragment.d.ts.map +1 -0
  196. package/dist/runtime-state-fragment.js +173 -0
  197. package/dist/runtime-state-fragment.js.map +1 -0
  198. package/dist/self-check.d.ts +98 -0
  199. package/dist/self-check.d.ts.map +1 -0
  200. package/dist/self-check.js +74 -0
  201. package/dist/self-check.js.map +1 -0
  202. package/dist/types/payload-operations.d.ts +8 -0
  203. package/dist/types/payload-operations.d.ts.map +1 -1
  204. package/dist/types/payload-operations.js +21 -5
  205. package/dist/types/payload-operations.js.map +1 -1
  206. package/dist/utils/ConversationMessageResolver.d.ts +4 -0
  207. package/dist/utils/ConversationMessageResolver.d.ts.map +1 -1
  208. package/dist/utils/ConversationMessageResolver.js +10 -2
  209. package/dist/utils/ConversationMessageResolver.js.map +1 -1
  210. package/dist/volatile-child-prompt.d.ts +33 -0
  211. package/dist/volatile-child-prompt.d.ts.map +1 -0
  212. package/dist/volatile-child-prompt.js +76 -0
  213. package/dist/volatile-child-prompt.js.map +1 -0
  214. package/package.json +21 -18
@@ -11,16 +11,19 @@
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, MakeAIAPIKeyResolver, 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 { ProviderRubricEngine } from '@memberjunction/rubrics';
25
+ import { ExecuteSelfCheck, PickSelfCheckLink } from './self-check.js';
26
+ import { CopyScalarsAndArrays, JSONValidator, MJGlobal, NormalizeUUID, SafeExpressionEvaluator, UUIDsEqual, EscapeSQLString, IsPlainObject, CleanAndParseJSON } from '@memberjunction/global';
24
27
  // token optimization via @memberjunction/context-crush (SmartCrusher/CacheAligner-inspired)
25
28
  import { CrushJSON, DescribeCrush, PartitionStablePrefix } from '@memberjunction/context-crush';
26
29
  // AST-aware code reduction (CodeCompressor-inspired) — opt-in per agent type
@@ -32,7 +35,7 @@ import { SelectRealtimeVendorForModel } from './realtime/realtime-vendor-resolut
32
35
  import { RealtimeClientSessionService, WarnOnUnmatchedProviderVoice } from './realtime/realtime-client-session-service.js';
33
36
  import { BuildRealtimeAgentFraming } from './realtime/realtime-tool-broker.js';
34
37
  import { RealtimeRecordingController } from './realtime/realtime-recording-capture.js';
35
- import { resolveRecordingStorageAccountID, storeRealtimeRecording } from './realtime/realtime-recording-store.js';
38
+ import { ResolveRecordingStorageAccountID, StoreRealtimeRecording } from './realtime/realtime-recording-store.js';
36
39
  import { AIEngine } from '@memberjunction/aiengine';
37
40
  import { ActionEngineServer } from '@memberjunction/actions';
38
41
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
@@ -43,12 +46,24 @@ import { FormatToolResultSection, FormatToolErrorSection, RenderToolResultData,
43
46
  import { PriorTurnToolResultCache } from './prior-turn-tool-result-cache.js';
44
47
  import { PromptComponentResolver, InjectScopedPromptParts } from './prompt-component-resolver.js';
45
48
  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';
49
+ import { StringifyForPersistence, AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, ResolveClientTools, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue, ExtractPromptResultText, GetTaskGraphSubmitter, SummarizeDecisionAnswers, SystemPlaceholderManager } from '@memberjunction/ai-core-plus';
50
+ import { ActionResult, RunActionParams } from '@memberjunction/actions-base';
51
+ import { TemplateEngineServer } from '@memberjunction/templates';
52
+ import { RuntimeStateFragmentBuilder, EscapeRuntimeStateTagsInMessage } from './runtime-state-fragment.js';
53
+ import { ResolveSpecializationPlacement } from './volatile-child-prompt.js';
54
+ 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
55
  import { AgentRunner } from './AgentRunner.js';
48
56
  import { PayloadManager } from './PayloadManager.js';
49
57
  import { ScratchpadManager } from './ScratchpadManager.js';
50
58
  import { ArtifactToolManager } from './ArtifactToolManager.js';
51
59
  import { MemoryWriteManager } from './MemoryWriteManager.js';
60
+ import { AgentDecisionService } from './AgentDecisionService.js';
61
+ import { PayloadFeedbackManager } from './PayloadFeedbackManager.js';
62
+ import { DEFAULT_LOOP_AGENT_PROMPT_PARAMS, MAX_DECISION_CALLS_PER_TURN, MAX_DECISION_REQUESTS_PER_TURN, ResolveFinishIfMode } from './agent-types/loop-agent-prompt-params.js';
63
+ import { FinishIfModeWarnings } from './finish-if-mode-warnings.js';
64
+ import { ApplyCatalogNarrowing, BuildCatalogNarrowingQuestions, CatalogNarrowingCandidatesToAsk, CatalogNarrowingNote, CATALOG_NARROWING_FIND_ACTIONS, IsAlwaysShownAction, IsCatalogListNarrowed, NoCatalogNarrowing, OpeningRequestText, ReachableCatalogNarrowingLimits, ResolveCatalogNarrowingLimits, SelectCatalogNarrowing, } from './catalog-narrowing.js';
65
+ import { BuildDecisionDiscoveryOptionSet, BuildDecisionDiscoveryQuestions, CanSearchEntities, DECISION_DISCOVERY_MAX_RECORDED_IDS, DECISION_DISCOVERY_MIN_CONFIDENCE, DECISION_DISCOVERY_MIN_OPTIONS, DECISION_DISCOVERY_TIMEOUT_MS, DecisionDiscoveryAgentSearch, DecisionDiscoveryFromResult, DecisionDiscoveryRunnableAgents, DecisionPromptOptionCap, FailedDecisionDiscovery, HostAllowedAgentIDs, IsDecisionDiscoveryOn, IsOpeningTurn, MentionsAgent, } from './decision-discovery.js';
66
+ import { BuildFinishIfQuestions, CapFinishIfState, FINISH_IF_STATE_EXCERPT, FormatActionForFinishIf, FormatSubAgentForFinishIf, JudgeFinishIf } from './finish-if-state.js';
52
67
  import { PipelineExecutor, PipelineToolRegistry, ActionInvocable, ArtifactToolInvocable, BuildPipelineToolDocs, formatFinalOutput, summarizePipelineStages, } from './pipeline/index.js';
53
68
  // The ONE payload-mapping dialect. Loop agents and the task-graph dispatcher read the same authored
54
69
  // mapping strings, so these must be the same functions rather than two copies that agree today.
@@ -106,6 +121,34 @@ import _ from 'lodash';
106
121
  * one) from saturating the model API / DB pool with N concurrent runs.
107
122
  */
108
123
  const PARALLEL_SUBAGENT_CONCURRENCY_LIMIT = 5;
124
+ /** Identical-arguments rule: this many failures with the same arguments block further identical calls. */
125
+ export const IDENTICAL_FAILURE_THRESHOLD = 2;
126
+ /** Attempt budget: this many consecutive failures, across any arguments, disable the action for the run. */
127
+ export const ACTION_FAILURE_BUDGET = 5;
128
+ /**
129
+ * The {@link ActionResult} returned when the run-scoped circuit breaker blocks a call before it
130
+ * reaches the action engine. Carries the rule that fired so the failure directive can name it
131
+ * directly instead of re-deriving it from the failure history, which a blocked call never updates.
132
+ */
133
+ export class CircuitBreakerActionResult extends ActionResult {
134
+ constructor(Reason) {
135
+ super();
136
+ this.Reason = Reason;
137
+ }
138
+ }
139
+ /** The top-level `reasoning` and `message` of a prompt's JSON response, when it has them. */
140
+ function responseReasoningAndMessage(result) {
141
+ const parsed = typeof result === 'string' ? CleanAndParseJSON(result) : result;
142
+ if (typeof parsed !== 'object' || parsed === null) {
143
+ return {};
144
+ }
145
+ const reasoning = 'reasoning' in parsed ? parsed.reasoning : undefined;
146
+ const message = 'message' in parsed ? parsed.message : undefined;
147
+ return {
148
+ Reasoning: typeof reasoning === 'string' ? reasoning : undefined,
149
+ Message: typeof message === 'string' ? message : undefined
150
+ };
151
+ }
109
152
  export class BaseAgent {
110
153
  constructor() {
111
154
  /**
@@ -128,6 +171,24 @@ export class BaseAgent {
128
171
  * @private
129
172
  */
130
173
  this._activeProvider = Metadata.Provider; // global-provider-ok: default until Execute() captures per-request provider
174
+ /**
175
+ * Index in conversationMessages where this agent run began, used to accurately restore
176
+ * turn 1's trailing state message in append-only mode without corrupting prior chat turns.
177
+ * @private
178
+ */
179
+ this._turn1InsertionIndex = -1;
180
+ /**
181
+ * Actions that have failed fatally (e.g., missing API key, unauthorized, or repeated unrecoverable errors)
182
+ * during the current agent run. Subsequent attempts to execute these actions are short-circuited in 0ms.
183
+ * @private
184
+ */
185
+ this._fatalActionFailures = new Set();
186
+ /**
187
+ * Parameter-aware failure tracking per action name for the current agent run.
188
+ * Differentiates identical retries (which trip quickly) from parameter modifications (which allow self-correction).
189
+ * @private
190
+ */
191
+ this._actionFailureHistory = new Map();
131
192
  /**
132
193
  * This is state information that is specific to the agent type. BaseAgent doesn't know what
133
194
  * this contains or care, it is just responsible for keeping this, giving the Agent Type the
@@ -246,6 +307,10 @@ export class BaseAgent {
246
307
  * Allows agents to explore input artifacts on demand.
247
308
  */
248
309
  this._artifactToolManager = new ArtifactToolManager();
310
+ /**
311
+ * Service for evaluating agent decisions inline via fast decision models.
312
+ */
313
+ this._agentDecisionService = new AgentDecisionService();
249
314
  /**
250
315
  * Manages conversation-history retrieval tools for the current agent run.
251
316
  * Armed only when the run has a conversationId (the cross-turn context gate).
@@ -272,6 +337,18 @@ export class BaseAgent {
272
337
  * @private
273
338
  */
274
339
  this._effectiveSubAgents = [];
340
+ /**
341
+ * The request that opened the run: the last user message when it started, before the framework
342
+ * added anything. Catalog narrowing judges the catalog against it.
343
+ * @private
344
+ */
345
+ this._openingRequest = '';
346
+ /**
347
+ * Whether the run answers its conversation's opening request ({@link IsOpeningTurn}), read from
348
+ * the messages it started with. Decision discovery asks about no other turn.
349
+ * @private
350
+ */
351
+ this._isOpeningTurn = false;
275
352
  /**
276
353
  * IDs of skills already activated during this run. Prevents re-activation from re-appending
277
354
  * the same instructions to context / re-pushing duplicate actionChanges/subAgentChanges entries
@@ -421,6 +498,20 @@ export class BaseAgent {
421
498
  * unshifted onto `conversationMessages` by the shared {@link AgentMemoryContextBuilder}.
422
499
  */
423
500
  this._injectedRAG = null;
501
+ /**
502
+ * Checks execution guardrails and modifies next step if limits are exceeded.
503
+ * This method is called after validation but before execution of non-terminal steps.
504
+ *
505
+ * @param params - Execution parameters
506
+ * @param nextStep - The validated next step
507
+ * @param currentPayload - Current payload
508
+ * @param agentRun - Current agent run
509
+ * @param currentStep - Current execution step
510
+ * @returns Modified next step if guardrails exceeded, or original next step
511
+ * @protected
512
+ */
513
+ this._selfCheckAttemptsByRun = new Map();
514
+ this._selfCheckLinkByAgent = new Map();
424
515
  /**
425
516
  * Determines the request type ID based on the Chat step's context.
426
517
  * - If the responseForm has only approve/reject-style buttons, uses "Approval"
@@ -460,6 +551,242 @@ export class BaseAgent {
460
551
  * @private
461
552
  */
462
553
  static { this.MAX_CONSECUTIVE_UNPRODUCTIVE_RETRIES = 10; }
554
+ /**
555
+ * The identity of one action call's arguments, for the circuit breaker's identical-arguments
556
+ * rule: two calls with the same normalized string are "the same call", whatever order the model
557
+ * wrote the keys in.
558
+ *
559
+ * Algorithm, top to bottom:
560
+ * 1. Null, undefined or a non-object yields `''` (a call with no arguments).
561
+ * 2. The top-level keys are sorted, and each `(key, value)` pair passes through
562
+ * {@link normalizeActionParamEntry}, which may rename it, rewrite its value, or drop it.
563
+ * 3. Every value passes through {@link normalizeActionParamValue}: plain objects are rebuilt with
564
+ * sorted keys at EVERY depth, arrays keep their order but normalize each element, and
565
+ * anything else (strings, numbers, booleans, null, Dates, entity instances) is kept as is.
566
+ * 4. The result is serialized with `JSON.stringify`. Should that throw (a circular reference,
567
+ * a BigInt), the fallback is a sorted list of the top-level keys — still deterministic, still
568
+ * distinguishes differently-shaped calls, and never throws.
569
+ *
570
+ * Three protected layers so a subclass can change one part without re-implementing the rest:
571
+ * override {@link normalizeActionParamEntry} to ignore a key (a trace id, a timestamp the model
572
+ * regenerates on every call), or {@link normalizeActionParamValue} to canonicalize values
573
+ * (case-fold a search query, trim whitespace) so near-identical retries count as identical.
574
+ */
575
+ normalizeActionParams(params) {
576
+ if (!params || typeof params !== 'object') {
577
+ return '';
578
+ }
579
+ try {
580
+ const normalized = {};
581
+ for (const key of Object.keys(params).sort()) {
582
+ const entry = this.normalizeActionParamEntry(key, params[key]);
583
+ if (entry) {
584
+ normalized[entry.key] = entry.value;
585
+ }
586
+ }
587
+ return JSON.stringify(normalized);
588
+ }
589
+ catch {
590
+ return `[unserializable:${Object.keys(params).sort().join(',')}]`;
591
+ }
592
+ }
593
+ /**
594
+ * Normalizes one top-level `(key, value)` pair of an action's arguments. The default keeps the
595
+ * key and normalizes the value through {@link normalizeActionParamValue}. Return `null` to drop
596
+ * the pair from the call's identity — the seam for ignoring arguments that legitimately differ
597
+ * between otherwise identical retries.
598
+ */
599
+ normalizeActionParamEntry(key, value) {
600
+ return { key, value: this.normalizeActionParamValue(value) };
601
+ }
602
+ /**
603
+ * Normalizes one value, recursively: a plain object is rebuilt with its keys sorted, an array
604
+ * keeps its order with each element normalized, and any other value is returned unchanged. The
605
+ * seam for canonicalizing values before they are compared.
606
+ *
607
+ * "Plain" here is by prototype (`Object.prototype` or none), not the structural
608
+ * `IsPlainObject` from `@memberjunction/global`: a Date, Map or entity instance must pass through
609
+ * as an opaque leaf and serialize as itself, not be rebuilt as an empty bag of sorted keys.
610
+ */
611
+ normalizeActionParamValue(value) {
612
+ if (Array.isArray(value)) {
613
+ return value.map(item => this.normalizeActionParamValue(item));
614
+ }
615
+ if (value !== null && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
616
+ const source = value;
617
+ const sorted = {};
618
+ for (const key of Object.keys(source).sort()) {
619
+ sorted[key] = this.normalizeActionParamValue(source[key]);
620
+ }
621
+ return sorted;
622
+ }
623
+ return value;
624
+ }
625
+ /**
626
+ * Detects whether an action error message represents a fatal configuration or credential
627
+ * problem: the tool cannot work in this environment no matter what arguments it is given,
628
+ * so retrying is pointless and the action is locked out for the rest of the run.
629
+ *
630
+ * Deliberately NOT fatal: HTTP 401/403, "unauthorized" and "forbidden". Those are usually
631
+ * per-resource (one site blocking a fetch, one record the user cannot read) or transient
632
+ * (a search provider using 403 as a rate limit), so they fall through to the parameter-aware
633
+ * failure history where the identical-arguments rule and the consecutive-attempt budget
634
+ * bound them without disabling the tool for every other resource.
635
+ *
636
+ * Also NOT fatal, for the same reason: a failure the model can fix by changing its arguments.
637
+ * A message that names a parameter is treated as an argument problem whatever else it says,
638
+ * and a call that itself carried credential-shaped arguments (password, API key, token) is
639
+ * never fatal even on "authentication failed", because the credential came from the model,
640
+ * not the environment. Demoting a message from fatal costs at most the attempt budget.
641
+ *
642
+ * @param message The action's failure message.
643
+ * @param actionParams The arguments the call was made with, when known.
644
+ */
645
+ isFatalActionError(message, actionParams) {
646
+ if (!message) {
647
+ return false;
648
+ }
649
+ // 1. Parameter/argument/input problems are recoverable (the agent can adjust inputs), so they
650
+ // never trip the fatal breaker, however the rest of the message is phrased.
651
+ const isParamError = /\b(?:parameter|argument|param|input|field|option|property|value|column|filter|header)\b/i.test(message);
652
+ if (isParamError || this.hasCredentialShapedArguments(actionParams)) {
653
+ return false;
654
+ }
655
+ // 2. Missing or invalid credentials, API keys, or authentication failures are unrecoverable in this run
656
+ 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;
657
+ if (fatalCredentialPattern.test(message)) {
658
+ return true;
659
+ }
660
+ // 3. Action/service/provider-level configuration problems where the tool itself cannot execute in this environment
661
+ 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;
662
+ return fatalConfigPattern.test(message);
663
+ }
664
+ /**
665
+ * True when any top-level argument name looks like a credential the model supplied itself
666
+ * (password, secret, credential, API key, or an access / auth / bearer / refresh / ID token).
667
+ * `maxTokens`-style names are deliberately not matched.
668
+ */
669
+ hasCredentialShapedArguments(actionParams) {
670
+ if (!actionParams || typeof actionParams !== 'object') {
671
+ return false;
672
+ }
673
+ const credentialKey = /password|passwd|secret|credential|api[_-]?key|(?:access|auth|bearer|refresh|id)[_-]?token|^token$/i;
674
+ return Object.keys(actionParams).some(key => credentialKey.test(key));
675
+ }
676
+ /**
677
+ * Records a non-successful outcome for the run-scoped action circuit breaker. A fatal
678
+ * configuration error locks the action out for the rest of the run; any other failure
679
+ * updates the parameter-aware history behind the identical-arguments rule and the
680
+ * consecutive-attempt budget.
681
+ */
682
+ recordActionFailure(action, actionEntity, message, normalizedParams) {
683
+ if (this.isFatalActionError(message, action.params)) {
684
+ this._fatalActionFailures.add(action.name);
685
+ if (actionEntity?.Name) {
686
+ this._fatalActionFailures.add(actionEntity.Name);
687
+ }
688
+ return;
689
+ }
690
+ const existing = this._actionFailureHistory.get(action.name) || (actionEntity?.Name ? this._actionFailureHistory.get(actionEntity.Name) : undefined);
691
+ const isIdentical = existing !== undefined && existing.lastParamsString === normalizedParams;
692
+ const record = {
693
+ lastParamsString: normalizedParams,
694
+ identicalFailures: isIdentical && existing ? existing.identicalFailures + 1 : 1,
695
+ totalConsecutiveFailures: (existing?.totalConsecutiveFailures ?? 0) + 1
696
+ };
697
+ this._actionFailureHistory.set(action.name, record);
698
+ if (actionEntity?.Name) {
699
+ this._actionFailureHistory.set(actionEntity.Name, record);
700
+ }
701
+ }
702
+ /**
703
+ * Clears the parameter-aware failure history for an action after it succeeds, so the
704
+ * identical-arguments rule and the consecutive-attempt budget start over.
705
+ */
706
+ clearActionFailureRecord(action, actionEntity) {
707
+ this._actionFailureHistory.delete(action.name);
708
+ if (actionEntity?.Name) {
709
+ this._actionFailureHistory.delete(actionEntity.Name);
710
+ }
711
+ }
712
+ /**
713
+ * Applies the three circuit-breaker rules to a call that is about to be dispatched. Returns a
714
+ * blocked result, with the rule that fired, when the call must not go to the action engine;
715
+ * null when it may proceed. Rules are checked fatal → identical-arguments → budget.
716
+ */
717
+ checkActionCircuitBreaker(params, action, actionEntity, normalizedParams) {
718
+ const entityKey = actionEntity?.Name;
719
+ if (this._fatalActionFailures.has(action.name) || (entityKey && this._fatalActionFailures.has(entityKey))) {
720
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited (0ms): fatal configuration or credential error earlier in this run`, false, params);
721
+ 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.`);
722
+ }
723
+ const record = this._actionFailureHistory.get(action.name) || (entityKey ? this._actionFailureHistory.get(entityKey) : undefined);
724
+ if (!record) {
725
+ return null;
726
+ }
727
+ if (record.lastParamsString === normalizedParams && record.identicalFailures >= IDENTICAL_FAILURE_THRESHOLD) {
728
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited on identical retry loop (0ms)`, false, params);
729
+ 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.`);
730
+ }
731
+ if (record.totalConsecutiveFailures >= ACTION_FAILURE_BUDGET) {
732
+ this.logStatus(` ⚡ Circuit breaker: Action '${action.name}' short-circuited on max retry attempts (0ms)`, false, params);
733
+ 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.`);
734
+ }
735
+ return null;
736
+ }
737
+ /** The failed {@link ActionResult} a blocked call returns in place of dispatching. */
738
+ buildBlockedActionResult(actionEntity, reason, message) {
739
+ const blocked = new CircuitBreakerActionResult(reason);
740
+ blocked.Success = false;
741
+ blocked.Message = message;
742
+ blocked.Params = [];
743
+ blocked.RunParams = new RunActionParams();
744
+ blocked.RunParams.Action = actionEntity;
745
+ return blocked;
746
+ }
747
+ /**
748
+ * Names the rule behind a failed action summary so the directive matches what actually
749
+ * happened. A blocked call reports the rule that blocked it. A dispatched failure is fatal if
750
+ * `recordActionFailure` locked the action out (the summary has no access to the call's
751
+ * arguments, so the decision is read back rather than re-derived from the message); otherwise
752
+ * the budget is checked BEFORE the identical-arguments rule, because once the budget is spent
753
+ * the action is blocked whatever the arguments are, and telling the model to change them
754
+ * would send it in circles.
755
+ */
756
+ classifyActionFailure(summary) {
757
+ if (summary.breakerReason) {
758
+ return summary.breakerReason;
759
+ }
760
+ if (this._fatalActionFailures.has(summary.actionName)) {
761
+ return 'fatal';
762
+ }
763
+ const record = this._actionFailureHistory.get(summary.actionName);
764
+ if (!record) {
765
+ return 'warning';
766
+ }
767
+ if (record.totalConsecutiveFailures >= ACTION_FAILURE_BUDGET) {
768
+ return 'attempts-exhausted';
769
+ }
770
+ if (record.identicalFailures >= IDENTICAL_FAILURE_THRESHOLD) {
771
+ return 'identical-arguments';
772
+ }
773
+ return 'warning';
774
+ }
775
+ /** The guidance line appended to the history for one failed action. */
776
+ formatActionFailureDirective(summary) {
777
+ const name = summary.actionName;
778
+ const record = this._actionFailureHistory.get(name);
779
+ switch (this.classifyActionFailure(summary)) {
780
+ case 'fatal':
781
+ 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.`;
782
+ case 'attempts-exhausted':
783
+ 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.`;
784
+ case 'identical-arguments':
785
+ 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.`;
786
+ default:
787
+ 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.`;
788
+ }
789
+ }
463
790
  /**
464
791
  * Returns the active metadata provider for this agent run. Subclasses MUST
465
792
  * use this getter (rather than `new Metadata()` or `Metadata.Provider`) so
@@ -575,12 +902,16 @@ export class BaseAgent {
575
902
  * }]);
576
903
  * ```
577
904
  */
578
- promoteMediaOutputs(mediaOutputs) {
905
+ PromoteMediaOutputs(mediaOutputs) {
579
906
  if (mediaOutputs && mediaOutputs.length > 0) {
580
907
  this._mediaOutputs.push(...mediaOutputs);
581
908
  this.logStatus(`📎 Promoted ${mediaOutputs.length} media output(s) to agent results`, true);
582
909
  }
583
910
  }
911
+ /** @deprecated Use {@link PromoteMediaOutputs}. */
912
+ promoteMediaOutputs(mediaOutputs) {
913
+ return this.PromoteMediaOutputs(mediaOutputs);
914
+ }
584
915
  /**
585
916
  * Gets the currently accumulated media outputs for this agent run.
586
917
  * @returns Array of promoted media outputs
@@ -830,6 +1161,8 @@ export class BaseAgent {
830
1161
  * @protected
831
1162
  */
832
1163
  get FileOutputs() { return this._fileOutputs; }
1164
+ /** The longest the catalog narrowing decision may take before the run shows the full catalog. */
1165
+ static { this.CATALOG_NARROWING_TIMEOUT_MS = 30000; }
833
1166
  /** The skills activated so far in this run (requested or self-activated), in activation order. */
834
1167
  get ActivatedSkillIDs() {
835
1168
  return this._activatedSkillIDs;
@@ -1196,6 +1529,11 @@ export class BaseAgent {
1196
1529
  this._executeParams = wrappedParams;
1197
1530
  this._agentConfig = undefined;
1198
1531
  this._lastModelSelectionInfo = undefined;
1532
+ this._lastVolatileStateMessage = undefined;
1533
+ this._resolvedTrailingStateMode = undefined;
1534
+ this._turn1InsertionIndex = -1;
1535
+ this._fatalActionFailures.clear();
1536
+ this._actionFailureHistory.clear();
1199
1537
  // Convert UI markup in conversation messages to plain text if requested (default: true)
1200
1538
  if (params.convertUIMarkupToPlainText !== false) {
1201
1539
  this.convertUIMarkupInMessages(wrappedParams.conversationMessages);
@@ -1206,7 +1544,8 @@ export class BaseAgent {
1206
1544
  this._memoryWriteManager.Clear();
1207
1545
  // Arm conversation-history retrieval tools — available only when the run has a
1208
1546
  // conversation to page against (the same gate as all cross-turn context features).
1209
- this._conversationToolManager.Initialize(wrappedParams.conversationId || null, params.contextUser);
1547
+ // A history floor holds here too: the tools page only rows from it onward.
1548
+ this._conversationToolManager.Initialize(wrappedParams.conversationId || null, params.contextUser, wrappedParams.ConversationHistoryFrom ?? null);
1210
1549
  this._conversationToolManager.SetSummaryHost(this.buildConversationSummaryHost(wrappedParams));
1211
1550
  // Initialize artifact tools with any input artifacts attached to the run.
1212
1551
  // Artifacts arrive as a typed first-class field on ExecuteAgentParams —
@@ -1278,6 +1617,9 @@ export class BaseAgent {
1278
1617
  this._dynamicActionLimits = {};
1279
1618
  this._mediaOutputs = [];
1280
1619
  this._messageLifecycleCallback = params.onMessageLifecycle;
1620
+ this._catalogNarrowing = undefined;
1621
+ this._openingRequest = OpeningRequestText(params.conversationMessages);
1622
+ this._isOpeningTurn = IsOpeningTurn(params.conversationMessages);
1281
1623
  // Resolve storage account for file artifacts
1282
1624
  this._resolvedStorageAccountId = await this.getStorageAccountID(wrappedParams);
1283
1625
  // Check for cancellation after Phase 1
@@ -1339,7 +1681,10 @@ export class BaseAgent {
1339
1681
  // Carry the previous turn's tool results forward (no-op without a
1340
1682
  // conversationId). Runs here so the results are in the messages before
1341
1683
  // the pre-turn compaction check and the first prompt.
1342
- this.injectPriorTurnToolResults(wrappedParams)
1684
+ this.injectPriorTurnToolResults(wrappedParams),
1685
+ // Decision discovery (plan Task 3.1), off unless the decisionsEnabled and decisionDiscovery prompt
1686
+ // params are true: may add a <suggested_agent> system message, within DECISION_DISCOVERY_TIMEOUT_MS.
1687
+ this.InjectDecisionDiscovery(params.agent, params.contextUser, wrappedParams.conversationMessages, params.data)
1343
1688
  ]);
1344
1689
  // Inject scope-resolved prompt parts (role-faithful) for this agent's prompt, alongside
1345
1690
  // memory/RAG. Synchronous — parts are cached on AIEngine. Uses the same run scope.
@@ -1428,6 +1773,9 @@ export class BaseAgent {
1428
1773
  // consumers that re-read `params` after the call see what they
1429
1774
  // passed in, not our chained signal.
1430
1775
  params.cancellationToken = upstreamToken;
1776
+ // A cancellation, or an error thrown out of the step loop, leaves any held decision
1777
+ // requests unasked. The terminate path has already logged and cleared its own.
1778
+ this.skipHeldDecisions(params, this.runEndForHeldDecisions());
1431
1779
  this.releasePerRunDataCache();
1432
1780
  await this.finalizeRun(this.deriveRunOutcome());
1433
1781
  }
@@ -1606,6 +1954,11 @@ export class BaseAgent {
1606
1954
  return {
1607
1955
  CoAgent: params.agent,
1608
1956
  TargetAgentID: targetID,
1957
+ // The run's runtime API keys, so the bridged voice session mints on the caller's key when
1958
+ // the run carries one for the realtime driver (the same run-key → platform order as
1959
+ // GetAIAPIKey; realtime does not consult MJ Credentials). Absent ⇒ platform keys, as before.
1960
+ // CreateBridgeRealtimeSession (the LiveKit / telephony factory) passes no apiKeys today.
1961
+ APIKeys: params.apiKeys,
1609
1962
  AgentSessionID: params.data?.agentSessionId ?? '',
1610
1963
  PreferredModelID: modelID,
1611
1964
  ConfigOverridesJson: BuildRealtimeOverridesJson(modelID, voice) ?? undefined,
@@ -1643,13 +1996,22 @@ export class BaseAgent {
1643
1996
  // would dead-end whenever the top model lacked a key — e.g. a power-11 model with no env key
1644
1997
  // (Inworld/AssemblyAI) outranking GPT Realtime — and surface "No usable Realtime model" even though
1645
1998
  // a usable model exists. This mirrors the same fix in RealtimeClientSessionService.
1999
+ // The run's runtime API keys, in GetAIAPIKey's order: the run's key for a driver class, else
2000
+ // the platform's. That is the legacy tier of prompt key resolution — a prompt consults MJ
2001
+ // Credentials / AICredentialBindings first, and realtime does not consult them at all.
2002
+ //
2003
+ // This also affects WHICH vendor runs the session, deliberately. Vendor selection walks
2004
+ // candidates by priority and takes the first whose key resolves, so an organization that
2005
+ // brings its own credential for a vendor we hold no platform key for now reaches that
2006
+ // vendor — which is the point of bringing your own key, not a side effect of it.
2007
+ const resolveAPIKey = MakeAIAPIKeyResolver(params.apiKeys);
1646
2008
  const candidates = this.selectRealtimeModelCandidates(params.agent, overrideModelID);
1647
2009
  for (const model of candidates) {
1648
- const vendor = SelectRealtimeVendorForModel(model.ID);
2010
+ const vendor = SelectRealtimeVendorForModel(model.ID, resolveAPIKey);
1649
2011
  if (!vendor) {
1650
2012
  continue;
1651
2013
  }
1652
- const apiKey = GetAIAPIKey(vendor.DriverClass);
2014
+ const apiKey = resolveAPIKey(vendor.DriverClass);
1653
2015
  if (!apiKey) {
1654
2016
  continue;
1655
2017
  }
@@ -1753,6 +2115,11 @@ export class BaseAgent {
1753
2115
  promptRun.ModelID = modelResolution.modelID;
1754
2116
  promptRun.VendorID = modelResolution.vendorID || null;
1755
2117
  promptRun.AgentID = params.agent.ID;
2118
+ promptRun.UserID = ResolvePromptRunUserID({
2119
+ UserID: params.userId,
2120
+ AgentRun: this._agentRun,
2121
+ ContextUser: params.contextUser,
2122
+ });
1756
2123
  promptRun.Status = 'Running';
1757
2124
  promptRun.RunAt = new Date();
1758
2125
  promptRun.StreamingEnabled = true;
@@ -2260,7 +2627,7 @@ export class BaseAgent {
2260
2627
  }
2261
2628
  // Storage: recording provider, else attachment provider; then that provider's first account.
2262
2629
  const storageAccountId = params.contextUser
2263
- ? await resolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2630
+ ? await ResolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2264
2631
  : null;
2265
2632
  if (!storageAccountId) {
2266
2633
  this.logStatus('🔴 Realtime recording on but no resolvable storage account (RecordingStorageProviderID/AttachmentStorageProviderID) — recording disabled.', false, params);
@@ -2311,7 +2678,7 @@ export class BaseAgent {
2311
2678
  // same mixed PCM as the WAV — persisted as a peaks.json sidecar so the player renders the
2312
2679
  // real waveform without re-decoding the audio. Best-effort: an empty array writes no sidecar.
2313
2680
  const peaks = controller.GetPeaks();
2314
- const stored = await storeRealtimeRecording({
2681
+ const stored = await StoreRealtimeRecording({
2315
2682
  Audio: encoded.Buffer,
2316
2683
  MimeType: 'audio/wav',
2317
2684
  Media: controller.Media,
@@ -2451,7 +2818,7 @@ export class BaseAgent {
2451
2818
  stepCount++;
2452
2819
  // Promote any media outputs from this step to the agent's outputs
2453
2820
  if (nextStep.promoteMediaOutputs && nextStep.promoteMediaOutputs.length > 0) {
2454
- this.promoteMediaOutputs(nextStep.promoteMediaOutputs);
2821
+ this.PromoteMediaOutputs(nextStep.promoteMediaOutputs);
2455
2822
  }
2456
2823
  // Track consecutive failed steps to prevent infinite retry loops.
2457
2824
  // Any non-Failed step resets the counter.
@@ -2509,6 +2876,7 @@ export class BaseAgent {
2509
2876
  // Check if we should continue or terminate
2510
2877
  if (nextStep.terminate) {
2511
2878
  continueExecution = false;
2879
+ this.skipHeldDecisions(params, 'the run ended after the step that carried them');
2512
2880
  this.logStatus(`🏁 Agent '${params.agent.Name}' terminating after ${stepCount} steps with result: ${nextStep.step}`, true, params);
2513
2881
  }
2514
2882
  else {
@@ -2583,6 +2951,25 @@ export class BaseAgent {
2583
2951
  }
2584
2952
  return 'combined';
2585
2953
  }
2954
+ /**
2955
+ * The observability context memory injection runs under for an agent run: the run's ID, the next
2956
+ * step number, and a callback that adds each rerank step to the run's steps. A rerank step carries
2957
+ * the rerank's prompt run, whose cost and token rollups include a prompt-backed reranker's own
2958
+ * prompt runs, so the run's totals and its `MaxCostPerRun` / `MaxTokensPerRun` guardrails count
2959
+ * them. Undefined when there is no run.
2960
+ *
2961
+ * @param run - The current agent run, or null before one exists
2962
+ */
2963
+ MemoryObservability(run) {
2964
+ if (!run) {
2965
+ return undefined;
2966
+ }
2967
+ return {
2968
+ agentRunID: run.ID,
2969
+ stepNumber: (run.Steps?.length || 0) + 1,
2970
+ OnStepCreated: step => run.Steps.push(step),
2971
+ };
2972
+ }
2586
2973
  /**
2587
2974
  * Inject notes and examples into agent context memory.
2588
2975
  * Called automatically before agent execution if injection is enabled on the agent.
@@ -2604,9 +2991,7 @@ export class BaseAgent {
2604
2991
  // Delegate the orchestration to the shared, reusable builder so both BaseAgent and the
2605
2992
  // Realtime agent type inject memory identically. The observability context and verbose
2606
2993
  // status logging are derived from this instance and passed through.
2607
- const observability = this._agentRun
2608
- ? { agentRunID: this._agentRun.ID, stepNumber: (this._agentRun.Steps?.length || 0) + 1 }
2609
- : undefined;
2994
+ const observability = this.MemoryObservability(this._agentRun);
2610
2995
  const result = await new AgentMemoryContextBuilder().InjectContextMemory(input, agent, userId, companyId, contextUser, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, secondaryScopeConfig, observability, (message, verboseOnly) => this.logStatus(message, verboseOnly));
2611
2996
  // Store for inclusion in result (externally observable behavior preserved)
2612
2997
  this._injectedMemory = result;
@@ -2671,6 +3056,301 @@ export class BaseAgent {
2671
3056
  this._injectedRAG = result;
2672
3057
  return result;
2673
3058
  }
3059
+ /**
3060
+ * Decision discovery (plan Task 3.1): suggests the agent to delegate to before the first prompt.
3061
+ *
3062
+ * Runs in parallel with the rest of Phase 2 of `Execute()`, and only when the merged agent-type
3063
+ * prompt params set `decisionsEnabled: true` (the master switch, {@link DecisionsEnabled}) and
3064
+ * `decisionDiscovery: true`, the run answers its conversation's opening request
3065
+ * ({@link IsOpeningTurn}), and that request @mentions no agent. A follow-up turn is never asked
3066
+ * about: it may be continuing work with an agent already engaged, and a suggestion must not pull
3067
+ * the agent away from it. One decision asks which of the agents the user may run (and the host
3068
+ * allows) should handle the request, and whether a specialist should. When both answers are
3069
+ * confident, a `<suggested_agent>` system message is unshifted onto `conversationMessages`, the
3070
+ * way pre-execution RAG adds `<retrieved_context>`. Each discovery is recorded as one
3071
+ * `Agent discovery` Decision step.
3072
+ *
3073
+ * Fails safe: an error, a timeout, a cancelled run, an unusable answer or an unsure one adds
3074
+ * nothing, so the agent behaves as it would without discovery. So do fewer than
3075
+ * {@link DECISION_DISCOVERY_MIN_OPTIONS} options. Never delays the first prompt by more than
3076
+ * {@link DECISION_DISCOVERY_TIMEOUT_MS}, and never throws.
3077
+ *
3078
+ * The any-applies question does not name the options, so the decision cannot say that none of
3079
+ * them fits: see {@link DECISION_DISCOVERY_APPLIES_INSTRUCTIONS} for why it stays that way for now.
3080
+ *
3081
+ * @param agent - The agent being executed.
3082
+ * @param contextUser - The user whose run permission decides the options.
3083
+ * @param conversationMessages - The mutated message array that flows to the LLM (the suggestion is unshifted here).
3084
+ * @param data - The run's `ExecuteAgentParams.data`. Its `__agentTypePromptParams` overrides the agent's prompt
3085
+ * params, and its `ALL_AVAILABLE_AGENTS`, when an array, is the host's allow-list: no other agent is suggested.
3086
+ */
3087
+ async InjectDecisionDiscovery(agent, contextUser, conversationMessages, data) {
3088
+ try {
3089
+ const override = data?.__agentTypePromptParams;
3090
+ const overrides = IsPlainObject(override) ? override : undefined;
3091
+ if (!this.isDecisionDiscoveryOnFor(agent, overrides) || !conversationMessages) {
3092
+ return;
3093
+ }
3094
+ if (!this._openingRequest || !contextUser) {
3095
+ this.logStatus(`Decision discovery skipped for '${agent.Name}': the run has no ${contextUser ? 'user message' : 'context user'}`, true);
3096
+ return;
3097
+ }
3098
+ if (!this._isOpeningTurn) {
3099
+ this.logStatus(`Decision discovery skipped for '${agent.Name}': a follow-up turn, which may continue with an agent already engaged`, true);
3100
+ return;
3101
+ }
3102
+ if (MentionsAgent(this._openingRequest, AIEngine.Instance.Agents, agent.ID)) {
3103
+ this.logStatus(`Decision discovery skipped for '${agent.Name}': the request @mentions an agent`, true);
3104
+ return;
3105
+ }
3106
+ const promptParams = this.decisionDiscoveryPromptParams(agent, overrides);
3107
+ const outcome = await this.runDecisionDiscovery(agent, contextUser, promptParams, HostAllowedAgentIDs(data));
3108
+ if (outcome.Injected && outcome.Message) {
3109
+ conversationMessages.unshift({ role: 'system', content: outcome.Message });
3110
+ this.logStatus(`Decision discovery suggested '${outcome.Answer?.Agent.Name}' to '${agent.Name}'`, true);
3111
+ }
3112
+ }
3113
+ catch (error) {
3114
+ this.warnDecisionDiscovery(agent, error instanceof Error ? error.message : String(error));
3115
+ }
3116
+ }
3117
+ /**
3118
+ * Whether discovery is on for this run: the master switch ({@link DecisionsEnabled}) and
3119
+ * `decisionDiscovery` are both on. Read without rebuilding the merged prompt params, since it is
3120
+ * asked on every run of every agent: each setting from the per-run override when it sets one,
3121
+ * otherwise from the agent's base params, cached on AIEngine with its base catalog. Only a cold
3122
+ * cache builds them.
3123
+ */
3124
+ isDecisionDiscoveryOnFor(agent, overrides) {
3125
+ let base;
3126
+ const paramsSetting = (key) => {
3127
+ if (overrides && key in overrides) {
3128
+ return overrides;
3129
+ }
3130
+ base ??= this.cachedBasePromptParams(agent) ?? this.buildDecisionDiscoveryPromptParams(agent, undefined);
3131
+ return base;
3132
+ };
3133
+ return this.DecisionsEnabled(paramsSetting('decisionsEnabled')) && IsDecisionDiscoveryOn(paramsSetting('decisionDiscovery'));
3134
+ }
3135
+ /**
3136
+ * The run's merged agent-type prompt params (schema defaults < agent < per-run overrides), once
3137
+ * discovery is on: the cached base params when there is no override, otherwise built.
3138
+ */
3139
+ decisionDiscoveryPromptParams(agent, overrides) {
3140
+ const cached = overrides ? undefined : this.cachedBasePromptParams(agent);
3141
+ return cached ?? this.buildDecisionDiscoveryPromptParams(agent, overrides);
3142
+ }
3143
+ /** The agent's base prompt params (no per-run overrides), from the base catalog AIEngine caches, when it holds one. */
3144
+ cachedBasePromptParams(agent) {
3145
+ return AIEngine.Instance.GetAgentBaseCatalog(agent.ID)?.baseAgentTypePromptParams;
3146
+ }
3147
+ /** Builds the merged prompt params, as `gatherPromptTemplateData` does. */
3148
+ buildDecisionDiscoveryPromptParams(agent, overrides) {
3149
+ const agentType = AIEngine.Instance.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
3150
+ return this.buildAgentTypePromptParams(agentType, agent, overrides);
3151
+ }
3152
+ /**
3153
+ * Records one `Agent discovery` step around a bounded discovery, and returns what it found. A
3154
+ * failed discovery is logged as a warning; an unsure one only in verbose logs, except that an
3155
+ * answer from a model with no discovery calibration is warned about once per model, because
3156
+ * then discovery can never suggest anything.
3157
+ */
3158
+ async runDecisionDiscovery(agent, contextUser, promptParams, hostAllowedIDs) {
3159
+ const promptName = typeof promptParams.decisionPromptName === 'string'
3160
+ ? promptParams.decisionPromptName
3161
+ : AgentDecisionService.DEFAULT_PROMPT_NAME;
3162
+ const step = await this.startDecisionDiscoveryStep(contextUser, promptName);
3163
+ const outcome = await this.boundDecisionDiscovery(signal => this.discoverAgent(agent, contextUser, promptName, hostAllowedIDs, signal), step);
3164
+ await this.finishDecisionDiscoveryStep(step, outcome);
3165
+ if (!outcome.Succeeded) {
3166
+ this.warnDecisionDiscovery(agent, outcome.Reason ?? 'unknown error');
3167
+ }
3168
+ else if (!outcome.Injected) {
3169
+ if (outcome.UncalibratedModel) {
3170
+ BaseAgent.warnUncalibratedDiscoveryModel(outcome.UncalibratedModel);
3171
+ }
3172
+ this.logStatus(`Decision discovery for '${agent.Name}' suggested no agent: ${outcome.Reason}`, true);
3173
+ }
3174
+ return outcome;
3175
+ }
3176
+ /** The answering models already warned about as having no discovery calibration, so each is warned about once per process. */
3177
+ static { this._uncalibratedDiscoveryModelsWarned = new Set(); }
3178
+ /**
3179
+ * Warns, once per model, that discovery answered by a model with no discovery calibration
3180
+ * suggests nothing. That happens when a decision model is added or renamed, when a vendor model
3181
+ * moves to another version (Jev's `APIName`), or when LLM Decision answers through a chat model
3182
+ * other than the one its calibration was fitted on; without the warning the only trace is each
3183
+ * `Agent discovery` step's reason.
3184
+ */
3185
+ static warnUncalibratedDiscoveryModel(model) {
3186
+ if (BaseAgent._uncalibratedDiscoveryModelsWarned.has(model)) {
3187
+ return;
3188
+ }
3189
+ BaseAgent._uncalibratedDiscoveryModelsWarned.add(model);
3190
+ LogErrorEx({
3191
+ message: `Decision discovery answered by ${model} suggests nothing: the model has no discovery calibration `
3192
+ + '(DECISION_DISCOVERY_CALIBRATION), so its answers are treated as unsure. Fit one with the agent-discovery '
3193
+ + 'Decision Eval, or check that the model was not renamed or moved to another version.',
3194
+ severity: 'warning',
3195
+ category: 'DecisionDiscovery',
3196
+ });
3197
+ }
3198
+ /**
3199
+ * Runs `discover` until it finishes, {@link DECISION_DISCOVERY_TIMEOUT_MS} passes, or the run is
3200
+ * cancelled. A timeout or a cancellation aborts the signal `discover` was given, which aborts the
3201
+ * decision call, and returns a failed discovery at once. What `discover` returns after that
3202
+ * suggests nothing, but the prompt run of its decision call is still linked to `step` once it
3203
+ * settles ({@link linkLateDecisionPromptRun}), so the run counts the call's cost.
3204
+ */
3205
+ async boundDecisionDiscovery(discover, step) {
3206
+ const controller = new AbortController();
3207
+ let stoppedOutcome;
3208
+ const stopped = new Promise(resolve => controller.signal.addEventListener('abort', () => {
3209
+ stoppedOutcome = FailedDecisionDiscovery(String(controller.signal.reason));
3210
+ resolve(stoppedOutcome);
3211
+ }, { once: true }));
3212
+ const runToken = this._executeParams?.cancellationToken;
3213
+ const relayRunAbort = () => controller.abort('the run was cancelled');
3214
+ if (runToken?.aborted) {
3215
+ relayRunAbort();
3216
+ }
3217
+ else {
3218
+ runToken?.addEventListener('abort', relayRunAbort, { once: true });
3219
+ }
3220
+ const timeoutMS = DECISION_DISCOVERY_TIMEOUT_MS;
3221
+ const timer = setTimeout(() => controller.abort(`the decision timed out after ${timeoutMS}ms`), timeoutMS);
3222
+ try {
3223
+ const discovering = discover(controller.signal);
3224
+ const outcome = await Promise.race([discovering, stopped]);
3225
+ if (outcome === stoppedOutcome) {
3226
+ this.linkLateDecisionPromptRun(step, discovering.then(late => late.Result));
3227
+ }
3228
+ return outcome;
3229
+ }
3230
+ catch (error) {
3231
+ return FailedDecisionDiscovery(error instanceof Error ? error.message : String(error));
3232
+ }
3233
+ finally {
3234
+ clearTimeout(timer);
3235
+ runToken?.removeEventListener('abort', relayRunAbort);
3236
+ }
3237
+ }
3238
+ /**
3239
+ * The discovery itself: the options, rebuilt from the permitted catalog, then one decision about
3240
+ * the opening request, and what its answer means. Never throws.
3241
+ */
3242
+ async discoverAgent(agent, contextUser, promptName, hostAllowedIDs, signal) {
3243
+ try {
3244
+ const { Options: options, Error: optionsError, ...rest } = await this.decisionDiscoveryOptionSet(agent, contextUser, promptName, hostAllowedIDs);
3245
+ const sizes = { ...rest, OptionCount: options.length };
3246
+ if (optionsError) {
3247
+ return { ...FailedDecisionDiscovery(optionsError), ...sizes };
3248
+ }
3249
+ if (options.length < DECISION_DISCOVERY_MIN_OPTIONS) {
3250
+ return { Injected: false, Succeeded: true, Reason: `there are ${options.length} agents to choose from, fewer than the ${DECISION_DISCOVERY_MIN_OPTIONS} a suggestion needs, so nothing was asked`, ...sizes };
3251
+ }
3252
+ if (signal.aborted) {
3253
+ return { ...FailedDecisionDiscovery(String(signal.reason)), ...sizes };
3254
+ }
3255
+ const result = await this._agentDecisionService.Ask({
3256
+ State: this._openingRequest,
3257
+ Questions: BuildDecisionDiscoveryQuestions(options),
3258
+ ContextUser: contextUser,
3259
+ AgentID: agent.ID,
3260
+ PromptName: promptName,
3261
+ CancellationToken: signal,
3262
+ });
3263
+ return { ...DecisionDiscoveryFromResult(result, options, DECISION_DISCOVERY_MIN_CONFIDENCE), ...sizes };
3264
+ }
3265
+ catch (error) {
3266
+ return FailedDecisionDiscovery(error instanceof Error ? error.message : String(error));
3267
+ }
3268
+ }
3269
+ /**
3270
+ * The Choice's options, rebuilt on every call and never cached ({@link BuildDecisionDiscoveryOptionSet}):
3271
+ * from AIEngine's current catalog, the agents the user may run, the decision prompt's option cap,
3272
+ * and the semantic search over the opening request when the provider can run it.
3273
+ */
3274
+ async decisionDiscoveryOptionSet(agent, contextUser, promptName, hostAllowedIDs) {
3275
+ const provider = this.ProviderToUse;
3276
+ return BuildDecisionDiscoveryOptionSet({
3277
+ Agents: await DecisionDiscoveryRunnableAgents(AIEngine.Instance.Agents, contextUser),
3278
+ RunningAgentID: agent.ID,
3279
+ HostAllowedIDs: hostAllowedIDs,
3280
+ DeclaredCap: DecisionPromptOptionCap(AIEngine.Instance, promptName),
3281
+ Search: CanSearchEntities(provider) ? DecisionDiscoveryAgentSearch(provider, this._openingRequest, contextUser) : undefined,
3282
+ });
3283
+ }
3284
+ /** Opens the `Agent discovery` Decision step. Without a run there is no step. */
3285
+ async startDecisionDiscoveryStep(contextUser, promptName) {
3286
+ if (!this._agentRun) {
3287
+ return undefined;
3288
+ }
3289
+ try {
3290
+ return await this.createStepEntity({
3291
+ stepType: 'Decision',
3292
+ stepName: 'Agent discovery',
3293
+ contextUser,
3294
+ inputData: {
3295
+ request: this._openingRequest,
3296
+ promptName,
3297
+ minConfidence: DECISION_DISCOVERY_MIN_CONFIDENCE,
3298
+ timeoutMS: DECISION_DISCOVERY_TIMEOUT_MS,
3299
+ },
3300
+ });
3301
+ }
3302
+ catch (error) {
3303
+ LogError(`Could not record the agent discovery step: ${error instanceof Error ? error.message : String(error)}`);
3304
+ return undefined;
3305
+ }
3306
+ }
3307
+ /**
3308
+ * Finalizes the `Agent discovery` step: the answer, whether it was injected, the host allow-list,
3309
+ * the agents left out for having no description (at most {@link DECISION_DISCOVERY_MAX_RECORDED_IDS}
3310
+ * IDs), any narrowing, and the call's usage.
3311
+ */
3312
+ async finishDecisionDiscoveryStep(step, outcome) {
3313
+ if (!step) {
3314
+ return;
3315
+ }
3316
+ const result = outcome.Result;
3317
+ // Like every decision call, it counts toward the run's cost and tokens through its step
3318
+ if (result) {
3319
+ this.attachDecisionPromptRun(step, result);
3320
+ }
3321
+ const answer = outcome.Answer;
3322
+ const withoutDescription = outcome.WithoutDescription ?? [];
3323
+ await this.finalizeStepEntity(step, outcome.Succeeded, outcome.Succeeded ? undefined : outcome.Reason, {
3324
+ injected: outcome.Injected,
3325
+ reason: outcome.Reason,
3326
+ catalogSize: outcome.CatalogSize,
3327
+ hostAllowList: outcome.HostAllowListSize === undefined ? false : { size: outcome.HostAllowListSize },
3328
+ withoutDescription: { count: withoutDescription.length, agentIds: withoutDescription.slice(0, DECISION_DISCOVERY_MAX_RECORDED_IDS) },
3329
+ options: outcome.OptionCount,
3330
+ optionLimit: outcome.OptionLimit,
3331
+ declaredOptionCap: outcome.DeclaredOptionCap,
3332
+ narrowed: outcome.NarrowedFrom === undefined ? false : { by: 'semantic search', from: outcome.NarrowedFrom, to: outcome.OptionCount },
3333
+ answer: answer ? {
3334
+ agentId: answer.Agent.ID,
3335
+ agent: answer.Agent.Name,
3336
+ confidence: answer.Confidence,
3337
+ anyApplies: answer.AnyApplies,
3338
+ probabilities: answer.Probabilities,
3339
+ } : undefined,
3340
+ executionTimeMS: result?.executionTimeMS,
3341
+ tokensUsed: result?.tokensUsed,
3342
+ model: result?.DecisionResult?.ResolvedModel ?? result?.modelInfo?.modelName,
3343
+ promptRunId: result?.promptRun?.ID,
3344
+ });
3345
+ }
3346
+ /** Logs that decision discovery failed, so nothing was suggested. */
3347
+ warnDecisionDiscovery(agent, reason) {
3348
+ LogErrorEx({
3349
+ message: `Decision discovery for '${agent.Name}' failed, so no agent is suggested: ${reason}`,
3350
+ severity: 'warning',
3351
+ category: 'DecisionDiscovery',
3352
+ });
3353
+ }
2674
3354
  /**
2675
3355
  * Converts UI markup (@{...} syntax) in user messages to plain text.
2676
3356
  * This prevents agents from being confused by UI-specific JSON syntax and reduces token usage.
@@ -2976,7 +3656,7 @@ export class BaseAgent {
2976
3656
  stepEntity.NativeToolCallCount = callCount;
2977
3657
  // A tool call wins, but a turn that ALSO carried a valid
2978
3658
  // envelope gave two answers, and the one we discard has to be counted somewhere.
2979
- stepEntity.NativeDualChannel = callCount > 0 ? looksLikeLoopEnvelope(message?.content) : null;
3659
+ stepEntity.NativeDualChannel = callCount > 0 ? LooksLikeLoopEnvelope(message?.content) : null;
2980
3660
  // whether this step's results went back as native tool-result turns.
2981
3661
  stepEntity.NativeToolResultsSent = GetToolCallingDecision(promptResult?.chatResult)?.toolResults === true;
2982
3662
  if (stepEntity.NativeDualChannel) {
@@ -3006,7 +3686,7 @@ export class BaseAgent {
3006
3686
  if (!turn?.sendResultsNatively || this._lastNativeTurnAppended === turn) {
3007
3687
  return;
3008
3688
  }
3009
- params.conversationMessages.push(buildAssistantToolCallTurn(turn));
3689
+ params.conversationMessages.push(BuildAssistantToolCallTurn(turn));
3010
3690
  this._lastNativeTurnAppended = turn;
3011
3691
  }
3012
3692
  /**
@@ -3051,13 +3731,13 @@ export class BaseAgent {
3051
3731
  }
3052
3732
  results.push({
3053
3733
  toolCallId: action.toolCallId,
3054
- toolName: sanitizeToolName(summary.actionName),
3734
+ toolName: SanitizeToolName(summary.actionName),
3055
3735
  content: this.formatActionResultsAsMarkdown([summary]),
3056
3736
  isError: !summary.success
3057
3737
  });
3058
3738
  }
3059
3739
  if (results.length > 0) {
3060
- params.conversationMessages.push(buildToolResultTurn(results, metadata));
3740
+ params.conversationMessages.push(BuildToolResultTurn(results, metadata));
3061
3741
  }
3062
3742
  if (orphans.length > 0) {
3063
3743
  params.conversationMessages.push({ role: 'user', content: `Action results:\n${this.formatActionResultsAsMarkdown(orphans)}`, metadata });
@@ -3117,7 +3797,7 @@ export class BaseAgent {
3117
3797
  if (unanswered.length === 0) {
3118
3798
  return;
3119
3799
  }
3120
- params.conversationMessages.push(buildToolResultTurn(unanswered.map((call) => ({
3800
+ params.conversationMessages.push(BuildToolResultTurn(unanswered.map((call) => ({
3121
3801
  toolCallId: call.id,
3122
3802
  toolName: call.name,
3123
3803
  content: 'Not executed — the agent did not run this call on this turn. See the message that follows.',
@@ -3129,7 +3809,7 @@ export class BaseAgent {
3129
3809
  * answers, which every provider rejects. Expiry and recovery stub its blocks instead.
3130
3810
  */
3131
3811
  stubToolTurn(message, note) {
3132
- return compactToolResultContent(message, () => note);
3812
+ return CompactToolResultContent(message, () => note);
3133
3813
  }
3134
3814
  applyNativeTools(promptParams, params) {
3135
3815
  this._nativeToolBindings = undefined;
@@ -3147,18 +3827,21 @@ export class BaseAgent {
3147
3827
  return;
3148
3828
  }
3149
3829
  // ...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)));
3830
+ // Catalog narrowing (plan Task 3.7) does not apply here, by design: it shortens the prose
3831
+ // catalog only. A call to an undeclared tool is a Retry, and this mode has no `Actions`
3832
+ // envelope step, so a hidden action or sub-agent left undeclared could not be called at all.
3833
+ const actions = FilterDeclarableActions(this.getEffectiveActionsForValidation(params.agent.ID), AIEngine.Instance.AgentActions.filter((aa) => UUIDsEqual(aa.AgentID, params.agent.ID)));
3151
3834
  const subAgents = this.getEffectiveSubAgentsForValidation(params.agent.ID);
3152
3835
  if (actions.length === 0 && subAgents.length === 0) {
3153
3836
  return;
3154
3837
  }
3155
3838
  try {
3156
- const actionSet = buildActionToolSet(actions, new Map(actions.map((a) => [a.ID, a.Params.Items])));
3839
+ const actionSet = BuildActionToolSet(actions, new Map(actions.map((a) => [a.ID, a.Params.Items])));
3157
3840
  // Under implicit control flow the agent cannot know which model will answer, so it declares the full
3158
3841
  // set — Actions plus the control-flow tools (one per sub-agent, payload_change_request,
3159
3842
  // ask_user) — and NAMES the control ones. The runner keeps them only when the selected
3160
3843
  // model's LLM.NativeControlFlow resolves to 'implicit'; a hybrid model never sees them.
3161
- const toolSet = buildNativeToolSet(actionSet, subAgents);
3844
+ const toolSet = BuildNativeToolSet(actionSet, subAgents);
3162
3845
  promptParams.tools = toolSet.tools;
3163
3846
  promptParams.controlFlowToolNames = toolSet.controlToolNames;
3164
3847
  promptParams.toolChoice = this.resolveToolChoiceForTurn(params);
@@ -3239,6 +3922,11 @@ export class BaseAgent {
3239
3922
  // Attribute the resulting AIPromptRun to this agent. Agents share agent-type-level system
3240
3923
  // prompts, so without this a parent's inference and its sub-agent's are indistinguishable.
3241
3924
  promptParams.agentId = params.agent.ID;
3925
+ promptParams.UserID = ResolvePromptRunUserID({
3926
+ UserID: params.userId,
3927
+ AgentRun: this._agentRun,
3928
+ ContextUser: params.contextUser,
3929
+ }) ?? undefined;
3242
3930
  // Handle case where systemPrompt is optional (e.g., Flow Agent Type)
3243
3931
  if (systemPrompt) {
3244
3932
  promptParams.prompt = systemPrompt;
@@ -3294,9 +3982,9 @@ export class BaseAgent {
3294
3982
  const agentTypePromptParams = promptParams.data.__agentTypePromptParams;
3295
3983
  const scratchpadEnabled = agentTypePromptParams?.includeScratchpadDocs !== false;
3296
3984
  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();
3985
+ promptParams.data[SCRATCHPAD_NOTES_PLACEHOLDER] = this._scratchpadManager.GetNotes() || '_(no notes yet)_';
3986
+ promptParams.data[SCRATCHPAD_TASKS_PLACEHOLDER] = this._scratchpadManager.ToPromptString();
3987
+ promptParams.data[SCRATCHPAD_TASK_SUMMARY_PLACEHOLDER] = this._scratchpadManager.GetTaskSummary();
3300
3988
  }
3301
3989
  // Inject artifact tools template variables if enabled and artifacts are present.
3302
3990
  // Note: prior tool results are NO LONGER injected via a per-turn template var.
@@ -3432,68 +4120,394 @@ export class BaseAgent {
3432
4120
  params.data?.SecondaryScopes,
3433
4121
  }, promptParams);
3434
4122
  }
4123
+ // Prompt-cache layout. The per-iteration state (and, when the child prompt is volatile, the
4124
+ // specialization) never lives in the system prompt; it rides as the FINAL message of THIS request.
4125
+ // In append-only mode (OpenAI prompt caching), prior runtime-state fragments are retained in history
4126
+ // so each turn extends the exact byte prefix of the previous request, maintaining ~93% cache hits.
4127
+ // In replace-in-place mode (Gemini/Cerebras), only the latest fragment is attached, keeping history lean.
4128
+ const volatileStateMessage = await this.buildVolatileStateMessage(params, promptParams, payload, childPrompt, agentType, systemPrompt);
4129
+ if (volatileStateMessage) {
4130
+ const isAppendOnly = this.shouldUseAppendOnlyTrailingState(promptParams);
4131
+ this.restoreTurn1VolatileStateIfNeeded(params, isAppendOnly);
4132
+ promptParams.conversationMessages = this.assembleOutgoingMessages(params.conversationMessages, volatileStateMessage, isAppendOnly);
4133
+ if (isAppendOnly) {
4134
+ params.conversationMessages.push(volatileStateMessage);
4135
+ }
4136
+ this._lastVolatileStateMessage = volatileStateMessage;
4137
+ }
3435
4138
  return promptParams;
3436
4139
  }
3437
4140
  /**
3438
- * Executes the configured prompt. Always uses the attemptJSONRepair option to try to fix LLM
3439
- * JSON syntax issues if they arise.
4141
+ * In append-only mode, restores turn 1's volatile state fragment if mode resolution
4142
+ * was deferred until after turn 1 (e.g. dynamic model selection).
3440
4143
  *
3441
- * @param {AIPromptParams} promptParams - The prompt parameters
3442
- * @returns {Promise<AIPromptRunResult>} The prompt execution result
3443
- * @protected
4144
+ * Restores the fragment at the exact message boundary where turn 1 executed, ensuring
4145
+ * earlier turns in multi-turn conversations are not corrupted.
4146
+ *
4147
+ * This is only correct for a replace → append-only flip between turn 1 and turn 2, when
4148
+ * `_lastVolatileStateMessage` still holds turn 1's fragment. `shouldUseAppendOnlyTrailingState`
4149
+ * freezes the mode at the first model selection precisely so that no later flip can occur.
3444
4150
  */
3445
- async executePrompt(promptParams) {
3446
- const newParams = {
3447
- ...promptParams,
3448
- attemptJSONRepair: true
3449
- };
3450
- return await this._promptRunner.ExecutePrompt(newParams);
4151
+ restoreTurn1VolatileStateIfNeeded(params, isAppendOnly) {
4152
+ if (this._turn1InsertionIndex < 0) {
4153
+ // Record the message boundary at Turn 1 before any loop messages are added
4154
+ this._turn1InsertionIndex = params.conversationMessages.length;
4155
+ }
4156
+ else if (isAppendOnly && this._lastVolatileStateMessage && !this.runHasVolatileStateMessage(params)) {
4157
+ // If append-only was resolved after turn 1 (via _lastModelSelectionInfo),
4158
+ // restore turn 1's fragment at the exact position where turn 1 executed it (the turn 1 boundary)
4159
+ // to ensure exact prefix match without corrupting pre-existing conversation history.
4160
+ const insertIdx = Math.min(this._turn1InsertionIndex, params.conversationMessages.length);
4161
+ params.conversationMessages.splice(insertIdx, 0, this._lastVolatileStateMessage);
4162
+ }
3451
4163
  }
3452
4164
  /**
3453
- * Base class method that determines the next step by contacting the agent type class for the specified agent type and delegating
3454
- * that decision. Sub-classes can override this method to implement custom next step logic if needed.
3455
- * @param params
3456
- * @param agentType
3457
- * @param promptResult
3458
- * @returns
4165
+ * Whether THIS run has already placed a volatile-state fragment in the history, i.e. at or after
4166
+ * the turn-1 boundary. Fragments before the boundary belong to an earlier run whose history the
4167
+ * caller reused; they must not suppress this run's turn-1 restore.
3459
4168
  */
3460
- async determineNextStep(params, agentType, promptResult, currentPayload) {
3461
- // Let the agent type determine the next step
3462
- this.logStatus(`🎯 Agent type '${agentType.Name}' determining next step`, true, params);
3463
- const nextStep = await this.AgentTypeInstance.DetermineNextStep(promptResult, params, currentPayload, this.AgentTypeState, this._nativeToolBindings);
3464
- return nextStep;
4169
+ runHasVolatileStateMessage(params) {
4170
+ const start = Math.max(0, this._turn1InsertionIndex);
4171
+ return params.conversationMessages.slice(start).some(m => m.metadata?.volatileState === true);
3465
4172
  }
3466
4173
  /**
3467
- * Validates if the next step is valid, or not. If the next step is invalid, it returns a retry step with an error message
3468
- * that can be processed by the agent via a retry prompt to attempt to correct the issue. Alternatively, subclasses can
3469
- * handle this scenario differently as desired.
4174
+ * The message array sent for ONE request under `'trailingMessage'` placement: a copy of the history
4175
+ * with every non-system message's fragment tag literals escaped, then the real fragment last.
3470
4176
  *
3471
- * The BaseAgent class implements checking for sub-agents and actions to ensure that the next step is valid in the
3472
- * context of the current agent. If the next step is a sub-agent, it checks if the sub-agent is active and available for execution.
3473
- * If the next step is actions, it checks if the actions are valid and available for execution.
3474
- * @param params
3475
- * @param nextStep
3476
- * @returns
4177
+ * Escaping at send time (rather than where text enters the history) covers every source at once —
4178
+ * user turns, action results, sub-agent results, skill activations — without rewriting stored data,
4179
+ * and it is deterministic, so the cached prefix stays byte-stable across iterations. System messages
4180
+ * and framework-authored volatile state messages are left alone.
3477
4181
  */
3478
- async validateNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
3479
- // Plan Mode enforcement: while active and not yet approved, block Actions/Sub-Agent so the
3480
- // agent cannot skip straight to execution — it must present a Plan first. Chat, Retry,
3481
- // Skill activation, ForEach/While, and ClientTools are all still allowed (e.g. asking a
3482
- // clarifying question, or loading a skill's instructions, before forming the plan).
3483
- if (this._planModeActive && !this._planApproved && (nextStep.step === 'Actions' || nextStep.step === 'Sub-Agent')) {
3484
- // nextStep.step is narrowed to 'Actions' | 'Sub-Agent' here, so it can never already be
3485
- // 'Retry' — always increment (we're demoting it to Retry from a non-retry step).
3486
- this._generalValidationRetryCount++;
3487
- return {
3488
- step: 'Retry',
3489
- terminate: false,
3490
- errorMessage: 'Plan mode is active for this request. Present your plan first via a "Plan" next step and wait for approval before executing actions or sub-agents.'
3491
- };
3492
- }
3493
- // for next step, let's do a little quick validation here for sub-agent and actions to ensure requests are valid
3494
- switch (nextStep.step) {
3495
- case 'Sub-Agent':
3496
- return this.validateSubAgentNextStep(params, nextStep, currentPayload, agentRun, currentStep);
4182
+ assembleOutgoingMessages(history, fragment, isAppendOnly = false) {
4183
+ const source = isAppendOnly ? history : history.filter(m => !m.metadata?.volatileState);
4184
+ const sanitized = source.map(m => (m.role === 'system' || m.metadata?.volatileState ? m : EscapeRuntimeStateTagsInMessage(m)));
4185
+ return [...sanitized, fragment];
4186
+ }
4187
+ /**
4188
+ * Determines whether the current prompt execution should use append-only trailing state retention.
4189
+ *
4190
+ * Why: OpenAI and xAI prompt caching operate on an exact byte prefix match from token 0. Replacing the
4191
+ * trailing runtime-state fragment turn-over-turn breaks the byte prefix after the system prompt,
4192
+ * dropping cache hit rates significantly. In append-only mode, prior runtime state messages are
4193
+ * retained in the message history so each turn is an exact prefix extension of the prior turn,
4194
+ * achieving ~93% cache hit rate. Providers with block-level or sliding caching (Gemini, Cerebras)
4195
+ * use replace-in-place to keep context compact.
4196
+ *
4197
+ * Which providers are which is METADATA, not code: the `PrefixPromptCache` flag in the model
4198
+ * catalog's `ModelConfiguration` cascade (Model Types < Models < Vendors' `Configuration.ModelDefaults`
4199
+ * < Model Vendors), read through {@link resolvePrefixPromptCache}. `true` means append-only;
4200
+ * anything else means replace.
4201
+ *
4202
+ * Decided ONCE per run. An explicit `trailingStateMode` or a runtime model override answers
4203
+ * immediately. Otherwise the answer is frozen at the first model selection and reused for every
4204
+ * later turn, so a failover to another vendor cannot flip the layout mid-run — a flip after turn 2
4205
+ * would leave stale fragments in the history or, worse, restore the wrong turn's fragment. On turn 1,
4206
+ * before any selection is known, the answer is replace-in-place: turn 1's fragment is kept and, if
4207
+ * turn 2 resolves to append-only, spliced back at the turn-1 boundary, which reproduces exactly the
4208
+ * bytes an append-only turn 1 would have sent. Nothing is lost by deferring, so the prompt's bound
4209
+ * models are deliberately NOT consulted — prompts commonly bind several vendors for failover, and
4210
+ * guessing from them mis-pins runs that end up selecting another vendor.
4211
+ */
4212
+ shouldUseAppendOnlyTrailingState(promptParams) {
4213
+ const data = promptParams.data ?? {};
4214
+ // An explicit trailingStateMode wins; 'auto' (the default) or an absent key falls through to
4215
+ // vendor/model detection below. See TrailingStateMode for when to force either mode.
4216
+ const agentTypePromptParams = data.__agentTypePromptParams;
4217
+ if (agentTypePromptParams?.trailingStateMode === 'appendOnly') {
4218
+ return true;
4219
+ }
4220
+ if (agentTypePromptParams?.trailingStateMode === 'replace') {
4221
+ return false;
4222
+ }
4223
+ // A model override pins the serving path for the whole run, so it answers now. A vendor-only
4224
+ // override cannot: the strategy lives on the model-vendor row, which needs the model too, so
4225
+ // that case is decided at the first selection like any other run.
4226
+ if (promptParams.override?.modelId) {
4227
+ const model = AIEngine.Instance?.ModelsByID?.get(NormalizeUUID(promptParams.override.modelId));
4228
+ const vendor = promptParams.override.vendorId ? AIEngine.Instance?.VendorsByID?.get(NormalizeUUID(promptParams.override.vendorId)) : undefined;
4229
+ return this.resolvePrefixPromptCache(model, vendor);
4230
+ }
4231
+ if (this._resolvedTrailingStateMode !== undefined) {
4232
+ return this._resolvedTrailingStateMode;
4233
+ }
4234
+ if (this._lastModelSelectionInfo) {
4235
+ const model = this._lastModelSelectionInfo.ModelSelected;
4236
+ const vendor = this._lastModelSelectionInfo.vendorSelected;
4237
+ this._resolvedTrailingStateMode = this.resolvePrefixPromptCache(model, vendor);
4238
+ return this._resolvedTrailingStateMode;
4239
+ }
4240
+ // Turn 1, nothing known yet: replace-in-place, resolved for good on turn 2 (see above).
4241
+ return false;
4242
+ }
4243
+ /**
4244
+ * Whether a model, as served by a vendor, sits behind a byte-prefix prompt cache
4245
+ * (`LLM.PrefixPromptCache`), read from the model catalog's `ModelConfiguration` cascade —
4246
+ * `AIModelType < AIModel < AIVendor.Configuration.ModelDefaults < AIModelVendor` — via
4247
+ * `AIEngine.GetEffectiveModelConfiguration`. The most specific layer is the INFERENCE-PROVIDER
4248
+ * model-vendor row for `vendor`, whose vendor row supplies the host-wide default that beats the
4249
+ * model's own bag; when the vendor is unknown, or has no inference row for this model, the model
4250
+ * and type layers still answer. False when no layer declares it, which callers
4251
+ * treat as a block cache (replace-in-place).
4252
+ *
4253
+ * Extension point: a subclass with out-of-catalog knowledge (an OpenAI-compatible gateway whose
4254
+ * rows carry no flag, say) can override this rather than the mode decision above.
4255
+ */
4256
+ resolvePrefixPromptCache(model, vendor) {
4257
+ if (!model) {
4258
+ return false;
4259
+ }
4260
+ const engine = AIEngine.Instance;
4261
+ const modelVendor = vendor
4262
+ ? (engine.ModelVendorsByModelID?.get(NormalizeUUID(model.ID)) ?? []).find(mv => UUIDsEqual(mv.VendorID, vendor.ID) && engine.IsInferenceProvider(mv))
4263
+ : undefined;
4264
+ return IsPrefixPromptCache(engine.GetEffectiveModelConfiguration(model.ID, modelVendor?.ID));
4265
+ }
4266
+ /**
4267
+ * Builds the framework-authored `user` message that carries the loop agent's volatile state as the
4268
+ * final message of the request; returns null only when every block is turned off, or when the
4269
+ * system prompt template in this database has not yet synced and still embeds the state itself
4270
+ * (see the guard below). The blocks mirror the sections the template used to render (see
4271
+ * {@link RuntimeStateFragmentBuilder}), and each honors the same include flag the template did.
4272
+ *
4273
+ * Why this exists: provider prompt caching is a prefix match over tools → system → messages, so
4274
+ * state that changes every iteration INSIDE the system prompt invalidates the entire history each
4275
+ * call. Measured on Sage: 36% → 83% cached on Gemini 2.5 Flash, 12% → 96% on Claude Opus 5 (with the
4276
+ * Anthropic adapter placing its breakpoint before this message), output quality unchanged.
4277
+ *
4278
+ * The message carries STATE only — never rules. It is marked `metadata.volatileState` so adapters
4279
+ * can recognize it without depending on this package's tag names.
4280
+ */
4281
+ async buildVolatileStateMessage(params, promptParams, payload, childPrompt, agentType, systemPrompt) {
4282
+ const data = promptParams.data ?? {};
4283
+ const agentTypePromptParams = data.__agentTypePromptParams;
4284
+ // Delivery gate: the fragment is emitted only for a system prompt whose template points the model
4285
+ // at it. See resolveRuntimeStateDelivery for the two ways a template can fail that test.
4286
+ const effectiveSystemPrompt = systemPrompt ?? promptParams.prompt;
4287
+ const delivery = await this.resolveRuntimeStateDelivery(effectiveSystemPrompt, params.contextUser);
4288
+ if (delivery === 'embedded') {
4289
+ this.logStatus('⚠️ System prompt template still contains volatile blocks (database template unsynced); skipping trailing runtime-state fragment to avoid duplicate state.', true, params);
4290
+ return null;
4291
+ }
4292
+ if (delivery === 'unsupported') {
4293
+ this.logStatus(`System prompt template has no <${RUNTIME_STATE_TAG}> pointer (not a Loop agent system prompt); skipping trailing runtime-state fragment.`, true, params);
4294
+ return null;
4295
+ }
4296
+ const includeDateTime = agentTypePromptParams?.includeDateTimeInPrompt !== false;
4297
+ const includeScratchpad = agentTypePromptParams?.includeScratchpadDocs !== false;
4298
+ const includePayload = agentTypePromptParams?.includePayloadInPrompt !== false;
4299
+ const specialization = await this.resolveRelocatedSpecialization(promptParams, childPrompt, agentType, params.contextUser);
4300
+ const fragment = new RuntimeStateFragmentBuilder().Build({
4301
+ DateTime: includeDateTime ? await this.resolveFragmentDateTime(promptParams) : null,
4302
+ Scratchpad: includeScratchpad ? this.readScratchpadFromTemplateData(data) : null,
4303
+ // Same value the agent type injects for the template (`payload || {}`), so both placements agree.
4304
+ Payload: includePayload ? { Value: payload || {} } : null,
4305
+ Specialization: specialization,
4306
+ });
4307
+ if (!fragment) {
4308
+ return null;
4309
+ }
4310
+ this.logStatus(`📦 Volatile state → trailing message (${fragment.length} chars${specialization ? ', specialization relocated' : ''})`, true, params);
4311
+ return { role: 'user', content: fragment, metadata: { volatileState: true, turnAdded: this._promptTurnCount } };
4312
+ }
4313
+ /**
4314
+ * Decides whether this run's specialization (child prompt) rides in the trailing message, and if so
4315
+ * pre-renders it and flags the template to render a stub in its place. Decided from the child
4316
+ * template's UNRENDERED text via {@link ResolveSpecializationPlacement}, so the answer is the same on
4317
+ * every iteration and the layout never flips mid-run. Returns the rendered specialization, or null
4318
+ * when it stays in the system prompt.
4319
+ */
4320
+ async resolveRelocatedSpecialization(promptParams, childPrompt, agentType, contextUser) {
4321
+ const placeholder = agentType.AgentPromptPlaceholder;
4322
+ if (!childPrompt || !placeholder || !promptParams.childPrompts || promptParams.childPrompts.length === 0) {
4323
+ return null;
4324
+ }
4325
+ const templateText = await this.loadChildPromptTemplateText(childPrompt, contextUser);
4326
+ const agentTypePromptParams = promptParams.data?.__agentTypePromptParams;
4327
+ if (ResolveSpecializationPlacement(agentTypePromptParams, templateText) !== 'trailingMessage') {
4328
+ return null;
4329
+ }
4330
+ const rendered = await this._promptRunner.RenderChildPromptTemplates(promptParams.childPrompts, promptParams);
4331
+ const text = rendered.renderedTemplates[placeholder];
4332
+ if (!text || text.trim().length === 0) {
4333
+ return null;
4334
+ }
4335
+ // Cache pre-rendered child templates so AIPromptRunner.ExecutePrompt does not re-render them
4336
+ promptParams.PreRenderedChildTemplates = rendered.renderedTemplates;
4337
+ // The same data object the parent template renders against — this switches the `## Specialization`
4338
+ // block to its stub and extends the Runtime State pointer.
4339
+ if (promptParams.data) {
4340
+ promptParams.data._SPECIALIZATION_RELOCATED = true;
4341
+ }
4342
+ return text;
4343
+ }
4344
+ /**
4345
+ * Decides, from the system prompt's UNRENDERED template text, whether the trailing runtime-state
4346
+ * fragment belongs on this request:
4347
+ *
4348
+ * - `'trailing'` — the template carries the `<mj-runtime-state>` pointer, so the model is told where
4349
+ * the state lives. The Loop agent system prompt.
4350
+ * - `'embedded'` — the template still renders the state blocks itself (an environment whose
4351
+ * TemplateContent has not synced the new Loop template, or the Flow template, which embeds the
4352
+ * payload). Emitting the fragment would deliver the same state twice.
4353
+ * - `'unsupported'` — the template has neither. The Harness system prompt, or a custom prompt run
4354
+ * without the Loop system prompt. The model would receive an unexplained block.
4355
+ * - `'unknown'` — no template text to inspect (no TemplateID, or the lookup failed). The caller
4356
+ * fails OPEN here: a Loop agent losing its payload from the model's view is far worse than a
4357
+ * non-Loop agent receiving an unexplained fragment, and in practice every agent type's system
4358
+ * prompt has a template, so this arises only from a lookup failure.
4359
+ */
4360
+ async resolveRuntimeStateDelivery(systemPrompt, contextUser) {
4361
+ const templateText = await this.loadPromptTemplateText(systemPrompt, contextUser);
4362
+ if (templateText === null) {
4363
+ return 'unknown';
4364
+ }
4365
+ if (this.templateTextEmbedsVolatileState(templateText)) {
4366
+ return 'embedded';
4367
+ }
4368
+ return templateText.includes(`<${RUNTIME_STATE_TAG}>`) ? 'trailing' : 'unsupported';
4369
+ }
4370
+ /**
4371
+ * The strings whose presence in a system prompt's unrendered template text means the template
4372
+ * still renders the volatile state itself, so the trailing fragment must be suppressed. Defaults
4373
+ * to {@link VOLATILE_TEMPLATE_MARKERS}: the three block headings plus the date and payload
4374
+ * placeholders. Extension point — an agent type whose template lays the state out under other
4375
+ * headings overrides this to return its own markers.
4376
+ */
4377
+ get volatileTemplateMarkers() {
4378
+ return VOLATILE_TEMPLATE_MARKERS;
4379
+ }
4380
+ /**
4381
+ * True when unrendered template text contains any of {@link volatileTemplateMarkers} — the legacy
4382
+ * Loop layout, or any template that embeds the payload.
4383
+ */
4384
+ templateTextEmbedsVolatileState(templateText) {
4385
+ return this.volatileTemplateMarkers.some(marker => templateText.includes(marker));
4386
+ }
4387
+ /**
4388
+ * Raw template text (placeholders intact) for an AI prompt from the cached template engine.
4389
+ * Null when the prompt has no template or the lookup fails.
4390
+ */
4391
+ async loadPromptTemplateText(prompt, contextUser) {
4392
+ if (!prompt?.TemplateID) {
4393
+ return null;
4394
+ }
4395
+ try {
4396
+ await TemplateEngineServer.Instance.Config(false, contextUser);
4397
+ const template = TemplateEngineServer.Instance.Templates?.find(t => UUIDsEqual(t.ID, prompt.TemplateID));
4398
+ return template?.GetHighestPriorityContent()?.TemplateText ?? null;
4399
+ }
4400
+ catch (e) {
4401
+ this.logError(e instanceof Error ? e : String(e), { category: 'RuntimeStateFragment', severity: 'warning' });
4402
+ return null;
4403
+ }
4404
+ }
4405
+ /**
4406
+ * The child prompt's raw template text (placeholders intact), from the cached template engine.
4407
+ * Null when the prompt has no template or the lookup fails — which fails CLOSED: with no text to
4408
+ * inspect, {@link ResolveSpecializationPlacement} keeps the specialization in the system prompt.
4409
+ */
4410
+ async loadChildPromptTemplateText(childPrompt, contextUser) {
4411
+ return this.loadPromptTemplateText(childPrompt, contextUser);
4412
+ }
4413
+ /**
4414
+ * The date/time strings exactly as the system placeholders would render them into the template.
4415
+ * Resolves only the three temporal placeholders (by name, through the same registry the template
4416
+ * uses, so a registered override applies here too) rather than every system placeholder — this runs
4417
+ * on every loop iteration.
4418
+ */
4419
+ async resolveFragmentDateTime(promptParams) {
4420
+ const resolve = async (name) => {
4421
+ const placeholder = SystemPlaceholderManager.getPlaceholders().find(p => p.name === name);
4422
+ if (!placeholder) {
4423
+ return null;
4424
+ }
4425
+ try {
4426
+ const value = await placeholder.getValue(promptParams);
4427
+ return value == null ? null : String(value);
4428
+ }
4429
+ catch (e) {
4430
+ this.logError(e instanceof Error ? e : String(e), { category: 'RuntimeStateFragment', severity: 'warning', metadata: { placeholder: name } });
4431
+ return null;
4432
+ }
4433
+ };
4434
+ const [date, dayOfWeek, time] = await Promise.all([resolve(CURRENT_DATE_PLACEHOLDER), resolve(CURRENT_DAY_OF_WEEK_PLACEHOLDER), resolve(CURRENT_TIME_PLACEHOLDER)]);
4435
+ if (!date || !dayOfWeek || !time) {
4436
+ return null;
4437
+ }
4438
+ return { Date: date, DayOfWeek: dayOfWeek, Time: time };
4439
+ }
4440
+ /**
4441
+ * The scratchpad strings already placed in the template data by the prep step (so the fragment shows
4442
+ * exactly what the template would have). Null when the scratchpad is disabled or absent.
4443
+ */
4444
+ readScratchpadFromTemplateData(data) {
4445
+ const notes = data[SCRATCHPAD_NOTES_PLACEHOLDER], tasks = data[SCRATCHPAD_TASKS_PLACEHOLDER], summary = data[SCRATCHPAD_TASK_SUMMARY_PLACEHOLDER];
4446
+ if (typeof notes !== 'string' || typeof tasks !== 'string' || typeof summary !== 'string') {
4447
+ return null;
4448
+ }
4449
+ return { Notes: notes, Tasks: tasks, TaskSummary: summary };
4450
+ }
4451
+ /**
4452
+ * Executes the configured prompt. Always uses the attemptJSONRepair option to try to fix LLM
4453
+ * JSON syntax issues if they arise.
4454
+ *
4455
+ * @param {AIPromptParams} promptParams - The prompt parameters
4456
+ * @returns {Promise<AIPromptRunResult>} The prompt execution result
4457
+ * @protected
4458
+ */
4459
+ async executePrompt(promptParams) {
4460
+ const newParams = {
4461
+ ...promptParams,
4462
+ attemptJSONRepair: true
4463
+ };
4464
+ return await this._promptRunner.ExecutePrompt(newParams);
4465
+ }
4466
+ /**
4467
+ * Base class method that determines the next step by contacting the agent type class for the specified agent type and delegating
4468
+ * that decision. Sub-classes can override this method to implement custom next step logic if needed.
4469
+ * @param params
4470
+ * @param agentType
4471
+ * @param promptResult
4472
+ * @returns
4473
+ */
4474
+ async determineNextStep(params, agentType, promptResult, currentPayload) {
4475
+ // Let the agent type determine the next step
4476
+ this.logStatus(`🎯 Agent type '${agentType.Name}' determining next step`, true, params);
4477
+ const nextStep = await this.AgentTypeInstance.DetermineNextStep(promptResult, params, currentPayload, this.AgentTypeState, this._nativeToolBindings);
4478
+ return nextStep;
4479
+ }
4480
+ /**
4481
+ * Validates if the next step is valid, or not. If the next step is invalid, it returns a retry step with an error message
4482
+ * that can be processed by the agent via a retry prompt to attempt to correct the issue. Alternatively, subclasses can
4483
+ * handle this scenario differently as desired.
4484
+ *
4485
+ * The BaseAgent class implements checking for sub-agents and actions to ensure that the next step is valid in the
4486
+ * context of the current agent. If the next step is a sub-agent, it checks if the sub-agent is active and available for execution.
4487
+ * If the next step is actions, it checks if the actions are valid and available for execution.
4488
+ * @param params
4489
+ * @param nextStep
4490
+ * @returns
4491
+ */
4492
+ async validateNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
4493
+ // Plan Mode enforcement: while active and not yet approved, block Actions/Sub-Agent so the
4494
+ // agent cannot skip straight to execution — it must present a Plan first. Chat, Retry,
4495
+ // Skill activation, ForEach/While, and ClientTools are all still allowed (e.g. asking a
4496
+ // clarifying question, or loading a skill's instructions, before forming the plan).
4497
+ if (this._planModeActive && !this._planApproved && (nextStep.step === 'Actions' || nextStep.step === 'Sub-Agent')) {
4498
+ // nextStep.step is narrowed to 'Actions' | 'Sub-Agent' here, so it can never already be
4499
+ // 'Retry' — always increment (we're demoting it to Retry from a non-retry step).
4500
+ this._generalValidationRetryCount++;
4501
+ return {
4502
+ step: 'Retry',
4503
+ terminate: false,
4504
+ errorMessage: 'Plan mode is active for this request. Present your plan first via a "Plan" next step and wait for approval before executing actions or sub-agents.'
4505
+ };
4506
+ }
4507
+ // for next step, let's do a little quick validation here for sub-agent and actions to ensure requests are valid
4508
+ switch (nextStep.step) {
4509
+ case 'Sub-Agent':
4510
+ return this.validateSubAgentNextStep(params, nextStep, currentPayload, agentRun, currentStep);
3497
4511
  case 'Actions':
3498
4512
  return this.validateActionsNextStep(params, nextStep, currentPayload, agentRun, currentStep);
3499
4513
  case 'Success':
@@ -4120,23 +5134,95 @@ export class BaseAgent {
4120
5134
  // Default behavior: let Chat propagate up (no remapping)
4121
5135
  return nextStep;
4122
5136
  }
4123
- /**
4124
- * Checks execution guardrails and modifies next step if limits are exceeded.
4125
- * This method is called after validation but before execution of non-terminal steps.
4126
- *
4127
- * @param params - Execution parameters
4128
- * @param nextStep - The validated next step
4129
- * @param currentPayload - Current payload
4130
- * @param agentRun - Current agent run
4131
- * @param currentStep - Current execution step
4132
- * @returns Modified next step if guardrails exceeded, or original next step
4133
- * @protected
4134
- */
5137
+ async applySelfCheck(params, nextStep, agentRun, currentPayload) {
5138
+ if (params.cancellationToken?.aborted)
5139
+ return null;
5140
+ const provider = params.provider || this._activeProvider;
5141
+ let row = this._selfCheckLinkByAgent.get(agentRun.AgentID);
5142
+ if (row === undefined) {
5143
+ try {
5144
+ const view = RunView.FromMetadataProvider(provider);
5145
+ const found = await view.RunView({
5146
+ EntityName: 'MJ: AI Agent Rubrics',
5147
+ ExtraFilter: `AgentID='${agentRun.AgentID}' AND Purpose='SelfCheck' AND Status='Active'`,
5148
+ OrderBy: 'ID',
5149
+ ResultType: 'simple',
5150
+ }, params.contextUser);
5151
+ if (!found.Success) {
5152
+ LogError(`Self-check lookup failed, so the run continues: ${found.ErrorMessage || 'the view did not succeed'}`);
5153
+ return null;
5154
+ }
5155
+ row = PickSelfCheckLink((found.Results ?? [])) ?? null;
5156
+ this._selfCheckLinkByAgent.set(agentRun.AgentID, row);
5157
+ }
5158
+ catch (error) {
5159
+ LogError(`Self-check lookup failed, so the run continues: ${error instanceof Error ? error.message : String(error)}`);
5160
+ return null;
5161
+ }
5162
+ }
5163
+ if (!row?.RubricID)
5164
+ return null;
5165
+ const link = {
5166
+ Purpose: String(row.Purpose ?? ''),
5167
+ Status: String(row.Status ?? ''),
5168
+ MaxAttempts: row.MaxSelfCheckAttempts ?? null,
5169
+ rubricId: String(row.RubricID),
5170
+ passThreshold: row.PassThreshold ?? null,
5171
+ };
5172
+ const attempt = (this._selfCheckAttemptsByRun.get(agentRun.ID) ?? 0) + 1;
5173
+ this._selfCheckAttemptsByRun.set(agentRun.ID, attempt);
5174
+ const record = async (step) => {
5175
+ const saved = await this.createStepEntity({
5176
+ stepType: 'Validation',
5177
+ stepName: 'Rubric self-check',
5178
+ contextUser: params.contextUser,
5179
+ inputData: { rubricEvaluationId: step.EvaluationId, passed: step.Passed },
5180
+ });
5181
+ await this.finalizeStepEntity(saved, step.Passed, step.Message || undefined);
5182
+ };
5183
+ try {
5184
+ const outcome = await ExecuteSelfCheck({
5185
+ engine: ProviderRubricEngine(provider, params.contextUser),
5186
+ link,
5187
+ runId: agentRun.ID,
5188
+ agentKind: this.AgentTypeInstance?.constructor?.name === 'LoopAgentType' ? 'loop' : 'flow',
5189
+ attempt,
5190
+ candidate: { Message: nextStep.message, Payload: nextStep.newPayload ?? currentPayload },
5191
+ record,
5192
+ });
5193
+ if (outcome.step === 'Success')
5194
+ return null;
5195
+ if (outcome.step === 'Retry')
5196
+ return { ...nextStep, step: 'Retry', message: outcome.decision.Message };
5197
+ return { ...nextStep, step: 'Failed', message: outcome.decision.Message, errorMessage: outcome.decision.Message };
5198
+ }
5199
+ catch (error) {
5200
+ const message = error instanceof Error ? error.message : String(error);
5201
+ LogError(`Self-check could not be saved, so the run continues: ${message}`);
5202
+ try {
5203
+ await record({ Passed: false, Message: message });
5204
+ }
5205
+ catch (recordError) {
5206
+ LogError(`Self-check validation step could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}`);
5207
+ }
5208
+ return null;
5209
+ }
5210
+ }
5211
+ /** Runs the self-check when a success exit did not pass through the guardrail hook. */
5212
+ async selfCheckSuccess(params, step, payload) {
5213
+ if (step.step !== 'Success' || !this._agentRun)
5214
+ return step;
5215
+ const checked = await this.applySelfCheck(params, step, this._agentRun, payload);
5216
+ return checked ?? step;
5217
+ }
4135
5218
  async checkExecutionGuardrails(params, nextStep, currentPayload, agentRun, currentStep) {
4136
- // Skip guardrail checks for terminal steps
4137
- if (nextStep.step === 'Success' || nextStep.step === 'Failed' || nextStep.step === 'Chat') {
5219
+ if (nextStep.step === 'Failed' || nextStep.step === 'Chat') {
4138
5220
  return nextStep;
4139
5221
  }
5222
+ if (nextStep.step === 'Success') {
5223
+ const checked = await this.applySelfCheck(params, nextStep, agentRun, currentPayload);
5224
+ return checked ?? nextStep;
5225
+ }
4140
5226
  // Check if any guardrails are exceeded
4141
5227
  const guardrailResult = await this.hasExceededAgentRunGuardrails(params, agentRun);
4142
5228
  if (guardrailResult.exceeded) {
@@ -4211,7 +5297,7 @@ export class BaseAgent {
4211
5297
  }
4212
5298
  // Check absolute maximum iterations (safety net to prevent infinite loops)
4213
5299
  const absoluteMaxIterations = params.absoluteMaxIterations ?? BaseAgent.DEFAULT_ABSOLUTE_MAX_ITERATIONS;
4214
- if (agentRun.TotalPromptIterations && agentRun.TotalPromptIterations >= absoluteMaxIterations) {
5300
+ if (agentRun.TotalPromptIterations != null && agentRun.TotalPromptIterations >= absoluteMaxIterations) {
4215
5301
  return {
4216
5302
  exceeded: true,
4217
5303
  type: 'iterations',
@@ -4221,7 +5307,7 @@ export class BaseAgent {
4221
5307
  };
4222
5308
  }
4223
5309
  // Check cost limit
4224
- if (agent.MaxCostPerRun && agentRun.TotalCost) {
5310
+ if (agent.MaxCostPerRun != null && agentRun.TotalCost != null) {
4225
5311
  if (agentRun.TotalCost >= agent.MaxCostPerRun) {
4226
5312
  return {
4227
5313
  exceeded: true,
@@ -4233,7 +5319,7 @@ export class BaseAgent {
4233
5319
  }
4234
5320
  }
4235
5321
  // Check token limit
4236
- if (agent.MaxTokensPerRun && agentRun.TotalTokensUsed) {
5322
+ if (agent.MaxTokensPerRun != null && agentRun.TotalTokensUsed != null) {
4237
5323
  if (agentRun.TotalTokensUsed >= agent.MaxTokensPerRun) {
4238
5324
  return {
4239
5325
  exceeded: true,
@@ -4245,7 +5331,7 @@ export class BaseAgent {
4245
5331
  }
4246
5332
  }
4247
5333
  // Check iteration limit
4248
- if (agent.MaxIterationsPerRun && agentRun.TotalPromptIterations) {
5334
+ if (agent.MaxIterationsPerRun != null && agentRun.TotalPromptIterations != null) {
4249
5335
  if (agentRun.TotalPromptIterations >= agent.MaxIterationsPerRun) {
4250
5336
  return {
4251
5337
  exceeded: true,
@@ -4257,7 +5343,7 @@ export class BaseAgent {
4257
5343
  }
4258
5344
  }
4259
5345
  // Check time limit
4260
- if (agent.MaxTimePerRun && agentRun.StartedAt) {
5346
+ if (agent.MaxTimePerRun != null && agentRun.StartedAt) {
4261
5347
  const elapsedSeconds = Math.floor((Date.now() - new Date(agentRun.StartedAt).getTime()) / 1000);
4262
5348
  if (elapsedSeconds >= agent.MaxTimePerRun) {
4263
5349
  return {
@@ -4625,6 +5711,60 @@ export class BaseAgent {
4625
5711
  serializePayloadAtEnd(payload) {
4626
5712
  return payload ? JSON.stringify(payload) : null;
4627
5713
  }
5714
+ /**
5715
+ * Recovery Strategy 0: drop stale runtime-state fragments.
5716
+ *
5717
+ * Under append-only trailing-state retention (prefix-cache providers: OpenAI, xAI) every
5718
+ * iteration leaves its `<mj-runtime-state>` message in the history so the next request is an
5719
+ * exact prefix extension of the last. Those copies are cached tokens on the wire, but they are
5720
+ * context all the same, and they carry nothing the model needs: the CURRENT state always rides
5721
+ * as the fresh fragment appended to the outgoing request. So when the context overflows they
5722
+ * are the first thing to go, oldest first, all but the most recent. Keeping the newest one
5723
+ * matters for two reasons: it is the history's only fragment after this pass, so
5724
+ * {@link restoreTurn1VolatileStateIfNeeded} does not splice a turn-1 copy back in, and it
5725
+ * keeps the prefix intact from that point forward. The cost is one cache miss on the next call;
5726
+ * the alternative was a failed run.
5727
+ *
5728
+ * A no-op under replace-in-place retention, where the history never holds a fragment.
5729
+ *
5730
+ * @param params - Agent execution parameters
5731
+ * @param tokensToSave - Target number of tokens to free
5732
+ * @param currentStepCount - Current turn number, for the lifecycle event
5733
+ * @returns Result with tokens saved and strategy description
5734
+ * @protected
5735
+ */
5736
+ recoveryStrategy_DropStaleVolatileState(params, tokensToSave, currentStepCount) {
5737
+ const fragmentIndices = params.conversationMessages
5738
+ .map((msg, index) => (msg.metadata?.volatileState === true ? index : -1))
5739
+ .filter(index => index >= 0);
5740
+ // All but the most recent, oldest first.
5741
+ const stale = fragmentIndices.slice(0, -1);
5742
+ if (stale.length === 0) {
5743
+ return { tokensSaved: 0, strategyName: 'No stale runtime-state fragments to drop' };
5744
+ }
5745
+ let tokensSaved = 0;
5746
+ const removedIndices = [];
5747
+ for (const index of stale) {
5748
+ if (tokensSaved >= tokensToSave)
5749
+ break;
5750
+ removedIndices.push(index);
5751
+ tokensSaved += this.estimateTokens(params.conversationMessages[index].content);
5752
+ }
5753
+ // Remove in reverse order to keep the remaining indices valid.
5754
+ removedIndices.sort((a, b) => b - a).forEach(index => {
5755
+ const removed = params.conversationMessages.splice(index, 1)[0];
5756
+ this.emitMessageLifecycleEvent({
5757
+ type: 'message-removed',
5758
+ turn: currentStepCount,
5759
+ messageIndex: index,
5760
+ message: removed,
5761
+ reason: 'Context recovery - stale runtime-state fragment (append-only retention)',
5762
+ tokensSaved: this.estimateTokens(removed.content)
5763
+ });
5764
+ });
5765
+ this.logStatus(`Dropped ${removedIndices.length} stale runtime-state fragment(s) (${tokensSaved} tokens); ${fragmentIndices.length - removedIndices.length} retained`, true, params);
5766
+ return { tokensSaved, strategyName: `Dropped ${removedIndices.length} stale runtime-state fragment(s) retained for prefix caching` };
5767
+ }
4628
5768
  /**
4629
5769
  * Recovery Strategy 1: Remove oldest tool-result messages.
4630
5770
  * Targets messages older than minAge turns for removal.
@@ -4730,7 +5870,7 @@ export class BaseAgent {
4730
5870
  : JSON.stringify(originalMessage.content);
4731
5871
  if (originalMessage.role === 'tool') {
4732
5872
  // 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));
5873
+ const compacted = CompactToolResultContent(originalMessage, (t) => (t.length > 500 ? `${t.slice(0, 500)}… [compacted from ${t.length} chars]` : t));
4734
5874
  const saved = originalTokens - this.estimateTokens(compacted.content);
4735
5875
  if (saved > 0) {
4736
5876
  params.conversationMessages[candidate.index] = {
@@ -4845,10 +5985,13 @@ export class BaseAgent {
4845
5985
  * @protected
4846
5986
  */
4847
5987
  recoveryStrategy_TrimLastUserMessage(params, tokensToSave) {
4848
- // Find the last user message (reverse search for compatibility)
5988
+ // Find the last user message (reverse search for compatibility). A retained runtime-state
5989
+ // fragment is user-role but is framework state, not the user's request: trimming it would
5990
+ // leave a damaged copy in the history and spare the message this strategy is meant to trim.
4849
5991
  let lastUserMessageIndex = -1;
4850
5992
  for (let i = params.conversationMessages.length - 1; i >= 0; i--) {
4851
- if (params.conversationMessages[i].role === 'user') {
5993
+ const candidate = params.conversationMessages[i];
5994
+ if (candidate.role === 'user' && candidate.metadata?.volatileState !== true) {
4852
5995
  lastUserMessageIndex = i;
4853
5996
  break;
4854
5997
  }
@@ -4922,6 +6065,9 @@ export class BaseAgent {
4922
6065
  const currentPromptTurn = this._promptTurnCount;
4923
6066
  // Try multiple recovery strategies in order
4924
6067
  const strategies = [
6068
+ // Retained runtime-state fragments (append-only mode) are pure cache filler: stale copies of
6069
+ // state the next request re-sends anyway. Freeing them costs one cache miss, never content.
6070
+ () => this.recoveryStrategy_DropStaleVolatileState(params, tokensToSave, currentPromptTurn),
4925
6071
  () => this.recoveryStrategy_RemoveOldestToolResults(params, tokensToSave, currentPromptTurn, 5),
4926
6072
  () => this.recoveryStrategy_CompactOldToolResults(params, tokensToSave, currentPromptTurn, 3),
4927
6073
  () => this.recoveryStrategy_RemoveOldestToolResults(params, tokensToSave, currentPromptTurn, 2),
@@ -4988,7 +6134,7 @@ The context is now within limits. Please retry your request with the recovered c
4988
6134
  // if we need to retry make sure we add the retry message to the conversation messages
4989
6135
  if (guardrailCheckedStep.step === 'Retry' && guardrailCheckedStep.payloadToolCallId && guardrailCheckedStep.nativeTurn?.sendResultsNatively) {
4990
6136
  // the payload-only turn is answered as a tool result for the payload_change_request call.
4991
- params.conversationMessages.push(buildToolResultTurn([{
6137
+ params.conversationMessages.push(BuildToolResultTurn([{
4992
6138
  toolCallId: guardrailCheckedStep.payloadToolCallId,
4993
6139
  toolName: 'payload_change_request',
4994
6140
  content: guardrailCheckedStep.retryInstructions || 'Payload change applied.',
@@ -5059,10 +6205,12 @@ The context is now within limits. Please retry your request with the recovered c
5059
6205
  * its direct predecessor's results, so context never compounds.
5060
6206
  *
5061
6207
  * Gated on conversationId + root depth — programmatic runs and sub-agents skip it.
6208
+ * Skipped under a history floor (`ConversationHistoryFrom`): the previous run's tool
6209
+ * results can quote messages from before the floor.
5062
6210
  * @protected
5063
6211
  */
5064
6212
  async injectPriorTurnToolResults(params) {
5065
- if (!params.conversationId || this._depth !== 0) {
6213
+ if (!params.conversationId || this._depth !== 0 || params.ConversationHistoryFrom) {
5066
6214
  return;
5067
6215
  }
5068
6216
  try {
@@ -5301,6 +6449,11 @@ The context is now within limits. Please retry your request with the recovered c
5301
6449
  promptParams.data = { lens, messages: rangeText };
5302
6450
  promptParams.contextUser = params.contextUser;
5303
6451
  promptParams.agentId = params.agent.ID;
6452
+ promptParams.UserID = ResolvePromptRunUserID({
6453
+ UserID: params.userId,
6454
+ AgentRun: this._agentRun,
6455
+ ContextUser: params.contextUser,
6456
+ }) ?? undefined;
5304
6457
  const result = await this._promptRunner.ExecutePrompt(promptParams);
5305
6458
  const text = ExtractPromptResultText(result);
5306
6459
  if (!result.success || text.length === 0) {
@@ -5625,8 +6778,11 @@ The context is now within limits. Please retry your request with the recovered c
5625
6778
  };
5626
6779
  // Operators (where/select/map/…) are pure code-defined verbs, not registry tools — only
5627
6780
  // 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))));
6781
+ // Actions — each wrapped to run via the existing single-action execution path. The
6782
+ // run-scoped circuit breaker is bypassed here: the pipeline executor's `map` stage does
6783
+ // its own per-element failure accounting and expects elements to be independent, and
6784
+ // there is no model in that loop to act on the breaker's guidance.
6785
+ 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
6786
  // Artifact tools — one invocable per distinct tool name; `artifactId` is supplied as a
5631
6787
  // call-time param so the same `{ tool, params }` step shape works across all substrates.
5632
6788
  this._artifactToolManager.GetAvailableToolNames().forEach((toolName) => register(new ArtifactToolInvocable(toolName, async (tool, p) => {
@@ -5721,6 +6877,479 @@ The context is now within limits. Please retry your request with the recovered c
5721
6877
  };
5722
6878
  params.conversationMessages.push(message);
5723
6879
  }
6880
+ /** At most this many decision calls run at once for one agent turn. */
6881
+ static { this.DECISION_CONCURRENCY = 8; }
6882
+ /**
6883
+ * Runs `fn` over `items` with at most `limit` calls in flight, keeping results in input order.
6884
+ * One call's failure never rejects the others: a call that throws is settled with `onError`'s
6885
+ * result in its place.
6886
+ */
6887
+ async runWithConcurrencyLimit(items, limit, fn, onError) {
6888
+ const results = new Array(items.length);
6889
+ let next = 0;
6890
+ const worker = async () => {
6891
+ while (next < items.length) {
6892
+ const index = next++;
6893
+ try {
6894
+ results[index] = await fn(items[index], index);
6895
+ }
6896
+ catch (error) {
6897
+ results[index] = onError(error, items[index], index);
6898
+ }
6899
+ }
6900
+ };
6901
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
6902
+ return results;
6903
+ }
6904
+ /**
6905
+ * Answers the decision requests from one agent turn, each logged as an `AIAgentRunStep`
6906
+ * (StepType 'Decision'), with bounded concurrency. A failed request never fails the run: it
6907
+ * becomes that request's `success: false`, which the agent reads on its next turn.
6908
+ *
6909
+ * Two limits bound the turn. At most `decisionsMaxRequests` requests run (default
6910
+ * {@link MAX_DECISION_REQUESTS_PER_TURN}); each request over that cap is not run and records no
6911
+ * step, and its result says why. And the requests make at most `decisionsMaxCallsPerTurn`
6912
+ * decision calls in total, counting every `forEachItemIn` item (default
6913
+ * {@link MAX_DECISION_CALLS_PER_TURN}). That budget is handed out in request order before any
6914
+ * call is made, so which calls run never depends on timing. A `forEachItemIn` request it cuts
6915
+ * short asks its first items and reports the rest in `skippedCount`. A request it leaves no calls
6916
+ * for is not run, records no step, and its result says why.
6917
+ *
6918
+ * @since 2.132.0
6919
+ */
6920
+ async executeDecisionRequestsAsSteps(requests, finalPayload, agentTypePromptParams, params) {
6921
+ const cap = this.decisionRequestCap(agentTypePromptParams);
6922
+ const toRun = requests.slice(0, cap);
6923
+ const overCap = requests.slice(cap);
6924
+ if (overCap.length > 0) {
6925
+ this.logStatus(`[Decisions] ${requests.length} decision requests on one turn — answering the first ${cap}, skipping ${overCap.length} (per-turn cap)`, true, params);
6926
+ }
6927
+ const budget = this.decisionCallBudget(agentTypePromptParams);
6928
+ const allowances = this.allotDecisionCalls(toRun, finalPayload, agentTypePromptParams, budget, params);
6929
+ const results = await this.runWithConcurrencyLimit(toRun, BaseAgent.DECISION_CONCURRENCY, async (req, index) => allowances[index].Wanted > 0 && allowances[index].Allowed === 0
6930
+ ? this.overBudgetDecisionResult(req, index, budget)
6931
+ : this.executeSingleDecisionRequestAsStep(req, finalPayload, agentTypePromptParams, params, allowances[index].Allowed), (error, req, index) => ({
6932
+ id: this.decisionRequestId(req, index),
6933
+ success: false,
6934
+ error: `The decision request failed: ${error instanceof Error ? error.message : String(error)}`,
6935
+ }));
6936
+ const skipped = overCap.map((req, i) => ({
6937
+ id: this.decisionRequestId(req, cap + i),
6938
+ success: false,
6939
+ error: `Not run: at most ${cap} decision requests are answered per turn. Ask again next turn if you still need it.`,
6940
+ }));
6941
+ return [...results, ...skipped];
6942
+ }
6943
+ /** The most decision requests one turn may run: `decisionsMaxRequests`, or {@link MAX_DECISION_REQUESTS_PER_TURN}. */
6944
+ decisionRequestCap(agentTypePromptParams) {
6945
+ return this.decisionLimit(agentTypePromptParams, 'decisionsMaxRequests', MAX_DECISION_REQUESTS_PER_TURN);
6946
+ }
6947
+ /**
6948
+ * The most decision calls one turn's requests make in total, `forEachItemIn` items included:
6949
+ * `decisionsMaxCallsPerTurn`, or {@link MAX_DECISION_CALLS_PER_TURN}.
6950
+ */
6951
+ decisionCallBudget(agentTypePromptParams) {
6952
+ return this.decisionLimit(agentTypePromptParams, 'decisionsMaxCallsPerTurn', MAX_DECISION_CALLS_PER_TURN);
6953
+ }
6954
+ /** The most items one `forEachItemIn` request asks about: `decisionsMaxItems`, or its default. */
6955
+ decisionMaxItems(agentTypePromptParams) {
6956
+ return this.decisionLimit(agentTypePromptParams, 'decisionsMaxItems', DEFAULT_LOOP_AGENT_PROMPT_PARAMS.decisionsMaxItems);
6957
+ }
6958
+ /** A decision limit from the prompt params when it is a non-negative integer, or `fallback` when it is absent or anything else. */
6959
+ decisionLimit(agentTypePromptParams, name, fallback) {
6960
+ const configured = agentTypePromptParams?.[name];
6961
+ return typeof configured === 'number' && Number.isInteger(configured) && configured >= 0 ? configured : fallback;
6962
+ }
6963
+ /**
6964
+ * Hands out the turn's decision-call budget to its requests in order, before any of them runs.
6965
+ * Each request gets the calls it wants while the budget lasts. The first one the budget cannot
6966
+ * cover gets what is left, and the ones after it get none.
6967
+ */
6968
+ allotDecisionCalls(requests, finalPayload, agentTypePromptParams, budget, params) {
6969
+ let remaining = budget;
6970
+ const allowances = requests.map((request) => {
6971
+ const wanted = this.decisionCallsWanted(request, finalPayload, agentTypePromptParams);
6972
+ const allowed = Math.min(wanted, remaining);
6973
+ remaining -= allowed;
6974
+ return { Wanted: wanted, Allowed: allowed };
6975
+ });
6976
+ const cut = allowances.reduce((sum, allowance) => sum + allowance.Wanted - allowance.Allowed, 0);
6977
+ if (cut > 0) {
6978
+ this.logStatus(`[Decisions] The turn's decision requests want ${budget + cut} decision calls — making ${budget}, skipping ${cut} (per-turn call budget)`, true, params);
6979
+ }
6980
+ return allowances;
6981
+ }
6982
+ /**
6983
+ * The decision calls a request makes with no budget: one, or one per `forEachItemIn` item up to
6984
+ * `decisionsMaxItems`. A request that cannot make any, because it is not an object or its
6985
+ * `forEachItemIn` is not an array, wants none. A request that fails before it asks, such as one
6986
+ * with no valid question, still holds what its shape wants: the budget can then go unused, but
6987
+ * it is never exceeded.
6988
+ */
6989
+ decisionCallsWanted(request, finalPayload, agentTypePromptParams) {
6990
+ if (!request || typeof request !== 'object') {
6991
+ return 0;
6992
+ }
6993
+ if (!request.forEachItemIn) {
6994
+ return 1;
6995
+ }
6996
+ const target = typeof request.forEachItemIn === 'string' ? this.resolvePayloadPath(request.forEachItemIn, finalPayload) : undefined;
6997
+ return Array.isArray(target) ? Math.min(target.length, this.decisionMaxItems(agentTypePromptParams)) : 0;
6998
+ }
6999
+ /**
7000
+ * The result of a request the turn's decision-call budget left no calls for. It was not run.
7001
+ * A budget of 0 turns decision calls off for the agent, so then asking again on a later turn
7002
+ * cannot help, and the result does not invite it.
7003
+ */
7004
+ overBudgetDecisionResult(request, index, budget) {
7005
+ const error = budget === 0
7006
+ ? 'Not run: this agent\'s decision-call budget is 0 (decisionsMaxCallsPerTurn), so it makes no decision calls on any turn. Asking again will not help: carry on without the answer.'
7007
+ : `Not run: one turn's decisions make at most ${budget} decision calls in total, counting each forEachItemIn item, and the requests before this one used them all. Ask again next turn if you still need it.`;
7008
+ return { id: this.decisionRequestId(request, index), success: false, error };
7009
+ }
7010
+ /** A request's id for its result, or its position when the model sent no usable id. */
7011
+ decisionRequestId(request, index) {
7012
+ const id = request && typeof request === 'object' ? request.id : undefined;
7013
+ return typeof id === 'string' && id.length > 0 ? id : `request ${index + 1}`;
7014
+ }
7015
+ /**
7016
+ * Runs a `'Decision'` next step: an agent type's decision requests, answered with no LLM turn.
7017
+ *
7018
+ * Each request goes through the same path a loop turn's decisions take
7019
+ * ({@link executeSingleDecisionRequestAsStep}), so it gets the same prompt default, state
7020
+ * resolution and error handling, and is logged as a `Decision` run step. Where a loop turn's
7021
+ * results go into the conversation, these come back to the agent type on the returned `'Retry'`
7022
+ * (`decisionResults`), because it routes on them. So each answer keeps its whole distribution
7023
+ * (`SummarizeDecisionAnswers`), which a path condition may read. A failed request is a result
7024
+ * with `success: false`, never a failed run: what a failure means is the agent type's call.
7025
+ *
7026
+ * @since 6.2.0
7027
+ */
7028
+ async executeDecisionStep(params, decision) {
7029
+ const payload = decision.newPayload ?? decision.previousPayload;
7030
+ const promptParams = decision.decisionPromptName ? { decisionPromptName: decision.decisionPromptName } : undefined;
7031
+ const results = await this.runWithConcurrencyLimit(decision.decisions ?? [], BaseAgent.DECISION_CONCURRENCY, request => this.executeSingleDecisionRequestAsStep(request, payload, promptParams, params, Number.POSITIVE_INFINITY, SummarizeDecisionAnswers), (error, request, index) => ({
7032
+ id: this.decisionRequestId(request, index),
7033
+ success: false,
7034
+ error: `The decision request failed: ${error instanceof Error ? error.message : String(error)}`,
7035
+ }));
7036
+ return { step: 'Retry', terminate: false, decisionResults: results, previousPayload: payload, newPayload: payload };
7037
+ }
7038
+ /**
7039
+ * Answers one decision request, either once against its `state` or once per item of its
7040
+ * `forEachItemIn` array, and records it as a Decision step. The request is unchecked model
7041
+ * output, so any error finishes the step as failed rather than escaping: this never throws
7042
+ * once the step exists.
7043
+ *
7044
+ * @param callAllowance The most decision calls the request may make, from the turn's
7045
+ * `decisionsMaxCallsPerTurn` budget. It caps a `forEachItemIn` request's items below
7046
+ * `decisionsMaxItems`. A single-state request makes one call, and
7047
+ * {@link executeDecisionRequestsAsSteps} never starts one the budget left without it.
7048
+ * Unbounded by default.
7049
+ * @param summarize how the typed answers are summarised for whoever reads them next; defaults
7050
+ * to the loop turn's model-facing summary
7051
+ * @since 2.132.0
7052
+ */
7053
+ async executeSingleDecisionRequestAsStep(request, finalPayload, agentTypePromptParams, params, callAllowance = Number.POSITIVE_INFINITY, summarize = AgentDecisionService.SummarizeAnswers) {
7054
+ const step = await this.createStepEntity({
7055
+ stepType: 'Decision',
7056
+ stepName: `Decision: ${request.id}`,
7057
+ contextUser: params.contextUser,
7058
+ inputData: { id: request.id, state: request.state, forEachItemIn: request.forEachItemIn, questions: request.questions },
7059
+ });
7060
+ try {
7061
+ const mapping = request.questions && typeof request.questions === 'object'
7062
+ ? AgentDecisionService.ToDecisionQuestions(request.questions)
7063
+ : { Questions: {}, Invalid: [] };
7064
+ if (Object.keys(mapping.Questions).length === 0) {
7065
+ const error = mapping.Invalid.length > 0
7066
+ ? `No valid questions: ${mapping.Invalid.join('; ')}`
7067
+ : 'At least one question is required';
7068
+ return await this.finishDecisionStep(step, request, { Error: error });
7069
+ }
7070
+ if (mapping.Invalid.length > 0) {
7071
+ this.logStatus(`[Decisions] "${request.id}": dropped ${mapping.Invalid.length} invalid question(s): ${mapping.Invalid.join('; ')}`, true, params);
7072
+ }
7073
+ const ask = this.decisionAsker(mapping.Questions, agentTypePromptParams, params);
7074
+ const outcome = request.forEachItemIn
7075
+ ? await this.askForEachItem(request, finalPayload, agentTypePromptParams, callAllowance, params, step, ask, summarize)
7076
+ : await this.askOnce(request, finalPayload, step, ask, summarize);
7077
+ return await this.finishDecisionStep(step, request, outcome, mapping.Invalid);
7078
+ }
7079
+ catch (error) {
7080
+ const message = `The decision request failed: ${error instanceof Error ? error.message : String(error)}`;
7081
+ return this.finishDecisionStep(step, request, { Error: message });
7082
+ }
7083
+ }
7084
+ /** Asks the request's questions about one state, with the configured decision prompt. */
7085
+ decisionAsker(questions, agentTypePromptParams, params) {
7086
+ const promptName = typeof agentTypePromptParams?.decisionPromptName === 'string'
7087
+ ? agentTypePromptParams.decisionPromptName
7088
+ : AgentDecisionService.DEFAULT_PROMPT_NAME;
7089
+ return (state) => this._agentDecisionService.Ask({
7090
+ State: state,
7091
+ Questions: questions,
7092
+ ContextUser: params.contextUser,
7093
+ AgentID: params.agent?.ID ?? this._agentRun?.AgentID,
7094
+ PromptName: promptName,
7095
+ CancellationToken: params.cancellationToken,
7096
+ });
7097
+ }
7098
+ /** Asks the questions once, against the request's state. */
7099
+ async askOnce(request, finalPayload, step, ask, summarize) {
7100
+ const state = this.resolveDecisionState(request.state, finalPayload);
7101
+ if ('Error' in state) {
7102
+ return state;
7103
+ }
7104
+ const result = await ask(state.State);
7105
+ this.attachDecisionPromptRun(step, result);
7106
+ if (!result.success) {
7107
+ return { Error: result.errorMessage || 'Decision evaluation failed' };
7108
+ }
7109
+ return { Answers: summarize(result.Answers), Results: [result] };
7110
+ }
7111
+ /**
7112
+ * Asks the same questions of each item of a payload array, up to `decisionsMaxItems` and the
7113
+ * request's share of the turn's call budget. Each item's call is its own child Decision step of
7114
+ * the request's step, carrying that call's prompt run, as each iteration of a ForEach is a child
7115
+ * step of its loop step.
7116
+ */
7117
+ async askForEachItem(request, finalPayload, agentTypePromptParams, callAllowance, params, step, ask, summarize) {
7118
+ const target = this.resolvePayloadPath(request.forEachItemIn ?? '', finalPayload);
7119
+ if (!Array.isArray(target)) {
7120
+ return { Error: `forEachItemIn target "${request.forEachItemIn}" is not an array` };
7121
+ }
7122
+ const maxItems = this.decisionMaxItems(agentTypePromptParams);
7123
+ const limit = Math.min(maxItems, callAllowance);
7124
+ const items = target.slice(0, limit);
7125
+ const skippedCount = target.length > limit ? target.length - limit : undefined;
7126
+ if (skippedCount) {
7127
+ const why = limit < maxItems ? "the turn's decision-call budget" : 'decisionsMaxItems';
7128
+ this.logStatus(`[Decisions] "${request.forEachItemIn}" has ${target.length} items; asking the first ${limit} (${skippedCount} skipped: ${why})`, true, params);
7129
+ }
7130
+ const results = await this.runWithConcurrencyLimit(items, BaseAgent.DECISION_CONCURRENCY, (item, index) => this.askItemAsStep(request, item, index, step, ask, params), (error) => ({ success: false, errorMessage: error instanceof Error ? error.message : String(error), Answers: {} }));
7131
+ const failedIndex = results.findIndex(r => !r.success);
7132
+ if (failedIndex >= 0) {
7133
+ return { Error: `Item ${failedIndex}: ${results[failedIndex].errorMessage || 'decision evaluation failed'}` };
7134
+ }
7135
+ return { Answers: results.map(r => summarize(r.Answers)), Results: results, SkippedCount: skippedCount };
7136
+ }
7137
+ /** Asks about one item of a `forEachItemIn` request, recorded as a child step of the request's step. */
7138
+ async askItemAsStep(request, item, index, parentStep, ask, params) {
7139
+ const itemStep = await this.createStepEntity({
7140
+ stepType: 'Decision',
7141
+ stepName: `Decision: ${request.id} (item ${index})`,
7142
+ contextUser: params.contextUser,
7143
+ inputData: { id: request.id, item: index },
7144
+ parentId: parentStep.ID,
7145
+ });
7146
+ let result;
7147
+ try {
7148
+ result = await ask(this.decisionStateText(item));
7149
+ }
7150
+ catch (error) {
7151
+ result = { success: false, errorMessage: `The decision call failed: ${error instanceof Error ? error.message : String(error)}`, Answers: {} };
7152
+ }
7153
+ this.attachDecisionPromptRun(itemStep, result);
7154
+ await this.finalizeStepEntity(itemStep, result.success, result.success ? undefined : result.errorMessage, result.success ? { answers: AgentDecisionService.SummarizeAnswers(result.Answers) } : undefined);
7155
+ return result;
7156
+ }
7157
+ /**
7158
+ * Links a decision call's prompt run to its step, as the Prompt step links its own, so the run's
7159
+ * cost and token totals and its `MaxCostPerRun` / `MaxTokensPerRun` checks count the call.
7160
+ */
7161
+ attachDecisionPromptRun(step, result) {
7162
+ if (result.promptRun?.ID) {
7163
+ step.TargetLogID = result.promptRun.ID;
7164
+ step.PromptRun = result.promptRun; // transient related object (not a persisted field)
7165
+ }
7166
+ }
7167
+ /**
7168
+ * Links the prompt run of a decision call the run stopped waiting for (a timeout or a cancellation)
7169
+ * to its step, once the call settles. `AIDecisionRunner` writes the prompt run before it calls the
7170
+ * model, so a stopped call still has one. The link keeps that row on the run's trace, and counts its
7171
+ * usage in the run's cost and token totals when it settles before the run ends, as
7172
+ * {@link attachDecisionPromptRun} does on time. The step may already be finalized, so the link is
7173
+ * saved on its own. Never throws, and a call that never settles links nothing.
7174
+ */
7175
+ linkLateDecisionPromptRun(step, late) {
7176
+ if (!step) {
7177
+ return;
7178
+ }
7179
+ late.then(result => {
7180
+ const promptRunID = result?.promptRun?.ID;
7181
+ if (!result || !promptRunID || step.TargetLogID === promptRunID) {
7182
+ return;
7183
+ }
7184
+ this.attachDecisionPromptRun(step, result);
7185
+ this.queueStepSave(step, (s) => { s.TargetLogID = promptRunID; });
7186
+ }).catch(error => {
7187
+ LogError(`Could not link a stopped decision call's prompt run to its step: ${error instanceof Error ? error.message : String(error)}`);
7188
+ });
7189
+ }
7190
+ /**
7191
+ * A literal state, or a value read from the payload: the whole payload for `payload`, one value
7192
+ * for a `payload.` path.
7193
+ */
7194
+ resolveDecisionState(state, finalPayload) {
7195
+ if (typeof state !== 'string') {
7196
+ return { Error: 'Either state or forEachItemIn is required' };
7197
+ }
7198
+ if (state.trim() !== 'payload' && !state.startsWith('payload.')) {
7199
+ return { State: state };
7200
+ }
7201
+ const value = this.resolvePayloadPath(state, finalPayload);
7202
+ return value === undefined ? { Error: `Path "${state}" not found in payload` } : { State: this.decisionStateText(value) };
7203
+ }
7204
+ /** Reads a `payload.`-prefixed dotted path (or `payload` itself) from the payload. */
7205
+ resolvePayloadPath(path, finalPayload) {
7206
+ const trimmed = path.trim();
7207
+ if (trimmed === 'payload') {
7208
+ return finalPayload;
7209
+ }
7210
+ return _.get(finalPayload, trimmed.startsWith('payload.') ? trimmed.substring('payload.'.length) : trimmed);
7211
+ }
7212
+ /** Strings pass through; anything else becomes indented JSON. */
7213
+ decisionStateText(value) {
7214
+ return typeof value === 'string' ? value : (value !== null && typeof value === 'object' ? JSON.stringify(value, undefined, 1) : String(value ?? ''));
7215
+ }
7216
+ /** Finalizes the Decision step and builds the result the agent reads next turn. */
7217
+ async finishDecisionStep(step, request, outcome, droppedQuestions = []) {
7218
+ if ('Error' in outcome) {
7219
+ await this.finalizeStepEntity(step, false, outcome.Error, { error: outcome.Error });
7220
+ return { id: request.id, success: false, error: outcome.Error };
7221
+ }
7222
+ const first = outcome.Results[0];
7223
+ await this.finalizeStepEntity(step, true, undefined, {
7224
+ answers: outcome.Answers,
7225
+ skippedCount: outcome.SkippedCount,
7226
+ executionTimeMS: outcome.Results.reduce((sum, r) => sum + (r.executionTimeMS ?? 0), 0),
7227
+ tokensUsed: outcome.Results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0),
7228
+ model: first?.DecisionResult?.ResolvedModel ?? first?.modelInfo?.modelName,
7229
+ promptRunId: first?.promptRun?.ID,
7230
+ driverClass: first?.DriverClass,
7231
+ ...(droppedQuestions.length > 0 && { droppedQuestions }),
7232
+ });
7233
+ return { id: request.id, success: true, answers: outcome.Answers, skippedCount: outcome.SkippedCount };
7234
+ }
7235
+ /**
7236
+ * Answers the decision requests a prompt returned, or holds them when the step that carries
7237
+ * them can end the run, because then no turn would read their answers. Held requests run before
7238
+ * the next prompt ({@link runHeldDecisions}), or are skipped if the run ends first
7239
+ * ({@link skipHeldDecisions}). Skips them all, unasked, while the master switch is off
7240
+ * ({@link DecisionsEnabled}) or the agent's `decisions` response field is.
7241
+ */
7242
+ async processTurnDecisions(decisions, nextStep, finalPayload, agentTypePromptParams, params) {
7243
+ if (!this.DecisionsEnabled(agentTypePromptParams)) {
7244
+ this.logStatus(`[Decisions] LLM requested decisions but decision-model use is off for this agent (decisionsEnabled is not true) — skipped`, true, params);
7245
+ return;
7246
+ }
7247
+ const responseTypeRules = agentTypePromptParams?.includeResponseTypeDefinition;
7248
+ if (responseTypeRules?.decisions === false) {
7249
+ this.logStatus(`[Decisions] LLM requested decisions but feature is disabled (includeResponseTypeDefinition.decisions=false) — skipped`, true, params);
7250
+ return;
7251
+ }
7252
+ if (!Array.isArray(decisions)) {
7253
+ this.injectDecisionResultsMessage(params, [{ id: 'decisions', success: false, error: '"decisions" must be an array of decision requests' }]);
7254
+ return;
7255
+ }
7256
+ const ids = decisions.map((d, i) => this.decisionRequestId(d, i)).join(', ');
7257
+ const holdReason = this.decisionsHoldReason(nextStep);
7258
+ if (holdReason) {
7259
+ this._heldDecisions = { Requests: decisions, Payload: finalPayload, PromptParams: agentTypePromptParams, Reason: holdReason };
7260
+ this.logStatus(`[Decisions] Holding ${decisions.length} decision request(s) (${ids}) until the step has run: ${holdReason}`, true, params);
7261
+ return;
7262
+ }
7263
+ this.logStatus(`[Decisions] LLM requested ${decisions.length} decision request(s): ${ids}`, true, params);
7264
+ const decisionResults = await this.executeDecisionRequestsAsSteps(decisions, finalPayload, agentTypePromptParams, params);
7265
+ this.injectDecisionResultsMessage(params, decisionResults);
7266
+ }
7267
+ /**
7268
+ * Why the decisions sent with this step would be paid for and never read, or `undefined` when
7269
+ * the next turn reads them. Three ways a step's own outcome ends the run: a passing finishIf gate
7270
+ * (only an Actions step or a single sub-agent is gated), a sub-agent's `terminateAfter`, and
7271
+ * client tools sent with `taskComplete`, which end the run once the tools return. Every other
7272
+ * step, including client tools without `taskComplete`, goes on to a prompt that reads them.
7273
+ */
7274
+ decisionsHoldReason(step) {
7275
+ const isSingleSubAgent = step.step === 'Sub-Agent' && !!step.subAgent && !step.subAgents?.length;
7276
+ if (step.finishIf && (step.step === 'Actions' || isSingleSubAgent) && this.finishIfActs()) {
7277
+ return `the ${step.step} step carries a finishIf gate, and a passing gate ends the run`;
7278
+ }
7279
+ if (step.step === 'Sub-Agent' && this.getRequestedSubAgents(step).some(r => r.terminateAfter === true)) {
7280
+ return 'the Sub-Agent step has terminateAfter, which ends the run after it';
7281
+ }
7282
+ if (step.step === 'ClientTools' && step.terminate === true) {
7283
+ return 'the client tools were sent with taskComplete, which ends the run once they return';
7284
+ }
7285
+ return undefined;
7286
+ }
7287
+ /**
7288
+ * Runs the decision requests a prompt held back, now that the step that carried them has run
7289
+ * without ending the run, so the prompt about to run reads their answers.
7290
+ */
7291
+ async runHeldDecisions(params) {
7292
+ const held = this._heldDecisions;
7293
+ if (!held) {
7294
+ return;
7295
+ }
7296
+ this._heldDecisions = undefined;
7297
+ this.logStatus(`[Decisions] The run continues, so answering ${held.Requests.length} held decision request(s)`, true, params);
7298
+ const results = await this.executeDecisionRequestsAsSteps(held.Requests, held.Payload, held.PromptParams, params);
7299
+ this.injectDecisionResultsMessage(params, results);
7300
+ }
7301
+ /**
7302
+ * Skips the decision requests a prompt held back, because the run ended before they could be
7303
+ * asked: the step that carried them ended it, or it was cancelled, or an error was thrown out of
7304
+ * the step loop. Called from the loop's terminate branch and from `Execute()`'s `finally`, so
7305
+ * every way out logs them. The held state is cleared before the log line, so the second call on
7306
+ * the terminate path logs nothing and each held request is logged exactly once.
7307
+ *
7308
+ * @param runEnd How the run ended, as a clause for the log line.
7309
+ */
7310
+ skipHeldDecisions(params, runEnd) {
7311
+ const held = this._heldDecisions;
7312
+ if (!held) {
7313
+ return;
7314
+ }
7315
+ this._heldDecisions = undefined;
7316
+ const ids = held.Requests.map((d, i) => this.decisionRequestId(d, i)).join(', ');
7317
+ this.logStatus(`[Decisions] Skipped ${held.Requests.length} held decision request(s) (${ids}) without asking them: ${runEnd}, so no turn would read the answers (they were held because ${held.Reason})`, false, params);
7318
+ }
7319
+ /** How the run ended, as a clause for held decision requests that a cancellation or an error left unasked. */
7320
+ runEndForHeldDecisions() {
7321
+ switch (this.deriveRunOutcome()) {
7322
+ case 'cancelled':
7323
+ return 'the run was cancelled before they were asked';
7324
+ case 'failure':
7325
+ return 'the run failed before they were asked';
7326
+ default:
7327
+ return 'the run ended before they were asked';
7328
+ }
7329
+ }
7330
+ /**
7331
+ * Injects decision results into the conversation as one tool-result message for the next turn.
7332
+ *
7333
+ * @since 2.132.0
7334
+ */
7335
+ injectDecisionResultsMessage(params, results) {
7336
+ if (!results || results.length === 0)
7337
+ return;
7338
+ const message = {
7339
+ role: 'user',
7340
+ content: `Decision results:\n${JSON.stringify(results)}`,
7341
+ metadata: {
7342
+ turnAdded: this._promptTurnCount,
7343
+ messageType: 'tool-result',
7344
+ expirationTurns: 3,
7345
+ expirationMode: 'Compact',
7346
+ compactMode: 'First N Chars',
7347
+ compactLength: 500,
7348
+ compactPromptId: '',
7349
+ },
7350
+ };
7351
+ params.conversationMessages.push(message);
7352
+ }
5724
7353
  /**
5725
7354
  * Creates a chat message containing sub-agent execution results.
5726
7355
  *
@@ -5819,10 +7448,8 @@ The context is now within limits. Please retry your request with the recovered c
5819
7448
  // and removes any cache-poisoning foot-gun should a future consumer write to it.
5820
7449
  agentTypePromptParams = { ...catalog.baseAgentTypePromptParams };
5821
7450
  }
5822
- // Build client tool details for the prompt (per-run; depends on extraData)
5823
- const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
5824
- // Build app context section if provided in extraData
5825
- const appContext = this.buildAppContextSection(extraData);
7451
+ // Store for the finishIf gate, which runs after this prompt in executeActionsStep / executeNextStep
7452
+ this._agentTypePromptParams = agentTypePromptParams;
5826
7453
  // Skill catalog (name + description only — progressive disclosure). This is the
5827
7454
  // SELF-ACTIVATION surface, so it uses the double-gated auto set: empty unless the
5828
7455
  // agent's SkillActivationMode is 'Auto', and containing only skills whose own
@@ -5832,7 +7459,20 @@ The context is now within limits. Please retry your request with the recovered c
5832
7459
  // is never even offered a skill the user isn't entitled to — the permission
5833
7460
  // boundary is enforced at the catalog, not just at activation.
5834
7461
  const availableSkills = await this.availableSkills(engine.GetAutoActivatableSkillsForAgent(agent, _contextUser), 'catalog', agent, _contextUser);
5835
- const skillsCatalog = this.formatSkillsCatalog(availableSkills);
7462
+ // Catalog narrowing (plan Task 3.7), off unless decisionsEnabled is true and maxActionsInPrompt /
7463
+ // maxSubAgentsInPrompt is positive and the list is longer. It narrows what the prompt SHOWS, once per run:
7464
+ // _effectiveActions / _effectiveSubAgents above stay whole, so validation forbids nothing.
7465
+ // Skills are narrowed here, after the filterAvailableSkills policy, never inside it: the
7466
+ // decision judges only skills the policy offers, and an override that skips `super` keeps it.
7467
+ // The counts stay whole: they are what the model can call, and a narrowed list says what it hides.
7468
+ await this.ensureCatalogNarrowing(agent, _contextUser, agentTypePromptParams, activeActions, uniqueActiveSubAgents, availableSkills);
7469
+ actionDetails = this.narrowedCatalogSection('action', activeActions, actionDetails, shown => this.formatActionDetails(shown));
7470
+ subAgentDetails = this.narrowedCatalogSection('agent', uniqueActiveSubAgents, subAgentDetails, shown => this.formatSubAgentDetails(shown));
7471
+ const skillsCatalog = this.narrowedCatalogSection('skill', availableSkills, this.formatSkillsCatalog(availableSkills), shown => this.formatSkillsCatalog(shown));
7472
+ // Build client tool details for the prompt (per-run; depends on extraData)
7473
+ const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
7474
+ // Build app context section if provided in extraData
7475
+ const appContext = this.buildAppContextSection(extraData);
5836
7476
  const contextData = {
5837
7477
  agentName: agent.Name,
5838
7478
  agentDescription: agent.Description,
@@ -5868,12 +7508,236 @@ The context is now within limits. Please retry your request with the recovered c
5868
7508
  ...restExtraData
5869
7509
  };
5870
7510
  }
5871
- else {
5872
- return result;
7511
+ else {
7512
+ return result;
7513
+ }
7514
+ }
7515
+ catch (error) {
7516
+ throw new Error(`Error gathering context data: ${error.message}`);
7517
+ }
7518
+ }
7519
+ /**
7520
+ * Narrows the catalog once per run, on its first prompt (plan Task 3.7), and caches the result for
7521
+ * every later step. With narrowing off, or the master switch ({@link DecisionsEnabled}) off, it
7522
+ * asks nothing and records nothing, so the prompt describes the whole catalog. Never throws: any
7523
+ * failure leaves the full catalog in place. It narrows the prose catalog only:
7524
+ * {@link applyNativeTools} still declares every action and sub-agent.
7525
+ */
7526
+ async ensureCatalogNarrowing(agent, contextUser, promptParams, actions, subAgents, skills) {
7527
+ if (this._catalogNarrowing) {
7528
+ return;
7529
+ }
7530
+ // Set first, so a failure below is cached as "hide nothing" rather than retried every step.
7531
+ this._catalogNarrowing = NoCatalogNarrowing();
7532
+ if (!this.DecisionsEnabled(promptParams)) {
7533
+ return;
7534
+ }
7535
+ const limits = this.reachableCatalogNarrowingLimits(agent, promptParams, actions);
7536
+ if (limits.Actions === 0 && limits.SubAgents === 0 && limits.Skills === 0) {
7537
+ return;
7538
+ }
7539
+ try {
7540
+ const candidates = this.catalogNarrowingCandidates(agent, actions, subAgents, skills);
7541
+ const asked = CatalogNarrowingCandidatesToAsk(candidates, limits);
7542
+ if (asked.length > 0) {
7543
+ this._catalogNarrowing = await this.narrowCatalog(agent, contextUser, promptParams, candidates, asked, limits);
7544
+ }
7545
+ }
7546
+ catch (error) {
7547
+ this.warnCatalogNarrowing(agent, error instanceof Error ? error.message : String(error));
7548
+ }
7549
+ }
7550
+ /**
7551
+ * The run's limits, with actions left whole when the agent lacks Find Candidate Actions: a hidden
7552
+ * action would then be unreachable. That case is logged as a warning when it keeps a long action
7553
+ * list whole, since the agent asked for narrowing and did not get it.
7554
+ */
7555
+ reachableCatalogNarrowingLimits(agent, promptParams, actions) {
7556
+ const configured = ResolveCatalogNarrowingLimits(promptParams);
7557
+ const limits = ReachableCatalogNarrowingLimits(configured, actions.map(a => a.Name));
7558
+ if (limits.Actions !== configured.Actions && IsCatalogListNarrowed(configured.Actions, actions.length)) {
7559
+ LogErrorEx({
7560
+ message: `Catalog narrowing for '${agent.Name}' shows every action: the agent has no ${CATALOG_NARROWING_FIND_ACTIONS} action, so a hidden action could not be found`,
7561
+ severity: 'warning',
7562
+ category: 'CatalogNarrowing',
7563
+ });
7564
+ }
7565
+ return limits;
7566
+ }
7567
+ /**
7568
+ * Every item of the three lists as a narrowing candidate. An action or sub-agent with
7569
+ * `MinExecutionsPerRun` set, and the Find Candidate tools, are pinned: always shown.
7570
+ */
7571
+ catalogNarrowingCandidates(agent, actions, subAgents, skills) {
7572
+ const requiredActionIDs = AIEngine.Instance.AgentActions
7573
+ .filter(aa => UUIDsEqual(aa.AgentID, agent.ID) && aa.Status === 'Active' && (aa.MinExecutionsPerRun ?? 0) > 0)
7574
+ .map(aa => aa.ActionID);
7575
+ const candidate = (kind, item, pinned) => ({ Kind: kind, ID: item.ID, Name: item.Name, Description: item.Description ?? '', Pinned: pinned });
7576
+ return [
7577
+ ...actions.map(a => candidate('action', a, IsAlwaysShownAction(a.Name) || requiredActionIDs.some(id => UUIDsEqual(id, a.ID)))),
7578
+ ...subAgents.filter(s => !!s).map(s => candidate('agent', s, (s.MinExecutionsPerRun ?? 0) > 0)),
7579
+ ...skills.map(s => candidate('skill', s, false)),
7580
+ ];
7581
+ }
7582
+ /**
7583
+ * Asks the one decision, records it as the `Catalog narrowing` step, and returns what to hide.
7584
+ * Any failure is logged as a warning and hides nothing, so the prompt shows the full catalog.
7585
+ */
7586
+ async narrowCatalog(agent, contextUser, promptParams, candidates, asked, limits) {
7587
+ if (!this._openingRequest || !contextUser) {
7588
+ this.warnCatalogNarrowing(agent, contextUser
7589
+ ? 'the run has no user message to judge the catalog against'
7590
+ : 'the run has no context user');
7591
+ return NoCatalogNarrowing();
7592
+ }
7593
+ const promptName = typeof promptParams.decisionPromptName === 'string'
7594
+ ? promptParams.decisionPromptName
7595
+ : AgentDecisionService.DEFAULT_PROMPT_NAME;
7596
+ const step = await this.startCatalogNarrowingStep(contextUser, promptName, asked, limits);
7597
+ const result = await this.askCatalogNarrowing(agent, contextUser, BuildCatalogNarrowingQuestions(asked), promptName, step);
7598
+ const outcome = result.success ? SelectCatalogNarrowing(candidates, asked, result.Answers, limits) : undefined;
7599
+ if (!outcome) {
7600
+ const reason = result.success ? 'an answer was missing or was not a probability' : (result.errorMessage || 'the decision call failed');
7601
+ this.warnCatalogNarrowing(agent, reason);
7602
+ await this.finishCatalogNarrowingStep(step, result, undefined, reason);
7603
+ return NoCatalogNarrowing();
7604
+ }
7605
+ await this.finishCatalogNarrowingStep(step, result, outcome);
7606
+ return outcome;
7607
+ }
7608
+ /**
7609
+ * The one decision call, against the opening request. Never throws, and never waits longer than
7610
+ * {@link CATALOG_NARROWING_TIMEOUT_MS} or past the run's cancellation: a throw, a timeout and a
7611
+ * cancelled run each come back as a failed result. Stopping also aborts the call, and links the
7612
+ * prompt run it still returns to `step` once it does ({@link linkLateDecisionPromptRun}).
7613
+ */
7614
+ async askCatalogNarrowing(agent, contextUser, questions, promptName, step) {
7615
+ const failed = (errorMessage) => ({ success: false, errorMessage, Answers: {} });
7616
+ const controller = new AbortController();
7617
+ let stoppedResult;
7618
+ const stopped = new Promise(resolve => controller.signal.addEventListener('abort', () => {
7619
+ stoppedResult = failed(String(controller.signal.reason));
7620
+ resolve(stoppedResult);
7621
+ }, { once: true }));
7622
+ const runToken = this._executeParams?.cancellationToken;
7623
+ const relayRunAbort = () => controller.abort('the run was cancelled');
7624
+ if (runToken?.aborted) {
7625
+ relayRunAbort();
7626
+ }
7627
+ else {
7628
+ runToken?.addEventListener('abort', relayRunAbort, { once: true });
7629
+ }
7630
+ const timeoutMS = BaseAgent.CATALOG_NARROWING_TIMEOUT_MS;
7631
+ const timer = setTimeout(() => controller.abort(`the decision call timed out after ${timeoutMS}ms`), timeoutMS);
7632
+ try {
7633
+ const ask = this._agentDecisionService.Ask({
7634
+ State: this._openingRequest,
7635
+ Questions: questions,
7636
+ ContextUser: contextUser,
7637
+ AgentID: agent.ID,
7638
+ PromptName: promptName,
7639
+ CancellationToken: controller.signal,
7640
+ });
7641
+ const result = await Promise.race([ask, stopped]);
7642
+ if (result === stoppedResult) {
7643
+ this.linkLateDecisionPromptRun(step, ask);
5873
7644
  }
7645
+ return result;
5874
7646
  }
5875
7647
  catch (error) {
5876
- throw new Error(`Error gathering context data: ${error.message}`);
7648
+ return failed(error instanceof Error ? error.message : String(error));
7649
+ }
7650
+ finally {
7651
+ clearTimeout(timer);
7652
+ runToken?.removeEventListener('abort', relayRunAbort);
7653
+ }
7654
+ }
7655
+ /** Opens the `Catalog narrowing` Decision step with the decision's inputs. Without a run there is no step. */
7656
+ async startCatalogNarrowingStep(contextUser, promptName, asked, limits) {
7657
+ if (!this._agentRun) {
7658
+ return undefined;
7659
+ }
7660
+ const count = (kind) => asked.filter(c => c.Kind === kind).length;
7661
+ try {
7662
+ return await this.createStepEntity({
7663
+ stepType: 'Decision',
7664
+ stepName: 'Catalog narrowing',
7665
+ contextUser,
7666
+ inputData: {
7667
+ request: this._openingRequest,
7668
+ promptName,
7669
+ limits: { actions: limits.Actions, subAgents: limits.SubAgents, skills: limits.Skills },
7670
+ asked: { actions: count('action'), subAgents: count('agent'), skills: count('skill') },
7671
+ },
7672
+ });
7673
+ }
7674
+ catch (error) {
7675
+ LogError(`Could not record the catalog narrowing step: ${error instanceof Error ? error.message : String(error)}`);
7676
+ return undefined;
7677
+ }
7678
+ }
7679
+ /** Finalizes the `Catalog narrowing` step: each narrowed list's counts, kept names and probabilities, or why it failed open. */
7680
+ async finishCatalogNarrowingStep(step, result, outcome, reason) {
7681
+ if (!step) {
7682
+ return;
7683
+ }
7684
+ // Like every decision call, it counts toward the run's cost and tokens through its step
7685
+ this.attachDecisionPromptRun(step, result);
7686
+ const usage = {
7687
+ executionTimeMS: result.executionTimeMS,
7688
+ tokensUsed: result.tokensUsed,
7689
+ model: result.DecisionResult?.ResolvedModel ?? result.modelInfo?.modelName,
7690
+ promptRunId: result.promptRun?.ID,
7691
+ };
7692
+ if (!outcome) {
7693
+ await this.finalizeStepEntity(step, false, reason, { failedOpen: true, reason, ...usage });
7694
+ return;
7695
+ }
7696
+ const lists = {};
7697
+ const stepKeys = { action: 'actions', agent: 'subAgents', skill: 'skills' };
7698
+ for (const kind of ['action', 'agent', 'skill']) {
7699
+ const list = outcome.Lists[kind];
7700
+ if (list) {
7701
+ lists[stepKeys[kind]] = { total: list.Total, limit: list.Limit, shown: list.Kept.length, kept: list.Kept, pinned: list.Pinned, probabilities: list.Probabilities };
7702
+ }
7703
+ }
7704
+ await this.finalizeStepEntity(step, true, undefined, { ...lists, ...usage });
7705
+ }
7706
+ /** Logs that catalog narrowing failed open. */
7707
+ warnCatalogNarrowing(agent, reason) {
7708
+ LogErrorEx({
7709
+ message: `Catalog narrowing for '${agent.Name}' failed, so the full catalog is shown: ${reason}`,
7710
+ severity: 'warning',
7711
+ category: 'CatalogNarrowing',
7712
+ });
7713
+ }
7714
+ /** The items of one list the prompt shows. Returns `items` itself when the run's narrowing hides none of them. */
7715
+ hideNarrowedOut(kind, items) {
7716
+ return ApplyCatalogNarrowing(items, this._catalogNarrowing?.Hidden[kind]);
7717
+ }
7718
+ /**
7719
+ * One catalog section as the prompt shows it: `full` when the run's narrowing hides none of
7720
+ * `items`, otherwise a {@link CatalogNarrowingNote} saying how many are hidden and how to reach
7721
+ * them, then the items still shown. A hidden item can still be called. Fails OPEN, unlike the
7722
+ * skill policy: an error shows `full` and logs a warning.
7723
+ */
7724
+ narrowedCatalogSection(kind, items, full, format) {
7725
+ try {
7726
+ const shown = this.hideNarrowedOut(kind, items);
7727
+ if (shown === items) {
7728
+ return full;
7729
+ }
7730
+ const shownSet = new Set(shown);
7731
+ const hiddenNames = items.filter(item => !shownSet.has(item)).map(item => item.Name);
7732
+ return `${CatalogNarrowingNote(kind, hiddenNames)}\n\n${format(shown)}`;
7733
+ }
7734
+ catch (error) {
7735
+ LogErrorEx({
7736
+ message: `Catalog narrowing could not narrow the ${kind} list, so all of it is shown: ${error instanceof Error ? error.message : String(error)}`,
7737
+ severity: 'warning',
7738
+ category: 'CatalogNarrowing',
7739
+ });
7740
+ return full;
5877
7741
  }
5878
7742
  }
5879
7743
  /**
@@ -6011,9 +7875,51 @@ The context is now within limits. Please retry your request with the recovered c
6011
7875
  // to distinguish between explicit user settings and schema defaults
6012
7876
  const explicitResponseType = runtimeOverrides?.includeResponseTypeDefinition ||
6013
7877
  agentParams.includeResponseTypeDefinition;
7878
+ this.warnOnceOnUnknownFinishIfMode(merged.finishIfMode, agent);
6014
7879
  this.applyResponseTypeAutoAlignment(merged, explicitResponseType);
6015
7880
  return merged;
6016
7881
  }
7882
+ /**
7883
+ * The agent and value pairs {@link warnOnceOnUnknownFinishIfMode} has already reported. It is
7884
+ * process-wide because the prompt params are merged again for every run of an agent, and on every
7885
+ * turn that carries runtime overrides. One warning per agent and value is enough. A run request
7886
+ * can set the value, so what this keeps is bounded: see {@link FinishIfModeWarnings}.
7887
+ */
7888
+ static { this.finishIfModeWarnings = new FinishIfModeWarnings(); }
7889
+ /**
7890
+ * Warns, once per agent and value, when the merged `finishIfMode` is set but is not a
7891
+ * {@link FinishIfMode}. {@link ResolveFinishIfMode} treats such a value as `'off'`, so without
7892
+ * this a typo such as `"On"` or `"true"` would turn the agent's gates off without a word. An
7893
+ * absent value is the default, so it is not reported.
7894
+ */
7895
+ warnOnceOnUnknownFinishIfMode(value, agent) {
7896
+ const warning = BaseAgent.finishIfModeWarnings.WarningFor(agent.ID, agent.Name, value);
7897
+ if (warning) {
7898
+ this.logError(warning, { agent, category: 'AgentConfiguration', severity: 'warning' });
7899
+ }
7900
+ }
7901
+ /**
7902
+ * The master switch for decision-model use by this agent: `decisionsEnabled` in its merged
7903
+ * agent-type prompt params (type default < agent < the run's `data.__agentTypePromptParams`).
7904
+ * Anything but `true` is off, the default.
7905
+ *
7906
+ * While it is off the agent never asks a decision model on its own. Each automatic use checks it
7907
+ * before it asks, whatever its own setting says: inline `decisions` ({@link processTurnDecisions}),
7908
+ * `finishIf` gates ({@link finishIfSettings}), decision discovery ({@link isDecisionDiscoveryOnFor}),
7909
+ * the payload change check ({@link isPayloadFeedbackCheckOn}), catalog narrowing
7910
+ * ({@link ensureCatalogNarrowing}) and the Memory Manager's note gate. And
7911
+ * {@link applyResponseTypeAutoAlignment} keeps the `decisions` and `finishIf` docs and response
7912
+ * fields out of the prompt. While it is on, each use keeps its own setting, and each is off by default.
7913
+ *
7914
+ * Explicit uses never read it: a Flow agent's Decision step ({@link executeDecisionStep}) runs because
7915
+ * someone placed it, and the task-graph Decision step and the Run Decision action call
7916
+ * {@link AgentDecisionService} directly. That is why the check sits at each automatic use, not in the service.
7917
+ *
7918
+ * @param promptParams - The merged agent-type prompt params.
7919
+ */
7920
+ DecisionsEnabled(promptParams) {
7921
+ return promptParams?.decisionsEnabled === true;
7922
+ }
6017
7923
  /**
6018
7924
  * Applies auto-alignment rules to includeResponseTypeDefinition based on other flags.
6019
7925
  *
@@ -6027,6 +7933,10 @@ The context is now within limits. Please retry your request with the recovered c
6027
7933
  * - includeCommandDocs → includeResponseTypeDefinition.commands
6028
7934
  * - includeForEachDocs → includeResponseTypeDefinition.forEach
6029
7935
  * - includeWhileDocs → includeResponseTypeDefinition.while
7936
+ * - includeDecisionsDocs → includeResponseTypeDefinition.decisions
7937
+ *
7938
+ * While the master switch is off ({@link DecisionsEnabled}), the decisions and finishIf docs and
7939
+ * response fields are off, even when set explicitly.
6030
7940
  *
6031
7941
  * @param params - The merged params object to modify in place
6032
7942
  * @param explicitResponseType - The explicitly set response type config from agent/runtime (not schema defaults)
@@ -6034,18 +7944,37 @@ The context is now within limits. Please retry your request with the recovered c
6034
7944
  * @since 2.132.0
6035
7945
  */
6036
7946
  applyResponseTypeAutoAlignment(params, explicitResponseType) {
7947
+ // Align a copy: the object may be the caller's per-run override, which sub-agents inherit with the
7948
+ // run's data. Writing into it would turn a key "explicit" for every later merge of that override.
7949
+ if (IsPlainObject(params.includeResponseTypeDefinition)) {
7950
+ params.includeResponseTypeDefinition = { ...params.includeResponseTypeDefinition };
7951
+ }
6037
7952
  // Ensure includeResponseTypeDefinition is an object
6038
- if (!params.includeResponseTypeDefinition || typeof params.includeResponseTypeDefinition !== 'object') {
7953
+ else if (!params.includeResponseTypeDefinition || typeof params.includeResponseTypeDefinition !== 'object') {
6039
7954
  params.includeResponseTypeDefinition = {
6040
7955
  payload: true,
6041
7956
  responseForms: true,
6042
7957
  commands: true,
6043
7958
  forEach: true,
6044
7959
  while: true,
6045
- scratchpad: true
7960
+ scratchpad: true,
7961
+ decisions: true,
7962
+ finishIf: true
6046
7963
  };
6047
7964
  }
6048
7965
  const responseType = params.includeResponseTypeDefinition;
7966
+ const decisionsEnabled = this.DecisionsEnabled(params);
7967
+ // finishIf gates are opt-in (`finishIfMode`), and need the master switch. While off, the model is
7968
+ // not taught to write one, whatever includeFinishIfDocs says, so the response field below follows it off too.
7969
+ if (!decisionsEnabled || ResolveFinishIfMode(params.finishIfMode) === 'off') {
7970
+ params.includeFinishIfDocs = false;
7971
+ }
7972
+ // `decisions` is opt-in too (`includeDecisionsDocs: true`), and needs the master switch. Its docs and
7973
+ // types add about 1,200 tokens to every turn, and on the Prompt Eval corpus no model used it
7974
+ // (typed-decision plan, Task 4.7). Unset means off, so the response field below follows it off too.
7975
+ if (!decisionsEnabled || params.includeDecisionsDocs !== true) {
7976
+ params.includeDecisionsDocs = false;
7977
+ }
6049
7978
  // Auto-alignment mappings: docs flag → response type property
6050
7979
  const alignmentMappings = [
6051
7980
  { docsFlag: 'includePayloadInPrompt', responseTypeKey: 'payload' },
@@ -6057,7 +7986,9 @@ The context is now within limits. Please retry your request with the recovered c
6057
7986
  { docsFlag: 'includeArtifactToolsDocs', responseTypeKey: 'artifactToolCalls' },
6058
7987
  { docsFlag: 'includeConversationToolsDocs', responseTypeKey: 'conversationToolCalls' },
6059
7988
  { docsFlag: 'includePipelineDocs', responseTypeKey: 'pipeline' },
6060
- { docsFlag: 'includeMemoryWritesDocs', responseTypeKey: 'memoryWrites' }
7989
+ { docsFlag: 'includeMemoryWritesDocs', responseTypeKey: 'memoryWrites' },
7990
+ { docsFlag: 'includeDecisionsDocs', responseTypeKey: 'decisions' },
7991
+ { docsFlag: 'includeFinishIfDocs', responseTypeKey: 'finishIf' }
6061
7992
  ];
6062
7993
  for (const { docsFlag, responseTypeKey } of alignmentMappings) {
6063
7994
  // Check if the user explicitly set this response type property
@@ -6075,6 +8006,12 @@ The context is now within limits. Please retry your request with the recovered c
6075
8006
  responseType[responseTypeKey] = true;
6076
8007
  }
6077
8008
  }
8009
+ // With the master switch off, neither decision field is offered, even when set explicitly: the
8010
+ // model must not be invited to ask for something the run would refuse.
8011
+ if (!decisionsEnabled) {
8012
+ responseType.decisions = false;
8013
+ responseType.finishIf = false;
8014
+ }
6078
8015
  // Capability gates align the OTHER way: default OFF, and the section appears only when the
6079
8016
  // gate is explicitly on. Running these through the loop above would invert their meaning —
6080
8017
  // that loop's `else` branch defaults an unset key to true, which is exactly what a gated
@@ -6120,6 +8057,320 @@ The context is now within limits. Please retry your request with the recovered c
6120
8057
  return {};
6121
8058
  }
6122
8059
  }
8060
+ /**
8061
+ * The finishIf settings of the current step, from its merged agent-type prompt params. A gate is
8062
+ * evaluated when the master switch is on ({@link DecisionsEnabled}), `finishIfMode` is `shadow` or
8063
+ * `on`, and `includeResponseTypeDefinition.finishIf` is not `false`; it ends the run only in `on`.
8064
+ */
8065
+ finishIfSettings() {
8066
+ const promptParams = this._agentTypePromptParams;
8067
+ const rules = promptParams?.includeResponseTypeDefinition;
8068
+ const threshold = promptParams?.finishIfThreshold;
8069
+ const mode = ResolveFinishIfMode(promptParams?.finishIfMode);
8070
+ return {
8071
+ Enabled: this.DecisionsEnabled(promptParams) && mode !== 'off' && rules?.finishIf !== false,
8072
+ Mode: mode,
8073
+ Threshold: typeof threshold === 'number' && threshold > 0 && threshold <= 1
8074
+ ? threshold
8075
+ : DEFAULT_LOOP_AGENT_PROMPT_PARAMS.finishIfThreshold,
8076
+ PromptName: typeof promptParams?.decisionPromptName === 'string'
8077
+ ? promptParams.decisionPromptName
8078
+ : AgentDecisionService.DEFAULT_PROMPT_NAME,
8079
+ };
8080
+ }
8081
+ /** Whether a passing finishIf gate ends the run: only in `on` mode. */
8082
+ finishIfActs() {
8083
+ const settings = this.finishIfSettings();
8084
+ return settings.Enabled && settings.Mode === 'on';
8085
+ }
8086
+ /**
8087
+ * Whether an evaluated gate ends the run. In shadow mode a passing gate is only logged: the
8088
+ * outcome is already recorded on its `Finish check` step, and the model takes its next turn.
8089
+ */
8090
+ gateEndsRun(outcome, params) {
8091
+ if (!outcome.Passed) {
8092
+ return false;
8093
+ }
8094
+ if (!this.finishIfActs()) {
8095
+ this.logStatus('[finishIf] Shadow mode: the gate passed, and the run continues as it would have', true, params);
8096
+ return false;
8097
+ }
8098
+ return true;
8099
+ }
8100
+ /**
8101
+ * Runs the finishIf gate after an Actions step, and returns the Success step when it passes.
8102
+ * ForEach and While call executeActionsStep without a conversation message, and are never gated.
8103
+ */
8104
+ async finishAfterActions(params, decision, actionResults, actionSummaries, payload, parentStepId, addConversationMessage) {
8105
+ const finishIf = decision.finishIf;
8106
+ if (!finishIf || !addConversationMessage || !this.finishIfSettings().Enabled) {
8107
+ return undefined;
8108
+ }
8109
+ // (In shadow mode the gate below is still evaluated and recorded; it just never ends the run.)
8110
+ // `success` means only that the action did not throw; `result.Success` is its outcome.
8111
+ if (!actionResults.every(r => r.success && r.result?.Success === true)) {
8112
+ return undefined;
8113
+ }
8114
+ // AIDirectives are instructions for the model's next turn, which a passing gate would take away.
8115
+ const withDirectives = actionSummaries.filter(a => (a.aiDirectives?.length ?? 0) > 0).map(a => a.actionName);
8116
+ if (withDirectives.length > 0) {
8117
+ this.logStatus(`[finishIf] Gate skipped: ${withDirectives.join(', ')} returned AIDirectives, which the model must see on its next turn`, false, params);
8118
+ return undefined;
8119
+ }
8120
+ const state = CapFinishIfState(actionSummaries.map(FormatActionForFinishIf).join('\n\n'));
8121
+ const outcome = await this.evaluateFinishIf(finishIf, state, params, decision, payload, parentStepId);
8122
+ return this.gateEndsRun(outcome, params)
8123
+ ? { ...this.finishIfSuccessStep(finishIf, decision, payload), priorStepResult: actionSummaries }
8124
+ : undefined;
8125
+ }
8126
+ /**
8127
+ * Runs the finishIf gate after a Sub-Agent step that succeeded without terminating, and returns
8128
+ * the step with `terminate: true` when it passes, as `terminateAfter` would.
8129
+ */
8130
+ async finishAfterSubAgent(params, decision, result) {
8131
+ const finishIf = decision.finishIf;
8132
+ if (!finishIf || result.terminate || result.step !== 'Success' || !this.finishIfSettings().Enabled) {
8133
+ return undefined;
8134
+ }
8135
+ const outcome = await this.evaluateFinishIf(finishIf, FormatSubAgentForFinishIf(result), params, decision, result.newPayload);
8136
+ return this.gateEndsRun(outcome, params) ? { ...result, terminate: true, message: finishIf.message } : undefined;
8137
+ }
8138
+ /**
8139
+ * Asks the finishIf questions about the step's results, then checks that the normal Success
8140
+ * validation would allow the run to end. Records the check as a `Finish check` Decision step.
8141
+ * Never throws: any error means the gate did not pass, and the run continues as it would have.
8142
+ */
8143
+ async evaluateFinishIf(finishIf, state, params, decision, payload, parentStepId) {
8144
+ const settings = this.finishIfSettings();
8145
+ let step;
8146
+ let outcome;
8147
+ try {
8148
+ step = await this.createStepEntity({
8149
+ stepType: 'Decision',
8150
+ stepName: 'Finish check',
8151
+ contextUser: params.contextUser,
8152
+ inputData: { questions: finishIf.questions, state: state.slice(0, FINISH_IF_STATE_EXCERPT) },
8153
+ parentId: parentStepId
8154
+ });
8155
+ outcome = await this.askFinishIf(finishIf, state, settings, params, step);
8156
+ if (outcome.Passed) {
8157
+ const refusal = await this.finishIfValidationRefusal(params, this.finishIfSuccessStep(finishIf, decision, payload), payload, step);
8158
+ if (refusal) {
8159
+ outcome = { ...outcome, Passed: false, Reason: refusal };
8160
+ }
8161
+ }
8162
+ }
8163
+ catch (error) {
8164
+ outcome = { Passed: false, Probabilities: {}, Reason: error instanceof Error ? error.message : String(error) };
8165
+ LogError(`finishIf gate failed, so the run continues: ${outcome.Reason}`);
8166
+ }
8167
+ await this.finalizeFinishCheckStep(step, outcome, settings);
8168
+ return outcome;
8169
+ }
8170
+ /**
8171
+ * Asks each finishIf question as a Likelihood. Every one must reach the threshold. The call's
8172
+ * prompt run is linked to the `Finish check` step, so the run's cost and tokens count it.
8173
+ */
8174
+ async askFinishIf(finishIf, state, settings, params, step) {
8175
+ const questions = BuildFinishIfQuestions(finishIf);
8176
+ const result = await this._agentDecisionService.Ask({
8177
+ State: state,
8178
+ Questions: questions,
8179
+ ContextUser: params.contextUser,
8180
+ AgentID: params.agent?.ID,
8181
+ PromptName: settings.PromptName,
8182
+ CancellationToken: params.cancellationToken
8183
+ });
8184
+ this.attachDecisionPromptRun(step, result);
8185
+ if (!result.success) {
8186
+ return { Passed: false, Probabilities: {}, Reason: `The decision call failed: ${result.errorMessage ?? 'no error message'}` };
8187
+ }
8188
+ return JudgeFinishIf(result.Answers, questions, settings.Threshold);
8189
+ }
8190
+ /**
8191
+ * Returns why the normal Success validation would refuse to end the run, or `undefined` when it
8192
+ * allows it. The check must not count as a validation retry, so the counters are restored:
8193
+ * otherwise a gate that keeps not passing could exhaust the retries and fail the run.
8194
+ */
8195
+ async finishIfValidationRefusal(params, successStep, payload, step) {
8196
+ if (!this._agentRun) {
8197
+ return 'There is no agent run to validate against';
8198
+ }
8199
+ const generalRetries = this._generalValidationRetryCount;
8200
+ const payloadRetries = this._validationRetryCount;
8201
+ try {
8202
+ const validated = await this.validateSuccessNextStep(params, successStep, payload, this._agentRun, step);
8203
+ if (validated.step === 'Success') {
8204
+ return undefined;
8205
+ }
8206
+ return validated.errorMessage || validated.retryInstructions || `Success validation returned '${validated.step}'`;
8207
+ }
8208
+ finally {
8209
+ this._generalValidationRetryCount = generalRetries;
8210
+ this._validationRetryCount = payloadRetries;
8211
+ }
8212
+ }
8213
+ /** The Success step a passing gate returns after actions, in the shape of the client-tools `terminateAfterExecution` branch. */
8214
+ finishIfSuccessStep(finishIf, decision, payload) {
8215
+ return {
8216
+ step: 'Success',
8217
+ terminate: true,
8218
+ message: finishIf.message,
8219
+ payloadChangeRequest: decision.payloadChangeRequest,
8220
+ previousPayload: decision.previousPayload,
8221
+ newPayload: payload,
8222
+ scratchpad: decision.scratchpad,
8223
+ responseForm: decision.responseForm,
8224
+ actionableCommands: decision.actionableCommands,
8225
+ automaticCommands: decision.automaticCommands,
8226
+ artifactDirective: decision.artifactDirective
8227
+ };
8228
+ }
8229
+ /** Finalizes the `Finish check` step with the outcome. A failure to save it never affects the run. */
8230
+ async finalizeFinishCheckStep(step, outcome, settings) {
8231
+ if (!step) {
8232
+ return;
8233
+ }
8234
+ try {
8235
+ await this.finalizeStepEntity(step, true, undefined, {
8236
+ passed: outcome.Passed,
8237
+ mode: settings.Mode,
8238
+ endedRun: outcome.Passed && settings.Mode === 'on',
8239
+ threshold: settings.Threshold,
8240
+ probabilities: outcome.Probabilities,
8241
+ reason: outcome.Reason
8242
+ });
8243
+ }
8244
+ catch (error) {
8245
+ LogError(`Could not save the finishIf step: ${error instanceof Error ? error.message : String(error)}`);
8246
+ }
8247
+ }
8248
+ /**
8249
+ * Whether the merged agent-type prompt params turn the payload change check on. It is off unless
8250
+ * the master switch ({@link DecisionsEnabled}) and `payloadFeedbackCheck` are both `true`.
8251
+ */
8252
+ isPayloadFeedbackCheckOn(agentTypePromptParams) {
8253
+ return this.DecisionsEnabled(agentTypePromptParams) && agentTypePromptParams?.payloadFeedbackCheck === true;
8254
+ }
8255
+ /**
8256
+ * Runs the payload change check ({@link checkPayloadChanges}) on a step's applied payload changes
8257
+ * when it applies: the analyzer flagged a change for feedback, the check is on
8258
+ * ({@link isPayloadFeedbackCheckOn}), and a turn will read the result. A step that ends the run
8259
+ * skips it: no call to pay for, and no message left in the conversation. Never throws.
8260
+ */
8261
+ async checkPayloadChangesWhenOn(changeResult, nextStep, promptResult, agentTypePromptParams, params) {
8262
+ if (!changeResult.requiresFeedback || !this.isPayloadFeedbackCheckOn(agentTypePromptParams)) {
8263
+ return;
8264
+ }
8265
+ if (this.stepEndsRun(nextStep)) {
8266
+ this.logStatus(`[Payload check] Skipped: the ${nextStep.step} step ends the run, so no turn would read the result`, true, params);
8267
+ return;
8268
+ }
8269
+ await this.checkPayloadChanges(changeResult.analysis, nextStep, promptResult, agentTypePromptParams, params);
8270
+ }
8271
+ /**
8272
+ * Whether the step ends the run, so no turn would read the payload change check's message: it
8273
+ * terminates, or it is a `Success` or `Chat` step, which hand the run back to the caller.
8274
+ */
8275
+ stepEndsRun(step) {
8276
+ return step.terminate === true || step.step === 'Success' || step.step === 'Chat';
8277
+ }
8278
+ /**
8279
+ * The payload change check. Asks whether each change the analyzer flagged was intended, as one
8280
+ * Likelihood per change in one decision call, and records it as a `Payload change check`
8281
+ * Decision step. The changes judged unintended are listed in a message for the agent's next
8282
+ * turn, which asks it to confirm or restore them.
8283
+ *
8284
+ * It never reverts or blocks a change: the agent decides. When the decision cannot answer,
8285
+ * every change is accepted, as it is with the check off. Never throws.
8286
+ */
8287
+ async checkPayloadChanges(analysis, nextStep, promptResult, agentTypePromptParams, params) {
8288
+ const promptName = agentTypePromptParams?.decisionPromptName;
8289
+ const manager = new PayloadFeedbackManager({ decisionPromptName: typeof promptName === 'string' ? promptName : undefined }, this._agentDecisionService);
8290
+ const questions = manager.GenerateQuestions(analysis?.warnings ?? []);
8291
+ if (questions.length === 0) {
8292
+ return;
8293
+ }
8294
+ let step;
8295
+ try {
8296
+ step = await this.createStepEntity({
8297
+ stepType: 'Decision',
8298
+ stepName: 'Payload change check',
8299
+ contextUser: params.contextUser,
8300
+ inputData: {
8301
+ questions: questions.map(q => ({ id: q.id, path: q.warning.path, type: q.warning.type, change: manager.DescribeChange(q.warning) }))
8302
+ }
8303
+ });
8304
+ const responses = await manager.QueryAgent(questions, this.payloadFeedbackContext(nextStep, promptResult, params), params.contextUser);
8305
+ // Like every decision call, it counts toward the run's cost and tokens through its step
8306
+ if (manager.LastDecisionResult) {
8307
+ this.attachDecisionPromptRun(step, manager.LastDecisionResult);
8308
+ }
8309
+ await this.finalizePayloadCheckStep(step, questions, responses, manager.IntendedThreshold);
8310
+ const message = manager.BuildUnintendedChangesMessage(questions, responses);
8311
+ if (message) {
8312
+ this.injectPayloadCheckMessage(params, message);
8313
+ }
8314
+ }
8315
+ catch (error) {
8316
+ const reason = error instanceof Error ? error.message : String(error);
8317
+ LogError(`The payload change check failed, so every change stands: ${reason}`);
8318
+ if (step) {
8319
+ await this.finalizeStepEntity(step, false, reason);
8320
+ }
8321
+ }
8322
+ }
8323
+ /**
8324
+ * What the payload change check's decision reads: the agent's own reasoning for the step (from its
8325
+ * response, else from the step), its message, and the reasoning it gave with the change. The check
8326
+ * never runs on a `Chat` or `Success` step, so the message comes from the response alone.
8327
+ */
8328
+ payloadFeedbackContext(nextStep, promptResult, params) {
8329
+ const response = responseReasoningAndMessage(promptResult?.result);
8330
+ return {
8331
+ Reasoning: response.Reasoning ?? nextStep.reasoning,
8332
+ ChangeReasoning: nextStep.payloadChangeRequest?.reasoning,
8333
+ Message: response.Message,
8334
+ AgentID: params.agent?.ID ?? this._agentRun?.AgentID,
8335
+ CancellationToken: params.cancellationToken
8336
+ };
8337
+ }
8338
+ /**
8339
+ * Finalizes the `Payload change check` step with each change's probability and outcome. The step
8340
+ * fails when no decision answered, and its error says why. A failure to save it never affects the run.
8341
+ */
8342
+ async finalizePayloadCheckStep(step, questions, responses, threshold) {
8343
+ const answered = responses.filter(r => typeof r.probability === 'number');
8344
+ const unintended = responses.filter(r => !r.intended).map(r => questions.find(q => q.id === r.questionId)?.warning.path);
8345
+ try {
8346
+ await this.finalizeStepEntity(step, answered.length > 0, answered.length > 0 ? undefined : responses[0]?.explanation, {
8347
+ threshold,
8348
+ probabilities: Object.fromEntries(answered.map(r => [r.questionId, r.probability])),
8349
+ responses,
8350
+ unintendedPaths: unintended
8351
+ });
8352
+ }
8353
+ catch (error) {
8354
+ LogError(`Could not save the payload change check step: ${error instanceof Error ? error.message : String(error)}`);
8355
+ }
8356
+ }
8357
+ /** Adds the payload change check's message to the conversation, for the agent's next turn. */
8358
+ injectPayloadCheckMessage(params, content) {
8359
+ const message = {
8360
+ role: 'user',
8361
+ content,
8362
+ metadata: {
8363
+ turnAdded: this._promptTurnCount,
8364
+ messageType: 'tool-result',
8365
+ expirationTurns: 3,
8366
+ expirationMode: 'Compact',
8367
+ compactMode: 'First N Chars',
8368
+ compactLength: 500,
8369
+ compactPromptId: '',
8370
+ },
8371
+ };
8372
+ params.conversationMessages.push(message);
8373
+ }
6123
8374
  /**
6124
8375
  * Whether THIS action may use the run's runtime API key for THIS driver class. The default is
6125
8376
  * yes: the run was started on those keys, and an action that calls a vendor on the user's behalf
@@ -6155,12 +8406,24 @@ The context is now within limits. Please retry your request with the recovered c
6155
8406
  * @param {ExecuteAgentParams} params - Parameters from agent execution for context passing
6156
8407
  * @param {AgentAction} action - Action to execute
6157
8408
  * @param {UserInfo} [contextUser] - Optional user context for permissions
8409
+ * @param {ExecuteSingleActionOptions} [options] - `skipCircuitBreaker` bypasses the run-scoped
8410
+ * circuit breaker for callers that do their own failure accounting (the pipeline executor)
6158
8411
  *
6159
8412
  * @returns {Promise<ActionResult>} ActionResult object from the action execution
6160
8413
  *
6161
8414
  * @throws {Error} If the action fails to execute
6162
8415
  */
6163
- async ExecuteSingleAction(params, action, actionEntity, contextUser) {
8416
+ async ExecuteSingleAction(params, action, actionEntity, contextUser, options) {
8417
+ const skipBreaker = options?.skipCircuitBreaker === true;
8418
+ const normalizedParams = this.normalizeActionParams(action.params);
8419
+ // Run-scoped circuit breaker: each rule short-circuits in 0ms with a result that carries the
8420
+ // rule that fired, so the failure directive can name it without consulting the history.
8421
+ if (!skipBreaker) {
8422
+ const blocked = this.checkActionCircuitBreaker(params, action, actionEntity, normalizedParams);
8423
+ if (blocked) {
8424
+ return blocked;
8425
+ }
8426
+ }
6164
8427
  try {
6165
8428
  const actionEngine = ActionEngineServer.Instance;
6166
8429
  // Convert params object to ActionParam array
@@ -6203,13 +8466,23 @@ The context is now within limits. Please retry your request with the recovered c
6203
8466
  });
6204
8467
  if (result.Success) {
6205
8468
  this.logStatus(` ✅ Action '${action.name}' completed successfully`, true, params);
8469
+ if (!skipBreaker) {
8470
+ this.clearActionFailureRecord(action, actionEntity);
8471
+ }
6206
8472
  }
6207
8473
  else {
6208
8474
  this.logStatus(` ❌ Action '${action.name}' failed: ${result.Message || 'Unknown error'}`, false, params);
8475
+ if (!skipBreaker) {
8476
+ this.recordActionFailure(action, actionEntity, result.Message, normalizedParams);
8477
+ }
6209
8478
  }
6210
8479
  return result;
6211
8480
  }
6212
8481
  catch (error) {
8482
+ const errorMsg = error instanceof Error ? error.message : String(error);
8483
+ if (!skipBreaker) {
8484
+ this.recordActionFailure(action, actionEntity, errorMsg, normalizedParams);
8485
+ }
6213
8486
  this.logError(error, {
6214
8487
  category: 'ActionExecution',
6215
8488
  metadata: {
@@ -6290,6 +8563,13 @@ The context is now within limits. Please retry your request with the recovered c
6290
8563
  * **Priority:** AIAgentRelationship.MessageMode takes precedence over AIAgent.MessageMode
6291
8564
  * to allow different parent agents to pass messages differently to the same sub-agent.
6292
8565
  *
8566
+ * **Runtime state never crosses the boundary.** The parent's trailing runtime-state messages
8567
+ * (`metadata.volatileState` — its payload, scratchpad and, when relocated, its specialization;
8568
+ * retained in the history under append-only mode) are dropped BEFORE any mode slices the
8569
+ * history, so a sub-agent never sees the parent's state, never has it counted against
8570
+ * `MaxMessages`, and never receives it unescaped when it builds no fragment of its own. The
8571
+ * sub-agent builds its own fragment at its prompt step.
8572
+ *
6293
8573
  * Subclasses can override this method to implement custom message preparation logic
6294
8574
  * specific to their domain (e.g., Skip agents adding special context).
6295
8575
  *
@@ -6309,6 +8589,9 @@ The context is now within limits. Please retry your request with the recovered c
6309
8589
  // Get MessageMode and MaxMessages from either relationship or child agent
6310
8590
  let messageMode = relationship?.MessageMode || subAgent.MessageMode || 'None';
6311
8591
  let maxMessages = relationship?.MaxMessages || subAgent.MaxMessages || null;
8592
+ // The parent's history minus its runtime-state messages (see the doc comment): every mode
8593
+ // slices THIS, so no fragment reaches the sub-agent and none spends a MaxMessages slot.
8594
+ const history = params.conversationMessages.filter(m => m.metadata?.volatileState !== true);
6312
8595
  // Apply message mode
6313
8596
  switch (messageMode) {
6314
8597
  case 'None':
@@ -6317,23 +8600,23 @@ The context is now within limits. Please retry your request with the recovered c
6317
8600
  break;
6318
8601
  case 'All':
6319
8602
  // Pass all parent conversation history
6320
- messages = [...params.conversationMessages];
8603
+ messages = [...history];
6321
8604
  break;
6322
8605
  case 'Latest':
6323
8606
  // Pass most recent N messages
6324
8607
  if (maxMessages && maxMessages > 0) {
6325
- messages = this.makeToolTurnsSelfConsistent(params.conversationMessages.slice(-maxMessages));
8608
+ messages = this.makeToolTurnsSelfConsistent(history.slice(-maxMessages));
6326
8609
  }
6327
8610
  else {
6328
- messages = [...params.conversationMessages];
8611
+ messages = [...history];
6329
8612
  }
6330
8613
  break;
6331
8614
  case 'Bookend':
6332
8615
  // Pass first 2 + most recent (N-2) with indicator message between
6333
- if (maxMessages && maxMessages > 2 && params.conversationMessages.length > maxMessages) {
6334
- const firstTwo = params.conversationMessages.slice(0, 2);
6335
- const remaining = params.conversationMessages.slice(-(maxMessages - 2));
6336
- const omittedCount = params.conversationMessages.length - maxMessages;
8616
+ if (maxMessages && maxMessages > 2 && history.length > maxMessages) {
8617
+ const firstTwo = history.slice(0, 2);
8618
+ const remaining = history.slice(-(maxMessages - 2));
8619
+ const omittedCount = history.length - maxMessages;
6337
8620
  messages = this.makeToolTurnsSelfConsistent([
6338
8621
  ...firstTwo,
6339
8622
  {
@@ -6344,7 +8627,7 @@ The context is now within limits. Please retry your request with the recovered c
6344
8627
  ]);
6345
8628
  }
6346
8629
  else {
6347
- messages = [...params.conversationMessages];
8630
+ messages = [...history];
6348
8631
  }
6349
8632
  break;
6350
8633
  default:
@@ -6441,6 +8724,7 @@ The context is now within limits. Please retry your request with the recovered c
6441
8724
  subAgentChanges: subAgentSubAgentChanges, // propagate filtered sub-agent changes to sub-agent
6442
8725
  PrimaryScopeEntityName: params.PrimaryScopeEntityName, // propagate scope to sub-agent
6443
8726
  PrimaryScopeRecordID: params.PrimaryScopeRecordID,
8727
+ companyId: params.companyId,
6444
8728
  SecondaryScopes: params.SecondaryScopes,
6445
8729
  onAgentRunCreated: async (agentRunId) => {
6446
8730
  stepEntity.TargetLogID = agentRunId;
@@ -7121,6 +9405,7 @@ The context is now within limits. Please retry your request with the recovered c
7121
9405
  }
7122
9406
  // Reset prompt turn counter for this execution
7123
9407
  this._promptTurnCount = 0;
9408
+ this._heldDecisions = undefined;
7124
9409
  // Resolve action-result compression config for this run (default on; opt out via
7125
9410
  // crushActionResults: false in the agent-type prompt params).
7126
9411
  this._actionResultCrush = this.resolveActionResultCrush(params);
@@ -7786,14 +10071,17 @@ The context is now within limits. Please retry your request with the recovered c
7786
10071
  }
7787
10072
  }
7788
10073
  return await this.executePromptStep(params, config, previousDecision, stepCount);
7789
- case 'Sub-Agent':
10074
+ case 'Sub-Agent': {
7790
10075
  // A Sub-Agent step carrying a task graph is a FOLDED graph (D9): the agent type
7791
10076
  // rewrote a one-node graph into an ordinary in-run call. Record it before running,
7792
10077
  // so the run shows what was emitted even if the sub-agent then fails.
7793
10078
  await this.recordFoldedTaskGraph(params, previousDecision);
7794
- return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
10079
+ const subAgentResult = await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
10080
+ const afterSubAgent = (await this.finishAfterSubAgent(params, previousDecision, subAgentResult)) ?? subAgentResult;
10081
+ return this.selfCheckSuccess(params, afterSubAgent, afterSubAgent.newPayload ?? subAgentResult.newPayload);
10082
+ }
7795
10083
  case 'Actions':
7796
- return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
10084
+ return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount, this.actionOptionsForAgentType());
7797
10085
  // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
7798
10086
  // union (non-terminal, like 'ClientTools') — LoopAgentType.DetermineNextStep() emits it
7799
10087
  // when the LLM chooses to activate a skill.
@@ -7813,6 +10101,11 @@ The context is now within limits. Please retry your request with the recovered c
7813
10101
  // step union — LoopAgentType.DetermineNextStep() emits it when the LLM chooses client tools.
7814
10102
  case 'ClientTools':
7815
10103
  return await this.executeClientToolsStep(params, config, previousDecision, stepCount);
10104
+ // Type assertion required because 'Decision' is not part of the BaseAgentNextStep step
10105
+ // union — FlowAgentType emits it for a Decision step, which is one decision call and no
10106
+ // LLM turn. executeDecisionStep returns a 'Retry' carrying the answers.
10107
+ case 'Decision':
10108
+ return await this.executeDecisionStep(params, previousDecision);
7816
10109
  case 'Chat':
7817
10110
  return await this.executeChatStep(params, previousDecision);
7818
10111
  case 'Success':
@@ -7951,6 +10244,9 @@ The context is now within limits. Please retry your request with the recovered c
7951
10244
  * @private
7952
10245
  */
7953
10246
  async executePromptStep(params, config, previousDecision, stepCount = 0) {
10247
+ // Decisions the last prompt held back: the step that carried them did not end the run, so
10248
+ // answer them now, where this prompt will read the answers
10249
+ await this.runHeldDecisions(params);
7954
10250
  // Ask the agent type if it has a custom prompt for this step
7955
10251
  const promptToUse = await this.AgentTypeInstance.GetPromptForStep(params, config, previousDecision?.newPayload || previousDecision?.previousPayload, this.AgentTypeState, previousDecision);
7956
10252
  const promptId = promptToUse?.ID;
@@ -8134,6 +10430,10 @@ The context is now within limits. Please retry your request with the recovered c
8134
10430
  currentStepPayloadChangeResult = this.buildPayloadChangeResultSummary(changeResult);
8135
10431
  // Set the final payload - the changeResult already respects the allowed paths
8136
10432
  finalPayload = changeResult.result;
10433
+ // Opt-in payload change check: asks whether the flagged changes were intended. It never
10434
+ // reverts or blocks a change; the agent reads the result on its next turn.
10435
+ const payloadCheckParams = promptParams.data?.__agentTypePromptParams;
10436
+ await this.checkPayloadChangesWhenOn(changeResult, initialNextStep, promptResult, payloadCheckParams, params);
8137
10437
  }
8138
10438
  // Apply scratchpad changes if provided (zero turn cost — processed inline)
8139
10439
  if (initialNextStep.scratchpad) {
@@ -8192,6 +10492,13 @@ The context is now within limits. Please retry your request with the recovered c
8192
10492
  const pipelineResult = await this.executePipelineAsStep(initialNextStep.pipeline, params);
8193
10493
  this.injectPipelineResultMessage(params, pipelineResult);
8194
10494
  }
10495
+ // Execute decision requests if provided (zero turn cost — processed inline), unless the
10496
+ // step that carries them can end the run: then they are held until it has run
10497
+ const decisions = initialNextStep.decisions;
10498
+ if (decisions?.length) {
10499
+ const agentTypePromptParams = promptParams.data?.__agentTypePromptParams;
10500
+ await this.processTurnDecisions(decisions, initialNextStep, finalPayload, agentTypePromptParams, params);
10501
+ }
8195
10502
  // now that we have processed the payload, we can process the next step which does validation and changes the next step if
8196
10503
  // validation fails
8197
10504
  const updatedNextStep = await this.processNextStep(initialNextStep, params, config.agentType, promptResult, finalPayload, stepEntity);
@@ -8684,9 +10991,9 @@ The context is now within limits. Please retry your request with the recovered c
8684
10991
  }
8685
10992
  if (previousDecision?.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
8686
10993
  // the delegate_to_* call is answered as a tool result.
8687
- params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
10994
+ params.conversationMessages.push(BuildToolResultTurn([...this.payloadToolResult(previousDecision), {
8688
10995
  toolCallId: subAgentRequest.toolCallId,
8689
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
10996
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(subAgentRequest.name)}`,
8690
10997
  content: resultMessage,
8691
10998
  isError: !subAgentResult.success
8692
10999
  }], subAgentMetadata));
@@ -9178,11 +11485,11 @@ The context is now within limits. Please retry your request with the recovered c
9178
11485
  const pairable = nativeResults ? allExecutions.filter((e) => !!e.request.toolCallId) : [];
9179
11486
  const unpairable = allExecutions.filter((e) => !pairable.includes(e));
9180
11487
  if (pairable.length > 0) {
9181
- params.conversationMessages.push(buildToolResultTurn([
11488
+ params.conversationMessages.push(BuildToolResultTurn([
9182
11489
  ...this.payloadToolResult(previousDecision),
9183
11490
  ...pairable.map((e) => ({
9184
11491
  toolCallId: e.request.toolCallId,
9185
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(e.request.name)}`,
11492
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(e.request.name)}`,
9186
11493
  content: this.buildParallelSubAgentSummary([e]),
9187
11494
  isError: !e.result.success
9188
11495
  }))
@@ -9406,9 +11713,9 @@ The context is now within limits. Please retry your request with the recovered c
9406
11713
  }
9407
11714
  if (previousDecision.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
9408
11715
  // the delegate_to_* call is answered as a tool result (same as the child path).
9409
- params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
11716
+ params.conversationMessages.push(BuildToolResultTurn([...this.payloadToolResult(previousDecision), {
9410
11717
  toolCallId: subAgentRequest.toolCallId,
9411
- toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
11718
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${SanitizeToolName(subAgentRequest.name)}`,
9412
11719
  content: relatedResultMessage,
9413
11720
  isError: !subAgentResult.success
9414
11721
  }], relatedMetadata));
@@ -9657,12 +11964,21 @@ The context is now within limits. Please retry your request with the recovered c
9657
11964
  * Supports both dot notation (obj.prop) and array indexing (arr[0]).
9658
11965
  *
9659
11966
 
11967
+ /**
11968
+ * The {@link ExecuteSingleActionOptions} the main loop passes for this run's agent type: the
11969
+ * circuit-breaker exemption when the type has opted out (`BaseAgentType.UsesActionCircuitBreaker`
11970
+ * is false — Flow), otherwise none. Kept as a seam so a subclass can widen or narrow the
11971
+ * exemption without touching the loop.
11972
+ */
11973
+ actionOptionsForAgentType() {
11974
+ return this.AgentTypeInstance?.UsesActionCircuitBreaker === false ? { skipCircuitBreaker: true } : undefined;
11975
+ }
9660
11976
  /**
9661
11977
  * Executes actions step and tracks it.
9662
11978
  *
9663
11979
  * @private
9664
11980
  */
9665
- async executeActionsStep(params, previousDecision, parentStepId, addConversationMessage = true, stepCount = 0) {
11981
+ async executeActionsStep(params, previousDecision, parentStepId, addConversationMessage = true, stepCount = 0, actionOptions) {
9666
11982
  try {
9667
11983
  const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
9668
11984
  const actions = previousDecision.actions || [];
@@ -9822,7 +12138,7 @@ The context is now within limits. Please retry your request with the recovered c
9822
12138
  let actionResult;
9823
12139
  try {
9824
12140
  // Execute the action
9825
- actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser);
12141
+ actionResult = await this.ExecuteSingleAction(params, aa, actionEntity, params.contextUser, actionOptions);
9826
12142
  // Update step entity with ActionExecutionLog ID if available
9827
12143
  if (actionResult.LogEntry?.ID) {
9828
12144
  const logId = actionResult.LogEntry.ID;
@@ -9842,7 +12158,7 @@ The context is now within limits. Please retry your request with the recovered c
9842
12158
  };
9843
12159
  // Finalize step entity with output data
9844
12160
  await this.finalizeStepEntity(stepEntity, actionResult.Success, actionResult.Success ? undefined : actionResult.Message, outputData);
9845
- return { success: true, result: actionResult, action: aa, actionEntity, stepEntity };
12161
+ return { success: actionResult.Success, result: actionResult, action: aa, actionEntity, stepEntity, error: actionResult.Success ? undefined : actionResult.Message };
9846
12162
  }
9847
12163
  catch (error) {
9848
12164
  await this.finalizeStepEntity(stepEntity, false, error.message);
@@ -9858,9 +12174,10 @@ The context is now within limits. Please retry your request with the recovered c
9858
12174
  // Build a clean summary of action results
9859
12175
  // Apply large binary content interception to prevent context overflow
9860
12176
  const actionSummaries = actionResults.map(result => {
9861
- const actionResult = result.success ? result.result : null;
12177
+ const actionResult = result.result;
12178
+ const isActionSuccess = Boolean(result.success && (actionResult ? actionResult.Success : true));
9862
12179
  // Filter to output params only
9863
- const outputParams = result.result?.Params?.filter(p => p.Type === 'Both' || p.Type === 'Output') || [];
12180
+ const outputParams = actionResult?.Params?.filter(p => p.Type === 'Both' || p.Type === 'Output') || [];
9864
12181
  // Intercept large media content (images, audio, video) and replace with placeholders
9865
12182
  // This prevents context overflow from base64 data (~700K tokens per 1024x1024 image)
9866
12183
  // Pass actionEntity for generic ValueType=MediaOutput detection from metadata
@@ -9870,11 +12187,12 @@ The context is now within limits. Please retry your request with the recovered c
9870
12187
  this._fileOutputs.push(...fileOutputs);
9871
12188
  return {
9872
12189
  actionName: result.action.name,
9873
- success: result.success,
12190
+ success: isActionSuccess,
9874
12191
  params: sanitizedParams,
9875
- resultCode: actionResult?.Result?.ResultCode || (result.success ? 'SUCCESS' : 'ERROR'),
9876
- message: result.success ? actionResult?.Message || 'Action completed' : result.error || 'Unknown error',
9877
- aiDirectives: result.success ? actionResult?.AIDirectives : undefined
12192
+ resultCode: actionResult?.Result?.ResultCode || (isActionSuccess ? 'SUCCESS' : 'ERROR'),
12193
+ message: actionResult?.Message || (isActionSuccess ? 'Action completed' : result.error || 'Unknown error'),
12194
+ aiDirectives: isActionSuccess ? actionResult?.AIDirectives : undefined,
12195
+ breakerReason: actionResult instanceof CircuitBreakerActionResult ? actionResult.Reason : undefined
9878
12196
  };
9879
12197
  });
9880
12198
  // Check if any actions failed
@@ -9940,6 +12258,16 @@ The context is now within limits. Please retry your request with the recovered c
9940
12258
  content: `IMPORTANT — Follow these directives from the action results:\n\n${directiveText}`
9941
12259
  });
9942
12260
  }
12261
+ // Surface failure guidance for failed actions so the model does not repeatedly loop on broken
12262
+ // tools. Not when the breaker is bypassed for this step: there is then no model in the loop to
12263
+ // act on it (Flow, ForEach, While, pipeline), and the directive would only pollute the history.
12264
+ if (failedActions.length > 0 && actionOptions?.skipCircuitBreaker !== true) {
12265
+ const failureText = failedActions.map(f => this.formatActionFailureDirective(f)).join('\n\n');
12266
+ params.conversationMessages.push({
12267
+ role: 'user',
12268
+ content: `IMPORTANT — Action Execution Failure Guidance:\n\n${failureText}`
12269
+ });
12270
+ }
9943
12271
  }
9944
12272
  // Call agent type's post-processing for actions
9945
12273
  let finalPayload = currentPayload;
@@ -9973,6 +12301,10 @@ The context is now within limits. Please retry your request with the recovered c
9973
12301
  catch (error) {
9974
12302
  LogError(`Error in PostProcessActionStep: ${error.message}`);
9975
12303
  }
12304
+ const finished = await this.finishAfterActions(params, previousDecision, actionResults, actionSummaries, finalPayload, parentStepId, addConversationMessage);
12305
+ if (finished) {
12306
+ return this.selfCheckSuccess(params, finished, finalPayload);
12307
+ }
9976
12308
  // After actions complete, we need to process the results
9977
12309
  // The retry step is used to re-execute the prompt with the action results
9978
12310
  // This allows the agent to analyze the results and determine what to do next
@@ -10092,7 +12424,7 @@ The context is now within limits. Please retry your request with the recovered c
10092
12424
  // If the LLM already declared taskComplete=true alongside the client tools,
10093
12425
  // honor that intent now that tools have executed — no need for another LLM call.
10094
12426
  if (previousDecision.terminateAfterExecution) {
10095
- return {
12427
+ return this.selfCheckSuccess(params, {
10096
12428
  step: 'Success',
10097
12429
  terminate: true,
10098
12430
  message: previousDecision.message || 'Client tools executed successfully.',
@@ -10105,7 +12437,7 @@ The context is now within limits. Please retry your request with the recovered c
10105
12437
  // own artifact directive has to come along. Omitting it silently discarded the
10106
12438
  // instruction whenever an agent declared client tools and taskComplete together.
10107
12439
  artifactDirective: previousDecision.artifactDirective
10108
- };
12440
+ }, previousDecision.newPayload);
10109
12441
  }
10110
12442
  return await this.executePromptStep(params, config, previousDecision, stepCount);
10111
12443
  }
@@ -10194,6 +12526,8 @@ The context is now within limits. Please retry your request with the recovered c
10194
12526
  * them calls this after MJ's gates
10195
12527
  * (AcceptsSkills, Status, agent grant, user Run permission, the ActivationMode double gate) and
10196
12528
  * before anything activates. The default is the identity: MJ's gates are the whole policy.
12529
+ * Catalog narrowing (plan Task 3.7) is not part of this policy: it runs after it, at the catalog
12530
+ * site, and only hides skills from the prompt. An override never needs to call `super` for it.
10197
12531
  *
10198
12532
  * Override it to layer a policy MJ has no table for — a tenant licensing model, a per-organization
10199
12533
  * entitlement, a feature flag — and it applies everywhere at once. Without this seam a subclass
@@ -11208,16 +13542,18 @@ The context is now within limits. Please retry your request with the recovered c
11208
13542
  async executeForEachLoop(params, config, previousDecision) {
11209
13543
  const forEach = previousDecision.forEach;
11210
13544
  if (!forEach) {
13545
+ // Not reported to the model: a Loop agent never gets here, because LoopAgentType turns a
13546
+ // ForEach step with no details into a Retry whose errorMessage the model is shown.
11211
13547
  return this.createFailedStep('ForEach configuration missing', previousDecision);
11212
13548
  }
11213
13549
  const validationMessage = this.validateForEachOperation(forEach);
11214
13550
  if (validationMessage) {
11215
- return this.createFailedStep(`ForEach configuration invalid: ${validationMessage}`, previousDecision);
13551
+ return this.failLoopBeforeFirstIteration('ForEach', forEach.collectionPath, `ForEach configuration invalid: ${validationMessage}`, previousDecision, params, forEach.action?.name);
11216
13552
  }
11217
13553
  const currentPayload = previousDecision.newPayload || previousDecision.previousPayload;
11218
13554
  const collection = this.getCollectionFromPayload(currentPayload, forEach.collectionPath);
11219
13555
  if (!collection) {
11220
- return this.createFailedStep(`Collection path "${forEach.collectionPath}" not an array`, previousDecision);
13556
+ return this.failLoopBeforeFirstIteration('ForEach', forEach.collectionPath, `Collection path "${forEach.collectionPath}" not an array`, previousDecision, params, forEach.action?.name);
11221
13557
  }
11222
13558
  const loopStepEntity = await this.createForEachLoopStep(forEach, collection, currentPayload, params);
11223
13559
  const loopResults = await this.executeForEachIterations(forEach, collection, currentPayload, loopStepEntity.ID, params, config);
@@ -11495,7 +13831,9 @@ The context is now within limits. Please retry your request with the recovered c
11495
13831
  params: resolvedParams
11496
13832
  };
11497
13833
  const actionStep = { step: 'Actions', actions: [resolvedAction], newPayload: currentPayload, previousPayload: currentPayload, terminate: false };
11498
- result = await this.executeActionsStep(params, actionStep, parentStepId, false);
13834
+ // Loop iterations bypass the circuit breaker: the loop does its own per-item accounting and
13835
+ // there is no model between items to act on the breaker's guidance.
13836
+ result = await this.executeActionsStep(params, actionStep, parentStepId, false, 0, { skipCircuitBreaker: true });
11499
13837
  }
11500
13838
  else if (forEach.subAgent) {
11501
13839
  const subAgentStep = { step: 'Sub-Agent', subAgent: forEach.subAgent, newPayload: currentPayload, previousPayload: currentPayload };
@@ -11521,7 +13859,7 @@ The context is now within limits. Please retry your request with the recovered c
11521
13859
  async completeForEachLoop(forEach, loopStepEntity, loopResults, previousDecision, params) {
11522
13860
  // Finalize the loop step now that loop is complete
11523
13861
  loopStepEntity.PayloadAtEnd = this.serializePayloadAtEnd(loopResults.finalPayload);
11524
- await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, loopResults.errors.join('\n\n'), loopResults);
13862
+ await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, this.formatLoopErrors(loopResults.errors), loopResults);
11525
13863
  if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
11526
13864
  this.injectLoopResultsMessage('ForEach', forEach.collectionPath, loopResults.results, loopResults.errors, params, forEach.action?.name);
11527
13865
  }
@@ -11551,6 +13889,32 @@ The context is now within limits. Please retry your request with the recovered c
11551
13889
  metadata
11552
13890
  });
11553
13891
  }
13892
+ /**
13893
+ * Fails a loop that never ran an iteration, and tells the model why.
13894
+ *
13895
+ * A Loop agent answers a Failed step by prompting again (`HandleStepFallback` returns null), and
13896
+ * nothing on that path reads the step's `errorMessage`. Without the injected message the model
13897
+ * gets another turn with no idea its loop failed, and is likely to emit the same loop again.
13898
+ * Flow agents don't inject loop results, so for them this is just the Failed step.
13899
+ */
13900
+ failLoopBeforeFirstIteration(loopType, collectionOrCondition, errorMessage, previousDecision, params, actionName) {
13901
+ if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
13902
+ this.injectLoopResultsMessage(loopType, collectionOrCondition, [], [{ index: 0, message: errorMessage }], params, actionName);
13903
+ }
13904
+ return this.createFailedStep(errorMessage, previousDecision);
13905
+ }
13906
+ /**
13907
+ * One loop error as readable text. Joining the error objects directly wrote "[object Object]"
13908
+ * into the step's ErrorMessage. Falls back to JSON when an iteration threw something with no
13909
+ * message (a thrown non-Error).
13910
+ */
13911
+ describeLoopError(err) {
13912
+ return err.message ? err.message : JSON.stringify(err);
13913
+ }
13914
+ /** All loop errors as text for a step's ErrorMessage. */
13915
+ formatLoopErrors(errors) {
13916
+ return errors.map(err => this.describeLoopError(err)).join('\n\n');
13917
+ }
11554
13918
  /**
11555
13919
  * Formats loop iteration results as markdown. Handles two distinct result shapes
11556
13920
  * depending on whether the loop body executed actions or sub-agents:
@@ -11591,8 +13955,7 @@ The context is now within limits. Please retry your request with the recovered c
11591
13955
  if (errors.length > 0) {
11592
13956
  lines.push(`### Errors`);
11593
13957
  for (const err of errors) {
11594
- const errMsg = typeof err === 'string' ? err : err?.message || JSON.stringify(err);
11595
- lines.push(`• ✗ ${errMsg}`);
13958
+ lines.push(`• ✗ ${this.describeLoopError(err)}`);
11596
13959
  }
11597
13960
  }
11598
13961
  return lines.join('\n');
@@ -11698,11 +14061,13 @@ The context is now within limits. Please retry your request with the recovered c
11698
14061
  async executeWhileLoop(params, config, previousDecision) {
11699
14062
  const whileOp = previousDecision.while;
11700
14063
  if (!whileOp) {
14064
+ // Not reported to the model: a Loop agent never gets here, because LoopAgentType turns a
14065
+ // While step with no details into a Retry whose errorMessage the model is shown.
11701
14066
  return this.createFailedStep('While configuration missing', previousDecision);
11702
14067
  }
11703
14068
  const validationMessage = this.validateWhileOperation(whileOp);
11704
14069
  if (validationMessage) {
11705
- return this.createFailedStep(`While configuration invalid: ${validationMessage}`, previousDecision);
14070
+ return this.failLoopBeforeFirstIteration('While', whileOp.condition, `While configuration invalid: ${validationMessage}`, previousDecision, params, whileOp.action?.name);
11706
14071
  }
11707
14072
  const currentPayload = previousDecision.newPayload || previousDecision.previousPayload;
11708
14073
  const loopStepEntity = await this.createWhileLoopStep(whileOp, currentPayload, params);
@@ -11731,6 +14096,7 @@ The context is now within limits. Please retry your request with the recovered c
11731
14096
  const results = [];
11732
14097
  const errors = [];
11733
14098
  let iterationCount = 0;
14099
+ let conditionError;
11734
14100
  const evaluator = new SafeExpressionEvaluator();
11735
14101
  // ACTUAL WHILE LOOP - simple and clear!
11736
14102
  while (iterationCount < maxIterations) {
@@ -11738,7 +14104,15 @@ The context is now within limits. Please retry your request with the recovered c
11738
14104
  await new Promise(resolve => setTimeout(resolve, whileOp.delayBetweenIterationsMs));
11739
14105
  }
11740
14106
  const evalResult = evaluator.evaluate(whileOp.condition, { payload: currentPayload, results, errors });
11741
- if (!evalResult.success || !evalResult.value) {
14107
+ if (!evalResult.success) {
14108
+ // "Could not evaluate" is not "evaluated false". Treating it as false used to end the
14109
+ // loop silently and finalize it as a success — a malformed condition produced a green,
14110
+ // zero-iteration loop with the evaluator's error discarded.
14111
+ conditionError = `While condition '${whileOp.condition}' could not be evaluated: ${evalResult.error ?? 'unknown error'}`;
14112
+ errors.push({ index: iterationCount, message: conditionError });
14113
+ break;
14114
+ }
14115
+ if (!evalResult.value) {
11742
14116
  break;
11743
14117
  }
11744
14118
  const attemptContext = { attemptNumber: iterationCount + 1, totalAttempts: iterationCount };
@@ -11754,7 +14128,7 @@ The context is now within limits. Please retry your request with the recovered c
11754
14128
  }
11755
14129
  iterationCount++;
11756
14130
  }
11757
- return { results, errors, finalPayload: currentPayload, iterations: iterationCount };
14131
+ return { results, errors, finalPayload: currentPayload, iterations: iterationCount, conditionError };
11758
14132
  }
11759
14133
  /**
11760
14134
  * Helper: Execute single While iteration
@@ -11783,7 +14157,8 @@ The context is now within limits. Please retry your request with the recovered c
11783
14157
  params: resolvedParams
11784
14158
  };
11785
14159
  const actionStep = { step: 'Actions', actions: [resolvedAction], newPayload: currentPayload, previousPayload: currentPayload, terminate: false };
11786
- result = await this.executeActionsStep(params, actionStep, parentStepId, false);
14160
+ // Same exemption as ForEach: the loop owns per-iteration accounting.
14161
+ result = await this.executeActionsStep(params, actionStep, parentStepId, false, 0, { skipCircuitBreaker: true });
11787
14162
  }
11788
14163
  else if (whileOp.subAgent) {
11789
14164
  const subAgentStep = { step: 'Sub-Agent', subAgent: whileOp.subAgent, newPayload: currentPayload, previousPayload: currentPayload };
@@ -11808,13 +14183,23 @@ The context is now within limits. Please retry your request with the recovered c
11808
14183
  */
11809
14184
  async completeWhileLoop(whileOp, loopStepEntity, loopResults, previousDecision, params) {
11810
14185
  loopStepEntity.PayloadAtEnd = this.serializePayloadAtEnd(loopResults.finalPayload);
11811
- await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, loopResults.errors.join('\n\n'), loopResults);
14186
+ await this.finalizeStepEntity(loopStepEntity, loopResults.errors.length === 0, this.formatLoopErrors(loopResults.errors), loopResults);
14187
+ // Inject before the early return below: a Loop agent re-prompts after a Failed step, and the
14188
+ // loop-results message (the condition error, under Errors) is the only way the model learns why.
11812
14189
  if (this.AgentTypeInstance.InjectLoopResultsAsMessage) {
11813
14190
  this.injectLoopResultsMessage('While', whileOp.condition, loopResults.results, loopResults.errors, params, whileOp.action?.name);
11814
14191
  }
14192
+ // A condition that never evaluated means the loop never ran: fail the step rather than
14193
+ // report a completed zero-iteration loop.
14194
+ if (loopResults.conditionError && loopResults.iterations === 0) {
14195
+ return this.createFailedStep(loopResults.conditionError, previousDecision);
14196
+ }
14197
+ const retryInstructions = loopResults.conditionError
14198
+ ? `While loop request using condition '${whileOp.condition}' stopped after ${loopResults.iterations} iteration(s): ${loopResults.conditionError}`
14199
+ : `Completed While loop request using condition '${whileOp.condition}' after ${loopResults.iterations} iteration(s)`;
11815
14200
  return {
11816
14201
  step: 'Retry',
11817
- retryInstructions: `Completed While loop request using condition '${whileOp.condition}' after ${loopResults.iterations} iteration(s)`,
14202
+ retryInstructions,
11818
14203
  terminate: false,
11819
14204
  newPayload: loopResults.finalPayload,
11820
14205
  previousPayload: previousDecision.previousPayload
@@ -11961,7 +14346,7 @@ The context is now within limits. Please retry your request with the recovered c
11961
14346
  }
11962
14347
  // Also promote any media from the final step's promoteMediaOutputs
11963
14348
  if (finalStep.promoteMediaOutputs && finalStep.promoteMediaOutputs.length > 0) {
11964
- this.promoteMediaOutputs(finalStep.promoteMediaOutputs);
14349
+ this.PromoteMediaOutputs(finalStep.promoteMediaOutputs);
11965
14350
  }
11966
14351
  // Return unified media outputs — all items are persisted by AgentRunner.
11967
14352
  // Sub-agents pass their mediaOutputs to parent for merging and placeholder resolution.
@@ -11998,8 +14383,10 @@ The context is now within limits. Please retry your request with the recovered c
11998
14383
  // Iterate through the agent run's steps to sum up tokens
11999
14384
  if (this._agentRun?.Steps) {
12000
14385
  for (const step of this._agentRun.Steps) {
12001
- if ((step.StepType === 'Prompt' || step.StepType === 'Compaction') && step.PromptRun) {
12002
- // Add tokens from prompt runs (rollup fields include any nested child prompt runs)
14386
+ if ((step.StepType === 'Prompt' || step.StepType === 'Compaction' || step.StepType === 'Decision') && step.PromptRun) {
14387
+ // Add tokens from prompt runs (rollup fields include any nested child prompt runs).
14388
+ // A Decision step carries its own decision call's run — the Finish check, a single-state
14389
+ // request, or one item of a forEachItemIn request — so each call is counted once.
12003
14390
  totalTokens += step.PromptRun.TokensUsedRollup || 0;
12004
14391
  promptTokens += step.PromptRun.TokensPromptRollup || 0;
12005
14392
  completionTokens += step.PromptRun.TokensCompletionRollup || 0;
@@ -12204,7 +14591,7 @@ The context is now within limits. Please retry your request with the recovered c
12204
14591
  if (item.message.role === 'tool') {
12205
14592
  // compact per block so the tool turn keeps answering its call.
12206
14593
  const limit = item.metadata.compactLength || 500;
12207
- const compactedTurn = compactToolResultContent(item.message, (t) => (t.length > limit ? `${t.slice(0, limit)}… [compacted from ${t.length} chars]` : t));
14594
+ const compactedTurn = CompactToolResultContent(item.message, (t) => (t.length > limit ? `${t.slice(0, limit)}… [compacted from ${t.length} chars]` : t));
12208
14595
  const saved = this.estimateTokens(originalContent) - this.estimateTokens(compactedTurn.content);
12209
14596
  params.conversationMessages[item.index] = {
12210
14597
  ...compactedTurn,
@@ -12304,10 +14691,12 @@ The context is now within limits. Please retry your request with the recovered c
12304
14691
  * (agent or type ContextWindowMaxTokens) — before the first prompt the model is
12305
14692
  * unknown, and compacting against the conservative default would over-trigger on
12306
14693
  * large-context models. The post-turn hook (real model known) covers those.
14694
+ * Skipped under a history floor (`ConversationHistoryFrom`): a summary folds in the
14695
+ * conversation from its first message, which is what the floor excludes.
12307
14696
  * @protected
12308
14697
  */
12309
14698
  async checkPreTurnCompaction(params, config) {
12310
- if (!params.conversationId || this._depth !== 0) {
14699
+ if (!params.conversationId || this._depth !== 0 || params.ConversationHistoryFrom) {
12311
14700
  return;
12312
14701
  }
12313
14702
  const budget = this.resolveCompactionBudget(params, config);
@@ -12332,10 +14721,19 @@ The context is now within limits. Please retry your request with the recovered c
12332
14721
  * final step (→ AwaitingFeedback) is the NORMAL ending of a conversational turn;
12333
14722
  * gating on 'Completed' alone silently disabled post-turn compaction for exactly
12334
14723
  * the long-chat scenario this feature targets.
14724
+ *
14725
+ * Skipped under a history floor (`ConversationHistoryFrom`). A run with a floor must not
14726
+ * write the conversation's summary: the summary covers every row below its boundary, and
14727
+ * a run that may not read the rows before the floor can't produce that — nor, once reads
14728
+ * are narrowed to what the asker can see, can it tell which rows it was not shown.
12335
14729
  * @protected
12336
14730
  */
12337
14731
  startPostTurnCompaction() {
12338
14732
  const params = this._executeParams;
14733
+ if (params?.ConversationHistoryFrom) {
14734
+ this.logStatus('Post-turn compaction skipped — the run has a history floor', true, params);
14735
+ return;
14736
+ }
12339
14737
  if (!params?.conversationId || this._depth !== 0 || !this._agentRun
12340
14738
  || !BaseAgent.settledRunStatuses.includes(this._agentRun.Status)) {
12341
14739
  // A quiet return here is indistinguishable from "the pass ran and found nothing to do":
@@ -12604,6 +15002,11 @@ The context is now within limits. Please retry your request with the recovered c
12604
15002
  };
12605
15003
  promptParams.contextUser = params.contextUser;
12606
15004
  promptParams.agentId = params.agent.ID;
15005
+ promptParams.UserID = ResolvePromptRunUserID({
15006
+ UserID: params.userId,
15007
+ AgentRun: this._agentRun,
15008
+ ContextUser: params.contextUser,
15009
+ }) ?? undefined;
12607
15010
  const runner = new AIPromptRunner();
12608
15011
  const result = await runner.ExecutePrompt(promptParams);
12609
15012
  // Update step with result