@memberjunction/ai-agents 5.43.0 → 5.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +8 -0
  2. package/dist/AgentRunner.d.ts +20 -0
  3. package/dist/AgentRunner.d.ts.map +1 -1
  4. package/dist/AgentRunner.js +62 -0
  5. package/dist/AgentRunner.js.map +1 -1
  6. package/dist/DuplicateReasoningAgentProvider.d.ts +35 -0
  7. package/dist/DuplicateReasoningAgentProvider.d.ts.map +1 -0
  8. package/dist/DuplicateReasoningAgentProvider.js +95 -0
  9. package/dist/DuplicateReasoningAgentProvider.js.map +1 -0
  10. package/dist/MJAIAgentRequestEntityServer.d.ts +7 -0
  11. package/dist/MJAIAgentRequestEntityServer.d.ts.map +1 -1
  12. package/dist/MJAIAgentRequestEntityServer.js +28 -1
  13. package/dist/MJAIAgentRequestEntityServer.js.map +1 -1
  14. package/dist/SkillImportExportService.d.ts +63 -0
  15. package/dist/SkillImportExportService.d.ts.map +1 -0
  16. package/dist/SkillImportExportService.js +148 -0
  17. package/dist/SkillImportExportService.js.map +1 -0
  18. package/dist/SkillMarkdownConverter.d.ts +82 -0
  19. package/dist/SkillMarkdownConverter.d.ts.map +1 -0
  20. package/dist/SkillMarkdownConverter.js +145 -0
  21. package/dist/SkillMarkdownConverter.js.map +1 -0
  22. package/dist/agent-types/loop-agent-response-type.d.ts +23 -2
  23. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  24. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  25. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  26. package/dist/agent-types/loop-agent-type.js +34 -1
  27. package/dist/agent-types/loop-agent-type.js.map +1 -1
  28. package/dist/base-agent.d.ts +434 -12
  29. package/dist/base-agent.d.ts.map +1 -1
  30. package/dist/base-agent.js +1215 -89
  31. package/dist/base-agent.js.map +1 -1
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/memory-manager-agent.d.ts +39 -0
  37. package/dist/memory-manager-agent.d.ts.map +1 -1
  38. package/dist/memory-manager-agent.js +118 -5
  39. package/dist/memory-manager-agent.js.map +1 -1
  40. package/dist/operations/AISkillMarkdownOperations.d.ts +11 -0
  41. package/dist/operations/AISkillMarkdownOperations.d.ts.map +1 -0
  42. package/dist/operations/AISkillMarkdownOperations.js +57 -0
  43. package/dist/operations/AISkillMarkdownOperations.js.map +1 -0
  44. package/dist/prompt-component-resolver.d.ts +73 -0
  45. package/dist/prompt-component-resolver.d.ts.map +1 -0
  46. package/dist/prompt-component-resolver.js +166 -0
  47. package/dist/prompt-component-resolver.js.map +1 -0
  48. package/dist/realtime/agent-media-library.d.ts +65 -0
  49. package/dist/realtime/agent-media-library.d.ts.map +1 -0
  50. package/dist/realtime/agent-media-library.js +160 -0
  51. package/dist/realtime/agent-media-library.js.map +1 -0
  52. package/dist/realtime/client-context-channel-server.d.ts +59 -0
  53. package/dist/realtime/client-context-channel-server.d.ts.map +1 -0
  54. package/dist/realtime/client-context-channel-server.js +78 -0
  55. package/dist/realtime/client-context-channel-server.js.map +1 -0
  56. package/dist/realtime/media-channel-server.d.ts +73 -0
  57. package/dist/realtime/media-channel-server.d.ts.map +1 -0
  58. package/dist/realtime/media-channel-server.js +145 -0
  59. package/dist/realtime/media-channel-server.js.map +1 -0
  60. package/dist/realtime/realtime-channel-server-data-context.d.ts +42 -0
  61. package/dist/realtime/realtime-channel-server-data-context.d.ts.map +1 -0
  62. package/dist/realtime/realtime-channel-server-data-context.js +29 -0
  63. package/dist/realtime/realtime-channel-server-data-context.js.map +1 -0
  64. package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -1
  65. package/dist/realtime/realtime-channel-server-host.js +8 -2
  66. package/dist/realtime/realtime-channel-server-host.js.map +1 -1
  67. package/dist/realtime/realtime-client-session-service.d.ts +114 -6
  68. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  69. package/dist/realtime/realtime-client-session-service.js +289 -30
  70. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  71. package/dist/realtime/realtime-coagent-config.d.ts +82 -3
  72. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  73. package/dist/realtime/realtime-coagent-config.js +147 -6
  74. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  75. package/dist/realtime/realtime-recording-capture.d.ts +125 -0
  76. package/dist/realtime/realtime-recording-capture.d.ts.map +1 -0
  77. package/dist/realtime/realtime-recording-capture.js +317 -0
  78. package/dist/realtime/realtime-recording-capture.js.map +1 -0
  79. package/dist/realtime/realtime-recording-store.d.ts +105 -0
  80. package/dist/realtime/realtime-recording-store.d.ts.map +1 -0
  81. package/dist/realtime/realtime-recording-store.js +216 -0
  82. package/dist/realtime/realtime-recording-store.js.map +1 -0
  83. package/dist/realtime/realtime-session-runner.d.ts +33 -2
  84. package/dist/realtime/realtime-session-runner.d.ts.map +1 -1
  85. package/dist/realtime/realtime-session-runner.js +45 -2
  86. package/dist/realtime/realtime-session-runner.js.map +1 -1
  87. package/dist/realtime/realtime-tool-broker.d.ts +41 -3
  88. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -1
  89. package/dist/realtime/realtime-tool-broker.js +67 -3
  90. package/dist/realtime/realtime-tool-broker.js.map +1 -1
  91. package/package.json +18 -17
@@ -17,16 +17,23 @@ import { AIPromptRunner } from '@memberjunction/ai-prompts';
17
17
  import { BaseRealtimeModel, GetAIAPIKey } from '@memberjunction/ai';
18
18
  import { BaseAgentType } from './agent-types/base-agent-type.js';
19
19
  import { CopyScalarsAndArrays, JSONValidator, MJGlobal, SafeExpressionEvaluator, UUIDsEqual } from '@memberjunction/global';
20
+ // token optimization via @memberjunction/context-crush (SmartCrusher/CacheAligner-inspired)
21
+ import { CrushJSON, DescribeCrush, PartitionStablePrefix } from '@memberjunction/context-crush';
22
+ // AST-aware code reduction (CodeCompressor-inspired) — opt-in per agent type
23
+ import { CrushCode } from '@memberjunction/context-crush/code';
20
24
  import { RealtimeSessionRunner } from './realtime/realtime-session-runner.js';
21
25
  import { ResolveNarrationInstructionsTemplate } from './realtime/realtime-narration.js';
22
26
  import { BuildRealtimeOverridesJson, BuildVoiceMannerSection, GetNarrationPaceMs, GetProviderVoiceSettings, ResolveEffectiveRealtimeConfig } from './realtime/realtime-coagent-config.js';
23
27
  import { RealtimeClientSessionService } from './realtime/realtime-client-session-service.js';
24
28
  import { BuildRealtimeAgentFraming } from './realtime/realtime-tool-broker.js';
29
+ import { RealtimeRecordingController } from './realtime/realtime-recording-capture.js';
30
+ import { resolveRecordingStorageAccountID, storeRealtimeRecording } from './realtime/realtime-recording-store.js';
25
31
  import { AIEngine } from '@memberjunction/aiengine';
26
32
  import { ActionEngineServer } from '@memberjunction/actions';
27
33
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
28
34
  import { AgentMemoryContextBuilder } from './agent-memory-context-builder.js';
29
- import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue } from '@memberjunction/ai-core-plus';
35
+ import { PromptComponentResolver, InjectScopedPromptParts } from './prompt-component-resolver.js';
36
+ import { AIPromptParams, ChildPromptParam, ConversationUtility, ParseFileOutputRef, parseAssignmentStrategy, ResolveClientTools, initAgentRunStep, finalizeAgentRunStep, AgentRunStepSaveQueue } from '@memberjunction/ai-core-plus';
30
37
  import { AgentRunner } from './AgentRunner.js';
31
38
  import { PayloadManager } from './PayloadManager.js';
32
39
  import { ScratchpadManager } from './ScratchpadManager.js';
@@ -219,6 +226,47 @@ export class BaseAgent {
219
226
  * @since 2.123.0
220
227
  */
221
228
  this._effectiveActions = [];
229
+ /**
230
+ * Effective sub-agents available to this agent after applying subAgentChanges — the sub-agent
231
+ * counterpart of {@link _effectiveActions}. Populated during gatherPromptTemplateData() and used
232
+ * for validation in {@link validateSubAgentNextStep} via {@link getEffectiveSubAgentsForValidation}.
233
+ * Without this, a sub-agent added at runtime (e.g. by Skill activation) would be advertised in the
234
+ * prompt catalog but rejected as "not found" when the agent tried to actually use it.
235
+ * @private
236
+ */
237
+ this._effectiveSubAgents = [];
238
+ /**
239
+ * IDs of skills already activated during this run. Prevents re-activation from re-appending
240
+ * the same instructions to context / re-pushing duplicate actionChanges/subAgentChanges entries
241
+ * when the LLM references an already-active skill again.
242
+ * @private
243
+ */
244
+ this._activatedSkillIDs = [];
245
+ /**
246
+ * Full observability records for every skill activated this run — one {@link AgentSkillInvocation}
247
+ * per activation, carrying activation type ('requested' | 'auto'), the provenance-of-authority
248
+ * gate values that admitted the skill, and the agent-stated reason when self-activated.
249
+ * Serialized onto `AIAgentRunStep.Skills`: Skill steps record their own activation(s), Prompt
250
+ * steps record the full set in effect for the turn, and Actions/Sub-Agent steps record the
251
+ * skill(s) that granted the executed tool (see {@link getSkillAttributionForAction} /
252
+ * {@link getSkillAttributionForSubAgent}).
253
+ */
254
+ this._skillInvocations = [];
255
+ /**
256
+ * Whether Plan Mode is active for this run — resolved once in {@link initializeAgentRun} via
257
+ * {@link resolvePlanModeGate}. True only when `agent.SupportsPlanMode` (capability, default ON)
258
+ * AND `params.planMode` (per-request, default OFF) are both true AND this is a root agent.
259
+ * @private
260
+ */
261
+ this._planModeActive = false;
262
+ /**
263
+ * Whether Plan Mode's approval gate has already been satisfied for this run — either because
264
+ * Plan Mode isn't active, or because a prior linked run's Plan step was approved. When active
265
+ * and NOT yet approved, `validateNextStep` blocks Actions/Sub-Agent steps until a Plan step
266
+ * has been presented and approved.
267
+ * @private
268
+ */
269
+ this._planApproved = false;
222
270
  /**
223
271
  * Counts only prompt (LLM) executions, NOT all agent steps.
224
272
  * Used for message expiration age calculations so that `expirationTurns`
@@ -230,6 +278,14 @@ export class BaseAgent {
230
278
  * prompt execution occurred after them.
231
279
  */
232
280
  this._promptTurnCount = 0;
281
+ /**
282
+ * Per-run config for structurally compressing inline action-result payloads.
283
+ * Resolved once at run start from the agent-type prompt params (default on);
284
+ * read by formatActionResultsAsMarkdown so both the direct and loop callers
285
+ * share the same setting without threading it through every signature.
286
+ * @private
287
+ */
288
+ this._actionResultCrush = undefined;
233
289
  /**
234
290
  * Execution limits for dynamically added actions.
235
291
  * Maps action IDs to their MaxExecutionsPerRun limit.
@@ -256,6 +312,34 @@ export class BaseAgent {
256
312
  * @private
257
313
  */
258
314
  this.MAX_RECOVERY_ATTEMPTS = 1;
315
+ /**
316
+ * Drives a session-driven (Realtime) agent run end-to-end.
317
+ *
318
+ * Resolves the realtime model, assembles the session parameters (system prompt + memory/context),
319
+ * builds the {@link RealtimeSessionRunnerDeps} from this agent's collaborators, runs the
320
+ * {@link RealtimeSessionRunner}, and maps the result onto the finalized `AIAgentRun`.
321
+ *
322
+ * If no realtime model can be resolved (expected today, before the P3 drivers / P4 model
323
+ * metadata land), it finalizes the run as a clean FAILED result with an actionable message
324
+ * rather than throwing — a mis-provisioned environment must not crash the caller.
325
+ *
326
+ * @template R The caller's expected payload type (unused on the realtime path; the session
327
+ * produces transcript/usage rather than a structured payload).
328
+ * @param params The wrapped execution parameters.
329
+ * @param config The loaded agent configuration (provides the system prompt, if any).
330
+ * @returns The finalized {@link ExecuteAgentResult}.
331
+ */
332
+ // ── Realtime per-session capture state (scoped to one executeRealtimeSession run) ──────────
333
+ /**
334
+ * In-flight realtime turn rows keyed by transcript role (`'user'`/`'assistant'`), driving the
335
+ * create-on-start / update-on-complete persistence lifecycle. Reset at the start of every
336
+ * realtime session so a prior run can never leak an in-flight id into the next.
337
+ */
338
+ this.realtimeInFlightTurns = new Map();
339
+ /** Active audio recording controller for the current realtime session, or `null` when recording is off. */
340
+ this.realtimeRecording = null;
341
+ /** Storage account id the active recording stores to (RecordingStorageProviderID ?? AttachmentStorageProviderID). */
342
+ this.realtimeRecordingAccountId = null;
259
343
  /**
260
344
  * Storage for injected notes and examples to include in result
261
345
  */
@@ -427,6 +511,13 @@ export class BaseAgent {
427
511
  * @private
428
512
  */
429
513
  static { this.LARGE_BINARY_THRESHOLD = 10000; }
514
+ /**
515
+ * Minimum stringified length (chars) of an object/array action-result value before
516
+ * structural JSON compression (CrushJSON) is applied. Small payloads aren't worth a
517
+ * legend, so they pass through verbatim.
518
+ * @private
519
+ */
520
+ static { this.ACTION_RESULT_CRUSH_THRESHOLD = 600; }
430
521
  /**
431
522
  * Inspects a set of action output params for any value matching the FileOutputRef shape
432
523
  * (an object with `fileName`, `mimeType`, and either `fileData` or `fileId`).
@@ -1134,6 +1225,9 @@ export class BaseAgent {
1134
1225
  this.InjectContextMemory(typeof inputText === 'string' ? inputText : '', params.agent, userId, companyId, params.contextUser, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, scopeConfig),
1135
1226
  this.InjectPreExecutionRAG(typeof inputText === 'string' ? inputText : '', params.agent, params.contextUser, wrappedParams.conversationMessages, params.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, params.payload)
1136
1227
  ]);
1228
+ // Inject scope-resolved prompt parts (role-faithful) for this agent's prompt, alongside
1229
+ // memory/RAG. Synchronous — parts are cached on AIEngine. Uses the same run scope.
1230
+ this.InjectScopedPromptParts(params.agent, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes);
1137
1231
  if (!config.success) {
1138
1232
  this.logError(`Failed to load agent configuration: ${config.errorMessage}`, {
1139
1233
  agent: params.agent,
@@ -1238,23 +1332,6 @@ export class BaseAgent {
1238
1332
  isSessionDrivenAgentType(agentType) {
1239
1333
  return agentType.IsSessionDriven === true;
1240
1334
  }
1241
- /**
1242
- * Drives a session-driven (Realtime) agent run end-to-end.
1243
- *
1244
- * Resolves the realtime model, assembles the session parameters (system prompt + memory/context),
1245
- * builds the {@link RealtimeSessionRunnerDeps} from this agent's collaborators, runs the
1246
- * {@link RealtimeSessionRunner}, and maps the result onto the finalized `AIAgentRun`.
1247
- *
1248
- * If no realtime model can be resolved (expected today, before the P3 drivers / P4 model
1249
- * metadata land), it finalizes the run as a clean FAILED result with an actionable message
1250
- * rather than throwing — a mis-provisioned environment must not crash the caller.
1251
- *
1252
- * @template R The caller's expected payload type (unused on the realtime path; the session
1253
- * produces transcript/usage rather than a structured payload).
1254
- * @param params The wrapped execution parameters.
1255
- * @param config The loaded agent configuration (provides the system prompt, if any).
1256
- * @returns The finalized {@link ExecuteAgentResult}.
1257
- */
1258
1335
  async executeRealtimeSession(params, config) {
1259
1336
  // 1) Resolve the realtime model (overridable seam — tests inject a mock).
1260
1337
  const modelResolution = await this.resolveRealtimeModel(params);
@@ -1268,7 +1345,12 @@ export class BaseAgent {
1268
1345
  }
1269
1346
  // 2) Create the single long-lived AIPromptRun that usage is checkpointed onto.
1270
1347
  const promptRun = await this.createRealtimePromptRun(params, config, modelResolution);
1271
- // 3) Build the injected deps and run the session.
1348
+ // 3) Resolve recording (OFF by default; runtime > agent > off; consent + storage gated) and reset
1349
+ // the per-session turn-lifecycle state, then build the injected deps and run the session.
1350
+ this.realtimeInFlightTurns = new Map();
1351
+ const recording = await this.resolveRealtimeRecording(params);
1352
+ this.realtimeRecording = recording?.controller ?? null;
1353
+ this.realtimeRecordingAccountId = recording?.storageAccountId ?? null;
1272
1354
  try {
1273
1355
  const deps = await this.buildRealtimeSessionDeps(params, config, modelResolution, promptRun);
1274
1356
  const runner = new RealtimeSessionRunner(deps);
@@ -1369,6 +1451,11 @@ export class BaseAgent {
1369
1451
  UserID: params.contextUser?.ID,
1370
1452
  DisableAutoResponse: meetingMode || undefined,
1371
1453
  SelfNames: selfNames,
1454
+ // App awareness (Move 1/3/4): the app the session runs in (sources the app cascade layer +
1455
+ // RelevantAgents → allowed-agent union) and the live app-context snapshot injected at mint.
1456
+ // Both ride params.data, the same conduit async agents use for appContext.
1457
+ ApplicationID: params.data?.applicationId?.trim() || undefined,
1458
+ AppContext: params.data?.appContext,
1372
1459
  };
1373
1460
  }
1374
1461
  /**
@@ -1559,6 +1646,8 @@ export class BaseAgent {
1559
1646
  DelegateToTarget: (request) => this.delegateRealtimeToTarget(params, config, request),
1560
1647
  ExecuteTool: (call) => this.executeRealtimeTool(params, call),
1561
1648
  PersistTranscript: (transcript) => this.persistRealtimeTranscript(params, transcript),
1649
+ Recording: this.realtimeRecording ?? undefined,
1650
+ FinalizeRecording: () => this.finalizeRealtimeRecording(params),
1562
1651
  CheckpointUsage: (usage) => this.checkpointRealtimeUsage(promptRun, usage),
1563
1652
  // DB-driven spoken-progress wording (shared lookup with the client-direct path);
1564
1653
  // null → the runner's documented built-in first-person fallback.
@@ -1800,34 +1889,211 @@ export class BaseAgent {
1800
1889
  return {};
1801
1890
  }
1802
1891
  /**
1803
- * Persists a single realtime transcript turn as a `ConversationDetail` stamped with the
1804
- * session id. User turns are written as `Role='User'`, assistant turns as `Role='AI'`. Only
1805
- * final transcripts are persisted (interim/partial updates are skipped to avoid churn).
1892
+ * Persists a realtime transcript turn as a `ConversationDetail` with a **create-on-start /
1893
+ * update-on-complete** lifecycle, so each turn carries both a start (`__mj_CreatedAt`) and an
1894
+ * immutable end (`TurnEndedAt`):
1895
+ * - **Interim** (`IsFinal=false`): on the FIRST delta for a role, CREATE the row with
1896
+ * `Status='In-Progress'` (so a live UI can show the turn streaming), stamping the recording-relative
1897
+ * `UtteranceStartMs` and the speaker `UserID` (user turns only). Subsequent interim deltas are no-ops.
1898
+ * - **Final** (`IsFinal=true`): UPDATE that in-flight row with the full text, `Status='Complete'`,
1899
+ * `TurnEndedAt`, and `UtteranceEndMs`. If no interim was seen (some providers only emit final), the
1900
+ * row is created and finalized in one step.
1901
+ *
1902
+ * Returns the new row's ID the first time a DISTINCT turn is created, and `null` when an existing
1903
+ * in-flight row is merely updated — the runner uses that to count turns (not events). User turns are
1904
+ * `Role='User'`, assistant turns `Role='AI'`. When recording is active, `MediaType='Audio'` and the
1905
+ * media-relative utterance offsets are stamped from the recording clock.
1806
1906
  *
1807
- * @param params The execution parameters (provides conversation id + context user).
1808
- * @param transcript The transcript turn emitted by the model.
1907
+ * @param params The execution parameters (provides conversation id + context user + session id).
1908
+ * @param transcript The transcript turn (interim delta or final) emitted by the model.
1909
+ * @returns The created row id on first creation of a turn, else `null`.
1809
1910
  */
1810
1911
  async persistRealtimeTranscript(params, transcript) {
1811
- if (!transcript.IsFinal || !transcript.Text?.trim()) {
1812
- return;
1912
+ if (!transcript.Text?.trim()) {
1913
+ return null;
1813
1914
  }
1814
1915
  const conversationID = params.data?.conversationId;
1815
1916
  if (!conversationID) {
1816
- return; // Without a conversation we have nowhere to durably attach the turn.
1917
+ return null; // Without a conversation we have nowhere to durably attach the turn.
1817
1918
  }
1818
1919
  const md = params.provider || this._activeProvider;
1819
- const detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1820
- detail.NewRecord();
1821
- detail.ConversationID = conversationID;
1822
- detail.Role = transcript.Role === 'user' ? 'User' : 'AI';
1920
+ const roleKey = transcript.Role; // 'user' | 'assistant'
1921
+ const mjRole = transcript.Role === 'user' ? 'User' : 'AI';
1922
+ // ── INTERIM: create the In-Progress row once per turn (first delta) ───────────────────────
1923
+ if (!transcript.IsFinal) {
1924
+ if (this.realtimeInFlightTurns.has(roleKey)) {
1925
+ return null; // already created for this turn; ignore subsequent deltas
1926
+ }
1927
+ const detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1928
+ detail.NewRecord();
1929
+ detail.ConversationID = conversationID;
1930
+ detail.Role = mjRole;
1931
+ detail.Message = transcript.Text;
1932
+ detail.Status = 'In-Progress';
1933
+ this.applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, /*atStart*/ true);
1934
+ if (params.agentSessionID) {
1935
+ detail.AgentSessionID = params.agentSessionID;
1936
+ }
1937
+ if (!await detail.Save()) {
1938
+ this.logError(`Failed to create in-progress realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1939
+ agent: params.agent, category: 'RealtimeSession'
1940
+ });
1941
+ return null;
1942
+ }
1943
+ this.realtimeInFlightTurns.set(roleKey, detail.ID);
1944
+ return detail.ID;
1945
+ }
1946
+ // ── FINAL: update the in-flight row (or create+finalize when no interim was seen) ─────────
1947
+ const inFlightId = this.realtimeInFlightTurns.get(roleKey);
1948
+ this.realtimeInFlightTurns.delete(roleKey);
1949
+ let detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1950
+ let created = false;
1951
+ if (inFlightId && await detail.Load(inFlightId)) {
1952
+ // updating the existing streaming row → not a new turn
1953
+ }
1954
+ else {
1955
+ detail.NewRecord();
1956
+ detail.ConversationID = conversationID;
1957
+ detail.Role = mjRole;
1958
+ this.applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, /*atStart*/ true);
1959
+ if (params.agentSessionID) {
1960
+ detail.AgentSessionID = params.agentSessionID;
1961
+ }
1962
+ created = true;
1963
+ }
1823
1964
  detail.Message = transcript.Text;
1824
- if (params.agentSessionID) {
1825
- detail.AgentSessionID = params.agentSessionID;
1965
+ detail.Status = 'Complete';
1966
+ detail.TurnEndedAt = new Date();
1967
+ if (this.realtimeRecording) {
1968
+ detail.UtteranceEndMs = this.realtimeRecording.NowOffsetMs();
1826
1969
  }
1827
1970
  if (!await detail.Save()) {
1828
- this.logError(`Failed to persist realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1829
- agent: params.agent,
1830
- category: 'RealtimeSession'
1971
+ this.logError(`Failed to finalize realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1972
+ agent: params.agent, category: 'RealtimeSession'
1973
+ });
1974
+ }
1975
+ return created ? detail.ID : null;
1976
+ }
1977
+ /**
1978
+ * Stamps the speaker identity and recording-relative media fields on a freshly-created turn row.
1979
+ * `UserID` is set only for **user** turns (an AI turn has no human speaker). When recording is
1980
+ * active, `MediaType='Audio'` and `UtteranceStartMs` is captured from the recording clock.
1981
+ *
1982
+ * @param detail The new conversation-detail row.
1983
+ * @param transcript The transcript turn.
1984
+ * @param params The execution parameters.
1985
+ * @param atStart Whether this is the turn's start (stamps `UtteranceStartMs`).
1986
+ */
1987
+ applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, atStart) {
1988
+ if (transcript.Role === 'user' && params.contextUser?.ID) {
1989
+ detail.UserID = params.contextUser.ID;
1990
+ }
1991
+ if (this.realtimeRecording) {
1992
+ detail.MediaType = 'Audio';
1993
+ if (atStart) {
1994
+ detail.UtteranceStartMs = this.realtimeRecording.NowOffsetMs();
1995
+ }
1996
+ }
1997
+ }
1998
+ /**
1999
+ * Resolves whether to record this realtime session, OFF by default, with the precedence
2000
+ * **runtime param > agent (`RecordingDefault`) > off**, hard-gated by consent and a resolvable
2001
+ * storage provider. Returns the recording controller + the resolved storage account, or `null`
2002
+ * to record nothing (fail-closed). Never throws — any resolution problem disables recording.
2003
+ *
2004
+ * Storage resolves to **`AIAgent.RecordingStorageProviderID` ?? `AIAgent.AttachmentStorageProviderID`**
2005
+ * (recordings default to the attachments account), then to that provider's first account. With no
2006
+ * provider configured, or consent not granted, recording is OFF.
2007
+ *
2008
+ * @param params The execution parameters (agent + runtime `data.recording`).
2009
+ * @returns `{ controller, storageAccountId }` when recording is enabled, else `null`.
2010
+ */
2011
+ async resolveRealtimeRecording(params) {
2012
+ try {
2013
+ const agent = params.agent;
2014
+ const runtime = (params.data?.recording ?? null);
2015
+ // Media: runtime > agent default > off.
2016
+ const rawMedia = runtime?.media ?? agent.RecordingDefault ?? 'None';
2017
+ const media = rawMedia === 'Audio' || rawMedia === 'AudioVideo' ? rawMedia : 'None';
2018
+ if (media === 'None') {
2019
+ return null; // recording off
2020
+ }
2021
+ // Consent is a HARD gate — never record without explicit consent.
2022
+ if (runtime?.consent !== true) {
2023
+ this.logStatus('🔴 Realtime recording requested but consent was not granted — recording disabled.', false, params);
2024
+ return null;
2025
+ }
2026
+ // Storage: recording provider, else attachment provider; then that provider's first account.
2027
+ const storageAccountId = params.contextUser
2028
+ ? await resolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2029
+ : null;
2030
+ if (!storageAccountId) {
2031
+ this.logStatus('🔴 Realtime recording on but no resolvable storage account (RecordingStorageProviderID/AttachmentStorageProviderID) — recording disabled.', false, params);
2032
+ return null;
2033
+ }
2034
+ const controller = new RealtimeRecordingController({ Media: media });
2035
+ return { controller, storageAccountId };
2036
+ }
2037
+ catch (error) {
2038
+ this.logError(`Failed to resolve realtime recording (recording disabled): ${error instanceof Error ? error.message : String(error)}`, {
2039
+ agent: params.agent, category: 'RealtimeSession'
2040
+ });
2041
+ return null;
2042
+ }
2043
+ }
2044
+ /**
2045
+ * Finalizes the active recording after the session closes: encodes the captured audio to a WAV,
2046
+ * stores it via MJStorage to the resolved account, links it to the `AIAgentSession` (via
2047
+ * `MJ: File Entity Record Links`), and stamps `RecordingFileID` / `RecordingMedia` /
2048
+ * `RecordingStartedAt` on the session. Never throws — a recording failure must not fail the
2049
+ * session run. No-op when recording is off, nothing was captured, or there is no session id.
2050
+ *
2051
+ * @param params The execution parameters (provides the session id + context user + provider).
2052
+ */
2053
+ async finalizeRealtimeRecording(params) {
2054
+ const controller = this.realtimeRecording;
2055
+ if (!controller) {
2056
+ return;
2057
+ }
2058
+ // One-shot: clear instance state up front so a re-entrant/duplicate Stop can't double-store.
2059
+ this.realtimeRecording = null;
2060
+ const storageAccountId = this.realtimeRecordingAccountId;
2061
+ this.realtimeRecordingAccountId = null;
2062
+ try {
2063
+ controller.Stop();
2064
+ const sessionID = params.agentSessionID;
2065
+ const contextUser = params.contextUser;
2066
+ if (!sessionID || !storageAccountId || !contextUser) {
2067
+ return; // nowhere to attach / store (or no user context to store under)
2068
+ }
2069
+ const encoded = controller.EncodeWav();
2070
+ if (!encoded) {
2071
+ this.logStatus('🔇 Realtime session produced no audio to record.', true, params);
2072
+ return;
2073
+ }
2074
+ const md = params.provider || this._activeProvider;
2075
+ // Capture-time waveform peaks (max-abs per bucket, normalized 0..1) computed from the
2076
+ // same mixed PCM as the WAV — persisted as a peaks.json sidecar so the player renders the
2077
+ // real waveform without re-decoding the audio. Best-effort: an empty array writes no sidecar.
2078
+ const peaks = controller.GetPeaks();
2079
+ const fileID = await storeRealtimeRecording({
2080
+ Audio: encoded.Buffer,
2081
+ MimeType: 'audio/wav',
2082
+ Media: controller.Media,
2083
+ StartedAt: controller.StartedAt ?? new Date(),
2084
+ StorageAccountID: storageAccountId,
2085
+ SessionID: sessionID,
2086
+ ContextUser: contextUser,
2087
+ Provider: md,
2088
+ Peaks: peaks.length > 0 ? peaks : undefined
2089
+ });
2090
+ if (fileID) {
2091
+ this.logStatus(`🎬 Realtime recording stored (${Math.round(encoded.DurationMs / 1000)}s, file ${fileID}).`, true, params);
2092
+ }
2093
+ }
2094
+ catch (error) {
2095
+ this.logError(`Failed to finalize realtime recording: ${error instanceof Error ? error.message : String(error)}`, {
2096
+ agent: params.agent, category: 'RealtimeSession'
1831
2097
  });
1832
2098
  }
1833
2099
  }
@@ -2093,6 +2359,32 @@ export class BaseAgent {
2093
2359
  this._injectedMemory = result;
2094
2360
  return result;
2095
2361
  }
2362
+ /**
2363
+ * Inject this agent's scoped prompt parts into the conversation, role-faithfully.
2364
+ *
2365
+ * Parallels {@link InjectContextMemory}: resolves `MJ: Scoped Prompt Parts` for the agent's
2366
+ * primary prompt under the run's polymorphic scope (the SAME PrimaryScope/SecondaryScopes the
2367
+ * runtime threads for memory), and unshifts the assembled role-tagged messages onto
2368
+ * `conversationMessages`. In-memory + synchronous (parts are cached on `AIEngine`). No-op when
2369
+ * the agent has no active prompt or no parts resolve for the scope.
2370
+ */
2371
+ InjectScopedPromptParts(agent, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes) {
2372
+ try {
2373
+ const prompts = AIEngine.Instance.AgentPrompts
2374
+ .filter(ap => UUIDsEqual(ap.AgentID, agent.ID) && ap.Status === 'Active')
2375
+ .sort((a, b) => (a.ExecutionOrder ?? 0) - (b.ExecutionOrder ?? 0));
2376
+ if (prompts.length === 0)
2377
+ return;
2378
+ // Obtain the (possibly downstream-overridden) resolver via the class factory, so any
2379
+ // consumer can plug in custom inclusion/scope logic by subclassing PromptComponentResolver.
2380
+ const resolver = MJGlobal.Instance.ClassFactory.CreateInstance(PromptComponentResolver) ??
2381
+ new PromptComponentResolver();
2382
+ InjectScopedPromptParts(resolver, prompts[0].PromptID, { primaryScopeEntityId, primaryScopeRecordId, secondaryScopes }, conversationMessages);
2383
+ }
2384
+ catch (e) {
2385
+ this.logError(e instanceof Error ? e : new Error(String(e)), { category: 'ScopedPromptParts' });
2386
+ }
2387
+ }
2096
2388
  /**
2097
2389
  * Inject pre-execution RAG context for this agent using scoped search.
2098
2390
  *
@@ -2608,6 +2900,20 @@ export class BaseAgent {
2608
2900
  * @returns
2609
2901
  */
2610
2902
  async validateNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
2903
+ // Plan Mode enforcement: while active and not yet approved, block Actions/Sub-Agent so the
2904
+ // agent cannot skip straight to execution — it must present a Plan first. Chat, Retry,
2905
+ // Skill activation, ForEach/While, and ClientTools are all still allowed (e.g. asking a
2906
+ // clarifying question, or loading a skill's instructions, before forming the plan).
2907
+ if (this._planModeActive && !this._planApproved && (nextStep.step === 'Actions' || nextStep.step === 'Sub-Agent')) {
2908
+ // nextStep.step is narrowed to 'Actions' | 'Sub-Agent' here, so it can never already be
2909
+ // 'Retry' — always increment (we're demoting it to Retry from a non-retry step).
2910
+ this._generalValidationRetryCount++;
2911
+ return {
2912
+ step: 'Retry',
2913
+ terminate: false,
2914
+ 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.'
2915
+ };
2916
+ }
2611
2917
  // for next step, let's do a little quick validation here for sub-agent and actions to ensure requests are valid
2612
2918
  switch (nextStep.step) {
2613
2919
  case 'Sub-Agent':
@@ -2628,6 +2934,15 @@ export class BaseAgent {
2628
2934
  case 'While':
2629
2935
  // While loops are valid - no additional validation needed
2630
2936
  return nextStep;
2937
+ // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
2938
+ // union (it's non-terminal, like 'ClientTools' — see the type's doc comment).
2939
+ case 'Skill':
2940
+ return this.validateSkillNextStep(params, nextStep, currentPayload, agentRun, currentStep);
2941
+ // Type assertion required because 'Plan' is not part of the BaseAgentNextStep step
2942
+ // union (it's non-terminal — the terminal step it produces is 'Chat', see
2943
+ // executePlanStep's doc comment for why).
2944
+ case 'Plan':
2945
+ return this.validatePlanNextStep(params, nextStep, currentPayload, agentRun, currentStep);
2631
2946
  case 'ClientTools':
2632
2947
  // Client tools are valid - execution handled by executeClientToolsStep
2633
2948
  return nextStep;
@@ -2670,7 +2985,7 @@ export class BaseAgent {
2670
2985
  return nextStep.subAgent ? [nextStep.subAgent] : [];
2671
2986
  }
2672
2987
  async validateSubAgentNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
2673
- const curAgentSubAgents = AIEngine.Instance.GetSubAgents(params.agent.ID, 'Active');
2988
+ const curAgentSubAgents = this.getEffectiveSubAgentsForValidation(params.agent.ID);
2674
2989
  // Collect requested sub-agents. Prefer plural `subAgents` (parallel fan-out);
2675
2990
  // fall back to singular `subAgent` for the classic single-sub-agent next step.
2676
2991
  const requested = this.getRequestedSubAgents(nextStep);
@@ -2849,6 +3164,118 @@ export class BaseAgent {
2849
3164
  const agentActions = AIEngine.Instance.AgentActions.filter(aa => UUIDsEqual(aa.AgentID, agentId) && aa.Status === 'Active');
2850
3165
  return ActionEngineServer.Instance.Actions.filter(a => agentActions.some(aa => UUIDsEqual(aa.ActionID, a.ID)) && a.Status === 'Active');
2851
3166
  }
3167
+ /**
3168
+ * Gets the effective sub-agents for validation, using runtime subAgentChanges if available.
3169
+ * Falls back to the database-configured relationship set if _effectiveSubAgents is empty.
3170
+ * Mirrors {@link getEffectiveActionsForValidation}.
3171
+ *
3172
+ * @param agentId - The ID of the agent to get sub-agents for
3173
+ * @returns Array of effective sub-agents available to the agent
3174
+ * @protected
3175
+ */
3176
+ getEffectiveSubAgentsForValidation(agentId) {
3177
+ if (this._effectiveSubAgents.length > 0) {
3178
+ return this._effectiveSubAgents;
3179
+ }
3180
+ // Fallback: compute from database configuration (ParentID children + AgentRelationships)
3181
+ return AIEngine.Instance.GetSubAgents(agentId, 'Active');
3182
+ }
3183
+ /**
3184
+ * Validates that the requested skill(s) are known and allowed for this agent (resolved via
3185
+ * {@link AIEngine.GetSkillsForAgent}, which enforces the agent's AcceptsSkills gate + the
3186
+ * catalog/grant Status chain). Subclasses can override to implement custom validation logic.
3187
+ *
3188
+ * Mirrors {@link validateActionsNextStep}'s fuzzy-name-matching UX: an exact case-insensitive
3189
+ * match is tried first, falling back to a CONTAINS match when exactly one candidate matches.
3190
+ *
3191
+ * @protected
3192
+ */
3193
+ async validateSkillNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
3194
+ const requested = nextStep.skillActivations ?? [];
3195
+ if (requested.length === 0) {
3196
+ if (nextStep.step !== 'Retry') {
3197
+ this._generalValidationRetryCount++;
3198
+ }
3199
+ return {
3200
+ step: 'Retry',
3201
+ terminate: false,
3202
+ errorMessage: 'When activating a skill, 1 or more skills must be specified'
3203
+ };
3204
+ }
3205
+ // Plan Mode × skills: agent-initiated activations are only legal BEFORE plan approval,
3206
+ // so the plan the human reviews always reflects the widened tool surface. Once the plan
3207
+ // is approved, a new activation would expand capabilities the reviewer never saw — the
3208
+ // agent must present an updated plan instead (the normal re-plan path).
3209
+ if (this._planModeActive && this._planApproved) {
3210
+ if (nextStep.step !== 'Retry') {
3211
+ this._generalValidationRetryCount++;
3212
+ }
3213
+ return {
3214
+ step: 'Retry',
3215
+ terminate: false,
3216
+ errorMessage: 'Skill activations are not allowed after your plan has been approved — ' +
3217
+ 'the approved plan did not include these capabilities. Present an updated plan ' +
3218
+ "(nextStep.type='Plan') that includes the skill(s) you need and why, so the user " +
3219
+ 'can review the expanded tool surface.'
3220
+ };
3221
+ }
3222
+ // Agent-initiated (self-)activation is governed by the DOUBLE activation gate: the agent's
3223
+ // SkillActivationMode AND each skill's ActivationMode must both be 'Auto'. RequestedOnly
3224
+ // skills can only enter a run via an explicit user /skill request (requestedSkillIDs) —
3225
+ // never via this step. This is the same set the prompt catalog was built from, so a
3226
+ // well-behaved model can only name skills that pass; the re-check here is the enforcement
3227
+ // boundary against hallucinated or smuggled names.
3228
+ const availableSkills = AIEngine.Instance.GetAutoActivatableSkillsForAgent(params.agent, params.contextUser);
3229
+ const missingSkills = requested.filter(req => {
3230
+ const requestedName = req.name.trim().toLowerCase();
3231
+ const exactMatch = availableSkills.find(s => s.Name.trim().toLowerCase() === requestedName);
3232
+ if (exactMatch)
3233
+ return false;
3234
+ const containsMatches = availableSkills.filter(s => s.Name.trim().toLowerCase().includes(requestedName));
3235
+ if (containsMatches.length === 1) {
3236
+ this.logStatus(`Skill name fuzzy matched: '${req.name}' → '${containsMatches[0].Name}'`, true, params);
3237
+ req.name = containsMatches[0].Name;
3238
+ return false;
3239
+ }
3240
+ return true;
3241
+ });
3242
+ if (missingSkills.length > 0) {
3243
+ const missingNames = missingSkills.map(s => s.name).join(', ');
3244
+ const availableNames = availableSkills.map(s => s.Name).join(', ') || '(none)';
3245
+ this.logError(`Skill(s) '${missingNames}' not found or not available for agent '${params.agent.Name}'. Available: ${availableNames}`, {
3246
+ agent: params.agent,
3247
+ category: 'SkillExecution'
3248
+ });
3249
+ if (nextStep.step !== 'Retry') {
3250
+ this._generalValidationRetryCount++;
3251
+ }
3252
+ return {
3253
+ step: 'Retry',
3254
+ terminate: false,
3255
+ errorMessage: `Skill(s) '${missingNames}' not found or not available. Available: ${availableNames}`
3256
+ };
3257
+ }
3258
+ return nextStep;
3259
+ }
3260
+ /**
3261
+ * Validates that a 'Plan' next step (Plan Mode) has plan text to present. Subclasses can
3262
+ * override to add additional plan-quality checks (e.g. minimum length, required sections).
3263
+ *
3264
+ * @protected
3265
+ */
3266
+ async validatePlanNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
3267
+ if (!nextStep.planDetails?.plan || nextStep.planDetails.plan.trim().length === 0) {
3268
+ if (nextStep.step !== 'Retry') {
3269
+ this._generalValidationRetryCount++;
3270
+ }
3271
+ return {
3272
+ step: 'Retry',
3273
+ terminate: false,
3274
+ errorMessage: 'Plan text is required when presenting a Plan for approval'
3275
+ };
3276
+ }
3277
+ return nextStep;
3278
+ }
2852
3279
  /**
2853
3280
  * Validates that the Success next step is valid and can be executed by the current agent. Subclasses can override
2854
3281
  * this method to implement custom validation logic if needed.
@@ -4367,6 +4794,8 @@ The context is now within limits. Please retry your request with the recovered c
4367
4794
  }
4368
4795
  // Store for later validation in executeActionsStep
4369
4796
  this._effectiveActions = activeActions;
4797
+ // Store for later validation in validateSubAgentNextStep (see getEffectiveSubAgentsForValidation)
4798
+ this._effectiveSubAgents = uniqueActiveSubAgents;
4370
4799
  // Agent type prompt params: reuse cached base merge unless a runtime override is present.
4371
4800
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
4372
4801
  let agentTypePromptParams;
@@ -4385,6 +4814,16 @@ The context is now within limits. Please retry your request with the recovered c
4385
4814
  const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
4386
4815
  // Build app context section if provided in extraData
4387
4816
  const appContext = this.buildAppContextSection(extraData);
4817
+ // Skill catalog (name + description only — progressive disclosure). This is the
4818
+ // SELF-ACTIVATION surface, so it uses the double-gated auto set: empty unless the
4819
+ // agent's SkillActivationMode is 'Auto', and containing only skills whose own
4820
+ // ActivationMode is 'Auto' (RequestedOnly skills never appear — they can only enter
4821
+ // a run via an explicit user /skill request). Also empty for AcceptsSkills='None',
4822
+ // and filtered by the acting user's Run permission (open-by-default) so the agent
4823
+ // is never even offered a skill the user isn't entitled to — the permission
4824
+ // boundary is enforced at the catalog, not just at activation.
4825
+ const availableSkills = engine.GetAutoActivatableSkillsForAgent(agent, _contextUser);
4826
+ const skillsCatalog = this.formatSkillsCatalog(availableSkills);
4388
4827
  const contextData = {
4389
4828
  agentName: agent.Name,
4390
4829
  agentDescription: agent.Description,
@@ -4394,6 +4833,10 @@ The context is now within limits. Please retry your request with the recovered c
4394
4833
  actionCount: activeActions.length,
4395
4834
  actionDetails: actionDetails,
4396
4835
  clientToolDetails: clientToolDetails,
4836
+ skillCount: availableSkills.length,
4837
+ skillsCatalog: skillsCatalog,
4838
+ planModeActive: this._planModeActive,
4839
+ planApproved: this._planApproved,
4397
4840
  appContext: appContext,
4398
4841
  };
4399
4842
  // Build the final result with __agentTypePromptParams injected
@@ -4925,6 +5368,17 @@ The context is now within limits. Please retry your request with the recovered c
4925
5368
  return line;
4926
5369
  }).join('\n');
4927
5370
  }
5371
+ /**
5372
+ * Formats the skill CATALOG as compact markdown — name + description ONLY. This is
5373
+ * progressive disclosure by design: the LLM sees just enough to decide whether to activate a
5374
+ * skill (via a 'Skill' next step), but never sees `Instructions` until it does. Instructions
5375
+ * are appended separately in {@link buildSkillActivationMessage} on activation.
5376
+ *
5377
+ * @private
5378
+ */
5379
+ formatSkillsCatalog(skills) {
5380
+ return skills.map(s => `- **${s.Name}** — ${s.Description ?? '(no description)'}`).join('\n');
5381
+ }
4928
5382
  /**
4929
5383
  * Utility method to get agent prompt parameters for a given agent. This gets the
4930
5384
  * highest priority prompt for the agent, and then gets the parameters for that
@@ -5000,44 +5454,41 @@ The context is now within limits. Please retry your request with the recovered c
5000
5454
  /**
5001
5455
  * Build the client tool prompt section for system prompt injection.
5002
5456
  *
5003
- * Tool sources (checked in order, all merged — first registration wins):
5004
- * 1. Metadata tools from AI Agent Client Tools junction table
5005
- * 2. Session-level enriched tools from ClientToolRequestManager (set by client SDK)
5006
- * 3. Tools provided directly in extraData.clientTools (runtime override)
5457
+ * Resolution is delegated to the shared, tier-agnostic {@link ResolveClientTools}
5458
+ * (`@memberjunction/ai-core-plus`) — the single source of truth used by the async
5459
+ * path (here), the realtime co-agent broker, and the conversations runtime. Tiers,
5460
+ * highest precedence first:
5461
+ *
5462
+ * 1. **override** — tools passed directly in the run's `data.clientTools`
5463
+ * 2. **session (dynamic)** — client-SDK enriched tools from {@link ClientToolRequestManager}
5464
+ * 3. **app** — tools the active surface published in the app-context capability manifest
5465
+ * 4. **static** — the agent's metadata tools from the `AI Agent Client Tools` junction
5466
+ *
5467
+ * NOTE (behavior change): the previous inline merge resolved *static-wins* (metadata
5468
+ * was added first and won name collisions). The unified resolver uses the more-correct
5469
+ * *override > session > app > static* — a runtime/dynamic tool now overrides a stale
5470
+ * static metadata tool of the same name. Collisions are rare in practice.
5007
5471
  */
5008
5472
  buildClientToolPromptSection(agent, extraData) {
5009
- const toolMap = new Map();
5010
- // 1. Metadata tools from junction table (authoritative source)
5473
+ // Static tier — agent's metadata tools from the AI Agent Client Tools junction.
5011
5474
  const engine = AIEngine.Instance;
5012
- const metadataTools = engine.GetClientToolsForAgent(agent.ID);
5013
- for (const tool of metadataTools) {
5014
- toolMap.set(tool.Name, {
5015
- Name: tool.Name,
5016
- Description: tool.Description,
5017
- InputSchema: tool.InputSchemaJSON ? JSON.parse(tool.InputSchemaJSON) : {},
5018
- OutputSchema: tool.OutputSchemaJSON ? JSON.parse(tool.OutputSchemaJSON) : undefined,
5019
- Category: tool.Category || undefined,
5020
- DefaultTimeoutMs: tool.DefaultTimeoutMs || undefined
5021
- });
5022
- }
5023
- // 2. Session-level enriched tools (client SDK decorated tools)
5475
+ const staticTools = engine.GetClientToolsForAgent(agent.ID).map(tool => ({
5476
+ Name: tool.Name,
5477
+ Description: tool.Description,
5478
+ InputSchema: tool.InputSchemaJSON ? JSON.parse(tool.InputSchemaJSON) : {},
5479
+ OutputSchema: tool.OutputSchemaJSON ? JSON.parse(tool.OutputSchemaJSON) : undefined,
5480
+ Category: tool.Category || undefined,
5481
+ DefaultTimeoutMs: tool.DefaultTimeoutMs || undefined
5482
+ }));
5483
+ // Dynamic (session) tier — client-SDK enriched tools for this session.
5024
5484
  const sessionID = extraData?.sessionID;
5025
- if (sessionID) {
5026
- for (const tool of ClientToolRequestManager.Instance.GetSessionTools(sessionID)) {
5027
- if (!toolMap.has(tool.Name)) {
5028
- toolMap.set(tool.Name, tool);
5029
- }
5030
- }
5031
- }
5032
- // 3. Runtime extraData override
5033
- if (extraData?.clientTools) {
5034
- for (const tool of extraData.clientTools) {
5035
- if (!toolMap.has(tool.Name)) {
5036
- toolMap.set(tool.Name, tool);
5037
- }
5038
- }
5039
- }
5040
- const tools = Array.from(toolMap.values());
5485
+ const sessionTools = sessionID ? ClientToolRequestManager.Instance.GetSessionTools(sessionID) : [];
5486
+ // App tier — tools the active surface published in the app-context capability manifest.
5487
+ const appContext = extraData?.appContext;
5488
+ const appTools = appContext?.Capabilities?.Tools ?? [];
5489
+ // Override tier — tools passed directly in the run's data.
5490
+ const overrideTools = extraData?.clientTools ?? [];
5491
+ const tools = ResolveClientTools({ agentId: agent.ID, staticTools, sessionTools, appTools, overrideTools });
5041
5492
  if (tools.length === 0) {
5042
5493
  return ''; // No client tools available
5043
5494
  }
@@ -5234,19 +5685,112 @@ The context is now within limits. Please retry your request with the recovered c
5234
5685
  if (typeof value === 'boolean' || typeof value === 'number') {
5235
5686
  return `\`${String(value)}\``;
5236
5687
  }
5237
- let stringValue;
5238
5688
  if (typeof value === 'string') {
5239
- stringValue = value;
5240
- }
5241
- else {
5242
- // Compact JSON (no pretty-printing) for objects/arrays
5243
- stringValue = JSON.stringify(value);
5689
+ // A string param may carry JSON (many actions JSON.stringify their payloads),
5690
+ // SQL/TS code, or plain text. Try structural JSON compression first — it is a
5691
+ // safe no-op on non-JSON (crushParamValue's internal JSON.parse failure is
5692
+ // caught and returns null) — then opt-in AST code reduction (SQL/TS), then pass
5693
+ // through (optionally length-capped).
5694
+ const crushedJson = this.crushParamValue(value);
5695
+ if (crushedJson !== null) {
5696
+ return crushedJson;
5697
+ }
5698
+ const crushedCode = this.crushCodeValue(value);
5699
+ if (crushedCode !== null) {
5700
+ return crushedCode;
5701
+ }
5702
+ return maxLength > 0 && value.length > maxLength ? `${value.substring(0, maxLength)}…` : value;
5703
+ }
5704
+ // Objects/arrays: compact JSON (no pretty-printing), optionally structurally
5705
+ // compressed via context-crush when crushing is enabled and the value is large.
5706
+ const stringValue = JSON.stringify(value);
5707
+ const crushed = this.crushParamValue(stringValue);
5708
+ if (crushed !== null) {
5709
+ return crushed;
5244
5710
  }
5245
5711
  if (maxLength > 0 && stringValue.length > maxLength) {
5246
5712
  return `${stringValue.substring(0, maxLength)}…`;
5247
5713
  }
5248
5714
  return stringValue;
5249
5715
  }
5716
+ /**
5717
+ * Resolve the per-run action-result compression config from the agent-type prompt
5718
+ * params. Crushing is on by default and only disabled when an agent explicitly sets
5719
+ * `crushActionResults: false`, mirroring the `includeXxxDocs` opt-out convention.
5720
+ * @private
5721
+ */
5722
+ resolveActionResultCrush(params) {
5723
+ const agentTypePromptParams = params.data?.__agentTypePromptParams;
5724
+ if (agentTypePromptParams?.crushActionResults === false) {
5725
+ return undefined;
5726
+ }
5727
+ const requestedLang = agentTypePromptParams?.crushCodeLang;
5728
+ const codeLang = requestedLang === 'sql' || requestedLang === 'typescript' ? requestedLang : undefined;
5729
+ return { threshold: BaseAgent.ACTION_RESULT_CRUSH_THRESHOLD, maxChars: undefined, codeLang };
5730
+ }
5731
+ /**
5732
+ * AST-reduce a large code-string action-result value when the agent opted into a code
5733
+ * language (via `crushCodeLang` or a subclass override) and the value clears the size
5734
+ * threshold. Returns reduced code plus a one-line legend, or null to keep the string
5735
+ * verbatim (crushing disabled, too small, or no net saving).
5736
+ *
5737
+ * token optimization via @memberjunction/context-crush (CodeCompressor-inspired)
5738
+ * @private
5739
+ */
5740
+ crushCodeValue(stringValue) {
5741
+ const config = this._actionResultCrush;
5742
+ if (!config || !config.codeLang || stringValue.length < config.threshold) {
5743
+ return null;
5744
+ }
5745
+ // Crushing is a best-effort optimization — it must never break an agent turn. Any
5746
+ // failure falls back to the verbatim value.
5747
+ try {
5748
+ const result = CrushCode(stringValue, config.codeLang);
5749
+ if (result.CrushedChars >= result.OriginalChars) {
5750
+ return null;
5751
+ }
5752
+ const legend = DescribeCrush(result);
5753
+ return legend ? `${result.Text}\n ↳ ${legend}` : result.Text;
5754
+ }
5755
+ catch {
5756
+ return null;
5757
+ }
5758
+ }
5759
+ /**
5760
+ * Structurally compress a JSON action-result value when crushing is enabled for the run
5761
+ * and the value clears the size threshold. Accepts either the `JSON.stringify` of an
5762
+ * object/array param, or a raw string param that itself contains JSON (many actions
5763
+ * stringify their payloads, e.g. `run-adhoc-query`'s `Results`). Returns crushed text
5764
+ * plus a one-line legend, or null when crushing is disabled, the value is too small, the
5765
+ * value isn't valid JSON, or compression wouldn't actually save characters — so callers
5766
+ * fall back to verbatim (and, for strings, to code crushing) behavior.
5767
+ *
5768
+ * token optimization via @memberjunction/context-crush (SmartCrusher-inspired)
5769
+ * @private
5770
+ */
5771
+ crushParamValue(stringValue) {
5772
+ const config = this._actionResultCrush;
5773
+ if (!config || stringValue.length < config.threshold) {
5774
+ return null;
5775
+ }
5776
+ // Crushing is a best-effort optimization — it must never break an agent turn. Any
5777
+ // failure (non-JSON input, pathologically deep payloads) falls back to verbatim.
5778
+ try {
5779
+ // Parse to a plain JSON value. This is the JSON.stringify of an object/array
5780
+ // param, or a raw string param that contains JSON; non-JSON strings throw here
5781
+ // and are caught below (caller then tries code crushing / verbatim).
5782
+ const json = JSON.parse(stringValue);
5783
+ const result = CrushJSON(json, { MaxChars: config.maxChars });
5784
+ if (result.CrushedChars >= result.OriginalChars) {
5785
+ return null; // no net saving — keep the verbatim JSON
5786
+ }
5787
+ const legend = DescribeCrush(result);
5788
+ return legend ? `${result.Text}\n ↳ ${legend}` : result.Text;
5789
+ }
5790
+ catch {
5791
+ return null;
5792
+ }
5793
+ }
5250
5794
  /**
5251
5795
  * Formats a parameter value for display in action execution messages.
5252
5796
  * Truncates long strings and formats objects/arrays for readability.
@@ -5458,6 +6002,9 @@ The context is now within limits. Please retry your request with the recovered c
5458
6002
  }
5459
6003
  // Reset prompt turn counter for this execution
5460
6004
  this._promptTurnCount = 0;
6005
+ // Resolve action-result compression config for this run (default on; opt out via
6006
+ // crushActionResults: false in the agent-type prompt params).
6007
+ this._actionResultCrush = this.resolveActionResultCrush(params);
5461
6008
  // Create MJAIAgentRunEntity
5462
6009
  this._agentRun = await (params.provider || this._activeProvider).GetEntityObject('MJ: AI Agent Runs', params.contextUser);
5463
6010
  this._agentRun.AgentID = params.agent.ID;
@@ -5581,6 +6128,20 @@ The context is now within limits. Please retry your request with the recovered c
5581
6128
  : [params.agent.Name || 'Unknown Agent'];
5582
6129
  this._depth = params.parentDepth !== undefined ? params.parentDepth + 1 : 0;
5583
6130
  this._parentStepCounts = params.parentStepCounts || [];
6131
+ // Resolve Plan Mode gate state for this run (must happen before the main loop starts —
6132
+ // gatherPromptTemplateData/validateNextStep both read _planModeActive/_planApproved — and
6133
+ // after _depth is set above, since the gate only applies to root agents).
6134
+ const planModeGate = await this.resolvePlanModeGate(params);
6135
+ this._planModeActive = planModeGate.active;
6136
+ this._planApproved = planModeGate.approved;
6137
+ // Stamp the run record so the UX (run-header Plan Mode chip) and plan-drift audits can
6138
+ // tell plan-mode runs apart without re-deriving gate state from steps/requests.
6139
+ if (this._agentRun && planModeGate.active) {
6140
+ this._agentRun.PlanMode = true;
6141
+ }
6142
+ // Pre-activate any user-requested skills (from a `/skill-name` composer mention). Must run
6143
+ // after _depth is set (root-only) and after the run is persisted (records a Skill step).
6144
+ await this.preActivateRequestedSkills(params);
5584
6145
  // Reset execution chain and progress tracking
5585
6146
  this._allProgressSteps = [];
5586
6147
  // Update params with the modified payload if auto-populated
@@ -5590,6 +6151,66 @@ The context is now within limits. Please retry your request with the recovered c
5590
6151
  params.payload = modifiedParams.payload;
5591
6152
  }
5592
6153
  }
6154
+ /**
6155
+ * Resolves whether Plan Mode is active for this run, and whether its approval gate is already
6156
+ * satisfied. Called once from {@link initializeAgentRun}, after `_depth` is set.
6157
+ *
6158
+ * - `active`: this is a root agent (`_depth === 0`) AND either `agent.RequirePlanMode`
6159
+ * (mandatory HITL — forces plan mode on every root run regardless of the per-request flag;
6160
+ * `SupportsPlanMode` is irrelevant when set) OR `agent.SupportsPlanMode` (capability,
6161
+ * default ON/opt-out) AND `params.planMode` (per-request, default OFF). Sub-agents never
6162
+ * gate on Plan Mode — only the top-level agent the user/caller invoked does.
6163
+ * - `approved`: only meaningful when `active`. True when `params.lastRunId` points to a prior
6164
+ * run whose Plan step's `MJ: AI Agent Requests` row resolved to `Approved` or `Responded`
6165
+ * (a `Rejected` plan — or no matching request at all — leaves the gate unsatisfied, sending
6166
+ * the agent back to present a revised plan).
6167
+ *
6168
+ * Override to change Plan Mode eligibility rules (e.g. gate on a specific agent category).
6169
+ *
6170
+ * @protected
6171
+ */
6172
+ async resolvePlanModeGate(params) {
6173
+ const requiredByAgent = params.agent.RequirePlanMode === true;
6174
+ const requestedByCaller = !!(params.agent.SupportsPlanMode && params.planMode === true);
6175
+ const active = this._depth === 0 && (requiredByAgent || requestedByCaller);
6176
+ if (!active) {
6177
+ return { active: false, approved: false };
6178
+ }
6179
+ if (!params.lastRunId) {
6180
+ return { active: true, approved: false };
6181
+ }
6182
+ const rv = new RunView();
6183
+ const requestResult = await rv.RunView({
6184
+ EntityName: 'MJ: AI Agent Requests',
6185
+ ExtraFilter: `OriginatingAgentRunID='${params.lastRunId}'`,
6186
+ Fields: ['Status', 'OriginatingAgentRunStepID'],
6187
+ OrderBy: '__mj_CreatedAt DESC',
6188
+ MaxRows: 1,
6189
+ ResultType: 'simple'
6190
+ }, params.contextUser);
6191
+ if (!requestResult.Success || requestResult.Results.length === 0) {
6192
+ return { active: true, approved: false };
6193
+ }
6194
+ const request = requestResult.Results[0];
6195
+ const resolved = request.Status === 'Approved' || request.Status === 'Responded';
6196
+ if (!resolved || !request.OriginatingAgentRunStepID) {
6197
+ return { active: true, approved: false };
6198
+ }
6199
+ // Confirm the request actually originated from a Plan step — a resolved request from an
6200
+ // unrelated Chat clarification (asked before the agent could even form a plan) must NOT
6201
+ // satisfy the Plan Mode gate.
6202
+ const stepResult = await rv.RunView({
6203
+ EntityName: 'MJ: AI Agent Run Steps',
6204
+ ExtraFilter: `ID='${request.OriginatingAgentRunStepID}'`,
6205
+ Fields: ['StepType'],
6206
+ MaxRows: 1,
6207
+ ResultType: 'simple'
6208
+ }, params.contextUser);
6209
+ const approved = stepResult.Success
6210
+ && stepResult.Results.length > 0
6211
+ && stepResult.Results[0].StepType === 'Plan';
6212
+ return { active: true, approved };
6213
+ }
5593
6214
  /**
5594
6215
  * Validates the agent with tracking.
5595
6216
  *
@@ -5664,9 +6285,25 @@ The context is now within limits. Please retry your request with the recovered c
5664
6285
  })
5665
6286
  : undefined
5666
6287
  });
6288
+ // Skill observability: persist the invocation records for this step. Prompt steps
6289
+ // default to everything currently in effect (so every turn's injection is auditable);
6290
+ // other step types only carry skills when the caller attributes them explicitly.
6291
+ const skillsForStep = params.skills
6292
+ ?? (params.stepType === 'Prompt' && this._skillInvocations.length > 0
6293
+ ? this._skillInvocations
6294
+ : undefined);
6295
+ if (skillsForStep && skillsForStep.length > 0) {
6296
+ stepEntity.Skills = JSON.stringify(skillsForStep);
6297
+ }
5667
6298
  // Fire-and-forget the 'started' INSERT — the agent flow never blocks on a step save. The queue
5668
6299
  // tracks the INSERT so every later UPDATE (queueStepSave) chains AFTER it commits.
5669
- this._stepSaveQueue.Insert(stepEntity);
6300
+ // When the step has a parent, chain the INSERT AFTER the parent's INSERT to satisfy the
6301
+ // self-referencing FK_AIAgentRunStep_ParentID constraint — without this, a child INSERT that
6302
+ // races the parent INSERT hits an FK violation (especially under large-payload parent INSERTs).
6303
+ const parentStepEntity = params.parentId && this._agentRun?.Steps
6304
+ ? this._agentRun.Steps.find(s => UUIDsEqual(s.ID, params.parentId))
6305
+ : undefined;
6306
+ this._stepSaveQueue.Insert(stepEntity, parentStepEntity);
5670
6307
  // Add the step to the agent run's Steps array
5671
6308
  if (this._agentRun) {
5672
6309
  this._agentRun.Steps.push(stepEntity);
@@ -5941,6 +6578,16 @@ The context is now within limits. Please retry your request with the recovered c
5941
6578
  return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
5942
6579
  case 'Actions':
5943
6580
  return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
6581
+ // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
6582
+ // union (non-terminal, like 'ClientTools') — LoopAgentType.DetermineNextStep() emits it
6583
+ // when the LLM chooses to activate a skill.
6584
+ case 'Skill':
6585
+ return await this.executeSkillStep(params, config, previousDecision, stepCount);
6586
+ // Type assertion required because 'Plan' is not part of the BaseAgentNextStep step
6587
+ // union — LoopAgentType.DetermineNextStep() emits it when the LLM presents a plan
6588
+ // (Plan Mode). executePlanStep's terminal return is 'Chat'-shaped (see its doc comment).
6589
+ case 'Plan':
6590
+ return await this.executePlanStep(params, previousDecision);
5944
6591
  // Type assertion required because 'ClientTools' is not part of the BaseAgentNextStep
5945
6592
  // step union — LoopAgentType.DetermineNextStep() emits it when the LLM chooses client tools.
5946
6593
  case 'ClientTools':
@@ -6521,7 +7168,7 @@ The context is now within limits. Please retry your request with the recovered c
6521
7168
  * @param parentStepId - Optional ID of parent step (e.g., ForEach/While loop step) for proper UI hierarchy
6522
7169
  * @param subAgentPayloadOverride - Optional payload override for sub-agent execution, if provided the normal payload computation is skipped
6523
7170
  */
6524
- async executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
7171
+ async executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0, resolvedSubAgentEntity) {
6525
7172
  const subAgentRequest = previousDecision.subAgent;
6526
7173
  // Check for cancellation before starting
6527
7174
  if (params.cancellationToken?.aborted) {
@@ -6557,13 +7204,18 @@ The context is now within limits. Please retry your request with the recovered c
6557
7204
  conversationMessages: params.conversationMessages,
6558
7205
  parentAgentHierarchy: this._agentHierarchy
6559
7206
  };
6560
- // Get sub-agent entity to access payload paths
6561
- const subAgentEntity = AIEngine.Instance.Agents.find(a => a.Name === subAgentRequest.name &&
6562
- UUIDsEqual(a.ParentID, params.agent.ID));
7207
+ // Get sub-agent entity to access payload paths. Prefer the entity the caller already
7208
+ // resolved (resolveSubAgentByName — covers ParentID children AND runtime-granted
7209
+ // sub-agents from skill activations / subAgentChanges); fall back to the ParentID
7210
+ // lookup, then the effective set, for any legacy direct callers of this method.
7211
+ const subAgentEntity = resolvedSubAgentEntity
7212
+ ?? AIEngine.Instance.Agents.find(a => a.Name === subAgentRequest.name &&
7213
+ UUIDsEqual(a.ParentID, params.agent.ID))
7214
+ ?? this.getEffectiveSubAgentsForValidation(params.agent.ID).find(a => a.Name.trim().toLowerCase() === subAgentRequest.name?.trim().toLowerCase());
6563
7215
  if (!subAgentEntity) {
6564
7216
  throw new Error(`Sub-agent '${subAgentRequest.name}' not found`);
6565
7217
  }
6566
- const stepEntity = await this.createStepEntity({ stepType: 'Sub-Agent', stepName: `Execute Sub-Agent: ${subAgentRequest.name}`, contextUser: params.contextUser, targetId: subAgentEntity.ID, inputData, payloadAtStart: previousDecision.newPayload, parentId: parentStepId });
7218
+ const stepEntity = await this.createStepEntity({ stepType: 'Sub-Agent', stepName: `Execute Sub-Agent: ${subAgentRequest.name}`, contextUser: params.contextUser, targetId: subAgentEntity.ID, inputData, payloadAtStart: previousDecision.newPayload, parentId: parentStepId, skills: this.getSkillAttributionForSubAgent(subAgentEntity, params.agent) });
6567
7219
  // Increment execution count for this sub-agent
6568
7220
  this.incrementExecutionCount(subAgentEntity.ID);
6569
7221
  try {
@@ -6826,16 +7478,24 @@ The context is now within limits. Please retry your request with the recovered c
6826
7478
  return await this.executeRelatedSubAgentStep(params, previousDecision, resolved.subAgentEntity, resolved.relationship, parentStepId, subAgentPayloadOverride, stepCount);
6827
7479
  }
6828
7480
  if (resolved) {
6829
- return await this.executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount);
6830
- }
6831
- this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'`, {
7481
+ return await this.executeChildSubAgentStep(params, previousDecision, parentStepId, subAgentPayloadOverride, stepCount, resolved.subAgentEntity);
7482
+ }
7483
+ // Execution-time resolution failure. Count it against the shared validation-retry cap
7484
+ // (MAX_VALIDATION_RETRIES) so a model that keeps picking an unresolvable sub-agent fails
7485
+ // the run with a clear guardrail message instead of looping forever, and tell the model
7486
+ // exactly which sub-agents ARE available so it can self-correct on the next turn.
7487
+ const availableNames = this.getEffectiveSubAgentsForValidation(params.agent.ID)
7488
+ .map(a => a.Name).join(', ') || '(none)';
7489
+ this.logError(`Sub-agent '${name}' not found or not active for agent '${params.agent.Name}'. Available sub-agents: ${availableNames}`, {
6832
7490
  agent: params.agent,
6833
7491
  category: 'SubAgentExecution'
6834
7492
  });
7493
+ this._generalValidationRetryCount++;
6835
7494
  return {
6836
7495
  step: 'Retry',
6837
7496
  terminate: false,
6838
- errorMessage: `Sub-agent '${name}' not found or not active`,
7497
+ errorMessage: `Sub-agent '${name}' not found or not active. Available sub-agents: ${availableNames}. ` +
7498
+ `Pick one of the available sub-agents, or complete the task another way — do not request '${name}' again.`,
6839
7499
  previousPayload: previousDecision.newPayload,
6840
7500
  newPayload: previousDecision.newPayload
6841
7501
  };
@@ -6871,6 +7531,16 @@ The context is now within limits. Please retry your request with the recovered c
6871
7531
  return { subAgentEntity: relatedAgent, relationship: rel };
6872
7532
  }
6873
7533
  }
7534
+ // 3) Runtime-granted sub-agents (skill activation / caller subAgentChanges): resolve from
7535
+ // the SAME effective set the prompt offered and validateSubAgentNextStep approved. Without
7536
+ // this branch, a skill-granted sub-agent passes validation but fails execution ("not found
7537
+ // or not active") — and because the catalog keeps offering it, the model re-picks the same
7538
+ // sub-agent forever (observed live: Research Agent looping 36+ turns on the skill-granted
7539
+ // Infographic Agent). No relationship row exists for these, so they dispatch child-style.
7540
+ const effectiveAgent = this.getEffectiveSubAgentsForValidation(params.agent.ID).find(a => a.Status === 'Active' && a.Name.trim().toLowerCase() === normalized);
7541
+ if (effectiveAgent) {
7542
+ return { subAgentEntity: effectiveAgent };
7543
+ }
6874
7544
  return undefined;
6875
7545
  }
6876
7546
  /**
@@ -7099,6 +7769,7 @@ The context is now within limits. Please retry your request with the recovered c
7099
7769
  stepName: `Execute Parallel Sub-Agent: ${request.name}`,
7100
7770
  contextUser: params.contextUser,
7101
7771
  targetId: subAgentEntity.ID,
7772
+ skills: this.getSkillAttributionForSubAgent(subAgentEntity, params.agent),
7102
7773
  inputData: {
7103
7774
  agentName: params.agent.Name,
7104
7775
  subAgentName: request.name,
@@ -7269,7 +7940,8 @@ The context is now within limits. Please retry your request with the recovered c
7269
7940
  targetId: subAgentEntity.ID,
7270
7941
  inputData,
7271
7942
  payloadAtStart: previousDecision.newPayload,
7272
- parentId: parentStepId
7943
+ parentId: parentStepId,
7944
+ skills: this.getSkillAttributionForSubAgent(subAgentEntity, params.agent)
7273
7945
  });
7274
7946
  // Increment execution count for this sub-agent
7275
7947
  this.incrementExecutionCount(subAgentEntity.ID);
@@ -7778,7 +8450,7 @@ The context is now within limits. Please retry your request with the recovered c
7778
8450
  actionName: aa.name,
7779
8451
  actionParams: aa.params
7780
8452
  };
7781
- const stepEntity = await this.createStepEntity({ stepType: 'Actions', stepName: `Execute Action: ${aa.name}`, contextUser: params.contextUser, targetId: actionEntity.ID, inputData: actionInputData, payloadAtStart: currentPayload, payloadAtEnd: currentPayload, parentId: parentStepId });
8453
+ const stepEntity = await this.createStepEntity({ stepType: 'Actions', stepName: `Execute Action: ${aa.name}`, contextUser: params.contextUser, targetId: actionEntity.ID, inputData: actionInputData, payloadAtStart: currentPayload, payloadAtEnd: currentPayload, parentId: parentStepId, skills: this.getSkillAttributionForAction(actionEntity.ID, params.agent) });
7782
8454
  lastStep = stepEntity;
7783
8455
  // Override step number to ensure unique values for parallel actions
7784
8456
  stepEntity.StepNumber = baseStepNumber + numActionsProcessed++;
@@ -8097,6 +8769,432 @@ The context is now within limits. Please retry your request with the recovered c
8097
8769
  });
8098
8770
  return `${header}\n${lines.join('\n')}`;
8099
8771
  }
8772
+ /**
8773
+ * Executes a 'Skill' next step: activates one or more skills the LLM requested by name.
8774
+ * Activating a skill (1) appends its full `Instructions` to the conversation so they take
8775
+ * effect for the remainder of the run, and (2) enables its bundled Actions/sub-agents by
8776
+ * pushing `root`-scoped `add` entries onto `params.actionChanges`/`params.subAgentChanges` —
8777
+ * the same runtime tool-surface-extension mechanism `ExecuteAgentParams` already exposes to
8778
+ * external callers. This is NOT a nested agent run; it never terminates the loop itself.
8779
+ *
8780
+ * Already-activated skills (tracked in `_activatedSkillIDs`) are skipped — re-requesting an
8781
+ * active skill is a harmless no-op rather than re-appending duplicate instructions.
8782
+ *
8783
+ * Decomposed into {@link resolveSkillActivations}, {@link buildSkillActivationMessage},
8784
+ * {@link enableSkillCapabilities}, and {@link recordSkillActivationStep} — override any of
8785
+ * those for fine-grained control (e.g. custom instruction formatting, additional side effects
8786
+ * on activation) without re-implementing the whole step.
8787
+ *
8788
+ * @protected
8789
+ */
8790
+ async executeSkillStep(params, config, previousDecision, stepCount = 0) {
8791
+ const requested = previousDecision.skillActivations ?? [];
8792
+ if (requested.length === 0) {
8793
+ // Nothing to activate — continue with next prompt
8794
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8795
+ }
8796
+ const resolvedSkills = this.resolveSkillActivations(requested, params.agent, params.contextUser);
8797
+ const newlyActivated = resolvedSkills.filter(skill => !this._activatedSkillIDs.some(id => UUIDsEqual(id, skill.ID)));
8798
+ if (newlyActivated.length === 0) {
8799
+ // All requested skills are already active this run — no-op, just continue
8800
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8801
+ }
8802
+ const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
8803
+ for (const skill of newlyActivated) {
8804
+ // Agent self-activation — carry the model's stated rationale (skillActivations[].reason)
8805
+ // into the provenance record. Names were fuzzy-corrected in validateSkillNextStep, so a
8806
+ // case-insensitive exact match against the request list is reliable here.
8807
+ const request = requested.find(r => r.name.trim().toLowerCase() === skill.Name.trim().toLowerCase());
8808
+ const invocation = this.buildSkillInvocation(skill, params.agent, 'auto', request?.reason);
8809
+ await this.recordSkillActivationStep(skill, currentPayload, params, invocation);
8810
+ this.enableSkillCapabilities(skill, params);
8811
+ this._activatedSkillIDs.push(skill.ID);
8812
+ this._skillInvocations.push(invocation);
8813
+ }
8814
+ const activationMessage = this.buildSkillActivationMessage(newlyActivated);
8815
+ params.conversationMessages.push({
8816
+ role: 'user',
8817
+ content: activationMessage,
8818
+ metadata: {
8819
+ turnAdded: this._promptTurnCount,
8820
+ messageType: 'skill-activation'
8821
+ }
8822
+ });
8823
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8824
+ }
8825
+ /**
8826
+ * Pre-activates skills the caller explicitly requested via {@link ExecuteAgentParams.requestedSkillIDs}
8827
+ * (typically an end user's `/skill-name` composer mentions), at run start — so their Instructions
8828
+ * and bundled Actions/sub-agents take effect from the first turn rather than waiting for the model
8829
+ * to discover and activate them through the catalog.
8830
+ *
8831
+ * **Root-agent only** (skills never cascade to sub-agents), and each requested skill activates
8832
+ * **only if it survives the guard**: it must be in the set {@link AIEngine.GetSkillsForAgent}
8833
+ * allows for this agent (the AcceptsSkills gate) AND the acting user must have Run permission on it
8834
+ * — both enforced by passing `params.contextUser` to `GetSkillsForAgent`. Requested IDs that fail
8835
+ * either check are silently dropped, so a client can never force-activate a skill the user or agent
8836
+ * isn't entitled to. Reuses the same {@link recordSkillActivationStep} / {@link enableSkillCapabilities}
8837
+ * / {@link buildSkillActivationMessage} machinery as the model-initiated `Skill` step, so activation
8838
+ * is recorded and takes effect identically. Plan Mode is unaffected — pre-activation widens the tool
8839
+ * surface, but the plan-approval gate still blocks executing those tools until the plan is approved.
8840
+ *
8841
+ * @protected
8842
+ */
8843
+ async preActivateRequestedSkills(params) {
8844
+ if (this._depth !== 0) {
8845
+ return; // skills are root-agent only; never pre-activate on sub-agents
8846
+ }
8847
+ const requestedIds = params.requestedSkillIDs;
8848
+ if (!requestedIds || requestedIds.length === 0) {
8849
+ return;
8850
+ }
8851
+ // Guard: intersect the requested IDs with the agent-accepted ∩ user-permitted set.
8852
+ const allowed = AIEngine.Instance.GetSkillsForAgent(params.agent, params.contextUser);
8853
+ const droppedIds = requestedIds.filter(id => !allowed.some(s => UUIDsEqual(id, s.ID)));
8854
+ if (droppedIds.length > 0) {
8855
+ this.notifyDroppedSkillRequests(droppedIds, params);
8856
+ }
8857
+ const newlyActivated = allowed.filter(s => requestedIds.some(id => UUIDsEqual(id, s.ID)) &&
8858
+ !this._activatedSkillIDs.some(id => UUIDsEqual(id, s.ID)));
8859
+ if (newlyActivated.length === 0) {
8860
+ return;
8861
+ }
8862
+ const currentPayload = params.payload;
8863
+ for (const skill of newlyActivated) {
8864
+ const invocation = this.buildSkillInvocation(skill, params.agent, 'requested');
8865
+ await this.recordSkillActivationStep(skill, currentPayload, params, invocation);
8866
+ this.enableSkillCapabilities(skill, params);
8867
+ this._activatedSkillIDs.push(skill.ID);
8868
+ this._skillInvocations.push(invocation);
8869
+ }
8870
+ const activationMessage = this.buildSkillActivationMessage(newlyActivated);
8871
+ if (!params.conversationMessages) {
8872
+ params.conversationMessages = [];
8873
+ }
8874
+ params.conversationMessages.push({
8875
+ role: 'user',
8876
+ content: activationMessage,
8877
+ metadata: {
8878
+ turnAdded: this._promptTurnCount,
8879
+ messageType: 'skill-activation'
8880
+ }
8881
+ });
8882
+ }
8883
+ /**
8884
+ * Handles user-requested skill IDs that failed the activation guard (agent-accepted ∩
8885
+ * user-permitted). A silent drop leaves both the user AND the agent blind to the refusal —
8886
+ * the agent then improvises around the missing capability instead of explaining it. This
8887
+ * emits a server-side warning log and injects a system note into the conversation so the
8888
+ * agent tells the user why the skill isn't available rather than working around it.
8889
+ *
8890
+ * @protected
8891
+ */
8892
+ notifyDroppedSkillRequests(droppedIds, params) {
8893
+ const names = droppedIds.map(id => AIEngine.Instance.Skills.find(s => UUIDsEqual(s.ID, id))?.Name ?? id);
8894
+ const reason = params.agent.AcceptsSkills === 'None'
8895
+ ? `agent '${params.agent.Name}' does not accept skills (AcceptsSkills='None')`
8896
+ : `the skill(s) are not available to agent '${params.agent.Name}' — not Active, not assigned to it (AcceptsSkills='Limited'), or the user lacks Run permission`;
8897
+ LogErrorEx({
8898
+ message: `Requested skill activation dropped for [${names.join(', ')}]: ${reason}`,
8899
+ severity: 'warning',
8900
+ category: 'AgentSkills'
8901
+ });
8902
+ if (!params.conversationMessages) {
8903
+ params.conversationMessages = [];
8904
+ }
8905
+ params.conversationMessages.push({
8906
+ role: 'user',
8907
+ content: `SYSTEM NOTE: The user requested activation of the following skill(s) for this run: ${names.join(', ')}. The request was NOT honored because ${reason}. Briefly inform the user that the requested skill(s) are not available to you and, if appropriate, suggest an agent that accepts skills or that an administrator can grant this capability. Do NOT attempt to build, delegate, or improvise a workaround for the missing capability.`,
8908
+ metadata: {
8909
+ turnAdded: this._promptTurnCount,
8910
+ messageType: 'skill-activation-refused'
8911
+ }
8912
+ });
8913
+ }
8914
+ /**
8915
+ * Resolves the LLM's requested skill names to `MJ: AI Skills` entities, restricted to what
8916
+ * {@link AIEngine.GetSkillsForAgent} allows for this agent (the AcceptsSkills gate + Status
8917
+ * chain). Names that don't resolve are silently dropped here — {@link validateSkillNextStep}
8918
+ * is responsible for rejecting unknown/disallowed names before execution ever reaches this
8919
+ * point, so by the time `executeSkillStep` runs, every requested name is expected to match.
8920
+ *
8921
+ * Override to change resolution semantics (e.g. resolve by ID instead of Name).
8922
+ *
8923
+ * @protected
8924
+ */
8925
+ resolveSkillActivations(requested, agent, contextUser) {
8926
+ // Agent-initiated activations resolve against the double-gated AUTO set only —
8927
+ // RequestedOnly skills (on either side of the gate) can never be self-activated, even if
8928
+ // a response somehow names one that validateSkillNextStep didn't catch.
8929
+ const availableSkills = AIEngine.Instance.GetAutoActivatableSkillsForAgent(agent, contextUser);
8930
+ const resolved = [];
8931
+ for (const req of requested) {
8932
+ const requestedName = req.name.trim().toLowerCase();
8933
+ const match = availableSkills.find(s => s.Name.trim().toLowerCase() === requestedName);
8934
+ if (match && !resolved.some(s => UUIDsEqual(s.ID, match.ID))) {
8935
+ resolved.push(match);
8936
+ }
8937
+ }
8938
+ return resolved;
8939
+ }
8940
+ /**
8941
+ * Builds the message appended to `conversationMessages` when skill(s) activate — this is what
8942
+ * actually puts each skill's `Instructions` into effect for the rest of the run. Override to
8943
+ * change formatting (e.g. a more compact representation for a high skill-activation-count agent).
8944
+ *
8945
+ * @protected
8946
+ */
8947
+ buildSkillActivationMessage(skills) {
8948
+ const sections = skills.map(s => `## Skill Activated: ${s.Name}\n\n${s.Instructions}`);
8949
+ return `The following skill(s) have been activated. Their instructions are now in effect ` +
8950
+ `for the remainder of this run:\n\n${sections.join('\n\n')}`;
8951
+ }
8952
+ /**
8953
+ * Enables a skill's bundled Actions and sub-agents by pushing `specific`-scoped `add` entries
8954
+ * (targeted at exactly the activating agent's ID) onto `params.actionChanges` /
8955
+ * `params.subAgentChanges`. `specific`/`[agent.ID]` is the correct scope for "apply to THIS
8956
+ * agent, at whatever depth it runs, and never leak to its sub-agents":
8957
+ * - {@link doesChangeScopeApply} returns true only when the running agent's ID is in the list,
8958
+ * so it applies to the activating agent regardless of depth (a sub-agent that activates a
8959
+ * skill still gets its tools — which a `root`-scoped change would NOT do, since `root` means
8960
+ * "the depth-0 agent," not "the current agent").
8961
+ * - {@link filterActionChangesForSubAgent} / {@link filterSubAgentChangesForSubAgent} propagate
8962
+ * `specific` as-is, and each downstream agent checks `includes(itsOwnID)` → false, so the
8963
+ * grant never cascades to sub-agents the activating agent later delegates to.
8964
+ * Because `params` is the same object reference used for the rest of this run, every subsequent
8965
+ * turn's `gatherPromptTemplateData()` call picks up the change automatically — no extra plumbing.
8966
+ *
8967
+ * Override to change propagation scope (e.g. a subclass that wants skill-granted capabilities
8968
+ * to cascade to sub-agents could push `scope: 'all-subagents'` instead).
8969
+ *
8970
+ * @protected
8971
+ */
8972
+ enableSkillCapabilities(skill, params) {
8973
+ const activatingAgentIds = [params.agent.ID];
8974
+ const actionIds = AIEngine.Instance.GetSkillActionIDs(skill.ID);
8975
+ if (actionIds.length > 0) {
8976
+ if (!params.actionChanges) {
8977
+ params.actionChanges = [];
8978
+ }
8979
+ params.actionChanges.push({
8980
+ scope: 'specific',
8981
+ mode: 'add',
8982
+ actionIds,
8983
+ agentIds: activatingAgentIds
8984
+ });
8985
+ }
8986
+ const subAgentIds = AIEngine.Instance.GetSkillSubAgentIDs(skill.ID);
8987
+ if (subAgentIds.length > 0) {
8988
+ if (!params.subAgentChanges) {
8989
+ params.subAgentChanges = [];
8990
+ }
8991
+ params.subAgentChanges.push({
8992
+ scope: 'specific',
8993
+ mode: 'add',
8994
+ subAgentIds,
8995
+ agentIds: activatingAgentIds
8996
+ });
8997
+ }
8998
+ }
8999
+ /**
9000
+ * Builds the {@link AgentSkillInvocation} observability record for a skill activation —
9001
+ * capturing WHO pulled the trigger and the provenance-of-authority gate values in effect at
9002
+ * activation time, so auditors can see exactly which configuration admitted the skill even
9003
+ * if that configuration later changes.
9004
+ *
9005
+ * @protected
9006
+ */
9007
+ buildSkillInvocation(skill, agent, activationType, reason) {
9008
+ return {
9009
+ SkillID: skill.ID,
9010
+ SkillName: skill.Name,
9011
+ ActivationType: activationType,
9012
+ Provenance: {
9013
+ AgentAcceptsSkills: agent.AcceptsSkills,
9014
+ SkillActivationMode: skill.ActivationMode,
9015
+ AgentSkillActivationMode: agent.SkillActivationMode,
9016
+ RequestedBy: activationType === 'requested' ? 'user-request' : 'agent-decision'
9017
+ },
9018
+ ...(reason ? { Reason: reason } : {})
9019
+ };
9020
+ }
9021
+ /**
9022
+ * Resolves which activated skill(s), if any, granted the given action to this agent — the
9023
+ * attribution recorded on the Actions step's `Skills` column. Returns `undefined` (no
9024
+ * linkage) when the action is one of the agent's NATIVE grants (an Active `MJ: AI Agent
9025
+ * Actions` row), even if an activated skill also bundles it: native authority takes
9026
+ * precedence, and `Skills = NULL` is the contract for "the agent had this tool anyway".
9027
+ *
9028
+ * @protected
9029
+ */
9030
+ getSkillAttributionForAction(actionId, agent) {
9031
+ if (this._skillInvocations.length === 0 || !actionId) {
9032
+ return undefined;
9033
+ }
9034
+ const isNative = AIEngine.Instance.AgentActions.some(aa => UUIDsEqual(aa.AgentID, agent.ID) && UUIDsEqual(aa.ActionID, actionId) && aa.Status === 'Active');
9035
+ if (isNative) {
9036
+ return undefined;
9037
+ }
9038
+ const granting = this._skillInvocations.filter(inv => AIEngine.Instance.GetSkillActionIDs(inv.SkillID).some(id => UUIDsEqual(id, actionId)));
9039
+ return granting.length > 0 ? granting : undefined;
9040
+ }
9041
+ /**
9042
+ * Resolves which activated skill(s), if any, granted the given sub-agent to this agent — the
9043
+ * attribution recorded on the Sub-Agent step's `Skills` column. Returns `undefined` when the
9044
+ * sub-agent is a NATIVE relationship (a `ParentID` child of this agent, or an Active
9045
+ * `MJ: AI Agent Relationships` referenced-sub-agent row), even if an activated skill also
9046
+ * bundles it — same native-precedence contract as {@link getSkillAttributionForAction}.
9047
+ *
9048
+ * @protected
9049
+ */
9050
+ getSkillAttributionForSubAgent(subAgent, agent) {
9051
+ if (this._skillInvocations.length === 0 || !subAgent) {
9052
+ return undefined;
9053
+ }
9054
+ const isParentChild = subAgent.ParentID != null && UUIDsEqual(subAgent.ParentID, agent.ID);
9055
+ const isReferenced = AIEngine.Instance.AgentRelationships.some(rel => UUIDsEqual(rel.AgentID, agent.ID) && UUIDsEqual(rel.SubAgentID, subAgent.ID) && rel.Status === 'Active');
9056
+ if (isParentChild || isReferenced) {
9057
+ return undefined;
9058
+ }
9059
+ const granting = this._skillInvocations.filter(inv => AIEngine.Instance.GetSkillSubAgentIDs(inv.SkillID).some(id => UUIDsEqual(id, subAgent.ID)));
9060
+ return granting.length > 0 ? granting : undefined;
9061
+ }
9062
+ /**
9063
+ * Creates and immediately finalizes the `AIAgentRunStep` (StepType='Skill') that records this
9064
+ * skill activation for observability/audit. The step's `Skills` column carries the single
9065
+ * {@link AgentSkillInvocation} performed (activation type, provenance of authority, and the
9066
+ * agent-stated reason when self-activated). Activation is not itself a failure mode today — it
9067
+ * always finalizes as successful — but subclasses can override to add richer InputData/OutputData
9068
+ * or to make activation conditionally fail (e.g. a licensing check).
9069
+ *
9070
+ * @protected
9071
+ */
9072
+ async recordSkillActivationStep(skill, currentPayload, params, invocation) {
9073
+ const stepEntity = await this.createStepEntity({
9074
+ stepType: 'Skill',
9075
+ stepName: `Skill: ${skill.Name}`,
9076
+ targetId: skill.ID,
9077
+ inputData: { skillName: skill.Name },
9078
+ contextUser: params.contextUser,
9079
+ payloadAtStart: currentPayload,
9080
+ payloadAtEnd: currentPayload,
9081
+ ...(invocation ? { skills: [invocation] } : {})
9082
+ });
9083
+ await this.finalizeStepEntity(stepEntity, true, undefined, {
9084
+ skillId: skill.ID,
9085
+ skillName: skill.Name,
9086
+ ...(invocation ? {
9087
+ activationType: invocation.ActivationType,
9088
+ requestedBy: invocation.Provenance.RequestedBy,
9089
+ ...(invocation.Reason ? { reason: invocation.Reason } : {})
9090
+ } : {})
9091
+ });
9092
+ }
9093
+ /**
9094
+ * Executes a 'Plan' next step (Plan Mode): records a `Plan` run-step, raises the standard
9095
+ * `MJ: AI Agent Requests` HITL request with an editable plan-approval `AgentResponseForm`, and
9096
+ * terminates this run awaiting the human's response — reusing the exact same pause/resume
9097
+ * infrastructure `executeChatStep` uses (`createFeedbackRequest` + the existing
9098
+ * `MJAIAgentRequestEntityServer.Save()` auto-resume-on-status-change hook). A rejected or
9099
+ * edited-and-resubmitted plan resumes as a new linked run via the normal run-chain mechanism;
9100
+ * `resolvePlanModeGate` re-checks approval on that new run so a rejection sends the agent back
9101
+ * to present a revised plan rather than through to execution.
9102
+ *
9103
+ * **Important**: the RETURNED `BaseAgentNextStep.step` is `'Chat'`, not `'Plan'` — 'Plan' is
9104
+ * only ever an intermediate classification (used for the `AIAgentRunStep.StepType` audit
9105
+ * record, which the UI reads to render a plan-approval card instead of a generic chat bubble).
9106
+ * The step returned to the framework must stay within `AIAgentRun.FinalStep`'s DB-CHECK-
9107
+ * constrained, terminal-only vocabulary — `'Plan'` is deliberately not part of it (see the
9108
+ * `BaseAgentNextStep.step` doc comment) — so a plan-approval pause is represented as the same
9109
+ * terminal shape `executeChatStep` already uses.
9110
+ *
9111
+ * @protected
9112
+ */
9113
+ async executePlanStep(params, previousDecision) {
9114
+ const planText = previousDecision.planDetails?.plan ?? '';
9115
+ const stepEntity = await this.createStepEntity({
9116
+ stepType: 'Plan',
9117
+ stepName: 'Plan Presented for Approval',
9118
+ contextUser: params.contextUser,
9119
+ inputData: { plan: planText }
9120
+ });
9121
+ await this.finalizeStepEntity(stepEntity, true, undefined, { plan: planText });
9122
+ const responseForm = this.buildPlanApprovalForm(planText);
9123
+ const planPresentation = {
9124
+ step: 'Plan',
9125
+ terminate: true,
9126
+ message: previousDecision.message || 'Please review the proposed plan before I proceed.',
9127
+ reasoning: previousDecision.reasoning,
9128
+ confidence: previousDecision.confidence,
9129
+ responseForm
9130
+ };
9131
+ // For root agents, create a persistent AIAgentRequest so the request is tracked in the
9132
+ // dashboard and can be responded to outside a conversation (mirrors executeChatStep).
9133
+ if (this._depth === 0) {
9134
+ await this.createFeedbackRequest(params, stepEntity, planPresentation);
9135
+ }
9136
+ return {
9137
+ step: 'Chat',
9138
+ terminate: true,
9139
+ message: planPresentation.message,
9140
+ reasoning: previousDecision.reasoning,
9141
+ confidence: previousDecision.confidence,
9142
+ previousPayload: previousDecision.previousPayload,
9143
+ newPayload: previousDecision.newPayload || previousDecision.previousPayload,
9144
+ responseForm
9145
+ };
9146
+ }
9147
+ /**
9148
+ * Builds the editable plan-approval `AgentResponseForm`: the Markdown-rendered plan (with an
9149
+ * Edit toggle so the human can amend it before approving), an optional feedback field that
9150
+ * travels back to the agent with the decision (most useful on Reject — it steers the re-plan),
9151
+ * and an Approve/Reject button group. Override to change the card's layout (e.g. split the
9152
+ * plan into per-step checkboxes instead of one field).
9153
+ *
9154
+ * Approval is a HIGHER-ORDER signal, not just a form reply: conversation hosts detect
9155
+ * `decision === 'approve'` on this form and switch the conversation out of Plan Mode
9156
+ * (see ng-conversations' plan-decision handling), so the follow-up run executes the approved
9157
+ * plan instead of planning again. Rejection keeps Plan Mode on — the agent re-plans with the
9158
+ * feedback in context.
9159
+ *
9160
+ * @protected
9161
+ */
9162
+ buildPlanApprovalForm(planText) {
9163
+ return {
9164
+ title: 'Review Plan',
9165
+ description: 'Review the proposed plan below. Edit it if needed, then approve to proceed — or reject (with a note on what to change) and the agent will re-plan.',
9166
+ submitLabel: 'Submit',
9167
+ questions: [
9168
+ {
9169
+ id: 'plan',
9170
+ label: 'Plan',
9171
+ // markdown: agents author plans in Markdown (see the plan-mode prompt
9172
+ // instructions); the UI renders a formatted preview with an Edit toggle.
9173
+ type: { type: 'textarea', markdown: true },
9174
+ defaultValue: planText,
9175
+ required: true
9176
+ },
9177
+ {
9178
+ id: 'reason',
9179
+ label: 'Feedback',
9180
+ type: { type: 'textarea', placeholder: 'Optional — if rejecting, tell the agent what to change and it will re-plan.' },
9181
+ required: false
9182
+ },
9183
+ {
9184
+ id: 'decision',
9185
+ label: 'Decision',
9186
+ type: {
9187
+ type: 'buttongroup',
9188
+ options: [
9189
+ { value: 'approve', label: 'Approve' },
9190
+ { value: 'reject', label: 'Reject' }
9191
+ ]
9192
+ },
9193
+ required: true
9194
+ }
9195
+ ]
9196
+ };
9197
+ }
8100
9198
  async executeChatStep(params, previousDecision) {
8101
9199
  const stepEntity = await this.createStepEntity({ stepType: 'Chat', stepName: 'User Interaction', contextUser: params.contextUser });
8102
9200
  // Chat steps are successful - they indicate a need for user interaction
@@ -9302,6 +10400,13 @@ The context is now within limits. Please retry your request with the recovered c
9302
10400
  async pruneAndCompactExpiredMessages(params, currentTurn) {
9303
10401
  const messagesToCompact = [];
9304
10402
  const messagesToRemove = [];
10403
+ // Cache-aware guard: confine pruning/compaction to the volatile tail so we don't
10404
+ // perturb the provider's KV-cached prompt prefix. The stable prefix is the maximal
10405
+ // contiguous leading run of non-result messages (system/RAG context, injected
10406
+ // memory, the original user request). Expired messages that fall inside that prefix
10407
+ // are deferred — genuine context overflow still reaches them via attemptContextRecovery.
10408
+ // token optimization via @memberjunction/context-crush (CacheAligner-inspired)
10409
+ const { Boundary: stablePrefixBoundary } = PartitionStablePrefix(params.conversationMessages, (msg) => !this.IsVolatileResultMessage(msg));
9305
10410
  // Phase 1: Identify expired messages
9306
10411
  for (let i = 0; i < params.conversationMessages.length; i++) {
9307
10412
  const msg = params.conversationMessages[i];
@@ -9318,6 +10423,12 @@ The context is now within limits. Please retry your request with the recovered c
9318
10423
  const turnsAlive = currentTurn - turnAdded;
9319
10424
  // Check if expired
9320
10425
  if (turnsAlive > msg.metadata.expirationTurns) {
10426
+ // Defer expiry of messages inside the cache-stable prefix to preserve the
10427
+ // provider's cached prompt prefix; overflow recovery handles them if needed.
10428
+ if (i < stablePrefixBoundary) {
10429
+ this.logStatus(`[Turn ${currentTurn}] Deferred expiry of cache-stable prefix message at index ${i}`, true, params);
10430
+ continue;
10431
+ }
9321
10432
  msg.metadata.isExpired = true;
9322
10433
  if (msg.metadata.expirationMode === 'Remove') {
9323
10434
  messagesToRemove.push(i);
@@ -9563,6 +10674,21 @@ The context is now within limits. Please retry your request with the recovered c
9563
10674
  || messageType === 'client-tool-result'
9564
10675
  || messageType === 'tool-result';
9565
10676
  }
10677
+ /**
10678
+ * Returns true if the message is a turn-generated result (action, tool, client tool,
10679
+ * sub-agent, or loop). These are the volatile, expirable messages that accumulate over
10680
+ * turns. Everything else — system/RAG context, injected memory, the original user
10681
+ * request — anchors the cache-stable prompt prefix and is protected from routine pruning.
10682
+ * @protected
10683
+ */
10684
+ IsVolatileResultMessage(msg) {
10685
+ const messageType = msg.metadata?.messageType;
10686
+ return messageType === 'action-result'
10687
+ || messageType === 'client-tool-result'
10688
+ || messageType === 'tool-result'
10689
+ || messageType === 'sub-agent-result'
10690
+ || messageType === 'loop-result';
10691
+ }
9566
10692
  estimateTokens(content, modelName) {
9567
10693
  const text = typeof content === 'string'
9568
10694
  ? content