@memberjunction/ai-agents 5.43.0 → 5.44.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 (87) hide show
  1. package/README.md +8 -0
  2. package/dist/DuplicateReasoningAgentProvider.d.ts +35 -0
  3. package/dist/DuplicateReasoningAgentProvider.d.ts.map +1 -0
  4. package/dist/DuplicateReasoningAgentProvider.js +95 -0
  5. package/dist/DuplicateReasoningAgentProvider.js.map +1 -0
  6. package/dist/MJAIAgentRequestEntityServer.d.ts +7 -0
  7. package/dist/MJAIAgentRequestEntityServer.d.ts.map +1 -1
  8. package/dist/MJAIAgentRequestEntityServer.js +28 -1
  9. package/dist/MJAIAgentRequestEntityServer.js.map +1 -1
  10. package/dist/SkillImportExportService.d.ts +63 -0
  11. package/dist/SkillImportExportService.d.ts.map +1 -0
  12. package/dist/SkillImportExportService.js +148 -0
  13. package/dist/SkillImportExportService.js.map +1 -0
  14. package/dist/SkillMarkdownConverter.d.ts +82 -0
  15. package/dist/SkillMarkdownConverter.d.ts.map +1 -0
  16. package/dist/SkillMarkdownConverter.js +145 -0
  17. package/dist/SkillMarkdownConverter.js.map +1 -0
  18. package/dist/agent-types/loop-agent-response-type.d.ts +17 -2
  19. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  20. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  21. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  22. package/dist/agent-types/loop-agent-type.js +33 -1
  23. package/dist/agent-types/loop-agent-type.js.map +1 -1
  24. package/dist/base-agent.d.ts +373 -12
  25. package/dist/base-agent.d.ts.map +1 -1
  26. package/dist/base-agent.js +1007 -78
  27. package/dist/base-agent.js.map +1 -1
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +11 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/memory-manager-agent.d.ts +39 -0
  33. package/dist/memory-manager-agent.d.ts.map +1 -1
  34. package/dist/memory-manager-agent.js +118 -5
  35. package/dist/memory-manager-agent.js.map +1 -1
  36. package/dist/operations/AISkillMarkdownOperations.d.ts +11 -0
  37. package/dist/operations/AISkillMarkdownOperations.d.ts.map +1 -0
  38. package/dist/operations/AISkillMarkdownOperations.js +57 -0
  39. package/dist/operations/AISkillMarkdownOperations.js.map +1 -0
  40. package/dist/prompt-component-resolver.d.ts +73 -0
  41. package/dist/prompt-component-resolver.d.ts.map +1 -0
  42. package/dist/prompt-component-resolver.js +166 -0
  43. package/dist/prompt-component-resolver.js.map +1 -0
  44. package/dist/realtime/agent-media-library.d.ts +65 -0
  45. package/dist/realtime/agent-media-library.d.ts.map +1 -0
  46. package/dist/realtime/agent-media-library.js +160 -0
  47. package/dist/realtime/agent-media-library.js.map +1 -0
  48. package/dist/realtime/client-context-channel-server.d.ts +59 -0
  49. package/dist/realtime/client-context-channel-server.d.ts.map +1 -0
  50. package/dist/realtime/client-context-channel-server.js +78 -0
  51. package/dist/realtime/client-context-channel-server.js.map +1 -0
  52. package/dist/realtime/media-channel-server.d.ts +73 -0
  53. package/dist/realtime/media-channel-server.d.ts.map +1 -0
  54. package/dist/realtime/media-channel-server.js +145 -0
  55. package/dist/realtime/media-channel-server.js.map +1 -0
  56. package/dist/realtime/realtime-channel-server-data-context.d.ts +42 -0
  57. package/dist/realtime/realtime-channel-server-data-context.d.ts.map +1 -0
  58. package/dist/realtime/realtime-channel-server-data-context.js +29 -0
  59. package/dist/realtime/realtime-channel-server-data-context.js.map +1 -0
  60. package/dist/realtime/realtime-channel-server-host.d.ts.map +1 -1
  61. package/dist/realtime/realtime-channel-server-host.js +8 -2
  62. package/dist/realtime/realtime-channel-server-host.js.map +1 -1
  63. package/dist/realtime/realtime-client-session-service.d.ts +85 -5
  64. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  65. package/dist/realtime/realtime-client-session-service.js +234 -25
  66. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  67. package/dist/realtime/realtime-coagent-config.d.ts +82 -3
  68. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  69. package/dist/realtime/realtime-coagent-config.js +147 -6
  70. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  71. package/dist/realtime/realtime-recording-capture.d.ts +125 -0
  72. package/dist/realtime/realtime-recording-capture.d.ts.map +1 -0
  73. package/dist/realtime/realtime-recording-capture.js +317 -0
  74. package/dist/realtime/realtime-recording-capture.js.map +1 -0
  75. package/dist/realtime/realtime-recording-store.d.ts +105 -0
  76. package/dist/realtime/realtime-recording-store.d.ts.map +1 -0
  77. package/dist/realtime/realtime-recording-store.js +216 -0
  78. package/dist/realtime/realtime-recording-store.js.map +1 -0
  79. package/dist/realtime/realtime-session-runner.d.ts +33 -2
  80. package/dist/realtime/realtime-session-runner.d.ts.map +1 -1
  81. package/dist/realtime/realtime-session-runner.js +45 -2
  82. package/dist/realtime/realtime-session-runner.js.map +1 -1
  83. package/dist/realtime/realtime-tool-broker.d.ts +41 -3
  84. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -1
  85. package/dist/realtime/realtime-tool-broker.js +67 -3
  86. package/dist/realtime/realtime-tool-broker.js.map +1 -1
  87. 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,37 @@ 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
+ * Whether Plan Mode is active for this run — resolved once in {@link initializeAgentRun} via
247
+ * {@link resolvePlanModeGate}. True only when `agent.SupportsPlanMode` (capability, default ON)
248
+ * AND `params.planMode` (per-request, default OFF) are both true AND this is a root agent.
249
+ * @private
250
+ */
251
+ this._planModeActive = false;
252
+ /**
253
+ * Whether Plan Mode's approval gate has already been satisfied for this run — either because
254
+ * Plan Mode isn't active, or because a prior linked run's Plan step was approved. When active
255
+ * and NOT yet approved, `validateNextStep` blocks Actions/Sub-Agent steps until a Plan step
256
+ * has been presented and approved.
257
+ * @private
258
+ */
259
+ this._planApproved = false;
222
260
  /**
223
261
  * Counts only prompt (LLM) executions, NOT all agent steps.
224
262
  * Used for message expiration age calculations so that `expirationTurns`
@@ -230,6 +268,14 @@ export class BaseAgent {
230
268
  * prompt execution occurred after them.
231
269
  */
232
270
  this._promptTurnCount = 0;
271
+ /**
272
+ * Per-run config for structurally compressing inline action-result payloads.
273
+ * Resolved once at run start from the agent-type prompt params (default on);
274
+ * read by formatActionResultsAsMarkdown so both the direct and loop callers
275
+ * share the same setting without threading it through every signature.
276
+ * @private
277
+ */
278
+ this._actionResultCrush = undefined;
233
279
  /**
234
280
  * Execution limits for dynamically added actions.
235
281
  * Maps action IDs to their MaxExecutionsPerRun limit.
@@ -256,6 +302,34 @@ export class BaseAgent {
256
302
  * @private
257
303
  */
258
304
  this.MAX_RECOVERY_ATTEMPTS = 1;
305
+ /**
306
+ * Drives a session-driven (Realtime) agent run end-to-end.
307
+ *
308
+ * Resolves the realtime model, assembles the session parameters (system prompt + memory/context),
309
+ * builds the {@link RealtimeSessionRunnerDeps} from this agent's collaborators, runs the
310
+ * {@link RealtimeSessionRunner}, and maps the result onto the finalized `AIAgentRun`.
311
+ *
312
+ * If no realtime model can be resolved (expected today, before the P3 drivers / P4 model
313
+ * metadata land), it finalizes the run as a clean FAILED result with an actionable message
314
+ * rather than throwing — a mis-provisioned environment must not crash the caller.
315
+ *
316
+ * @template R The caller's expected payload type (unused on the realtime path; the session
317
+ * produces transcript/usage rather than a structured payload).
318
+ * @param params The wrapped execution parameters.
319
+ * @param config The loaded agent configuration (provides the system prompt, if any).
320
+ * @returns The finalized {@link ExecuteAgentResult}.
321
+ */
322
+ // ── Realtime per-session capture state (scoped to one executeRealtimeSession run) ──────────
323
+ /**
324
+ * In-flight realtime turn rows keyed by transcript role (`'user'`/`'assistant'`), driving the
325
+ * create-on-start / update-on-complete persistence lifecycle. Reset at the start of every
326
+ * realtime session so a prior run can never leak an in-flight id into the next.
327
+ */
328
+ this.realtimeInFlightTurns = new Map();
329
+ /** Active audio recording controller for the current realtime session, or `null` when recording is off. */
330
+ this.realtimeRecording = null;
331
+ /** Storage account id the active recording stores to (RecordingStorageProviderID ?? AttachmentStorageProviderID). */
332
+ this.realtimeRecordingAccountId = null;
259
333
  /**
260
334
  * Storage for injected notes and examples to include in result
261
335
  */
@@ -427,6 +501,13 @@ export class BaseAgent {
427
501
  * @private
428
502
  */
429
503
  static { this.LARGE_BINARY_THRESHOLD = 10000; }
504
+ /**
505
+ * Minimum stringified length (chars) of an object/array action-result value before
506
+ * structural JSON compression (CrushJSON) is applied. Small payloads aren't worth a
507
+ * legend, so they pass through verbatim.
508
+ * @private
509
+ */
510
+ static { this.ACTION_RESULT_CRUSH_THRESHOLD = 600; }
430
511
  /**
431
512
  * Inspects a set of action output params for any value matching the FileOutputRef shape
432
513
  * (an object with `fileName`, `mimeType`, and either `fileData` or `fileId`).
@@ -1134,6 +1215,9 @@ export class BaseAgent {
1134
1215
  this.InjectContextMemory(typeof inputText === 'string' ? inputText : '', params.agent, userId, companyId, params.contextUser, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, scopeConfig),
1135
1216
  this.InjectPreExecutionRAG(typeof inputText === 'string' ? inputText : '', params.agent, params.contextUser, wrappedParams.conversationMessages, params.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, params.payload)
1136
1217
  ]);
1218
+ // Inject scope-resolved prompt parts (role-faithful) for this agent's prompt, alongside
1219
+ // memory/RAG. Synchronous — parts are cached on AIEngine. Uses the same run scope.
1220
+ this.InjectScopedPromptParts(params.agent, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes);
1137
1221
  if (!config.success) {
1138
1222
  this.logError(`Failed to load agent configuration: ${config.errorMessage}`, {
1139
1223
  agent: params.agent,
@@ -1238,23 +1322,6 @@ export class BaseAgent {
1238
1322
  isSessionDrivenAgentType(agentType) {
1239
1323
  return agentType.IsSessionDriven === true;
1240
1324
  }
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
1325
  async executeRealtimeSession(params, config) {
1259
1326
  // 1) Resolve the realtime model (overridable seam — tests inject a mock).
1260
1327
  const modelResolution = await this.resolveRealtimeModel(params);
@@ -1268,7 +1335,12 @@ export class BaseAgent {
1268
1335
  }
1269
1336
  // 2) Create the single long-lived AIPromptRun that usage is checkpointed onto.
1270
1337
  const promptRun = await this.createRealtimePromptRun(params, config, modelResolution);
1271
- // 3) Build the injected deps and run the session.
1338
+ // 3) Resolve recording (OFF by default; runtime > agent > off; consent + storage gated) and reset
1339
+ // the per-session turn-lifecycle state, then build the injected deps and run the session.
1340
+ this.realtimeInFlightTurns = new Map();
1341
+ const recording = await this.resolveRealtimeRecording(params);
1342
+ this.realtimeRecording = recording?.controller ?? null;
1343
+ this.realtimeRecordingAccountId = recording?.storageAccountId ?? null;
1272
1344
  try {
1273
1345
  const deps = await this.buildRealtimeSessionDeps(params, config, modelResolution, promptRun);
1274
1346
  const runner = new RealtimeSessionRunner(deps);
@@ -1369,6 +1441,11 @@ export class BaseAgent {
1369
1441
  UserID: params.contextUser?.ID,
1370
1442
  DisableAutoResponse: meetingMode || undefined,
1371
1443
  SelfNames: selfNames,
1444
+ // App awareness (Move 1/3/4): the app the session runs in (sources the app cascade layer +
1445
+ // RelevantAgents → allowed-agent union) and the live app-context snapshot injected at mint.
1446
+ // Both ride params.data, the same conduit async agents use for appContext.
1447
+ ApplicationID: params.data?.applicationId?.trim() || undefined,
1448
+ AppContext: params.data?.appContext,
1372
1449
  };
1373
1450
  }
1374
1451
  /**
@@ -1559,6 +1636,8 @@ export class BaseAgent {
1559
1636
  DelegateToTarget: (request) => this.delegateRealtimeToTarget(params, config, request),
1560
1637
  ExecuteTool: (call) => this.executeRealtimeTool(params, call),
1561
1638
  PersistTranscript: (transcript) => this.persistRealtimeTranscript(params, transcript),
1639
+ Recording: this.realtimeRecording ?? undefined,
1640
+ FinalizeRecording: () => this.finalizeRealtimeRecording(params),
1562
1641
  CheckpointUsage: (usage) => this.checkpointRealtimeUsage(promptRun, usage),
1563
1642
  // DB-driven spoken-progress wording (shared lookup with the client-direct path);
1564
1643
  // null → the runner's documented built-in first-person fallback.
@@ -1800,34 +1879,211 @@ export class BaseAgent {
1800
1879
  return {};
1801
1880
  }
1802
1881
  /**
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).
1882
+ * Persists a realtime transcript turn as a `ConversationDetail` with a **create-on-start /
1883
+ * update-on-complete** lifecycle, so each turn carries both a start (`__mj_CreatedAt`) and an
1884
+ * immutable end (`TurnEndedAt`):
1885
+ * - **Interim** (`IsFinal=false`): on the FIRST delta for a role, CREATE the row with
1886
+ * `Status='In-Progress'` (so a live UI can show the turn streaming), stamping the recording-relative
1887
+ * `UtteranceStartMs` and the speaker `UserID` (user turns only). Subsequent interim deltas are no-ops.
1888
+ * - **Final** (`IsFinal=true`): UPDATE that in-flight row with the full text, `Status='Complete'`,
1889
+ * `TurnEndedAt`, and `UtteranceEndMs`. If no interim was seen (some providers only emit final), the
1890
+ * row is created and finalized in one step.
1891
+ *
1892
+ * Returns the new row's ID the first time a DISTINCT turn is created, and `null` when an existing
1893
+ * in-flight row is merely updated — the runner uses that to count turns (not events). User turns are
1894
+ * `Role='User'`, assistant turns `Role='AI'`. When recording is active, `MediaType='Audio'` and the
1895
+ * media-relative utterance offsets are stamped from the recording clock.
1806
1896
  *
1807
- * @param params The execution parameters (provides conversation id + context user).
1808
- * @param transcript The transcript turn emitted by the model.
1897
+ * @param params The execution parameters (provides conversation id + context user + session id).
1898
+ * @param transcript The transcript turn (interim delta or final) emitted by the model.
1899
+ * @returns The created row id on first creation of a turn, else `null`.
1809
1900
  */
1810
1901
  async persistRealtimeTranscript(params, transcript) {
1811
- if (!transcript.IsFinal || !transcript.Text?.trim()) {
1812
- return;
1902
+ if (!transcript.Text?.trim()) {
1903
+ return null;
1813
1904
  }
1814
1905
  const conversationID = params.data?.conversationId;
1815
1906
  if (!conversationID) {
1816
- return; // Without a conversation we have nowhere to durably attach the turn.
1907
+ return null; // Without a conversation we have nowhere to durably attach the turn.
1817
1908
  }
1818
1909
  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';
1910
+ const roleKey = transcript.Role; // 'user' | 'assistant'
1911
+ const mjRole = transcript.Role === 'user' ? 'User' : 'AI';
1912
+ // ── INTERIM: create the In-Progress row once per turn (first delta) ───────────────────────
1913
+ if (!transcript.IsFinal) {
1914
+ if (this.realtimeInFlightTurns.has(roleKey)) {
1915
+ return null; // already created for this turn; ignore subsequent deltas
1916
+ }
1917
+ const detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1918
+ detail.NewRecord();
1919
+ detail.ConversationID = conversationID;
1920
+ detail.Role = mjRole;
1921
+ detail.Message = transcript.Text;
1922
+ detail.Status = 'In-Progress';
1923
+ this.applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, /*atStart*/ true);
1924
+ if (params.agentSessionID) {
1925
+ detail.AgentSessionID = params.agentSessionID;
1926
+ }
1927
+ if (!await detail.Save()) {
1928
+ this.logError(`Failed to create in-progress realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1929
+ agent: params.agent, category: 'RealtimeSession'
1930
+ });
1931
+ return null;
1932
+ }
1933
+ this.realtimeInFlightTurns.set(roleKey, detail.ID);
1934
+ return detail.ID;
1935
+ }
1936
+ // ── FINAL: update the in-flight row (or create+finalize when no interim was seen) ─────────
1937
+ const inFlightId = this.realtimeInFlightTurns.get(roleKey);
1938
+ this.realtimeInFlightTurns.delete(roleKey);
1939
+ let detail = await md.GetEntityObject('MJ: Conversation Details', params.contextUser);
1940
+ let created = false;
1941
+ if (inFlightId && await detail.Load(inFlightId)) {
1942
+ // updating the existing streaming row → not a new turn
1943
+ }
1944
+ else {
1945
+ detail.NewRecord();
1946
+ detail.ConversationID = conversationID;
1947
+ detail.Role = mjRole;
1948
+ this.applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, /*atStart*/ true);
1949
+ if (params.agentSessionID) {
1950
+ detail.AgentSessionID = params.agentSessionID;
1951
+ }
1952
+ created = true;
1953
+ }
1823
1954
  detail.Message = transcript.Text;
1824
- if (params.agentSessionID) {
1825
- detail.AgentSessionID = params.agentSessionID;
1955
+ detail.Status = 'Complete';
1956
+ detail.TurnEndedAt = new Date();
1957
+ if (this.realtimeRecording) {
1958
+ detail.UtteranceEndMs = this.realtimeRecording.NowOffsetMs();
1826
1959
  }
1827
1960
  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'
1961
+ this.logError(`Failed to finalize realtime transcript turn: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`, {
1962
+ agent: params.agent, category: 'RealtimeSession'
1963
+ });
1964
+ }
1965
+ return created ? detail.ID : null;
1966
+ }
1967
+ /**
1968
+ * Stamps the speaker identity and recording-relative media fields on a freshly-created turn row.
1969
+ * `UserID` is set only for **user** turns (an AI turn has no human speaker). When recording is
1970
+ * active, `MediaType='Audio'` and `UtteranceStartMs` is captured from the recording clock.
1971
+ *
1972
+ * @param detail The new conversation-detail row.
1973
+ * @param transcript The transcript turn.
1974
+ * @param params The execution parameters.
1975
+ * @param atStart Whether this is the turn's start (stamps `UtteranceStartMs`).
1976
+ */
1977
+ applyRealtimeTurnSpeakerAndMedia(detail, transcript, params, atStart) {
1978
+ if (transcript.Role === 'user' && params.contextUser?.ID) {
1979
+ detail.UserID = params.contextUser.ID;
1980
+ }
1981
+ if (this.realtimeRecording) {
1982
+ detail.MediaType = 'Audio';
1983
+ if (atStart) {
1984
+ detail.UtteranceStartMs = this.realtimeRecording.NowOffsetMs();
1985
+ }
1986
+ }
1987
+ }
1988
+ /**
1989
+ * Resolves whether to record this realtime session, OFF by default, with the precedence
1990
+ * **runtime param > agent (`RecordingDefault`) > off**, hard-gated by consent and a resolvable
1991
+ * storage provider. Returns the recording controller + the resolved storage account, or `null`
1992
+ * to record nothing (fail-closed). Never throws — any resolution problem disables recording.
1993
+ *
1994
+ * Storage resolves to **`AIAgent.RecordingStorageProviderID` ?? `AIAgent.AttachmentStorageProviderID`**
1995
+ * (recordings default to the attachments account), then to that provider's first account. With no
1996
+ * provider configured, or consent not granted, recording is OFF.
1997
+ *
1998
+ * @param params The execution parameters (agent + runtime `data.recording`).
1999
+ * @returns `{ controller, storageAccountId }` when recording is enabled, else `null`.
2000
+ */
2001
+ async resolveRealtimeRecording(params) {
2002
+ try {
2003
+ const agent = params.agent;
2004
+ const runtime = (params.data?.recording ?? null);
2005
+ // Media: runtime > agent default > off.
2006
+ const rawMedia = runtime?.media ?? agent.RecordingDefault ?? 'None';
2007
+ const media = rawMedia === 'Audio' || rawMedia === 'AudioVideo' ? rawMedia : 'None';
2008
+ if (media === 'None') {
2009
+ return null; // recording off
2010
+ }
2011
+ // Consent is a HARD gate — never record without explicit consent.
2012
+ if (runtime?.consent !== true) {
2013
+ this.logStatus('🔴 Realtime recording requested but consent was not granted — recording disabled.', false, params);
2014
+ return null;
2015
+ }
2016
+ // Storage: recording provider, else attachment provider; then that provider's first account.
2017
+ const storageAccountId = params.contextUser
2018
+ ? await resolveRecordingStorageAccountID(agent, params.contextUser, params.provider || this._activeProvider)
2019
+ : null;
2020
+ if (!storageAccountId) {
2021
+ this.logStatus('🔴 Realtime recording on but no resolvable storage account (RecordingStorageProviderID/AttachmentStorageProviderID) — recording disabled.', false, params);
2022
+ return null;
2023
+ }
2024
+ const controller = new RealtimeRecordingController({ Media: media });
2025
+ return { controller, storageAccountId };
2026
+ }
2027
+ catch (error) {
2028
+ this.logError(`Failed to resolve realtime recording (recording disabled): ${error instanceof Error ? error.message : String(error)}`, {
2029
+ agent: params.agent, category: 'RealtimeSession'
2030
+ });
2031
+ return null;
2032
+ }
2033
+ }
2034
+ /**
2035
+ * Finalizes the active recording after the session closes: encodes the captured audio to a WAV,
2036
+ * stores it via MJStorage to the resolved account, links it to the `AIAgentSession` (via
2037
+ * `MJ: File Entity Record Links`), and stamps `RecordingFileID` / `RecordingMedia` /
2038
+ * `RecordingStartedAt` on the session. Never throws — a recording failure must not fail the
2039
+ * session run. No-op when recording is off, nothing was captured, or there is no session id.
2040
+ *
2041
+ * @param params The execution parameters (provides the session id + context user + provider).
2042
+ */
2043
+ async finalizeRealtimeRecording(params) {
2044
+ const controller = this.realtimeRecording;
2045
+ if (!controller) {
2046
+ return;
2047
+ }
2048
+ // One-shot: clear instance state up front so a re-entrant/duplicate Stop can't double-store.
2049
+ this.realtimeRecording = null;
2050
+ const storageAccountId = this.realtimeRecordingAccountId;
2051
+ this.realtimeRecordingAccountId = null;
2052
+ try {
2053
+ controller.Stop();
2054
+ const sessionID = params.agentSessionID;
2055
+ const contextUser = params.contextUser;
2056
+ if (!sessionID || !storageAccountId || !contextUser) {
2057
+ return; // nowhere to attach / store (or no user context to store under)
2058
+ }
2059
+ const encoded = controller.EncodeWav();
2060
+ if (!encoded) {
2061
+ this.logStatus('🔇 Realtime session produced no audio to record.', true, params);
2062
+ return;
2063
+ }
2064
+ const md = params.provider || this._activeProvider;
2065
+ // Capture-time waveform peaks (max-abs per bucket, normalized 0..1) computed from the
2066
+ // same mixed PCM as the WAV — persisted as a peaks.json sidecar so the player renders the
2067
+ // real waveform without re-decoding the audio. Best-effort: an empty array writes no sidecar.
2068
+ const peaks = controller.GetPeaks();
2069
+ const fileID = await storeRealtimeRecording({
2070
+ Audio: encoded.Buffer,
2071
+ MimeType: 'audio/wav',
2072
+ Media: controller.Media,
2073
+ StartedAt: controller.StartedAt ?? new Date(),
2074
+ StorageAccountID: storageAccountId,
2075
+ SessionID: sessionID,
2076
+ ContextUser: contextUser,
2077
+ Provider: md,
2078
+ Peaks: peaks.length > 0 ? peaks : undefined
2079
+ });
2080
+ if (fileID) {
2081
+ this.logStatus(`🎬 Realtime recording stored (${Math.round(encoded.DurationMs / 1000)}s, file ${fileID}).`, true, params);
2082
+ }
2083
+ }
2084
+ catch (error) {
2085
+ this.logError(`Failed to finalize realtime recording: ${error instanceof Error ? error.message : String(error)}`, {
2086
+ agent: params.agent, category: 'RealtimeSession'
1831
2087
  });
1832
2088
  }
1833
2089
  }
@@ -2093,6 +2349,32 @@ export class BaseAgent {
2093
2349
  this._injectedMemory = result;
2094
2350
  return result;
2095
2351
  }
2352
+ /**
2353
+ * Inject this agent's scoped prompt parts into the conversation, role-faithfully.
2354
+ *
2355
+ * Parallels {@link InjectContextMemory}: resolves `MJ: Scoped Prompt Parts` for the agent's
2356
+ * primary prompt under the run's polymorphic scope (the SAME PrimaryScope/SecondaryScopes the
2357
+ * runtime threads for memory), and unshifts the assembled role-tagged messages onto
2358
+ * `conversationMessages`. In-memory + synchronous (parts are cached on `AIEngine`). No-op when
2359
+ * the agent has no active prompt or no parts resolve for the scope.
2360
+ */
2361
+ InjectScopedPromptParts(agent, conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes) {
2362
+ try {
2363
+ const prompts = AIEngine.Instance.AgentPrompts
2364
+ .filter(ap => UUIDsEqual(ap.AgentID, agent.ID) && ap.Status === 'Active')
2365
+ .sort((a, b) => (a.ExecutionOrder ?? 0) - (b.ExecutionOrder ?? 0));
2366
+ if (prompts.length === 0)
2367
+ return;
2368
+ // Obtain the (possibly downstream-overridden) resolver via the class factory, so any
2369
+ // consumer can plug in custom inclusion/scope logic by subclassing PromptComponentResolver.
2370
+ const resolver = MJGlobal.Instance.ClassFactory.CreateInstance(PromptComponentResolver) ??
2371
+ new PromptComponentResolver();
2372
+ InjectScopedPromptParts(resolver, prompts[0].PromptID, { primaryScopeEntityId, primaryScopeRecordId, secondaryScopes }, conversationMessages);
2373
+ }
2374
+ catch (e) {
2375
+ this.logError(e instanceof Error ? e : new Error(String(e)), { category: 'ScopedPromptParts' });
2376
+ }
2377
+ }
2096
2378
  /**
2097
2379
  * Inject pre-execution RAG context for this agent using scoped search.
2098
2380
  *
@@ -2608,6 +2890,20 @@ export class BaseAgent {
2608
2890
  * @returns
2609
2891
  */
2610
2892
  async validateNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
2893
+ // Plan Mode enforcement: while active and not yet approved, block Actions/Sub-Agent so the
2894
+ // agent cannot skip straight to execution — it must present a Plan first. Chat, Retry,
2895
+ // Skill activation, ForEach/While, and ClientTools are all still allowed (e.g. asking a
2896
+ // clarifying question, or loading a skill's instructions, before forming the plan).
2897
+ if (this._planModeActive && !this._planApproved && (nextStep.step === 'Actions' || nextStep.step === 'Sub-Agent')) {
2898
+ // nextStep.step is narrowed to 'Actions' | 'Sub-Agent' here, so it can never already be
2899
+ // 'Retry' — always increment (we're demoting it to Retry from a non-retry step).
2900
+ this._generalValidationRetryCount++;
2901
+ return {
2902
+ step: 'Retry',
2903
+ terminate: false,
2904
+ 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.'
2905
+ };
2906
+ }
2611
2907
  // for next step, let's do a little quick validation here for sub-agent and actions to ensure requests are valid
2612
2908
  switch (nextStep.step) {
2613
2909
  case 'Sub-Agent':
@@ -2628,6 +2924,15 @@ export class BaseAgent {
2628
2924
  case 'While':
2629
2925
  // While loops are valid - no additional validation needed
2630
2926
  return nextStep;
2927
+ // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
2928
+ // union (it's non-terminal, like 'ClientTools' — see the type's doc comment).
2929
+ case 'Skill':
2930
+ return this.validateSkillNextStep(params, nextStep, currentPayload, agentRun, currentStep);
2931
+ // Type assertion required because 'Plan' is not part of the BaseAgentNextStep step
2932
+ // union (it's non-terminal — the terminal step it produces is 'Chat', see
2933
+ // executePlanStep's doc comment for why).
2934
+ case 'Plan':
2935
+ return this.validatePlanNextStep(params, nextStep, currentPayload, agentRun, currentStep);
2631
2936
  case 'ClientTools':
2632
2937
  // Client tools are valid - execution handled by executeClientToolsStep
2633
2938
  return nextStep;
@@ -2670,7 +2975,7 @@ export class BaseAgent {
2670
2975
  return nextStep.subAgent ? [nextStep.subAgent] : [];
2671
2976
  }
2672
2977
  async validateSubAgentNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
2673
- const curAgentSubAgents = AIEngine.Instance.GetSubAgents(params.agent.ID, 'Active');
2978
+ const curAgentSubAgents = this.getEffectiveSubAgentsForValidation(params.agent.ID);
2674
2979
  // Collect requested sub-agents. Prefer plural `subAgents` (parallel fan-out);
2675
2980
  // fall back to singular `subAgent` for the classic single-sub-agent next step.
2676
2981
  const requested = this.getRequestedSubAgents(nextStep);
@@ -2849,6 +3154,95 @@ export class BaseAgent {
2849
3154
  const agentActions = AIEngine.Instance.AgentActions.filter(aa => UUIDsEqual(aa.AgentID, agentId) && aa.Status === 'Active');
2850
3155
  return ActionEngineServer.Instance.Actions.filter(a => agentActions.some(aa => UUIDsEqual(aa.ActionID, a.ID)) && a.Status === 'Active');
2851
3156
  }
3157
+ /**
3158
+ * Gets the effective sub-agents for validation, using runtime subAgentChanges if available.
3159
+ * Falls back to the database-configured relationship set if _effectiveSubAgents is empty.
3160
+ * Mirrors {@link getEffectiveActionsForValidation}.
3161
+ *
3162
+ * @param agentId - The ID of the agent to get sub-agents for
3163
+ * @returns Array of effective sub-agents available to the agent
3164
+ * @protected
3165
+ */
3166
+ getEffectiveSubAgentsForValidation(agentId) {
3167
+ if (this._effectiveSubAgents.length > 0) {
3168
+ return this._effectiveSubAgents;
3169
+ }
3170
+ // Fallback: compute from database configuration (ParentID children + AgentRelationships)
3171
+ return AIEngine.Instance.GetSubAgents(agentId, 'Active');
3172
+ }
3173
+ /**
3174
+ * Validates that the requested skill(s) are known and allowed for this agent (resolved via
3175
+ * {@link AIEngine.GetSkillsForAgent}, which enforces the agent's AcceptsSkills gate + the
3176
+ * catalog/grant Status chain). Subclasses can override to implement custom validation logic.
3177
+ *
3178
+ * Mirrors {@link validateActionsNextStep}'s fuzzy-name-matching UX: an exact case-insensitive
3179
+ * match is tried first, falling back to a CONTAINS match when exactly one candidate matches.
3180
+ *
3181
+ * @protected
3182
+ */
3183
+ async validateSkillNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
3184
+ const requested = nextStep.skillActivations ?? [];
3185
+ if (requested.length === 0) {
3186
+ if (nextStep.step !== 'Retry') {
3187
+ this._generalValidationRetryCount++;
3188
+ }
3189
+ return {
3190
+ step: 'Retry',
3191
+ terminate: false,
3192
+ errorMessage: 'When activating a skill, 1 or more skills must be specified'
3193
+ };
3194
+ }
3195
+ const availableSkills = AIEngine.Instance.GetSkillsForAgent(params.agent, params.contextUser);
3196
+ const missingSkills = requested.filter(req => {
3197
+ const requestedName = req.name.trim().toLowerCase();
3198
+ const exactMatch = availableSkills.find(s => s.Name.trim().toLowerCase() === requestedName);
3199
+ if (exactMatch)
3200
+ return false;
3201
+ const containsMatches = availableSkills.filter(s => s.Name.trim().toLowerCase().includes(requestedName));
3202
+ if (containsMatches.length === 1) {
3203
+ this.logStatus(`Skill name fuzzy matched: '${req.name}' → '${containsMatches[0].Name}'`, true, params);
3204
+ req.name = containsMatches[0].Name;
3205
+ return false;
3206
+ }
3207
+ return true;
3208
+ });
3209
+ if (missingSkills.length > 0) {
3210
+ const missingNames = missingSkills.map(s => s.name).join(', ');
3211
+ const availableNames = availableSkills.map(s => s.Name).join(', ') || '(none)';
3212
+ this.logError(`Skill(s) '${missingNames}' not found or not available for agent '${params.agent.Name}'. Available: ${availableNames}`, {
3213
+ agent: params.agent,
3214
+ category: 'SkillExecution'
3215
+ });
3216
+ if (nextStep.step !== 'Retry') {
3217
+ this._generalValidationRetryCount++;
3218
+ }
3219
+ return {
3220
+ step: 'Retry',
3221
+ terminate: false,
3222
+ errorMessage: `Skill(s) '${missingNames}' not found or not available. Available: ${availableNames}`
3223
+ };
3224
+ }
3225
+ return nextStep;
3226
+ }
3227
+ /**
3228
+ * Validates that a 'Plan' next step (Plan Mode) has plan text to present. Subclasses can
3229
+ * override to add additional plan-quality checks (e.g. minimum length, required sections).
3230
+ *
3231
+ * @protected
3232
+ */
3233
+ async validatePlanNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
3234
+ if (!nextStep.planDetails?.plan || nextStep.planDetails.plan.trim().length === 0) {
3235
+ if (nextStep.step !== 'Retry') {
3236
+ this._generalValidationRetryCount++;
3237
+ }
3238
+ return {
3239
+ step: 'Retry',
3240
+ terminate: false,
3241
+ errorMessage: 'Plan text is required when presenting a Plan for approval'
3242
+ };
3243
+ }
3244
+ return nextStep;
3245
+ }
2852
3246
  /**
2853
3247
  * Validates that the Success next step is valid and can be executed by the current agent. Subclasses can override
2854
3248
  * this method to implement custom validation logic if needed.
@@ -4367,6 +4761,8 @@ The context is now within limits. Please retry your request with the recovered c
4367
4761
  }
4368
4762
  // Store for later validation in executeActionsStep
4369
4763
  this._effectiveActions = activeActions;
4764
+ // Store for later validation in validateSubAgentNextStep (see getEffectiveSubAgentsForValidation)
4765
+ this._effectiveSubAgents = uniqueActiveSubAgents;
4370
4766
  // Agent type prompt params: reuse cached base merge unless a runtime override is present.
4371
4767
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
4372
4768
  let agentTypePromptParams;
@@ -4385,6 +4781,13 @@ The context is now within limits. Please retry your request with the recovered c
4385
4781
  const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
4386
4782
  // Build app context section if provided in extraData
4387
4783
  const appContext = this.buildAppContextSection(extraData);
4784
+ // Skill catalog (name + description only — progressive disclosure). Empty for
4785
+ // AcceptsSkills='None' since GetSkillsForAgent already returns [] in that case.
4786
+ // Filtered by the acting user's Run permission (open-by-default) so the agent is
4787
+ // never even offered a skill the user isn't entitled to — the permission boundary
4788
+ // is enforced at the catalog, not just at activation.
4789
+ const availableSkills = engine.GetSkillsForAgent(agent, _contextUser);
4790
+ const skillsCatalog = this.formatSkillsCatalog(availableSkills);
4388
4791
  const contextData = {
4389
4792
  agentName: agent.Name,
4390
4793
  agentDescription: agent.Description,
@@ -4394,6 +4797,10 @@ The context is now within limits. Please retry your request with the recovered c
4394
4797
  actionCount: activeActions.length,
4395
4798
  actionDetails: actionDetails,
4396
4799
  clientToolDetails: clientToolDetails,
4800
+ skillCount: availableSkills.length,
4801
+ skillsCatalog: skillsCatalog,
4802
+ planModeActive: this._planModeActive,
4803
+ planApproved: this._planApproved,
4397
4804
  appContext: appContext,
4398
4805
  };
4399
4806
  // Build the final result with __agentTypePromptParams injected
@@ -4925,6 +5332,17 @@ The context is now within limits. Please retry your request with the recovered c
4925
5332
  return line;
4926
5333
  }).join('\n');
4927
5334
  }
5335
+ /**
5336
+ * Formats the skill CATALOG as compact markdown — name + description ONLY. This is
5337
+ * progressive disclosure by design: the LLM sees just enough to decide whether to activate a
5338
+ * skill (via a 'Skill' next step), but never sees `Instructions` until it does. Instructions
5339
+ * are appended separately in {@link buildSkillActivationMessage} on activation.
5340
+ *
5341
+ * @private
5342
+ */
5343
+ formatSkillsCatalog(skills) {
5344
+ return skills.map(s => `- **${s.Name}** — ${s.Description ?? '(no description)'}`).join('\n');
5345
+ }
4928
5346
  /**
4929
5347
  * Utility method to get agent prompt parameters for a given agent. This gets the
4930
5348
  * highest priority prompt for the agent, and then gets the parameters for that
@@ -5000,44 +5418,41 @@ The context is now within limits. Please retry your request with the recovered c
5000
5418
  /**
5001
5419
  * Build the client tool prompt section for system prompt injection.
5002
5420
  *
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)
5421
+ * Resolution is delegated to the shared, tier-agnostic {@link ResolveClientTools}
5422
+ * (`@memberjunction/ai-core-plus`) — the single source of truth used by the async
5423
+ * path (here), the realtime co-agent broker, and the conversations runtime. Tiers,
5424
+ * highest precedence first:
5425
+ *
5426
+ * 1. **override** — tools passed directly in the run's `data.clientTools`
5427
+ * 2. **session (dynamic)** — client-SDK enriched tools from {@link ClientToolRequestManager}
5428
+ * 3. **app** — tools the active surface published in the app-context capability manifest
5429
+ * 4. **static** — the agent's metadata tools from the `AI Agent Client Tools` junction
5430
+ *
5431
+ * NOTE (behavior change): the previous inline merge resolved *static-wins* (metadata
5432
+ * was added first and won name collisions). The unified resolver uses the more-correct
5433
+ * *override > session > app > static* — a runtime/dynamic tool now overrides a stale
5434
+ * static metadata tool of the same name. Collisions are rare in practice.
5007
5435
  */
5008
5436
  buildClientToolPromptSection(agent, extraData) {
5009
- const toolMap = new Map();
5010
- // 1. Metadata tools from junction table (authoritative source)
5437
+ // Static tier — agent's metadata tools from the AI Agent Client Tools junction.
5011
5438
  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)
5439
+ const staticTools = engine.GetClientToolsForAgent(agent.ID).map(tool => ({
5440
+ Name: tool.Name,
5441
+ Description: tool.Description,
5442
+ InputSchema: tool.InputSchemaJSON ? JSON.parse(tool.InputSchemaJSON) : {},
5443
+ OutputSchema: tool.OutputSchemaJSON ? JSON.parse(tool.OutputSchemaJSON) : undefined,
5444
+ Category: tool.Category || undefined,
5445
+ DefaultTimeoutMs: tool.DefaultTimeoutMs || undefined
5446
+ }));
5447
+ // Dynamic (session) tier — client-SDK enriched tools for this session.
5024
5448
  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());
5449
+ const sessionTools = sessionID ? ClientToolRequestManager.Instance.GetSessionTools(sessionID) : [];
5450
+ // App tier — tools the active surface published in the app-context capability manifest.
5451
+ const appContext = extraData?.appContext;
5452
+ const appTools = appContext?.Capabilities?.Tools ?? [];
5453
+ // Override tier — tools passed directly in the run's data.
5454
+ const overrideTools = extraData?.clientTools ?? [];
5455
+ const tools = ResolveClientTools({ agentId: agent.ID, staticTools, sessionTools, appTools, overrideTools });
5041
5456
  if (tools.length === 0) {
5042
5457
  return ''; // No client tools available
5043
5458
  }
@@ -5234,19 +5649,112 @@ The context is now within limits. Please retry your request with the recovered c
5234
5649
  if (typeof value === 'boolean' || typeof value === 'number') {
5235
5650
  return `\`${String(value)}\``;
5236
5651
  }
5237
- let stringValue;
5238
5652
  if (typeof value === 'string') {
5239
- stringValue = value;
5240
- }
5241
- else {
5242
- // Compact JSON (no pretty-printing) for objects/arrays
5243
- stringValue = JSON.stringify(value);
5653
+ // A string param may carry JSON (many actions JSON.stringify their payloads),
5654
+ // SQL/TS code, or plain text. Try structural JSON compression first — it is a
5655
+ // safe no-op on non-JSON (crushParamValue's internal JSON.parse failure is
5656
+ // caught and returns null) — then opt-in AST code reduction (SQL/TS), then pass
5657
+ // through (optionally length-capped).
5658
+ const crushedJson = this.crushParamValue(value);
5659
+ if (crushedJson !== null) {
5660
+ return crushedJson;
5661
+ }
5662
+ const crushedCode = this.crushCodeValue(value);
5663
+ if (crushedCode !== null) {
5664
+ return crushedCode;
5665
+ }
5666
+ return maxLength > 0 && value.length > maxLength ? `${value.substring(0, maxLength)}…` : value;
5667
+ }
5668
+ // Objects/arrays: compact JSON (no pretty-printing), optionally structurally
5669
+ // compressed via context-crush when crushing is enabled and the value is large.
5670
+ const stringValue = JSON.stringify(value);
5671
+ const crushed = this.crushParamValue(stringValue);
5672
+ if (crushed !== null) {
5673
+ return crushed;
5244
5674
  }
5245
5675
  if (maxLength > 0 && stringValue.length > maxLength) {
5246
5676
  return `${stringValue.substring(0, maxLength)}…`;
5247
5677
  }
5248
5678
  return stringValue;
5249
5679
  }
5680
+ /**
5681
+ * Resolve the per-run action-result compression config from the agent-type prompt
5682
+ * params. Crushing is on by default and only disabled when an agent explicitly sets
5683
+ * `crushActionResults: false`, mirroring the `includeXxxDocs` opt-out convention.
5684
+ * @private
5685
+ */
5686
+ resolveActionResultCrush(params) {
5687
+ const agentTypePromptParams = params.data?.__agentTypePromptParams;
5688
+ if (agentTypePromptParams?.crushActionResults === false) {
5689
+ return undefined;
5690
+ }
5691
+ const requestedLang = agentTypePromptParams?.crushCodeLang;
5692
+ const codeLang = requestedLang === 'sql' || requestedLang === 'typescript' ? requestedLang : undefined;
5693
+ return { threshold: BaseAgent.ACTION_RESULT_CRUSH_THRESHOLD, maxChars: undefined, codeLang };
5694
+ }
5695
+ /**
5696
+ * AST-reduce a large code-string action-result value when the agent opted into a code
5697
+ * language (via `crushCodeLang` or a subclass override) and the value clears the size
5698
+ * threshold. Returns reduced code plus a one-line legend, or null to keep the string
5699
+ * verbatim (crushing disabled, too small, or no net saving).
5700
+ *
5701
+ * token optimization via @memberjunction/context-crush (CodeCompressor-inspired)
5702
+ * @private
5703
+ */
5704
+ crushCodeValue(stringValue) {
5705
+ const config = this._actionResultCrush;
5706
+ if (!config || !config.codeLang || stringValue.length < config.threshold) {
5707
+ return null;
5708
+ }
5709
+ // Crushing is a best-effort optimization — it must never break an agent turn. Any
5710
+ // failure falls back to the verbatim value.
5711
+ try {
5712
+ const result = CrushCode(stringValue, config.codeLang);
5713
+ if (result.CrushedChars >= result.OriginalChars) {
5714
+ return null;
5715
+ }
5716
+ const legend = DescribeCrush(result);
5717
+ return legend ? `${result.Text}\n ↳ ${legend}` : result.Text;
5718
+ }
5719
+ catch {
5720
+ return null;
5721
+ }
5722
+ }
5723
+ /**
5724
+ * Structurally compress a JSON action-result value when crushing is enabled for the run
5725
+ * and the value clears the size threshold. Accepts either the `JSON.stringify` of an
5726
+ * object/array param, or a raw string param that itself contains JSON (many actions
5727
+ * stringify their payloads, e.g. `run-adhoc-query`'s `Results`). Returns crushed text
5728
+ * plus a one-line legend, or null when crushing is disabled, the value is too small, the
5729
+ * value isn't valid JSON, or compression wouldn't actually save characters — so callers
5730
+ * fall back to verbatim (and, for strings, to code crushing) behavior.
5731
+ *
5732
+ * token optimization via @memberjunction/context-crush (SmartCrusher-inspired)
5733
+ * @private
5734
+ */
5735
+ crushParamValue(stringValue) {
5736
+ const config = this._actionResultCrush;
5737
+ if (!config || stringValue.length < config.threshold) {
5738
+ return null;
5739
+ }
5740
+ // Crushing is a best-effort optimization — it must never break an agent turn. Any
5741
+ // failure (non-JSON input, pathologically deep payloads) falls back to verbatim.
5742
+ try {
5743
+ // Parse to a plain JSON value. This is the JSON.stringify of an object/array
5744
+ // param, or a raw string param that contains JSON; non-JSON strings throw here
5745
+ // and are caught below (caller then tries code crushing / verbatim).
5746
+ const json = JSON.parse(stringValue);
5747
+ const result = CrushJSON(json, { MaxChars: config.maxChars });
5748
+ if (result.CrushedChars >= result.OriginalChars) {
5749
+ return null; // no net saving — keep the verbatim JSON
5750
+ }
5751
+ const legend = DescribeCrush(result);
5752
+ return legend ? `${result.Text}\n ↳ ${legend}` : result.Text;
5753
+ }
5754
+ catch {
5755
+ return null;
5756
+ }
5757
+ }
5250
5758
  /**
5251
5759
  * Formats a parameter value for display in action execution messages.
5252
5760
  * Truncates long strings and formats objects/arrays for readability.
@@ -5458,6 +5966,9 @@ The context is now within limits. Please retry your request with the recovered c
5458
5966
  }
5459
5967
  // Reset prompt turn counter for this execution
5460
5968
  this._promptTurnCount = 0;
5969
+ // Resolve action-result compression config for this run (default on; opt out via
5970
+ // crushActionResults: false in the agent-type prompt params).
5971
+ this._actionResultCrush = this.resolveActionResultCrush(params);
5461
5972
  // Create MJAIAgentRunEntity
5462
5973
  this._agentRun = await (params.provider || this._activeProvider).GetEntityObject('MJ: AI Agent Runs', params.contextUser);
5463
5974
  this._agentRun.AgentID = params.agent.ID;
@@ -5581,6 +6092,15 @@ The context is now within limits. Please retry your request with the recovered c
5581
6092
  : [params.agent.Name || 'Unknown Agent'];
5582
6093
  this._depth = params.parentDepth !== undefined ? params.parentDepth + 1 : 0;
5583
6094
  this._parentStepCounts = params.parentStepCounts || [];
6095
+ // Resolve Plan Mode gate state for this run (must happen before the main loop starts —
6096
+ // gatherPromptTemplateData/validateNextStep both read _planModeActive/_planApproved — and
6097
+ // after _depth is set above, since the gate only applies to root agents).
6098
+ const planModeGate = await this.resolvePlanModeGate(params);
6099
+ this._planModeActive = planModeGate.active;
6100
+ this._planApproved = planModeGate.approved;
6101
+ // Pre-activate any user-requested skills (from a `/skill-name` composer mention). Must run
6102
+ // after _depth is set (root-only) and after the run is persisted (records a Skill step).
6103
+ await this.preActivateRequestedSkills(params);
5584
6104
  // Reset execution chain and progress tracking
5585
6105
  this._allProgressSteps = [];
5586
6106
  // Update params with the modified payload if auto-populated
@@ -5590,6 +6110,62 @@ The context is now within limits. Please retry your request with the recovered c
5590
6110
  params.payload = modifiedParams.payload;
5591
6111
  }
5592
6112
  }
6113
+ /**
6114
+ * Resolves whether Plan Mode is active for this run, and whether its approval gate is already
6115
+ * satisfied. Called once from {@link initializeAgentRun}, after `_depth` is set.
6116
+ *
6117
+ * - `active`: `agent.SupportsPlanMode` (capability, default ON/opt-out) AND `params.planMode`
6118
+ * (per-request, default OFF) AND this is a root agent (`_depth === 0`). Sub-agents never gate
6119
+ * on Plan Mode — only the top-level agent the user/caller invoked does.
6120
+ * - `approved`: only meaningful when `active`. True when `params.lastRunId` points to a prior
6121
+ * run whose Plan step's `MJ: AI Agent Requests` row resolved to `Approved` or `Responded`
6122
+ * (a `Rejected` plan — or no matching request at all — leaves the gate unsatisfied, sending
6123
+ * the agent back to present a revised plan).
6124
+ *
6125
+ * Override to change Plan Mode eligibility rules (e.g. gate on a specific agent category).
6126
+ *
6127
+ * @protected
6128
+ */
6129
+ async resolvePlanModeGate(params) {
6130
+ const active = !!(params.agent.SupportsPlanMode && params.planMode === true && this._depth === 0);
6131
+ if (!active) {
6132
+ return { active: false, approved: false };
6133
+ }
6134
+ if (!params.lastRunId) {
6135
+ return { active: true, approved: false };
6136
+ }
6137
+ const rv = new RunView();
6138
+ const requestResult = await rv.RunView({
6139
+ EntityName: 'MJ: AI Agent Requests',
6140
+ ExtraFilter: `OriginatingAgentRunID='${params.lastRunId}'`,
6141
+ Fields: ['Status', 'OriginatingAgentRunStepID'],
6142
+ OrderBy: '__mj_CreatedAt DESC',
6143
+ MaxRows: 1,
6144
+ ResultType: 'simple'
6145
+ }, params.contextUser);
6146
+ if (!requestResult.Success || requestResult.Results.length === 0) {
6147
+ return { active: true, approved: false };
6148
+ }
6149
+ const request = requestResult.Results[0];
6150
+ const resolved = request.Status === 'Approved' || request.Status === 'Responded';
6151
+ if (!resolved || !request.OriginatingAgentRunStepID) {
6152
+ return { active: true, approved: false };
6153
+ }
6154
+ // Confirm the request actually originated from a Plan step — a resolved request from an
6155
+ // unrelated Chat clarification (asked before the agent could even form a plan) must NOT
6156
+ // satisfy the Plan Mode gate.
6157
+ const stepResult = await rv.RunView({
6158
+ EntityName: 'MJ: AI Agent Run Steps',
6159
+ ExtraFilter: `ID='${request.OriginatingAgentRunStepID}'`,
6160
+ Fields: ['StepType'],
6161
+ MaxRows: 1,
6162
+ ResultType: 'simple'
6163
+ }, params.contextUser);
6164
+ const approved = stepResult.Success
6165
+ && stepResult.Results.length > 0
6166
+ && stepResult.Results[0].StepType === 'Plan';
6167
+ return { active: true, approved };
6168
+ }
5593
6169
  /**
5594
6170
  * Validates the agent with tracking.
5595
6171
  *
@@ -5666,7 +6242,13 @@ The context is now within limits. Please retry your request with the recovered c
5666
6242
  });
5667
6243
  // Fire-and-forget the 'started' INSERT — the agent flow never blocks on a step save. The queue
5668
6244
  // tracks the INSERT so every later UPDATE (queueStepSave) chains AFTER it commits.
5669
- this._stepSaveQueue.Insert(stepEntity);
6245
+ // When the step has a parent, chain the INSERT AFTER the parent's INSERT to satisfy the
6246
+ // self-referencing FK_AIAgentRunStep_ParentID constraint — without this, a child INSERT that
6247
+ // races the parent INSERT hits an FK violation (especially under large-payload parent INSERTs).
6248
+ const parentStepEntity = params.parentId && this._agentRun?.Steps
6249
+ ? this._agentRun.Steps.find(s => UUIDsEqual(s.ID, params.parentId))
6250
+ : undefined;
6251
+ this._stepSaveQueue.Insert(stepEntity, parentStepEntity);
5670
6252
  // Add the step to the agent run's Steps array
5671
6253
  if (this._agentRun) {
5672
6254
  this._agentRun.Steps.push(stepEntity);
@@ -5941,6 +6523,16 @@ The context is now within limits. Please retry your request with the recovered c
5941
6523
  return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
5942
6524
  case 'Actions':
5943
6525
  return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
6526
+ // Type assertion required because 'Skill' is not part of the BaseAgentNextStep step
6527
+ // union (non-terminal, like 'ClientTools') — LoopAgentType.DetermineNextStep() emits it
6528
+ // when the LLM chooses to activate a skill.
6529
+ case 'Skill':
6530
+ return await this.executeSkillStep(params, config, previousDecision, stepCount);
6531
+ // Type assertion required because 'Plan' is not part of the BaseAgentNextStep step
6532
+ // union — LoopAgentType.DetermineNextStep() emits it when the LLM presents a plan
6533
+ // (Plan Mode). executePlanStep's terminal return is 'Chat'-shaped (see its doc comment).
6534
+ case 'Plan':
6535
+ return await this.executePlanStep(params, previousDecision);
5944
6536
  // Type assertion required because 'ClientTools' is not part of the BaseAgentNextStep
5945
6537
  // step union — LoopAgentType.DetermineNextStep() emits it when the LLM chooses client tools.
5946
6538
  case 'ClientTools':
@@ -8097,6 +8689,315 @@ The context is now within limits. Please retry your request with the recovered c
8097
8689
  });
8098
8690
  return `${header}\n${lines.join('\n')}`;
8099
8691
  }
8692
+ /**
8693
+ * Executes a 'Skill' next step: activates one or more skills the LLM requested by name.
8694
+ * Activating a skill (1) appends its full `Instructions` to the conversation so they take
8695
+ * effect for the remainder of the run, and (2) enables its bundled Actions/sub-agents by
8696
+ * pushing `root`-scoped `add` entries onto `params.actionChanges`/`params.subAgentChanges` —
8697
+ * the same runtime tool-surface-extension mechanism `ExecuteAgentParams` already exposes to
8698
+ * external callers. This is NOT a nested agent run; it never terminates the loop itself.
8699
+ *
8700
+ * Already-activated skills (tracked in `_activatedSkillIDs`) are skipped — re-requesting an
8701
+ * active skill is a harmless no-op rather than re-appending duplicate instructions.
8702
+ *
8703
+ * Decomposed into {@link resolveSkillActivations}, {@link buildSkillActivationMessage},
8704
+ * {@link enableSkillCapabilities}, and {@link recordSkillActivationStep} — override any of
8705
+ * those for fine-grained control (e.g. custom instruction formatting, additional side effects
8706
+ * on activation) without re-implementing the whole step.
8707
+ *
8708
+ * @protected
8709
+ */
8710
+ async executeSkillStep(params, config, previousDecision, stepCount = 0) {
8711
+ const requested = previousDecision.skillActivations ?? [];
8712
+ if (requested.length === 0) {
8713
+ // Nothing to activate — continue with next prompt
8714
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8715
+ }
8716
+ const resolvedSkills = this.resolveSkillActivations(requested, params.agent, params.contextUser);
8717
+ const newlyActivated = resolvedSkills.filter(skill => !this._activatedSkillIDs.some(id => UUIDsEqual(id, skill.ID)));
8718
+ if (newlyActivated.length === 0) {
8719
+ // All requested skills are already active this run — no-op, just continue
8720
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8721
+ }
8722
+ const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
8723
+ for (const skill of newlyActivated) {
8724
+ await this.recordSkillActivationStep(skill, currentPayload, params);
8725
+ this.enableSkillCapabilities(skill, params);
8726
+ this._activatedSkillIDs.push(skill.ID);
8727
+ }
8728
+ const activationMessage = this.buildSkillActivationMessage(newlyActivated);
8729
+ params.conversationMessages.push({
8730
+ role: 'user',
8731
+ content: activationMessage,
8732
+ metadata: {
8733
+ turnAdded: this._promptTurnCount,
8734
+ messageType: 'skill-activation'
8735
+ }
8736
+ });
8737
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
8738
+ }
8739
+ /**
8740
+ * Pre-activates skills the caller explicitly requested via {@link ExecuteAgentParams.requestedSkillIDs}
8741
+ * (typically an end user's `/skill-name` composer mentions), at run start — so their Instructions
8742
+ * and bundled Actions/sub-agents take effect from the first turn rather than waiting for the model
8743
+ * to discover and activate them through the catalog.
8744
+ *
8745
+ * **Root-agent only** (skills never cascade to sub-agents), and each requested skill activates
8746
+ * **only if it survives the guard**: it must be in the set {@link AIEngine.GetSkillsForAgent}
8747
+ * allows for this agent (the AcceptsSkills gate) AND the acting user must have Run permission on it
8748
+ * — both enforced by passing `params.contextUser` to `GetSkillsForAgent`. Requested IDs that fail
8749
+ * either check are silently dropped, so a client can never force-activate a skill the user or agent
8750
+ * isn't entitled to. Reuses the same {@link recordSkillActivationStep} / {@link enableSkillCapabilities}
8751
+ * / {@link buildSkillActivationMessage} machinery as the model-initiated `Skill` step, so activation
8752
+ * is recorded and takes effect identically. Plan Mode is unaffected — pre-activation widens the tool
8753
+ * surface, but the plan-approval gate still blocks executing those tools until the plan is approved.
8754
+ *
8755
+ * @protected
8756
+ */
8757
+ async preActivateRequestedSkills(params) {
8758
+ if (this._depth !== 0) {
8759
+ return; // skills are root-agent only; never pre-activate on sub-agents
8760
+ }
8761
+ const requestedIds = params.requestedSkillIDs;
8762
+ if (!requestedIds || requestedIds.length === 0) {
8763
+ return;
8764
+ }
8765
+ // Guard: intersect the requested IDs with the agent-accepted ∩ user-permitted set.
8766
+ const allowed = AIEngine.Instance.GetSkillsForAgent(params.agent, params.contextUser);
8767
+ const newlyActivated = allowed.filter(s => requestedIds.some(id => UUIDsEqual(id, s.ID)) &&
8768
+ !this._activatedSkillIDs.some(id => UUIDsEqual(id, s.ID)));
8769
+ if (newlyActivated.length === 0) {
8770
+ return;
8771
+ }
8772
+ const currentPayload = params.payload;
8773
+ for (const skill of newlyActivated) {
8774
+ await this.recordSkillActivationStep(skill, currentPayload, params);
8775
+ this.enableSkillCapabilities(skill, params);
8776
+ this._activatedSkillIDs.push(skill.ID);
8777
+ }
8778
+ const activationMessage = this.buildSkillActivationMessage(newlyActivated);
8779
+ if (!params.conversationMessages) {
8780
+ params.conversationMessages = [];
8781
+ }
8782
+ params.conversationMessages.push({
8783
+ role: 'user',
8784
+ content: activationMessage,
8785
+ metadata: {
8786
+ turnAdded: this._promptTurnCount,
8787
+ messageType: 'skill-activation'
8788
+ }
8789
+ });
8790
+ }
8791
+ /**
8792
+ * Resolves the LLM's requested skill names to `MJ: AI Skills` entities, restricted to what
8793
+ * {@link AIEngine.GetSkillsForAgent} allows for this agent (the AcceptsSkills gate + Status
8794
+ * chain). Names that don't resolve are silently dropped here — {@link validateSkillNextStep}
8795
+ * is responsible for rejecting unknown/disallowed names before execution ever reaches this
8796
+ * point, so by the time `executeSkillStep` runs, every requested name is expected to match.
8797
+ *
8798
+ * Override to change resolution semantics (e.g. resolve by ID instead of Name).
8799
+ *
8800
+ * @protected
8801
+ */
8802
+ resolveSkillActivations(requested, agent, contextUser) {
8803
+ const availableSkills = AIEngine.Instance.GetSkillsForAgent(agent, contextUser);
8804
+ const resolved = [];
8805
+ for (const req of requested) {
8806
+ const requestedName = req.name.trim().toLowerCase();
8807
+ const match = availableSkills.find(s => s.Name.trim().toLowerCase() === requestedName);
8808
+ if (match && !resolved.some(s => UUIDsEqual(s.ID, match.ID))) {
8809
+ resolved.push(match);
8810
+ }
8811
+ }
8812
+ return resolved;
8813
+ }
8814
+ /**
8815
+ * Builds the message appended to `conversationMessages` when skill(s) activate — this is what
8816
+ * actually puts each skill's `Instructions` into effect for the rest of the run. Override to
8817
+ * change formatting (e.g. a more compact representation for a high skill-activation-count agent).
8818
+ *
8819
+ * @protected
8820
+ */
8821
+ buildSkillActivationMessage(skills) {
8822
+ const sections = skills.map(s => `## Skill Activated: ${s.Name}\n\n${s.Instructions}`);
8823
+ return `The following skill(s) have been activated. Their instructions are now in effect ` +
8824
+ `for the remainder of this run:\n\n${sections.join('\n\n')}`;
8825
+ }
8826
+ /**
8827
+ * Enables a skill's bundled Actions and sub-agents by pushing `specific`-scoped `add` entries
8828
+ * (targeted at exactly the activating agent's ID) onto `params.actionChanges` /
8829
+ * `params.subAgentChanges`. `specific`/`[agent.ID]` is the correct scope for "apply to THIS
8830
+ * agent, at whatever depth it runs, and never leak to its sub-agents":
8831
+ * - {@link doesChangeScopeApply} returns true only when the running agent's ID is in the list,
8832
+ * so it applies to the activating agent regardless of depth (a sub-agent that activates a
8833
+ * skill still gets its tools — which a `root`-scoped change would NOT do, since `root` means
8834
+ * "the depth-0 agent," not "the current agent").
8835
+ * - {@link filterActionChangesForSubAgent} / {@link filterSubAgentChangesForSubAgent} propagate
8836
+ * `specific` as-is, and each downstream agent checks `includes(itsOwnID)` → false, so the
8837
+ * grant never cascades to sub-agents the activating agent later delegates to.
8838
+ * Because `params` is the same object reference used for the rest of this run, every subsequent
8839
+ * turn's `gatherPromptTemplateData()` call picks up the change automatically — no extra plumbing.
8840
+ *
8841
+ * Override to change propagation scope (e.g. a subclass that wants skill-granted capabilities
8842
+ * to cascade to sub-agents could push `scope: 'all-subagents'` instead).
8843
+ *
8844
+ * @protected
8845
+ */
8846
+ enableSkillCapabilities(skill, params) {
8847
+ const activatingAgentIds = [params.agent.ID];
8848
+ const actionIds = AIEngine.Instance.GetSkillActionIDs(skill.ID);
8849
+ if (actionIds.length > 0) {
8850
+ if (!params.actionChanges) {
8851
+ params.actionChanges = [];
8852
+ }
8853
+ params.actionChanges.push({
8854
+ scope: 'specific',
8855
+ mode: 'add',
8856
+ actionIds,
8857
+ agentIds: activatingAgentIds
8858
+ });
8859
+ }
8860
+ const subAgentIds = AIEngine.Instance.GetSkillSubAgentIDs(skill.ID);
8861
+ if (subAgentIds.length > 0) {
8862
+ if (!params.subAgentChanges) {
8863
+ params.subAgentChanges = [];
8864
+ }
8865
+ params.subAgentChanges.push({
8866
+ scope: 'specific',
8867
+ mode: 'add',
8868
+ subAgentIds,
8869
+ agentIds: activatingAgentIds
8870
+ });
8871
+ }
8872
+ }
8873
+ /**
8874
+ * Creates and immediately finalizes the `AIAgentRunStep` (StepType='Skill') that records this
8875
+ * skill activation for observability/audit. Activation is not itself a failure mode today — it
8876
+ * always finalizes as successful — but subclasses can override to add richer InputData/OutputData
8877
+ * or to make activation conditionally fail (e.g. a licensing check).
8878
+ *
8879
+ * @protected
8880
+ */
8881
+ async recordSkillActivationStep(skill, currentPayload, params) {
8882
+ const stepEntity = await this.createStepEntity({
8883
+ stepType: 'Skill',
8884
+ stepName: `Skill: ${skill.Name}`,
8885
+ targetId: skill.ID,
8886
+ inputData: { skillName: skill.Name },
8887
+ contextUser: params.contextUser,
8888
+ payloadAtStart: currentPayload,
8889
+ payloadAtEnd: currentPayload
8890
+ });
8891
+ await this.finalizeStepEntity(stepEntity, true, undefined, {
8892
+ skillId: skill.ID,
8893
+ skillName: skill.Name
8894
+ });
8895
+ }
8896
+ /**
8897
+ * Executes a 'Plan' next step (Plan Mode): records a `Plan` run-step, raises the standard
8898
+ * `MJ: AI Agent Requests` HITL request with an editable plan-approval `AgentResponseForm`, and
8899
+ * terminates this run awaiting the human's response — reusing the exact same pause/resume
8900
+ * infrastructure `executeChatStep` uses (`createFeedbackRequest` + the existing
8901
+ * `MJAIAgentRequestEntityServer.Save()` auto-resume-on-status-change hook). A rejected or
8902
+ * edited-and-resubmitted plan resumes as a new linked run via the normal run-chain mechanism;
8903
+ * `resolvePlanModeGate` re-checks approval on that new run so a rejection sends the agent back
8904
+ * to present a revised plan rather than through to execution.
8905
+ *
8906
+ * **Important**: the RETURNED `BaseAgentNextStep.step` is `'Chat'`, not `'Plan'` — 'Plan' is
8907
+ * only ever an intermediate classification (used for the `AIAgentRunStep.StepType` audit
8908
+ * record, which the UI reads to render a plan-approval card instead of a generic chat bubble).
8909
+ * The step returned to the framework must stay within `AIAgentRun.FinalStep`'s DB-CHECK-
8910
+ * constrained, terminal-only vocabulary — `'Plan'` is deliberately not part of it (see the
8911
+ * `BaseAgentNextStep.step` doc comment) — so a plan-approval pause is represented as the same
8912
+ * terminal shape `executeChatStep` already uses.
8913
+ *
8914
+ * @protected
8915
+ */
8916
+ async executePlanStep(params, previousDecision) {
8917
+ const planText = previousDecision.planDetails?.plan ?? '';
8918
+ const stepEntity = await this.createStepEntity({
8919
+ stepType: 'Plan',
8920
+ stepName: 'Plan Presented for Approval',
8921
+ contextUser: params.contextUser,
8922
+ inputData: { plan: planText }
8923
+ });
8924
+ await this.finalizeStepEntity(stepEntity, true, undefined, { plan: planText });
8925
+ const responseForm = this.buildPlanApprovalForm(planText);
8926
+ const planPresentation = {
8927
+ step: 'Plan',
8928
+ terminate: true,
8929
+ message: previousDecision.message || 'Please review the proposed plan before I proceed.',
8930
+ reasoning: previousDecision.reasoning,
8931
+ confidence: previousDecision.confidence,
8932
+ responseForm
8933
+ };
8934
+ // For root agents, create a persistent AIAgentRequest so the request is tracked in the
8935
+ // dashboard and can be responded to outside a conversation (mirrors executeChatStep).
8936
+ if (this._depth === 0) {
8937
+ await this.createFeedbackRequest(params, stepEntity, planPresentation);
8938
+ }
8939
+ return {
8940
+ step: 'Chat',
8941
+ terminate: true,
8942
+ message: planPresentation.message,
8943
+ reasoning: previousDecision.reasoning,
8944
+ confidence: previousDecision.confidence,
8945
+ previousPayload: previousDecision.previousPayload,
8946
+ newPayload: previousDecision.newPayload || previousDecision.previousPayload,
8947
+ responseForm
8948
+ };
8949
+ }
8950
+ /**
8951
+ * Builds the editable plan-approval `AgentResponseForm`: the Markdown-rendered plan (with an
8952
+ * Edit toggle so the human can amend it before approving), an optional feedback field that
8953
+ * travels back to the agent with the decision (most useful on Reject — it steers the re-plan),
8954
+ * and an Approve/Reject button group. Override to change the card's layout (e.g. split the
8955
+ * plan into per-step checkboxes instead of one field).
8956
+ *
8957
+ * Approval is a HIGHER-ORDER signal, not just a form reply: conversation hosts detect
8958
+ * `decision === 'approve'` on this form and switch the conversation out of Plan Mode
8959
+ * (see ng-conversations' plan-decision handling), so the follow-up run executes the approved
8960
+ * plan instead of planning again. Rejection keeps Plan Mode on — the agent re-plans with the
8961
+ * feedback in context.
8962
+ *
8963
+ * @protected
8964
+ */
8965
+ buildPlanApprovalForm(planText) {
8966
+ return {
8967
+ title: 'Review Plan',
8968
+ 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.',
8969
+ submitLabel: 'Submit',
8970
+ questions: [
8971
+ {
8972
+ id: 'plan',
8973
+ label: 'Plan',
8974
+ // markdown: agents author plans in Markdown (see the plan-mode prompt
8975
+ // instructions); the UI renders a formatted preview with an Edit toggle.
8976
+ type: { type: 'textarea', markdown: true },
8977
+ defaultValue: planText,
8978
+ required: true
8979
+ },
8980
+ {
8981
+ id: 'reason',
8982
+ label: 'Feedback',
8983
+ type: { type: 'textarea', placeholder: 'Optional — if rejecting, tell the agent what to change and it will re-plan.' },
8984
+ required: false
8985
+ },
8986
+ {
8987
+ id: 'decision',
8988
+ label: 'Decision',
8989
+ type: {
8990
+ type: 'buttongroup',
8991
+ options: [
8992
+ { value: 'approve', label: 'Approve' },
8993
+ { value: 'reject', label: 'Reject' }
8994
+ ]
8995
+ },
8996
+ required: true
8997
+ }
8998
+ ]
8999
+ };
9000
+ }
8100
9001
  async executeChatStep(params, previousDecision) {
8101
9002
  const stepEntity = await this.createStepEntity({ stepType: 'Chat', stepName: 'User Interaction', contextUser: params.contextUser });
8102
9003
  // Chat steps are successful - they indicate a need for user interaction
@@ -9302,6 +10203,13 @@ The context is now within limits. Please retry your request with the recovered c
9302
10203
  async pruneAndCompactExpiredMessages(params, currentTurn) {
9303
10204
  const messagesToCompact = [];
9304
10205
  const messagesToRemove = [];
10206
+ // Cache-aware guard: confine pruning/compaction to the volatile tail so we don't
10207
+ // perturb the provider's KV-cached prompt prefix. The stable prefix is the maximal
10208
+ // contiguous leading run of non-result messages (system/RAG context, injected
10209
+ // memory, the original user request). Expired messages that fall inside that prefix
10210
+ // are deferred — genuine context overflow still reaches them via attemptContextRecovery.
10211
+ // token optimization via @memberjunction/context-crush (CacheAligner-inspired)
10212
+ const { Boundary: stablePrefixBoundary } = PartitionStablePrefix(params.conversationMessages, (msg) => !this.IsVolatileResultMessage(msg));
9305
10213
  // Phase 1: Identify expired messages
9306
10214
  for (let i = 0; i < params.conversationMessages.length; i++) {
9307
10215
  const msg = params.conversationMessages[i];
@@ -9318,6 +10226,12 @@ The context is now within limits. Please retry your request with the recovered c
9318
10226
  const turnsAlive = currentTurn - turnAdded;
9319
10227
  // Check if expired
9320
10228
  if (turnsAlive > msg.metadata.expirationTurns) {
10229
+ // Defer expiry of messages inside the cache-stable prefix to preserve the
10230
+ // provider's cached prompt prefix; overflow recovery handles them if needed.
10231
+ if (i < stablePrefixBoundary) {
10232
+ this.logStatus(`[Turn ${currentTurn}] Deferred expiry of cache-stable prefix message at index ${i}`, true, params);
10233
+ continue;
10234
+ }
9321
10235
  msg.metadata.isExpired = true;
9322
10236
  if (msg.metadata.expirationMode === 'Remove') {
9323
10237
  messagesToRemove.push(i);
@@ -9563,6 +10477,21 @@ The context is now within limits. Please retry your request with the recovered c
9563
10477
  || messageType === 'client-tool-result'
9564
10478
  || messageType === 'tool-result';
9565
10479
  }
10480
+ /**
10481
+ * Returns true if the message is a turn-generated result (action, tool, client tool,
10482
+ * sub-agent, or loop). These are the volatile, expirable messages that accumulate over
10483
+ * turns. Everything else — system/RAG context, injected memory, the original user
10484
+ * request — anchors the cache-stable prompt prefix and is protected from routine pruning.
10485
+ * @protected
10486
+ */
10487
+ IsVolatileResultMessage(msg) {
10488
+ const messageType = msg.metadata?.messageType;
10489
+ return messageType === 'action-result'
10490
+ || messageType === 'client-tool-result'
10491
+ || messageType === 'tool-result'
10492
+ || messageType === 'sub-agent-result'
10493
+ || messageType === 'loop-result';
10494
+ }
9566
10495
  estimateTokens(content, modelName) {
9567
10496
  const text = typeof content === 'string'
9568
10497
  ? content