@memberjunction/ai-agents 5.10.1 → 5.12.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.
@@ -19,7 +19,7 @@ import { ActionEngineServer } from '@memberjunction/actions';
19
19
  import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
20
20
  import { AgentContextInjector } from './agent-context-injector.js';
21
21
  import { RerankerService } from '@memberjunction/ai-reranker';
22
- import { AIPromptParams, ChildPromptParam, ConversationUtility } from '@memberjunction/ai-core-plus';
22
+ import { AIPromptParams, ChildPromptParam, ConversationUtility, parseAssignmentStrategy } from '@memberjunction/ai-core-plus';
23
23
  import { AgentRunner } from './AgentRunner.js';
24
24
  import { PayloadManager } from './PayloadManager.js';
25
25
  import { AgentDataPreloader } from './AgentDataPreloader.js';
@@ -104,6 +104,13 @@ export class BaseAgent {
104
104
  * @private
105
105
  */
106
106
  this._agentRun = null;
107
+ /**
108
+ * Stores the ID of an AIAgentRequest created when a Chat step fires.
109
+ * Populated by executeChatStep(), returned in ExecuteAgentResult.feedbackRequestId.
110
+ * Only set for root agents (depth 0), not sub-agents.
111
+ * @private
112
+ */
113
+ this._feedbackRequestId = null;
107
114
  /**
108
115
  * Agent hierarchy for display purposes (e.g., ["Marketing Agent", "Copywriter Agent"]).
109
116
  * Tracked separately as it's display-only and doesn't need persistence.
@@ -192,6 +199,22 @@ export class BaseAgent {
192
199
  * Storage for injected notes and examples to include in result
193
200
  */
194
201
  this._injectedMemory = { notes: [], examples: [] };
202
+ /**
203
+ * Walks up the agent's category hierarchy looking for an AssignmentStrategy.
204
+ * Loads categories via RunView and caches them for the duration of this run.
205
+ * @private
206
+ */
207
+ this._categoryCache = null;
208
+ /**
209
+ * Determines the request type ID based on the Chat step's context.
210
+ * - If the responseForm has only approve/reject-style buttons, uses "Approval"
211
+ * - If there's a responseForm with fields, uses "Information"
212
+ * - Otherwise defaults to "Information"
213
+ *
214
+ * Caches the request type lookup for the duration of this agent execution.
215
+ * @private
216
+ */
217
+ this._requestTypeCache = null;
195
218
  }
196
219
  /**
197
220
  * Maximum allowed validation retries before forcing failure.
@@ -4402,6 +4425,13 @@ The context is now within limits. Please retry your request with the recovered c
4402
4425
  await this.finalizeStepEntity(stepEntity, promptResult.success, promptResult.success ? undefined : promptResult.errorMessage, outputData);
4403
4426
  // Return based on next step
4404
4427
  if (updatedNextStep.step === 'Chat') {
4428
+ // For root agents, create a persistent AIAgentRequest so the request is
4429
+ // tracked in the dashboard and can be responded to outside a conversation.
4430
+ // This is done here because Chat decisions from executePromptStep terminate
4431
+ // immediately and never reach executeChatStep in the main loop.
4432
+ if (this._depth === 0) {
4433
+ await this.createFeedbackRequest(params, stepEntity, updatedNextStep);
4434
+ }
4405
4435
  return { ...updatedNextStep, terminate: true };
4406
4436
  }
4407
4437
  else if (updatedNextStep.step === 'Success' || updatedNextStep.step === 'Failed') {
@@ -5505,6 +5535,20 @@ The context is now within limits. Please retry your request with the recovered c
5505
5535
  content: resultsMessage,
5506
5536
  metadata: metadata
5507
5537
  });
5538
+ // Surface substantive action result messages as a separate instruction message.
5539
+ // When action messages contain multi-line directives (e.g., "YOUR NEXT ACTION — pick one"),
5540
+ // they get buried inside the JSON results structure above. LLMs treat JSON-wrapped text as
5541
+ // data to acknowledge, not instructions to follow. By re-surfacing these messages as a
5542
+ // standalone user message, the LLM reads them as explicit instructions.
5543
+ const substantiveMessages = actionSummaries
5544
+ .filter(a => a.success && a.message && a.message.includes('\n'))
5545
+ .map(a => a.message);
5546
+ if (substantiveMessages.length > 0) {
5547
+ params.conversationMessages.push({
5548
+ role: 'user',
5549
+ content: `IMPORTANT — Follow these instructions from the action results:\n\n${substantiveMessages.join('\n\n')}`
5550
+ });
5551
+ }
5508
5552
  }
5509
5553
  // Call agent type's post-processing for actions
5510
5554
  let finalPayload = currentPayload;
@@ -5567,12 +5611,22 @@ The context is now within limits. Please retry your request with the recovered c
5567
5611
  * Executes a chat step - these should bubble up to the user for interaction.
5568
5612
  * Chat steps are terminal and indicate the agent needs user input.
5569
5613
  *
5614
+ * For root agents (depth 0), this also creates a persistent AIAgentRequest row
5615
+ * so the request is tracked even when the agent isn't running in a conversation.
5616
+ * The feedbackRequestId is returned in ExecuteAgentResult for callers to use
5617
+ * (e.g., sending notifications, syncing conversation responses).
5618
+ *
5570
5619
  * @private
5571
5620
  */
5572
5621
  async executeChatStep(params, previousDecision) {
5573
5622
  const stepEntity = await this.createStepEntity({ stepType: 'Chat', stepName: 'User Interaction', contextUser: params.contextUser });
5574
5623
  // Chat steps are successful - they indicate a need for user interaction
5575
5624
  await this.finalizeStepEntity(stepEntity, true);
5625
+ // For root agents, create a persistent AIAgentRequest so the request is
5626
+ // tracked in the dashboard and can be responded to outside a conversation.
5627
+ if (this._depth === 0) {
5628
+ await this.createFeedbackRequest(params, stepEntity, previousDecision);
5629
+ }
5576
5630
  return {
5577
5631
  step: 'Chat',
5578
5632
  terminate: true,
@@ -5587,6 +5641,214 @@ The context is now within limits. Please retry your request with the recovered c
5587
5641
  automaticCommands: previousDecision.automaticCommands
5588
5642
  };
5589
5643
  }
5644
+ /**
5645
+ * Creates a persistent AIAgentRequest row when a Chat step fires.
5646
+ * Resolves the target user via the assignment chain:
5647
+ * 1. contextUser (explicit caller)
5648
+ * 2. AgentRun.UserID (run initiator)
5649
+ * 3. Conversation.UserID (conversation fallback)
5650
+ * 4. Agent.OwnerUserID (agent owner)
5651
+ * 5. null (system-level, visible to admins)
5652
+ *
5653
+ * @private
5654
+ */
5655
+ async createFeedbackRequest(params, stepEntity, previousDecision) {
5656
+ try {
5657
+ const requestTypeId = await this.resolveRequestTypeId(previousDecision, params.contextUser);
5658
+ const resolvedStrategy = await this.resolveAssignmentStrategy(params, requestTypeId);
5659
+ const requestForUserId = this.resolveUserFromStrategy(resolvedStrategy, params);
5660
+ const priority = resolvedStrategy?.priority ?? 50;
5661
+ const expirationMinutes = resolvedStrategy?.expirationMinutes;
5662
+ const request = await this._metadata.GetEntityObject('MJ: AI Agent Requests', params.contextUser);
5663
+ request.NewRecord();
5664
+ request.AgentID = params.agent.ID;
5665
+ request.RequestedAt = new Date();
5666
+ request.RequestForUserID = requestForUserId;
5667
+ request.Status = 'Requested';
5668
+ request.Request = previousDecision.message || 'Agent needs user input';
5669
+ request.RequestTypeID = requestTypeId;
5670
+ request.ResponseSchema = previousDecision.responseForm
5671
+ ? JSON.stringify(previousDecision.responseForm)
5672
+ : null;
5673
+ request.Priority = priority;
5674
+ request.OriginatingAgentRunID = this._agentRun?.ID || null;
5675
+ request.OriginatingAgentRunStepID = stepEntity.ID;
5676
+ if (expirationMinutes != null && expirationMinutes > 0) {
5677
+ request.ExpiresAt = new Date(Date.now() + expirationMinutes * 60_000);
5678
+ }
5679
+ const saved = await request.Save();
5680
+ if (saved) {
5681
+ this._feedbackRequestId = request.ID;
5682
+ this.logStatus(`📋 Created feedback request ${request.ID} for user ${requestForUserId || '(system)'}`, true, params);
5683
+ }
5684
+ else {
5685
+ LogError(`Failed to save AIAgentRequest for agent ${params.agent.Name}`);
5686
+ }
5687
+ }
5688
+ catch (error) {
5689
+ // Don't let request creation failure break the agent execution
5690
+ LogError(`Error creating feedback request: ${error.message}`);
5691
+ }
5692
+ }
5693
+ /**
5694
+ * Walks the assignment strategy resolution chain (bottom-up, first-non-null wins):
5695
+ * 1. ExecuteAgentParams.assignmentStrategy (per-invocation)
5696
+ * 2. Agent Type's AssignmentStrategy
5697
+ * 3. Agent's Category AssignmentStrategy (walks up ParentID tree)
5698
+ * 4. Request Type's DefaultAssignmentStrategy
5699
+ * 5. Fallback: null (caller uses contextUser + warning)
5700
+ * @private
5701
+ */
5702
+ async resolveAssignmentStrategy(params, requestTypeId) {
5703
+ // 1. Per-invocation override (highest precedence)
5704
+ if (params.assignmentStrategy) {
5705
+ return params.assignmentStrategy;
5706
+ }
5707
+ // 2. Agent Type's AssignmentStrategy
5708
+ const agentType = AIEngine.Instance.AgentTypes.find(at => UUIDsEqual(at.ID, params.agent.TypeID));
5709
+ const typeStrategy = parseAssignmentStrategy(agentType?.AssignmentStrategy ?? null);
5710
+ if (typeStrategy) {
5711
+ return typeStrategy;
5712
+ }
5713
+ // 3. Agent's Category AssignmentStrategy (walk up ParentID tree)
5714
+ const categoryStrategy = await this.resolveCategoryAssignmentStrategy(params);
5715
+ if (categoryStrategy) {
5716
+ return categoryStrategy;
5717
+ }
5718
+ // 4. Request Type's DefaultAssignmentStrategy
5719
+ if (requestTypeId && this._requestTypeCache) {
5720
+ const requestType = this._requestTypeCache.find(t => UUIDsEqual(t.ID, requestTypeId));
5721
+ if (requestType) {
5722
+ const rtStrategy = parseAssignmentStrategy(requestType.DefaultAssignmentStrategy);
5723
+ if (rtStrategy) {
5724
+ return rtStrategy;
5725
+ }
5726
+ }
5727
+ }
5728
+ return null;
5729
+ }
5730
+ async resolveCategoryAssignmentStrategy(params) {
5731
+ const categoryId = params.agent.CategoryID;
5732
+ if (!categoryId)
5733
+ return null;
5734
+ try {
5735
+ // Load all categories if not cached
5736
+ if (!this._categoryCache) {
5737
+ const rv = new RunView();
5738
+ const result = await rv.RunView({
5739
+ EntityName: 'MJ: AI Agent Categories',
5740
+ Fields: ['ID', 'ParentID', 'AssignmentStrategy'],
5741
+ ResultType: 'simple'
5742
+ }, params.contextUser);
5743
+ this._categoryCache = result.Success ? result.Results : [];
5744
+ }
5745
+ // Walk up the tree from the agent's category to the root
5746
+ let currentId = categoryId;
5747
+ const visited = new Set(); // prevent infinite loops
5748
+ while (currentId && !visited.has(currentId)) {
5749
+ visited.add(currentId);
5750
+ const cat = this._categoryCache.find(c => UUIDsEqual(c.ID, currentId));
5751
+ if (!cat)
5752
+ break;
5753
+ const strategy = parseAssignmentStrategy(cat.AssignmentStrategy);
5754
+ if (strategy)
5755
+ return strategy;
5756
+ currentId = cat.ParentID;
5757
+ }
5758
+ }
5759
+ catch (error) {
5760
+ LogError(`Error resolving category assignment strategy: ${error.message}`);
5761
+ }
5762
+ return null;
5763
+ }
5764
+ /**
5765
+ * Resolves the target user ID from a resolved assignment strategy.
5766
+ * For simple strategies (RunUser, AgentOwner, SpecificUser), resolves immediately.
5767
+ * For List/SharedInbox strategies, delegates to list-based resolution.
5768
+ * Falls back to contextUser with a warning if no strategy is provided.
5769
+ * @private
5770
+ */
5771
+ resolveUserFromStrategy(strategy, params) {
5772
+ if (!strategy) {
5773
+ // No strategy resolved anywhere — fall back to contextUser + warning
5774
+ if (params.contextUser?.ID) {
5775
+ LogStatus(`⚠️ No assignment strategy configured for agent ${params.agent.Name}; defaulting to context user`);
5776
+ return params.contextUser.ID;
5777
+ }
5778
+ LogStatus(`⚠️ No assignment strategy and no context user for agent ${params.agent.Name}; request will be unassigned`);
5779
+ return null;
5780
+ }
5781
+ switch (strategy.type) {
5782
+ case 'RunUser':
5783
+ return params.contextUser?.ID
5784
+ ?? this._agentRun?.UserID
5785
+ ?? null;
5786
+ case 'AgentOwner':
5787
+ return params.agent.OwnerUserID ?? null;
5788
+ case 'SpecificUser':
5789
+ return strategy.userID ?? null;
5790
+ case 'List':
5791
+ // List-based resolution (RoundRobin, LeastBusy, Random) requires async DB lookups.
5792
+ // For now, fall back to contextUser. Full list resolution is a future enhancement
5793
+ // that will query ListDetail records and track assignment state.
5794
+ LogStatus(`ℹ️ List-based assignment strategy configured but not yet implemented; defaulting to context user`);
5795
+ return params.contextUser?.ID ?? null;
5796
+ case 'SharedInbox':
5797
+ // SharedInbox means "unassigned — anyone in the list can claim it"
5798
+ return null;
5799
+ default:
5800
+ return params.contextUser?.ID ?? null;
5801
+ }
5802
+ }
5803
+ async resolveRequestTypeId(previousDecision, contextUser) {
5804
+ try {
5805
+ // Load request types if not cached
5806
+ if (!this._requestTypeCache) {
5807
+ const rv = new RunView();
5808
+ const result = await rv.RunView({
5809
+ EntityName: 'MJ: AI Agent Request Types',
5810
+ ResultType: 'entity_object'
5811
+ }, contextUser);
5812
+ this._requestTypeCache = result.Success ? result.Results : [];
5813
+ }
5814
+ // Determine type name based on responseForm content
5815
+ let typeName = 'Information'; // default
5816
+ if (previousDecision.responseForm) {
5817
+ typeName = this.detectRequestTypeName(previousDecision.responseForm);
5818
+ }
5819
+ const matchedType = this._requestTypeCache.find(t => t.Name === typeName);
5820
+ return matchedType?.ID || null;
5821
+ }
5822
+ catch (error) {
5823
+ LogError(`Error resolving request type: ${error.message}`);
5824
+ return null;
5825
+ }
5826
+ }
5827
+ /**
5828
+ * Inspects an AgentResponseForm to determine the best request type name.
5829
+ * Returns "Approval" if the form is a simple two-option approve/reject pattern,
5830
+ * otherwise returns "Information".
5831
+ * @private
5832
+ */
5833
+ detectRequestTypeName(form) {
5834
+ const q = form.questions?.[0];
5835
+ if (!q || form.questions.length !== 1) {
5836
+ return 'Information';
5837
+ }
5838
+ const qType = q.type;
5839
+ if ((qType.type === 'buttongroup' || qType.type === 'radio') && 'options' in qType) {
5840
+ const opts = qType.options;
5841
+ if (opts.length === 2) {
5842
+ const labels = opts.map(o => o.label.toLowerCase());
5843
+ const hasPositive = labels.some(l => l.includes('approv') || l.includes('yes') || l.includes('accept'));
5844
+ const hasNegative = labels.some(l => l.includes('reject') || l.includes('no') || l.includes('deny'));
5845
+ if (hasPositive && hasNegative) {
5846
+ return 'Approval';
5847
+ }
5848
+ }
5849
+ }
5850
+ return 'Information';
5851
+ }
5590
5852
  /**
5591
5853
  * Executes a ForEach loop with actual for loop
5592
5854
  * @private
@@ -6178,6 +6440,10 @@ The context is now within limits. Please retry your request with the recovered c
6178
6440
  // set status to Failed
6179
6441
  this._agentRun.Status = 'Failed';
6180
6442
  }
6443
+ else if (finalStep.step === 'Chat') {
6444
+ // Chat steps mean the agent is waiting for human input
6445
+ this._agentRun.Status = 'AwaitingFeedback';
6446
+ }
6181
6447
  else {
6182
6448
  this._agentRun.Status = 'Completed';
6183
6449
  }
@@ -6216,7 +6482,8 @@ The context is now within limits. Please retry your request with the recovered c
6216
6482
  memoryContext: this._injectedMemory.notes.length > 0 || this._injectedMemory.examples.length > 0
6217
6483
  ? this._injectedMemory
6218
6484
  : undefined,
6219
- mediaOutputs: this._mediaOutputs.length > 0 ? this._mediaOutputs : undefined
6485
+ mediaOutputs: this._mediaOutputs.length > 0 ? this._mediaOutputs : undefined,
6486
+ feedbackRequestId: this._feedbackRequestId || undefined
6220
6487
  };
6221
6488
  }
6222
6489
  /**