@memberjunction/ai-agents 5.39.0 → 5.40.1

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.
@@ -292,6 +292,22 @@ export class BaseAgent {
292
292
  * @private
293
293
  */
294
294
  static { this.MAX_CONSECUTIVE_FAILED_STEPS = 10; }
295
+ /**
296
+ * Maximum consecutive *unproductive* retry steps before forcing termination.
297
+ *
298
+ * An unproductive retry is a 'Retry' next-step that carries an errorMessage — i.e. one
299
+ * produced by {@link BaseAgentType.createRetryStep} because the model's output could not
300
+ * be parsed or failed structural validation (e.g. the LLM returned conversational prose
301
+ * instead of the required JSON envelope). These do NOT count as 'Failed' steps, so they
302
+ * bypass {@link MAX_CONSECUTIVE_FAILED_STEPS} entirely and — without this guard — loop
303
+ * until the far-higher absolute iteration cap (effectively forever, burning time and tokens).
304
+ *
305
+ * Legitimate yield/await retries (pipeline / client-tools / sub-agent re-entry) are created
306
+ * via createNextStep('Retry', …) WITHOUT an errorMessage, so they do not increment this
307
+ * counter. Any productive (non-unproductive-retry) step resets it.
308
+ * @private
309
+ */
310
+ static { this.MAX_CONSECUTIVE_UNPRODUCTIVE_RETRIES = 10; }
295
311
  /**
296
312
  * Returns the active metadata provider for this agent run. Subclasses MUST
297
313
  * use this getter (rather than `new Metadata()` or `Metadata.Provider`) so
@@ -1216,6 +1232,7 @@ export class BaseAgent {
1216
1232
  let currentNextStep = null;
1217
1233
  let stepCount = 0;
1218
1234
  let consecutiveFailedSteps = 0;
1235
+ let consecutiveUnproductiveRetries = 0;
1219
1236
  while (continueExecution) {
1220
1237
  // Check for cancellation before each step
1221
1238
  if (params.cancellationToken?.aborted) {
@@ -1255,6 +1272,37 @@ export class BaseAgent {
1255
1272
  else if (nextStep.step !== 'Failed') {
1256
1273
  consecutiveFailedSteps = 0;
1257
1274
  }
1275
+ // Track consecutive *unproductive* retries to prevent infinite loops that the
1276
+ // consecutive-failed-steps net above cannot catch. A model that repeatedly returns
1277
+ // output we can't parse/validate (e.g. conversational prose instead of the required
1278
+ // JSON envelope) yields a stream of 'Retry' steps — never 'Failed' — so the failed-step
1279
+ // counter resets every turn and never trips. Such retries are produced via
1280
+ // createRetryStep(), which always sets an errorMessage; legitimate yield/await retries
1281
+ // (pipeline / client-tools / sub-agent re-entry) carry no errorMessage and are exempt.
1282
+ const isUnproductiveRetry = nextStep.step === 'Retry' && !nextStep.terminate && !!nextStep.errorMessage;
1283
+ if (isUnproductiveRetry) {
1284
+ consecutiveUnproductiveRetries++;
1285
+ if (consecutiveUnproductiveRetries >= BaseAgent.MAX_CONSECUTIVE_UNPRODUCTIVE_RETRIES) {
1286
+ this.logError(`⛔ Agent '${params.agent.Name}' reached maximum consecutive unproductive retries ` +
1287
+ `(${BaseAgent.MAX_CONSECUTIVE_UNPRODUCTIVE_RETRIES}). The model is repeatedly returning output ` +
1288
+ `that cannot be parsed or validated. Forcing termination to prevent infinite loop.`, {
1289
+ agent: params.agent,
1290
+ category: 'ExecutionSafetyNet',
1291
+ metadata: {
1292
+ consecutiveUnproductiveRetries,
1293
+ lastError: nextStep.errorMessage
1294
+ }
1295
+ });
1296
+ nextStep.step = 'Failed';
1297
+ nextStep.terminate = true;
1298
+ nextStep.errorMessage = `Agent terminated after ${consecutiveUnproductiveRetries} consecutive unproductive retries ` +
1299
+ `(model repeatedly returned output that could not be parsed or validated). ` +
1300
+ `Last error: ${nextStep.errorMessage || 'Unknown'}`;
1301
+ }
1302
+ }
1303
+ else {
1304
+ consecutiveUnproductiveRetries = 0;
1305
+ }
1258
1306
  // Check if we should continue or terminate
1259
1307
  if (nextStep.terminate) {
1260
1308
  continueExecution = false;
@@ -5628,9 +5676,13 @@ The context is now within limits. Please retry your request with the recovered c
5628
5676
  }
5629
5677
  });
5630
5678
  // Add assistant message indicating we're executing a sub-agent
5679
+ // Recorded as a `user`-role environment annotation (not an `assistant` turn) for the same
5680
+ // reason as the action record above: the model's real output is the JSON envelope, and
5681
+ // storing framework prose as an `assistant` turn trains strong in-context models to imitate
5682
+ // the prose and drift off the required JSON format. See the note at the action-record push.
5631
5683
  params.conversationMessages.push({
5632
- role: 'assistant',
5633
- content: `I'm delegating this task to the "${subAgentRequest.name}" agent.\n\nReason: ${subAgentRequest.message}`
5684
+ role: 'user',
5685
+ content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
5634
5686
  });
5635
5687
  // Prepare input data for the step
5636
5688
  const inputData = {
@@ -6030,9 +6082,11 @@ The context is now within limits. Please retry your request with the recovered c
6030
6082
  hierarchicalStep: this.buildHierarchicalStep(stepCount + 1, this._parentStepCounts)
6031
6083
  }
6032
6084
  });
6085
+ // `user`-role environment annotation (not an `assistant` turn) — see the note on the
6086
+ // single-delegation push above for why framework prose must not be stored as assistant turns.
6033
6087
  params.conversationMessages.push({
6034
- role: 'assistant',
6035
- content: `I'm delegating this task to the parallel sub-agent "${request.name}".\n\nReason: ${request.message}`
6088
+ role: 'user',
6089
+ content: `[You delegated this task to the parallel sub-agent "${request.name}". Reason: ${request.message}]`
6036
6090
  });
6037
6091
  return { request: request, subAgentEntity, relationship };
6038
6092
  }
@@ -6326,9 +6380,13 @@ The context is now within limits. Please retry your request with the recovered c
6326
6380
  }
6327
6381
  });
6328
6382
  // Add assistant message indicating we're executing a related sub-agent
6383
+ // Recorded as a `user`-role environment annotation (not an `assistant` turn) for the same
6384
+ // reason as the action record above: the model's real output is the JSON envelope, and
6385
+ // storing framework prose as an `assistant` turn trains strong in-context models to imitate
6386
+ // the prose and drift off the required JSON format. See the note at the action-record push.
6329
6387
  params.conversationMessages.push({
6330
- role: 'assistant',
6331
- content: `I'm delegating this task to the "${subAgentRequest.name}" agent.\n\nReason: ${subAgentRequest.message}`
6388
+ role: 'user',
6389
+ content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
6332
6390
  });
6333
6391
  // Prepare input data for the step
6334
6392
  const inputData = {
@@ -6767,12 +6825,23 @@ The context is now within limits. Please retry your request with the recovered c
6767
6825
  },
6768
6826
  displayMode: 'live' // Only show in live mode
6769
6827
  });
6770
- // Build detailed action execution message with parameters using markdown formatting
6771
- // This creates a permanent, lightweight record of what was requested
6828
+ // Build a detailed record of the action(s) invoked, with parameters, in markdown.
6829
+ // This is a permanent, lightweight memory of what was requested.
6830
+ //
6831
+ // IMPORTANT — this record is injected as a `user`-role environment annotation, NOT an
6832
+ // `assistant` turn. The model's actual output is the JSON envelope, but we don't store
6833
+ // that raw JSON; we store this human-readable summary instead. If it were recorded as an
6834
+ // `assistant` turn, then after a few action-heavy turns the model's entire visible
6835
+ // assistant history would be prose like "I'm executing the X action with parameters: …",
6836
+ // and strong in-context learners (e.g. Gemini Flash) imitate that demonstrated pattern
6837
+ // over the system-prompt instruction — drifting into prose and breaking JSON parsing,
6838
+ // which (pre-guardrail) looped forever. Phrasing it in second person under the `user`
6839
+ // role keeps the memory while removing the false assistant-prose exemplar. The
6840
+ // human-facing narration is emitted separately via onProgress above.
6772
6841
  let actionMessage;
6773
6842
  if (actions.length === 1) {
6774
6843
  const aa = actions[0];
6775
- actionMessage = `I'm executing the **${aa.name}** action`;
6844
+ actionMessage = `[You invoked the **${aa.name}** action`;
6776
6845
  // Add parameters if they exist
6777
6846
  if (aa.params && Object.keys(aa.params).length > 0) {
6778
6847
  const paramsList = Object.entries(aa.params)
@@ -6781,14 +6850,14 @@ The context is now within limits. Please retry your request with the recovered c
6781
6850
  return `• **${key}**: ${displayValue}`;
6782
6851
  })
6783
6852
  .join('\n');
6784
- actionMessage += ` with parameters:\n${paramsList}`;
6853
+ actionMessage += ` with parameters:\n${paramsList}\n]`;
6785
6854
  }
6786
6855
  else {
6787
- actionMessage += '.';
6856
+ actionMessage += '.]';
6788
6857
  }
6789
6858
  }
6790
6859
  else {
6791
- actionMessage = `I'm executing **${actions.length} actions** in parallel:\n\n` + actions.map((aa, index) => {
6860
+ actionMessage = `[You invoked **${actions.length} actions** in parallel:\n\n` + actions.map((aa, index) => {
6792
6861
  let actionText = `${index + 1}. **${aa.name}**`;
6793
6862
  // Add parameters if they exist
6794
6863
  if (aa.params && Object.keys(aa.params).length > 0) {
@@ -6801,12 +6870,13 @@ The context is now within limits. Please retry your request with the recovered c
6801
6870
  actionText += `\n${paramsList}`;
6802
6871
  }
6803
6872
  return actionText;
6804
- }).join('\n\n');
6873
+ }).join('\n\n') + '\n]';
6805
6874
  }
6806
6875
  if (addConversationMessage) {
6807
- // Add assistant message (no metadata - this is a permanent record)
6876
+ // Record as a `user`-role environment annotation (no metadata - permanent record).
6877
+ // See the note above on why this is NOT an `assistant` turn.
6808
6878
  params.conversationMessages.push({
6809
- role: 'assistant',
6879
+ role: 'user',
6810
6880
  content: actionMessage
6811
6881
  });
6812
6882
  }