@memberjunction/ai-agents 5.22.0 → 5.23.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
  /**
@@ -1673,6 +1674,9 @@ export class BaseAgent {
1673
1674
  case 'While':
1674
1675
  // While loops are valid - no additional validation needed
1675
1676
  return nextStep;
1677
+ case 'ClientTools':
1678
+ // Client tools are valid - execution handled by executeClientToolsStep
1679
+ return nextStep;
1676
1680
  default:
1677
1681
  // if we get here, the next step is not recognized, we can return a retry step
1678
1682
  this.logError(`Invalid next step '${nextStep.step}' for agent '${params.agent.Name}'`, {
@@ -2598,9 +2602,9 @@ export class BaseAgent {
2598
2602
  ? currentStepCount - msg.metadata.turnAdded
2599
2603
  : 0,
2600
2604
  tokens: this.estimateTokens(msg.content),
2601
- isActionResult: msg.metadata?.messageType === 'action-result'
2605
+ isToolResult: this.IsToolResultMessage(msg)
2602
2606
  }))
2603
- .filter(c => c.isActionResult && c.age >= minAge)
2607
+ .filter(c => c.isToolResult && c.age >= minAge)
2604
2608
  .sort((a, b) => b.age - a.age); // Oldest first
2605
2609
  // Remove messages until we've saved enough
2606
2610
  for (const candidate of candidates) {
@@ -2651,10 +2655,10 @@ export class BaseAgent {
2651
2655
  ? currentStepCount - msg.metadata.turnAdded
2652
2656
  : 0,
2653
2657
  tokens: this.estimateTokens(msg.content),
2654
- isActionResult: msg.metadata?.messageType === 'action-result',
2658
+ isToolResult: this.IsToolResultMessage(msg),
2655
2659
  alreadyCompacted: msg.metadata?.wasCompacted === true
2656
2660
  }))
2657
- .filter(c => c.isActionResult && c.age >= minAge && !c.alreadyCompacted)
2661
+ .filter(c => c.isToolResult && c.age >= minAge && !c.alreadyCompacted)
2658
2662
  .sort((a, b) => b.age - a.age); // Oldest first
2659
2663
  for (const candidate of candidates) {
2660
2664
  if (tokensSaved >= tokensToSave)
@@ -2713,10 +2717,10 @@ export class BaseAgent {
2713
2717
  message: msg,
2714
2718
  index: index,
2715
2719
  tokens: this.estimateTokens(msg.content),
2716
- isActionResult: msg.metadata?.messageType === 'action-result',
2720
+ isToolResult: this.IsToolResultMessage(msg),
2717
2721
  alreadyCompacted: msg.metadata?.wasCompacted === true
2718
2722
  }))
2719
- .filter(c => c.isActionResult && !c.alreadyCompacted && c.tokens > 200)
2723
+ .filter(c => c.isToolResult && !c.alreadyCompacted && c.tokens > 200)
2720
2724
  .sort((a, b) => b.tokens - a.tokens); // Largest first
2721
2725
  for (const candidate of candidates) {
2722
2726
  if (tokensSaved >= tokensToSave)
@@ -3004,6 +3008,10 @@ The context is now within limits. Please retry your request with the recovered c
3004
3008
  const agentType = engine.AgentTypes.find(at => UUIDsEqual(at.ID, agent.TypeID));
3005
3009
  const runtimePromptParamOverrides = extraData?.__agentTypePromptParams;
3006
3010
  const agentTypePromptParams = this.buildAgentTypePromptParams(agentType, agent, runtimePromptParamOverrides);
3011
+ // Build client tool details for the prompt
3012
+ const clientToolDetails = this.buildClientToolPromptSection(agent, extraData);
3013
+ // Build app context section if provided in extraData
3014
+ const appContext = this.buildAppContextSection(extraData);
3007
3015
  const contextData = {
3008
3016
  agentName: agent.Name,
3009
3017
  agentDescription: agent.Description,
@@ -3012,6 +3020,8 @@ The context is now within limits. Please retry your request with the recovered c
3012
3020
  subAgentDetails: this.formatSubAgentDetails(uniqueActiveSubAgents),
3013
3021
  actionCount: activeActions.length,
3014
3022
  actionDetails: this.formatActionDetails(activeActions),
3023
+ clientToolDetails: clientToolDetails,
3024
+ appContext: appContext,
3015
3025
  };
3016
3026
  // Build the final result with __agentTypePromptParams injected
3017
3027
  // Note: extraData can override contextData properties, but __agentTypePromptParams
@@ -3023,7 +3033,11 @@ The context is now within limits. Please retry your request with the recovered c
3023
3033
  if (extraData) {
3024
3034
  // Spread extraData but don't let it override __agentTypePromptParams
3025
3035
  // (which was already built with runtime overrides included)
3026
- const { __agentTypePromptParams: _ignored, ...restExtraData } = extraData;
3036
+ // Spread extraData into the result but exclude properties that were already
3037
+ // processed into formatted prompt sections above. Without this exclusion,
3038
+ // the raw objects from extraData would overwrite the formatted markdown strings
3039
+ // in contextData (e.g., appContext object would replace the markdown string).
3040
+ const { __agentTypePromptParams: _ignored, appContext: _ignoredAppContext, ...restExtraData } = extraData;
3027
3041
  return {
3028
3042
  ...result,
3029
3043
  ...restExtraData
@@ -3501,6 +3515,111 @@ The context is now within limits. Please retry your request with the recovered c
3501
3515
  return lines.join('\n');
3502
3516
  }).join('\n\n');
3503
3517
  }
3518
+ /**
3519
+ * Build the client tool prompt section for system prompt injection.
3520
+ *
3521
+ * Tool sources (checked in order, all merged — first registration wins):
3522
+ * 1. Metadata tools from AI Agent Client Tools junction table
3523
+ * 2. Session-level enriched tools from ClientToolRequestManager (set by client SDK)
3524
+ * 3. Tools provided directly in extraData.clientTools (runtime override)
3525
+ */
3526
+ buildClientToolPromptSection(agent, extraData) {
3527
+ const toolMap = new Map();
3528
+ // 1. Metadata tools from junction table (authoritative source)
3529
+ const engine = AIEngine.Instance;
3530
+ const metadataTools = engine.GetClientToolsForAgent(agent.ID);
3531
+ for (const tool of metadataTools) {
3532
+ toolMap.set(tool.Name, {
3533
+ Name: tool.Name,
3534
+ Description: tool.Description,
3535
+ InputSchema: tool.InputSchemaJSON ? JSON.parse(tool.InputSchemaJSON) : {},
3536
+ OutputSchema: tool.OutputSchemaJSON ? JSON.parse(tool.OutputSchemaJSON) : undefined,
3537
+ Category: tool.Category || undefined,
3538
+ DefaultTimeoutMs: tool.DefaultTimeoutMs || undefined
3539
+ });
3540
+ }
3541
+ // 2. Session-level enriched tools (client SDK decorated tools)
3542
+ const sessionID = extraData?.sessionID;
3543
+ if (sessionID) {
3544
+ for (const tool of ClientToolRequestManager.Instance.GetSessionTools(sessionID)) {
3545
+ if (!toolMap.has(tool.Name)) {
3546
+ toolMap.set(tool.Name, tool);
3547
+ }
3548
+ }
3549
+ }
3550
+ // 3. Runtime extraData override
3551
+ if (extraData?.clientTools) {
3552
+ for (const tool of extraData.clientTools) {
3553
+ if (!toolMap.has(tool.Name)) {
3554
+ toolMap.set(tool.Name, tool);
3555
+ }
3556
+ }
3557
+ }
3558
+ const tools = Array.from(toolMap.values());
3559
+ if (tools.length === 0) {
3560
+ return ''; // No client tools available
3561
+ }
3562
+ const lines = [];
3563
+ lines.push('### Client Tools (execute in the user\'s browser)');
3564
+ lines.push('Client tools run in the user\'s browser and interact with the UI. Use these when you need');
3565
+ lines.push('to navigate the user somewhere, display a specific view, switch dashboard tabs, or show');
3566
+ lines.push('records. When you choose client tools, set nextStep.type to "ClientTools".');
3567
+ lines.push('');
3568
+ lines.push('NOTE: Do NOT use client tools for asking the user questions or collecting input.');
3569
+ lines.push('Use the "Chat" step for that. Client tools are for programmatic UI interaction only.');
3570
+ lines.push('');
3571
+ for (const tool of tools) {
3572
+ const categoryTag = tool.Category ? ` [${tool.Category}]` : '';
3573
+ lines.push(`- **${tool.Name}**${categoryTag}: ${tool.Description}`);
3574
+ // Show input parameters from InputSchema
3575
+ const props = tool.InputSchema?.properties;
3576
+ const required = tool.InputSchema?.required;
3577
+ if (props) {
3578
+ const paramParts = Object.entries(props).map(([name, schema]) => {
3579
+ const req = required?.includes(name) ? '\\*' : '';
3580
+ const desc = schema.description ? ` — ${schema.description}` : '';
3581
+ return `\`${name}\`${req}${desc}`;
3582
+ });
3583
+ lines.push(` Inputs: ${paramParts.join(', ')}`);
3584
+ }
3585
+ }
3586
+ return lines.join('\n');
3587
+ }
3588
+ /**
3589
+ * Build the app context section for system prompt injection.
3590
+ * Reads the AppContextSnapshot from extraData.appContext and formats
3591
+ * it as a concise markdown section the LLM can reference.
3592
+ */
3593
+ buildAppContextSection(extraData) {
3594
+ const ctx = extraData?.appContext;
3595
+ if (!ctx)
3596
+ return '';
3597
+ const app = ctx['App'];
3598
+ const activeNav = ctx['ActiveNavItem'];
3599
+ const otherNavs = ctx['OtherNavItems'];
3600
+ const user = ctx['User'];
3601
+ if (!app?.Name)
3602
+ return '';
3603
+ const lines = [];
3604
+ lines.push('### Current Application Context');
3605
+ lines.push(`The user is currently in the **${app.Name}** application${app.Description ? ` — ${app.Description}` : ''}.`);
3606
+ if (activeNav?.Name) {
3607
+ lines.push('');
3608
+ lines.push(`**Active view:** ${activeNav.Name}${activeNav.Description ? ` — ${activeNav.Description}` : ''}${activeNav.ResourceType ? ` (${activeNav.ResourceType})` : ''}`);
3609
+ }
3610
+ if (otherNavs && otherNavs.length > 0) {
3611
+ lines.push('');
3612
+ lines.push('**Other views available in this app:**');
3613
+ for (const nav of otherNavs) {
3614
+ lines.push(`- ${nav.Name}${nav.Description ? ` — ${nav.Description}` : ''}`);
3615
+ }
3616
+ }
3617
+ if (user?.Name) {
3618
+ lines.push('');
3619
+ lines.push(`**User:** ${user.Name}${user.Roles?.length ? ` (Roles: ${user.Roles.join(', ')})` : ''}`);
3620
+ }
3621
+ return lines.join('\n');
3622
+ }
3504
3623
  /**
3505
3624
  * Formats a single action parameter as a compact inline string.
3506
3625
  * Uses \* suffix for required, (array) when IsArray, and only shows
@@ -4247,6 +4366,10 @@ The context is now within limits. Please retry your request with the recovered c
4247
4366
  return await this.processSubAgentStep(params, previousDecision, undefined, undefined, stepCount);
4248
4367
  case 'Actions':
4249
4368
  return await this.executeActionsStep(params, previousDecision, undefined, true, stepCount);
4369
+ // Type assertion required because 'ClientTools' is not yet in the DB StepType value list.
4370
+ // The LoopAgentType.DetermineNextStep() emits this value when the LLM chooses client tools.
4371
+ case 'ClientTools':
4372
+ return await this.executeClientToolsStep(params, config, previousDecision, stepCount);
4250
4373
  case 'Chat':
4251
4374
  return await this.executeChatStep(params, previousDecision);
4252
4375
  case 'Success':
@@ -4620,6 +4743,17 @@ The context is now within limits. Please retry your request with the recovered c
4620
4743
  else if (updatedNextStep.step === 'Success' || updatedNextStep.step === 'Failed') {
4621
4744
  return { ...updatedNextStep, terminate: true };
4622
4745
  }
4746
+ else if (updatedNextStep.step === 'ClientTools') {
4747
+ // ClientTools must return terminate: false so the main loop continues
4748
+ // to the next iteration where executeClientToolsStep actually dispatches
4749
+ // and awaits the tool. The LLM's original terminate intent is preserved in
4750
+ // terminateAfterExecution so executeClientToolsStep can honor it post-execution.
4751
+ return {
4752
+ ...updatedNextStep,
4753
+ terminate: false,
4754
+ terminateAfterExecution: updatedNextStep.terminate
4755
+ };
4756
+ }
4623
4757
  else {
4624
4758
  return { ...updatedNextStep, terminate: false };
4625
4759
  }
@@ -5821,6 +5955,130 @@ The context is now within limits. Please retry your request with the recovered c
5821
5955
  *
5822
5956
  * @private
5823
5957
  */
5958
+ // ================================================================
5959
+ // Client Tools Step Execution
5960
+ // ================================================================
5961
+ /**
5962
+ * Execute client-side tools requested by the agent.
5963
+ * Sends each tool invocation via PubSub, awaits the client's response
5964
+ * (or timeout), then adds results to the conversation and continues.
5965
+ */
5966
+ async executeClientToolsStep(params, config, previousDecision, stepCount = 0) {
5967
+ const clientTools = previousDecision.clientTools ?? [];
5968
+ if (clientTools.length === 0) {
5969
+ // No tools to execute — continue with next prompt
5970
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
5971
+ }
5972
+ if (!params.sessionID) {
5973
+ // No session ID — can't communicate with client
5974
+ const errorMsg = 'Cannot execute client tools: no sessionID provided in ExecuteAgentParams';
5975
+ LogError(errorMsg);
5976
+ params.conversationMessages.push({
5977
+ role: 'user',
5978
+ content: `Client tool execution skipped: ${errorMsg}`
5979
+ });
5980
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
5981
+ }
5982
+ const currentPayload = previousDecision?.newPayload || previousDecision?.previousPayload || params.payload;
5983
+ // Build assistant message describing the tool invocations
5984
+ const toolMessage = clientTools.length === 1
5985
+ ? `I'm invoking the **${clientTools[0].Name}** client tool${clientTools[0].Description ? ` — ${clientTools[0].Description}` : ''}.`
5986
+ : `I'm invoking **${clientTools.length} client tools**:\n\n` +
5987
+ clientTools.map((t, i) => `${i + 1}. **${t.Name}**${t.Description ? ` — ${t.Description}` : ''}`).join('\n');
5988
+ params.conversationMessages.push({
5989
+ role: 'assistant',
5990
+ content: toolMessage
5991
+ });
5992
+ // Report progress
5993
+ params.onProgress?.({
5994
+ step: 'action_execution', // Reuse action_execution step type for progress reporting
5995
+ message: this.formatHierarchicalMessage(toolMessage),
5996
+ metadata: {
5997
+ toolCount: clientTools.length,
5998
+ toolNames: clientTools.map(t => t.Name),
5999
+ stepCount: stepCount + 1,
6000
+ hierarchicalStep: this.buildHierarchicalStep(stepCount + 1, this._parentStepCounts)
6001
+ },
6002
+ displayMode: 'live'
6003
+ });
6004
+ // Resolve default timeout: per-tool > params > agent config > 30s
6005
+ const defaultTimeout = params.clientToolTimeoutMs ?? 30_000;
6006
+ const results = [];
6007
+ const agentRunID = this._agentRun?.ID ?? 'unknown';
6008
+ // Execute tools sequentially (client may not support parallel UI operations)
6009
+ for (const tool of clientTools) {
6010
+ const stepEntity = await this.createStepEntity({
6011
+ // INTENTIONAL: We use 'Actions' as the DB step type because the MJ: AI Agent Run Steps
6012
+ // entity's StepType value list does not yet include 'ClientTools'. A future database
6013
+ // migration will add 'ClientTools' to the allowed values in the StepType CHECK constraint
6014
+ // and CodeGen will regenerate the types. Until then, client tool steps are recorded under
6015
+ // 'Actions' in the run history. The step name ("Client Tool: {name}") distinguishes them.
6016
+ stepType: 'Actions',
6017
+ stepName: `Client Tool: ${tool.Name}`,
6018
+ inputData: { toolName: tool.Name, params: tool.Params },
6019
+ contextUser: params.contextUser,
6020
+ payloadAtStart: currentPayload,
6021
+ payloadAtEnd: currentPayload
6022
+ });
6023
+ const timeoutMs = tool.TimeoutMs ?? defaultTimeout;
6024
+ 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);
6025
+ await this.finalizeStepEntity(stepEntity, response.Success, response.ErrorMessage, { result: response.Result });
6026
+ results.push({
6027
+ ToolName: tool.Name,
6028
+ Success: response.Success,
6029
+ Result: response.Result,
6030
+ ErrorMessage: response.ErrorMessage
6031
+ });
6032
+ }
6033
+ // Format results as conversation message
6034
+ const resultsMarkdown = this.formatClientToolResultsAsMarkdown(results);
6035
+ params.conversationMessages.push({
6036
+ role: 'user',
6037
+ content: resultsMarkdown,
6038
+ metadata: {
6039
+ turnAdded: this._promptTurnCount,
6040
+ messageType: 'client-tool-result'
6041
+ }
6042
+ });
6043
+ // If the LLM already declared taskComplete=true alongside the client tools,
6044
+ // honor that intent now that tools have executed — no need for another LLM call.
6045
+ if (previousDecision.terminateAfterExecution) {
6046
+ return {
6047
+ step: 'Success',
6048
+ terminate: true,
6049
+ message: previousDecision.message || 'Client tools executed successfully.',
6050
+ payloadChangeRequest: previousDecision.payloadChangeRequest,
6051
+ scratchpad: previousDecision.scratchpad,
6052
+ responseForm: previousDecision.responseForm,
6053
+ actionableCommands: previousDecision.actionableCommands,
6054
+ automaticCommands: previousDecision.automaticCommands
6055
+ };
6056
+ }
6057
+ return await this.executePromptStep(params, config, previousDecision, stepCount);
6058
+ }
6059
+ /**
6060
+ * Format client tool results as a compact markdown summary for the conversation.
6061
+ */
6062
+ formatClientToolResultsAsMarkdown(results) {
6063
+ const failedCount = results.filter(r => !r.Success).length;
6064
+ const header = failedCount > 0
6065
+ ? `${failedCount} of ${results.length} client tool(s) failed:`
6066
+ : 'Client tool results:';
6067
+ const lines = results.map(r => {
6068
+ const icon = r.Success ? '✓' : '✗';
6069
+ let line = `${icon} **${r.ToolName}**: ${r.Success ? 'succeeded' : 'failed'}`;
6070
+ if (r.ErrorMessage)
6071
+ line += ` — ${r.ErrorMessage}`;
6072
+ if (r.Success && r.Result != null) {
6073
+ const resultStr = typeof r.Result === 'string' ? r.Result : JSON.stringify(r.Result);
6074
+ if (resultStr.length <= 500) {
6075
+ line += `\n Result: ${resultStr}`;
6076
+ }
6077
+ }
6078
+ return line;
6079
+ });
6080
+ return `${header}\n${lines.join('\n')}`;
6081
+ }
5824
6082
  async executeChatStep(params, previousDecision) {
5825
6083
  const stepEntity = await this.createStepEntity({ stepType: 'Chat', stepName: 'User Interaction', contextUser: params.contextUser });
5826
6084
  // Chat steps are successful - they indicate a need for user interaction
@@ -7165,6 +7423,14 @@ The context is now within limits. Please retry your request with the recovered c
7165
7423
  * @param modelName - Optional model name for accurate tokenization
7166
7424
  * @protected
7167
7425
  */
7426
+ /**
7427
+ * Returns true if the message is a tool result (action or client tool).
7428
+ * Used by recovery strategies to identify compactable result messages.
7429
+ */
7430
+ IsToolResultMessage(msg) {
7431
+ const messageType = msg.metadata?.messageType;
7432
+ return messageType === 'action-result' || messageType === 'client-tool-result';
7433
+ }
7168
7434
  estimateTokens(content, modelName) {
7169
7435
  const text = typeof content === 'string'
7170
7436
  ? content