@memberjunction/ai-agents 5.22.0 → 5.24.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.
@@ -24,6 +24,7 @@ import { AgentRunner } from './AgentRunner.js';
24
24
  import { PayloadManager } from './PayloadManager.js';
25
25
  import { ScratchpadManager } from './ScratchpadManager.js';
26
26
  import { AgentDataPreloader } from './AgentDataPreloader.js';
27
+ import { ClientToolRequestManager } from './ClientToolRequestManager.js';
27
28
  import { ConversationMessageResolver } from './utils/ConversationMessageResolver.js';
28
29
  import _ from 'lodash';
29
30
  /**
@@ -831,13 +832,36 @@ export class BaseAgent {
831
832
  this._activeProvider = params.provider || Metadata.Provider;
832
833
  try {
833
834
  this.logStatus(`🤖 Starting execution of agent '${params.agent.Name}'`, true, params);
834
- // Check permissions - user must have run permission or be the owner
835
- const canRun = await AIAgentPermissionHelper.HasPermission(params.agent.ID, params.contextUser, 'run');
836
- if (!canRun) {
837
- const errorMessage = `User ${params.contextUser.Email} does not have permission to run agent '${params.agent.Name}' (ID: ${params.agent.ID})`;
838
- this.logStatus(`🚫 ${errorMessage}`, false, params);
839
- throw new Error(errorMessage);
840
- }
835
+ // =====================================================================================
836
+ // LATENCY OPTIMIZATION (Opt #4): Parallelized initialization sequence.
837
+ //
838
+ // The agent initialization pipeline was originally fully sequential: each operation
839
+ // awaited before the next began, even when there were no data dependencies between
840
+ // them. This added ~150-200ms of unnecessary serial wait time.
841
+ //
842
+ // The restructured pipeline uses 4 phases:
843
+ //
844
+ // PRE-WORK (sync/fast): Parameter wrapping, markup conversion, payload init,
845
+ // cancellation check, payload validation (may exit early).
846
+ //
847
+ // PHASE 1 (parallel): Permission check + Engine init + AgentRun creation.
848
+ // These three are mutually independent. If permission fails, we mark the
849
+ // already-created AgentRun as failed and terminate — the wasted run record
850
+ // is a negligible cost vs. the ~150ms saved by not serializing these.
851
+ //
852
+ // PHASE 2 (parallel): Config load + Data preload + Context memory injection.
853
+ // All three depend on Phase 1 completing (engines loaded, agentRun exists)
854
+ // but NOT on each other. Config load reads from AIEngine's in-memory cache.
855
+ // Data preload fetches agent data sources. Context memory loads notes/examples
856
+ // and injects them into the conversation messages.
857
+ //
858
+ // PHASE 3 (sequential): Agent type initialization — must wait for config from
859
+ // Phase 2 because it needs the resolved agent type and prompt configuration.
860
+ //
861
+ // Original total init time: ~sum of all operations (~400-500ms)
862
+ // Optimized: ~max(Phase1) + max(Phase2) + Phase3 (~200-300ms)
863
+ // =====================================================================================
864
+ // --- PRE-WORK: Fast synchronous setup and early-exit checks ---
841
865
  // Wrap the progress callback to capture all events
842
866
  const wrappedParams = {
843
867
  ...params,
@@ -849,12 +873,20 @@ export class BaseAgent {
849
873
  }
850
874
  // Reset scratchpad for each new execution (ephemeral per run)
851
875
  this._scratchpadManager.Clear();
876
+ // Initialize starting payload — must complete before AgentRun creation since the
877
+ // run record stores the starting payload snapshot.
852
878
  await this.initializeStartingPayload(wrappedParams);
853
879
  // Check for cancellation at start
854
880
  if (params.cancellationToken?.aborted) {
855
881
  this.logStatus(`⚠️ Agent '${params.agent.Name}' execution cancelled before start`, true, params);
856
882
  return await this.createCancelledResult('Cancelled before execution started', params.contextUser);
857
883
  }
884
+ // Handle starting payload validation if configured — may return early with a
885
+ // validation failure result, so we run this before launching expensive parallel work.
886
+ const startingValidationResult = await this.handleStartingPayloadValidation(wrappedParams);
887
+ if (startingValidationResult) {
888
+ return startingValidationResult;
889
+ }
858
890
  // Report initialization progress
859
891
  wrappedParams.onProgress?.({
860
892
  step: 'initialization',
@@ -864,31 +896,44 @@ export class BaseAgent {
864
896
  hierarchicalStep: this.buildHierarchicalStep(0, this._parentStepCounts)
865
897
  }
866
898
  });
867
- // Initialize execution tracking
868
- await this.initializeAgentRun(wrappedParams);
869
- // Reset validation retry counters for this run
899
+ // --- PHASE 1: Permission check + Engine init + AgentRun creation (parallel) ---
900
+ // These three operations have zero data dependencies on each other:
901
+ // - Permission check queries the AIAgentPermission entity
902
+ // - Engine init calls AIEngine.Instance.Config() and ActionEngineServer.Instance.Config()
903
+ // - AgentRun creation inserts a new AIAgentRun record
904
+ //
905
+ // If permission check fails, we mark the AgentRun as failed. This is acceptable:
906
+ // an orphaned "failed" run record is harmless and far cheaper than serializing these
907
+ // three operations (~150ms savings).
908
+ const [canRun] = await Promise.all([
909
+ AIAgentPermissionHelper.HasPermission(params.agent.ID, params.contextUser, 'run'),
910
+ this.initializeEngines(params.contextUser),
911
+ this.initializeAgentRun(wrappedParams)
912
+ ]);
913
+ if (!canRun) {
914
+ // Permission denied — mark the already-created AgentRun as failed so it doesn't
915
+ // appear as a phantom "running" record in the UI.
916
+ const errorMessage = `User ${params.contextUser.Email} does not have permission to run agent '${params.agent.Name}' (ID: ${params.agent.ID})`;
917
+ this.logStatus(`🚫 ${errorMessage}`, false, params);
918
+ if (this._agentRun) {
919
+ this._agentRun.Status = 'Failed';
920
+ this._agentRun.ErrorMessage = errorMessage;
921
+ await this._agentRun.Save();
922
+ }
923
+ throw new Error(errorMessage);
924
+ }
925
+ // Reset per-run state (sync, instant — no parallelization needed)
870
926
  this._validationRetryCount = 0;
871
927
  this._generalValidationRetryCount = 0;
872
928
  this._contextRecoveryAttempts = 0;
873
- // Reset effective actions and dynamic limits for this run
874
929
  this._effectiveActions = [];
875
930
  this._dynamicActionLimits = {};
876
- // Reset media outputs accumulator for this run
877
- // (unified array now includes both promoted media and intercepted binary with refIds)
878
931
  this._mediaOutputs = [];
879
- // Store message lifecycle callback if provided
880
932
  this._messageLifecycleCallback = params.onMessageLifecycle;
881
- // Initialize engines
882
- await this.initializeEngines(params.contextUser);
883
- // Check for cancellation after initialization
933
+ // Check for cancellation after Phase 1
884
934
  if (params.cancellationToken?.aborted) {
885
935
  return await this.createCancelledResult('Cancelled during initialization', params.contextUser);
886
936
  }
887
- // Handle starting payload validation if configured
888
- const startingValidationResult = await this.handleStartingPayloadValidation(wrappedParams);
889
- if (startingValidationResult) {
890
- return startingValidationResult;
891
- }
892
937
  // Report validation progress
893
938
  wrappedParams.onProgress?.({
894
939
  step: 'validation',
@@ -898,34 +943,26 @@ export class BaseAgent {
898
943
  hierarchicalStep: this.buildHierarchicalStep(0, this._parentStepCounts)
899
944
  }
900
945
  });
901
- // Create and track validation step
946
+ // Validate agent — may return early with a failure result. Runs after engines are
947
+ // initialized (Phase 1) since validation may inspect AIEngine metadata.
902
948
  const validationResult = await this.validateAgentWithTracking(params.agent, params.contextUser);
903
949
  if (validationResult)
904
950
  return validationResult;
905
- // Load agent configuration
951
+ // --- PHASE 2: Config load + Data preload + Context memory injection (parallel) ---
952
+ // All three depend on Phase 1 completing (engines initialized, agentRun exists) but
953
+ // have no dependencies on each other:
954
+ // - Config load reads agent type, prompts, and agent prompts from AIEngine's in-memory
955
+ // cache — typically < 5ms but async due to potential Config() refresh.
956
+ // - Data preload fetches agent data sources and creates a tracking step entity.
957
+ // - Context memory resolves scope configuration (pure computation), then loads notes
958
+ // and examples from the DB and injects them into conversation messages.
906
959
  this.logStatus(`📋 Loading configuration for agent '${params.agent.Name}'`, true, params);
907
- const config = await this.loadAgentConfiguration(params.agent);
908
- if (!config.success) {
909
- this.logError(`Failed to load agent configuration: ${config.errorMessage}`, {
910
- agent: params.agent,
911
- category: 'AgentConfiguration'
912
- });
913
- return await this.createFailureResult(config.errorMessage || 'Failed to load agent configuration', params.contextUser);
914
- }
915
- // Preload agent data sources unless disabled
916
- await this.preloadAgentData(wrappedParams);
917
- // now initialize the agent type which gets us the instance setup in our class plus also gets the agent type to initialize
918
- // its state
919
- await this.initializeAgentType(wrappedParams, config);
920
- // Inject context memory (notes and examples) before execution
960
+ // Pre-compute scope configuration for context memory injection (pure computation,
961
+ // no I/O — safe to do before launching the parallel phase).
921
962
  const userId = params.userId || params.contextUser?.ID;
922
963
  const companyId = params.companyId;
923
- // Extract input text from conversation messages (last user message)
924
- const lastUserMessage = params.conversationMessages
925
- .filter(m => m.role === 'user')
926
- .pop();
964
+ const lastUserMessage = params.conversationMessages.filter(m => m.role === 'user').pop();
927
965
  const inputText = lastUserMessage?.content || '';
928
- // Parse agent-level scope config for note/example filtering
929
966
  const scopeConfigJson = params.agent.ScopeConfig;
930
967
  let scopeConfig = null;
931
968
  if (scopeConfigJson) {
@@ -934,11 +971,9 @@ export class BaseAgent {
934
971
  }
935
972
  catch { /* ignore bad JSON */ }
936
973
  }
937
- // Resolve scope params from top-level params or data fallback (for GraphQL callers)
938
974
  const primaryScopeEntityName = params.PrimaryScopeEntityName ?? params.data?.PrimaryScopeEntityName;
939
975
  const primaryScopeRecordId = params.PrimaryScopeRecordID ?? params.data?.PrimaryScopeRecordID;
940
976
  const secondaryScopes = params.SecondaryScopes ?? params.data?.SecondaryScopes;
941
- // Resolve entity name to entity ID for scope filtering
942
977
  let primaryScopeEntityId;
943
978
  if (primaryScopeEntityName) {
944
979
  const primaryEntity = this._metadata.Entities.find(e => e.Name === primaryScopeEntityName);
@@ -946,8 +981,22 @@ export class BaseAgent {
946
981
  primaryScopeEntityId = primaryEntity.ID;
947
982
  }
948
983
  }
949
- // Inject context memory (notes and examples) into conversation messages
950
- await this.InjectContextMemory(typeof inputText === 'string' ? inputText : '', params.agent, userId, companyId, params.contextUser, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, scopeConfig);
984
+ const [config] = await Promise.all([
985
+ this.loadAgentConfiguration(params.agent),
986
+ this.preloadAgentData(wrappedParams),
987
+ this.InjectContextMemory(typeof inputText === 'string' ? inputText : '', params.agent, userId, companyId, params.contextUser, wrappedParams.conversationMessages, primaryScopeEntityId, primaryScopeRecordId, secondaryScopes, scopeConfig)
988
+ ]);
989
+ if (!config.success) {
990
+ this.logError(`Failed to load agent configuration: ${config.errorMessage}`, {
991
+ agent: params.agent,
992
+ category: 'AgentConfiguration'
993
+ });
994
+ return await this.createFailureResult(config.errorMessage || 'Failed to load agent configuration', params.contextUser);
995
+ }
996
+ // --- PHASE 3: Agent type initialization (sequential) ---
997
+ // Must wait for config from Phase 2 because it needs the resolved agent type and
998
+ // prompt configuration to initialize the type-specific state machine.
999
+ await this.initializeAgentType(wrappedParams, config);
951
1000
  // Execute the agent's internal logic with wrapped parameters
952
1001
  this.logStatus(`🚀 Executing agent '${params.agent.Name}' internal logic`, true, params);
953
1002
  const executionResult = await this.executeAgentInternal(wrappedParams, config);
@@ -1673,6 +1722,9 @@ export class BaseAgent {
1673
1722
  case 'While':
1674
1723
  // While loops are valid - no additional validation needed
1675
1724
  return nextStep;
1725
+ case 'ClientTools':
1726
+ // Client tools are valid - execution handled by executeClientToolsStep
1727
+ return nextStep;
1676
1728
  default:
1677
1729
  // if we get here, the next step is not recognized, we can return a retry step
1678
1730
  this.logError(`Invalid next step '${nextStep.step}' for agent '${params.agent.Name}'`, {
@@ -2598,9 +2650,9 @@ export class BaseAgent {
2598
2650
  ? currentStepCount - msg.metadata.turnAdded
2599
2651
  : 0,
2600
2652
  tokens: this.estimateTokens(msg.content),
2601
- isActionResult: msg.metadata?.messageType === 'action-result'
2653
+ isToolResult: this.IsToolResultMessage(msg)
2602
2654
  }))
2603
- .filter(c => c.isActionResult && c.age >= minAge)
2655
+ .filter(c => c.isToolResult && c.age >= minAge)
2604
2656
  .sort((a, b) => b.age - a.age); // Oldest first
2605
2657
  // Remove messages until we've saved enough
2606
2658
  for (const candidate of candidates) {
@@ -2651,10 +2703,10 @@ export class BaseAgent {
2651
2703
  ? currentStepCount - msg.metadata.turnAdded
2652
2704
  : 0,
2653
2705
  tokens: this.estimateTokens(msg.content),
2654
- isActionResult: msg.metadata?.messageType === 'action-result',
2706
+ isToolResult: this.IsToolResultMessage(msg),
2655
2707
  alreadyCompacted: msg.metadata?.wasCompacted === true
2656
2708
  }))
2657
- .filter(c => c.isActionResult && c.age >= minAge && !c.alreadyCompacted)
2709
+ .filter(c => c.isToolResult && c.age >= minAge && !c.alreadyCompacted)
2658
2710
  .sort((a, b) => b.age - a.age); // Oldest first
2659
2711
  for (const candidate of candidates) {
2660
2712
  if (tokensSaved >= tokensToSave)
@@ -2713,10 +2765,10 @@ export class BaseAgent {
2713
2765
  message: msg,
2714
2766
  index: index,
2715
2767
  tokens: this.estimateTokens(msg.content),
2716
- isActionResult: msg.metadata?.messageType === 'action-result',
2768
+ isToolResult: this.IsToolResultMessage(msg),
2717
2769
  alreadyCompacted: msg.metadata?.wasCompacted === true
2718
2770
  }))
2719
- .filter(c => c.isActionResult && !c.alreadyCompacted && c.tokens > 200)
2771
+ .filter(c => c.isToolResult && !c.alreadyCompacted && c.tokens > 200)
2720
2772
  .sort((a, b) => b.tokens - a.tokens); // Largest first
2721
2773
  for (const candidate of candidates) {
2722
2774
  if (tokensSaved >= tokensToSave)
@@ -3004,6 +3056,10 @@ The context is now within limits. Please retry your request with the recovered c
3004
3056
  const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
3005
3057
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
3006
3058
  const agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
3059
+ // Build client tool details for the prompt
3060
+ const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
3061
+ // Build app context section if provided in extraData
3062
+ const appContext = this.buildAppContextSection(extraData);
3007
3063
  const contextData = {
3008
3064
  agentName: agent.Name,
3009
3065
  agentDescription: agent.Description,
@@ -3012,6 +3068,8 @@ The context is now within limits. Please retry your request with the recovered c
3012
3068
  subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
3013
3069
  actionCount: activeActions.length,
3014
3070
  actionDetails: this.formatActionDetails(activeActions),
3071
+ clientToolDetails: clientToolDetails,
3072
+ appContext: appContext,
3015
3073
  };
3016
3074
  // Build the final result with __agentTypePromptParams injected
3017
3075
  // Note: extraData can override contextData properties, but __agentTypePromptParams
@@ -3023,7 +3081,11 @@ The context is now within limits. Please retry your request with the recovered c
3023
3081
  if (extraData) {
3024
3082
  // Spread extraData but don't let it override __agentTypePromptParams
3025
3083
  // (which was already built with runtime overrides included)
3026
- const { __agentTypePromptParams: _ignored, ...restExtraData } = extraData;
3084
+ // Spread extraData into the result but exclude properties that were already
3085
+ // processed into formatted prompt sections above. Without this exclusion,
3086
+ // the raw objects from extraData would overwrite the formatted markdown strings
3087
+ // in contextData (e.g., appContext object would replace the markdown string).
3088
+ const { __agentTypePromptParams: _ignored, appContext: _ignoredAppContext, ...restExtraData } = extraData;
3027
3089
  return {
3028
3090
  ...result,
3029
3091
  ...restExtraData
@@ -3501,6 +3563,123 @@ The context is now within limits. Please retry your request with the recovered c
3501
3563
  return lines.join('\n');
3502
3564
  }).join('\n\n');
3503
3565
  }
3566
+ /**
3567
+ * Build the client tool prompt section for system prompt injection.
3568
+ *
3569
+ * Tool sources (checked in order, all merged — first registration wins):
3570
+ * 1. Metadata tools from AI Agent Client Tools junction table
3571
+ * 2. Session-level enriched tools from ClientToolRequestManager (set by client SDK)
3572
+ * 3. Tools provided directly in extraData.clientTools (runtime override)
3573
+ */
3574
+ buildClientToolPromptSection(agent, extraData) {
3575
+ const toolMap = new Map();
3576
+ // 1. Metadata tools from junction table (authoritative source)
3577
+ const engine = AIEngine.Instance;
3578
+ const metadataTools = engine.GetClientToolsForAgent(agent.ID);
3579
+ for (const tool of metadataTools) {
3580
+ toolMap.set(tool.Name, {
3581
+ Name: tool.Name,
3582
+ Description: tool.Description,
3583
+ InputSchema: tool.InputSchemaJSON ? JSON.parse(tool.InputSchemaJSON) : {},
3584
+ OutputSchema: tool.OutputSchemaJSON ? JSON.parse(tool.OutputSchemaJSON) : undefined,
3585
+ Category: tool.Category || undefined,
3586
+ DefaultTimeoutMs: tool.DefaultTimeoutMs || undefined
3587
+ });
3588
+ }
3589
+ // 2. Session-level enriched tools (client SDK decorated tools)
3590
+ const sessionID = extraData?.sessionID;
3591
+ if (sessionID) {
3592
+ for (const tool of ClientToolRequestManager.Instance.GetSessionTools(sessionID)) {
3593
+ if (!toolMap.has(tool.Name)) {
3594
+ toolMap.set(tool.Name, tool);
3595
+ }
3596
+ }
3597
+ }
3598
+ // 3. Runtime extraData override
3599
+ if (extraData?.clientTools) {
3600
+ for (const tool of extraData.clientTools) {
3601
+ if (!toolMap.has(tool.Name)) {
3602
+ toolMap.set(tool.Name, tool);
3603
+ }
3604
+ }
3605
+ }
3606
+ const tools = Array.from(toolMap.values());
3607
+ if (tools.length === 0) {
3608
+ return ''; // No client tools available
3609
+ }
3610
+ const lines = [];
3611
+ lines.push('### Client Tools (execute in the user\'s browser)');
3612
+ lines.push('Client tools run in the user\'s browser and interact with the UI. Use these when you need');
3613
+ lines.push('to navigate the user somewhere, display a specific view, switch dashboard tabs, or show');
3614
+ lines.push('records. When you choose client tools, set nextStep.type to "ClientTools".');
3615
+ lines.push('');
3616
+ lines.push('NOTE: Do NOT use client tools for asking the user questions or collecting input.');
3617
+ lines.push('Use the "Chat" step for that. Client tools are for programmatic UI interaction only.');
3618
+ lines.push('');
3619
+ for (const tool of tools) {
3620
+ const categoryTag = tool.Category ? ` [${tool.Category}]` : '';
3621
+ lines.push(`- **${tool.Name}**${categoryTag}: ${tool.Description}`);
3622
+ // Show input parameters from InputSchema
3623
+ const props = tool.InputSchema?.properties;
3624
+ const required = tool.InputSchema?.required;
3625
+ if (props) {
3626
+ const paramParts = Object.entries(props).map(([name, schema]) => {
3627
+ const req = required?.includes(name) ? '\\*' : '';
3628
+ const desc = schema.description ? ` — ${schema.description}` : '';
3629
+ return `\`${name}\`${req}${desc}`;
3630
+ });
3631
+ lines.push(` Inputs: ${paramParts.join(', ')}`);
3632
+ }
3633
+ }
3634
+ return lines.join('\n');
3635
+ }
3636
+ /**
3637
+ * Build the app context section for system prompt injection.
3638
+ * Reads the AppContextSnapshot from extraData.appContext and formats
3639
+ * it as a concise markdown section the LLM can reference.
3640
+ */
3641
+ buildAppContextSection(extraData) {
3642
+ const ctx = extraData?.appContext;
3643
+ if (!ctx)
3644
+ return '';
3645
+ const app = ctx['App'];
3646
+ const activeNav = ctx['ActiveNavItem'];
3647
+ const otherNavs = ctx['OtherNavItems'];
3648
+ const user = ctx['User'];
3649
+ if (!app?.Name)
3650
+ return '';
3651
+ const lines = [];
3652
+ lines.push('### Current Application Context');
3653
+ lines.push(`The user is currently in the **${app.Name}** application${app.Description ? ` — ${app.Description}` : ''}.`);
3654
+ if (activeNav?.Name) {
3655
+ lines.push('');
3656
+ lines.push(`**Active view:** ${activeNav.Name}${activeNav.Description ? ` — ${activeNav.Description}` : ''}${activeNav.ResourceType ? ` (${activeNav.ResourceType})` : ''}`);
3657
+ }
3658
+ if (otherNavs && otherNavs.length > 0) {
3659
+ lines.push('');
3660
+ lines.push('**Other views available in this app:**');
3661
+ for (const nav of otherNavs) {
3662
+ lines.push(`- ${nav.Name}${nav.Description ? ` — ${nav.Description}` : ''}`);
3663
+ }
3664
+ }
3665
+ if (user?.Name) {
3666
+ lines.push('');
3667
+ lines.push(`**User:** ${user.Name}${user.Roles?.length ? ` (Roles: ${user.Roles.join(', ')})` : ''}`);
3668
+ }
3669
+ // Additional context reported by the active view/component
3670
+ const dashboardCtx = ctx['AdditionalContext'];
3671
+ if (dashboardCtx && Object.keys(dashboardCtx).length > 0) {
3672
+ lines.push('');
3673
+ lines.push('**Dashboard state:**');
3674
+ for (const [key, value] of Object.entries(dashboardCtx)) {
3675
+ if (value === null || value === undefined)
3676
+ continue;
3677
+ const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
3678
+ lines.push(`- ${key}: ${displayValue}`);
3679
+ }
3680
+ }
3681
+ return lines.join('\n');
3682
+ }
3504
3683
  /**
3505
3684
  * Formats a single action parameter as a compact inline string.
3506
3685
  * Uses \* suffix for required, (array) when IsArray, and only shows
@@ -4247,6 +4426,10 @@ The context is now within limits. Please retry your request with the recovered c
4247
4426
  return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
4248
4427
  case 'Actions':
4249
4428
  return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
4429
+ // Type assertion required because 'ClientTools' is not yet in the DB StepType value list.
4430
+ // The LoopAgentType.DetermineNextStep() emits this value when the LLM chooses client tools.
4431
+ case 'ClientTools':
4432
+ return await this.executeClientToolsStep(params, config, previousDecision, stepCount);
4250
4433
  case 'Chat':
4251
4434
  return await this.executeChatStep(params, previousDecision);
4252
4435
  case 'Success':
@@ -4620,6 +4803,17 @@ The context is now within limits. Please retry your request with the recovered c
4620
4803
  else if (updatedNextStep.step === 'Success' || updatedNextStep.step === 'Failed') {
4621
4804
  return { ...updatedNextStep, terminate: true };
4622
4805
  }
4806
+ else if (updatedNextStep.step === 'ClientTools') {
4807
+ // ClientTools must return terminate: false so the main loop continues
4808
+ // to the next iteration where executeClientToolsStep actually dispatches
4809
+ // and awaits the tool. The LLM's original terminate intent is preserved in
4810
+ // terminateAfterExecution so executeClientToolsStep can honor it post-execution.
4811
+ return {
4812
+ ...updatedNextStep,
4813
+ terminate: false,
4814
+ terminateAfterExecution: updatedNextStep.terminate
4815
+ };
4816
+ }
4623
4817
  else {
4624
4818
  return { ...updatedNextStep, terminate: false };
4625
4819
  }
@@ -5821,6 +6015,130 @@ The context is now within limits. Please retry your request with the recovered c
5821
6015
  *
5822
6016
  * @private
5823
6017
  */
6018
+ // ================================================================
6019
+ // Client Tools Step Execution
6020
+ // ================================================================
6021
+ /**
6022
+ * Execute client-side tools requested by the agent.
6023
+ * Sends each tool invocation via PubSub, awaits the client's response
6024
+ * (or timeout), then adds results to the conversation and continues.
6025
+ */
6026
+ async executeClientToolsStep(params, config, previousDecision, stepCount = 0) {
6027
+ const clientTools = previousDecision.clientTools ?? [];
6028
+ if (clientTools.length === 0) {
6029
+ // No tools to execute — continue with next prompt
6030
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
6031
+ }
6032
+ if (!params.sessionID) {
6033
+ // No session ID — can't communicate with client
6034
+ const errorMsg = 'Cannot execute client tools: no sessionID provided in ExecuteAgentParams';
6035
+ LogError(errorMsg);
6036
+ params.conversationMessages.push({
6037
+ role: 'user',
6038
+ content: `Client tool execution skipped: ${errorMsg}`
6039
+ });
6040
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
6041
+ }
6042
+ const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
6043
+ // Build assistant message describing the tool invocations
6044
+ const toolMessage = clientTools.length === 1
6045
+ ? `I'm invoking the **${clientTools[0].Name}** client tool${clientTools[0].Description ? ` — ${clientTools[0].Description}` : ''}.`
6046
+ : `I'm invoking **${clientTools.length} client tools**:\n\n` +
6047
+ clientTools.map((t, i) => `${i + 1}. **${t.Name}**${t.Description ? ` — ${t.Description}` : ''}`).join('\n');
6048
+ params.conversationMessages.push({
6049
+ role: 'assistant',
6050
+ content: toolMessage
6051
+ });
6052
+ // Report progress
6053
+ params.onProgress?.({
6054
+ step: 'action_execution', // Reuse action_execution step type for progress reporting
6055
+ message: this.formatHierarchicalMessage(toolMessage),
6056
+ metadata: {
6057
+ toolCount: clientTools.length,
6058
+ toolNames: clientTools.map(t => t.Name),
6059
+ stepCount: stepCount + 1,
6060
+ hierarchicalStep: this.buildHierarchicalStep(stepCount + 1, this._parentStepCounts)
6061
+ },
6062
+ displayMode: 'live'
6063
+ });
6064
+ // Resolve default timeout: per-tool > params > agent config > 30s
6065
+ const defaultTimeout = params.clientToolTimeoutMs ?? 30_000;
6066
+ const results = [];
6067
+ const agentRunID = this._agentRun?.ID ?? 'unknown';
6068
+ // Execute tools sequentially (client may not support parallel UI operations)
6069
+ for (const tool of clientTools) {
6070
+ const stepEntity = await this.createStepEntity({
6071
+ // INTENTIONAL: We use 'Actions' as the DB step type because the MJ: AI Agent Run Steps
6072
+ // entity's StepType value list does not yet include 'ClientTools'. A future database
6073
+ // migration will add 'ClientTools' to the allowed values in the StepType CHECK constraint
6074
+ // and CodeGen will regenerate the types. Until then, client tool steps are recorded under
6075
+ // 'Actions' in the run history. The step name ("Client Tool: {name}") distinguishes them.
6076
+ stepType: 'Actions',
6077
+ stepName: `Client Tool: ${tool.Name}`,
6078
+ inputData: { toolName: tool.Name, params: tool.Params },
6079
+ contextUser: params.contextUser,
6080
+ payloadAtStart: currentPayload,
6081
+ payloadAtEnd: currentPayload
6082
+ });
6083
+ const timeoutMs = tool.TimeoutMs ?? defaultTimeout;
6084
+ const response = await ClientToolRequestManager.Instance.RequestClientTool(`ct_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, tool.Name, tool.Params, params.sessionID, agentRunID, timeoutMs, tool.Description);
6085
+ await this.finalizeStepEntity(stepEntity, response.Success, response.ErrorMessage, { result: response.Result });
6086
+ results.push({
6087
+ ToolName: tool.Name,
6088
+ Success: response.Success,
6089
+ Result: response.Result,
6090
+ ErrorMessage: response.ErrorMessage
6091
+ });
6092
+ }
6093
+ // Format results as conversation message
6094
+ const resultsMarkdown = this.formatClientToolResultsAsMarkdown(results);
6095
+ params.conversationMessages.push({
6096
+ role: 'user',
6097
+ content: resultsMarkdown,
6098
+ metadata: {
6099
+ turnAdded: this._promptTurnCount,
6100
+ messageType: 'client-tool-result'
6101
+ }
6102
+ });
6103
+ // If the LLM already declared taskComplete=true alongside the client tools,
6104
+ // honor that intent now that tools have executed — no need for another LLM call.
6105
+ if (previousDecision.terminateAfterExecution) {
6106
+ return {
6107
+ step: 'Success',
6108
+ terminate: true,
6109
+ message: previousDecision.message || 'Client tools executed successfully.',
6110
+ payloadChangeRequest: previousDecision.payloadChangeRequest,
6111
+ scratchpad: previousDecision.scratchpad,
6112
+ responseForm: previousDecision.responseForm,
6113
+ actionableCommands: previousDecision.actionableCommands,
6114
+ automaticCommands: previousDecision.automaticCommands
6115
+ };
6116
+ }
6117
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
6118
+ }
6119
+ /**
6120
+ * Format client tool results as a compact markdown summary for the conversation.
6121
+ */
6122
+ formatClientToolResultsAsMarkdown(results) {
6123
+ const failedCount = results.filter(r => !r.Success).length;
6124
+ const header = failedCount > 0
6125
+ ? `${failedCount} of ${results.length} client tool(s) failed:`
6126
+ : 'Client tool results:';
6127
+ const lines = results.map(r => {
6128
+ const icon = r.Success ? '✓' : '✗';
6129
+ let line = `${icon} **${r.ToolName}**: ${r.Success ? 'succeeded' : 'failed'}`;
6130
+ if (r.ErrorMessage)
6131
+ line += ` — ${r.ErrorMessage}`;
6132
+ if (r.Success && r.Result != null) {
6133
+ const resultStr = typeof r.Result === 'string' ? r.Result : JSON.stringify(r.Result);
6134
+ if (resultStr.length <= 500) {
6135
+ line += `\n Result: ${resultStr}`;
6136
+ }
6137
+ }
6138
+ return line;
6139
+ });
6140
+ return `${header}\n${lines.join('\n')}`;
6141
+ }
5824
6142
  async executeChatStep(params, previousDecision) {
5825
6143
  const stepEntity = await this.createStepEntity({ stepType: 'Chat', stepName: 'User Interaction', contextUser: params.contextUser });
5826
6144
  // Chat steps are successful - they indicate a need for user interaction
@@ -7165,6 +7483,14 @@ The context is now within limits. Please retry your request with the recovered c
7165
7483
  * @param modelName - Optional model name for accurate tokenization
7166
7484
  * @protected
7167
7485
  */
7486
+ /**
7487
+ * Returns true if the message is a tool result (action or client tool).
7488
+ * Used by recovery strategies to identify compactable result messages.
7489
+ */
7490
+ IsToolResultMessage(msg) {
7491
+ const messageType = msg.metadata?.messageType;
7492
+ return messageType === 'action-result' || messageType === 'client-tool-result';
7493
+ }
7168
7494
  estimateTokens(content, modelName) {
7169
7495
  const text = typeof content === 'string'
7170
7496
  ? content