@memberjunction/ai-agents 5.17.0 → 5.18.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.
@@ -22,6 +22,7 @@ import { RerankerService } from '@memberjunction/ai-reranker';
22
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
+ import { ScratchpadManager } from './ScratchpadManager.js';
25
26
  import { AgentDataPreloader } from './AgentDataPreloader.js';
26
27
  import { ConversationMessageResolver } from './utils/ConversationMessageResolver.js';
27
28
  import _ from 'lodash';
@@ -158,6 +159,13 @@ export class BaseAgent {
158
159
  * @private
159
160
  */
160
161
  this._payloadManager = new PayloadManager();
162
+ /**
163
+ * Scratchpad manager for private agent working memory (notes + task list).
164
+ * Instantiated per agent run, garbage collected when the run ends.
165
+ * @private
166
+ * @since 2.46.0
167
+ */
168
+ this._scratchpadManager = new ScratchpadManager();
161
169
  /**
162
170
  * Effective actions available to this agent after applying actionChanges.
163
171
  * Populated during gatherPromptTemplateData() and used for validation in executeActionsStep().
@@ -818,6 +826,8 @@ export class BaseAgent {
818
826
  if (params.convertUIMarkupToPlainText !== false) {
819
827
  this.convertUIMarkupInMessages(wrappedParams.conversationMessages);
820
828
  }
829
+ // Reset scratchpad for each new execution (ephemeral per run)
830
+ this._scratchpadManager.Clear();
821
831
  await this.initializeStartingPayload(wrappedParams);
822
832
  // Check for cancellation at start
823
833
  if (params.cancellationToken?.aborted) {
@@ -1506,6 +1516,16 @@ export class BaseAgent {
1506
1516
  agentId: params.agent.ID,
1507
1517
  agentRunId: this._agentRun?.ID
1508
1518
  });
1519
+ // Inject scratchpad template variables if scratchpad is enabled
1520
+ if (promptParams.data) {
1521
+ const agentTypePromptParams = promptParams.data.__agentTypePromptParams;
1522
+ const scratchpadEnabled = agentTypePromptParams?.includeScratchpadDocs !== false;
1523
+ if (scratchpadEnabled && this._scratchpadManager) {
1524
+ promptParams.data['_SCRATCHPAD_NOTES'] = this._scratchpadManager.GetNotes() || '_(no notes yet)_';
1525
+ promptParams.data['_SCRATCHPAD_TASKS'] = this._scratchpadManager.ToPromptString();
1526
+ promptParams.data['_SCRATCHPAD_TASK_SUMMARY'] = this._scratchpadManager.GetTaskSummary();
1527
+ }
1528
+ }
1509
1529
  // Only set up child prompts if we have a system prompt
1510
1530
  if (systemPrompt) {
1511
1531
  // Setup child prompt parameters
@@ -2879,24 +2899,21 @@ The context is now within limits. Please retry your request with the recovered c
2879
2899
  * @protected
2880
2900
  */
2881
2901
  createActionResultMessage(actions, results) {
2882
- const resultSummary = actions.map((action, index) => {
2902
+ const actionSummaries = actions.map((action, index) => {
2883
2903
  const result = results[index];
2884
- const outputParams = result.Params?.filter((p) => p.Type === 'Output' || p.Type === 'Both') || [];
2904
+ const outputParams = result.Params?.filter(p => p.Type === 'Output' || p.Type === 'Both') || [];
2885
2905
  return {
2886
2906
  actionName: action.name,
2887
2907
  success: result.Success,
2908
+ params: outputParams,
2888
2909
  resultCode: result.Result?.ResultCode || 'N/A',
2889
- message: result.Message || null,
2910
+ message: result.Message || '(no message)',
2890
2911
  aiDirectives: result.AIDirectives,
2891
- outputs: outputParams.reduce((acc, param) => {
2892
- acc[param.Name] = param.Value;
2893
- return acc;
2894
- }, {})
2895
2912
  };
2896
2913
  });
2897
2914
  return {
2898
2915
  role: 'user',
2899
- content: `Action results:\n${JSON.stringify(resultSummary, null, 2)}`
2916
+ content: `Action results:\n${this.formatActionResultsAsMarkdown(actionSummaries)}`
2900
2917
  };
2901
2918
  }
2902
2919
  /**
@@ -2910,7 +2927,7 @@ The context is now within limits. Please retry your request with the recovered c
2910
2927
  createSubAgentResultMessage(subAgent, result) {
2911
2928
  return {
2912
2929
  role: 'user',
2913
- content: `Sub-agent '${subAgent.name}' result:\n${typeof result === 'string' ? result : JSON.stringify(result, null, 2)}`
2930
+ content: this.formatSubAgentResultAsMarkdown(subAgent.name, result)
2914
2931
  };
2915
2932
  }
2916
2933
  /**
@@ -3068,7 +3085,8 @@ The context is now within limits. Please retry your request with the recovered c
3068
3085
  responseForms: true,
3069
3086
  commands: true,
3070
3087
  forEach: true,
3071
- while: true
3088
+ while: true,
3089
+ scratchpad: true
3072
3090
  };
3073
3091
  }
3074
3092
  const responseType = params.includeResponseTypeDefinition;
@@ -3078,7 +3096,8 @@ The context is now within limits. Please retry your request with the recovered c
3078
3096
  { docsFlag: 'includeResponseFormDocs', responseTypeKey: 'responseForms' },
3079
3097
  { docsFlag: 'includeCommandDocs', responseTypeKey: 'commands' },
3080
3098
  { docsFlag: 'includeForEachDocs', responseTypeKey: 'forEach' },
3081
- { docsFlag: 'includeWhileDocs', responseTypeKey: 'while' }
3099
+ { docsFlag: 'includeWhileDocs', responseTypeKey: 'while' },
3100
+ { docsFlag: 'includeScratchpadDocs', responseTypeKey: 'scratchpad' }
3082
3101
  ];
3083
3102
  for (const { docsFlag, responseTypeKey } of alignmentMappings) {
3084
3103
  // Check if the user explicitly set this response type property
@@ -3370,27 +3389,20 @@ The context is now within limits. Please retry your request with the recovered c
3370
3389
  }
3371
3390
  }
3372
3391
  /**
3373
- * Formats sub-agent details for inclusion in prompt context.
3392
+ * Formats sub-agent details as compact markdown for inclusion in prompt context.
3374
3393
  *
3375
3394
  * @param {MJAIAgentEntityExtended[]} subAgents - Array of sub-agent entities
3376
- * @returns {string} JSON formatted string with sub-agent details
3395
+ * @returns {string} Markdown formatted string with sub-agent details
3377
3396
  * @private
3378
3397
  */
3379
3398
  formatSubAgentDetails(subAgents) {
3380
- return JSON.stringify(subAgents.map(sa => {
3381
- const result = {
3382
- Name: sa.Name,
3383
- Description: sa.Description
3384
- };
3399
+ return subAgents.map(sa => {
3400
+ let line = `- **${sa.Name}** — ${sa.Description}`;
3385
3401
  if (sa.ExecutionMode !== 'Sequential') {
3386
- // no need to include these two attributes for sub-agents
3387
- // that are sequential and the order is implied via the array order
3388
- // saves tokens
3389
- result['ExecutionMode'] = sa.ExecutionMode;
3390
- result['ExecutionOrder'] = sa.ExecutionOrder;
3402
+ line += ` _(${sa.ExecutionMode}, order: ${sa.ExecutionOrder})_`;
3391
3403
  }
3392
- return result;
3393
- }), null, 2);
3404
+ return line;
3405
+ }).join('\n');
3394
3406
  }
3395
3407
  /**
3396
3408
  * Utility method to get agent prompt parameters for a given agent. This gets the
@@ -3422,53 +3434,178 @@ The context is now within limits. Please retry your request with the recovered c
3422
3434
  })), null, 2);
3423
3435
  }
3424
3436
  /**
3425
- * Formats action details for inclusion in prompt context.
3437
+ * Formats action details as compact markdown for inclusion in prompt context.
3438
+ * Produces ~75% fewer tokens than the previous JSON format while preserving
3439
+ * all information the LLM needs to invoke actions correctly.
3426
3440
  *
3427
3441
  * @param {MJActionEntityExtended[]} actions - Array of action entities
3428
- * @returns {string} JSON formatted string with comprehensive action details
3442
+ * @returns {string} Markdown formatted string with action details
3429
3443
  * @private
3430
3444
  */
3431
3445
  formatActionDetails(actions) {
3432
- return JSON.stringify(actions.map(action => ({
3433
- Name: action.Name,
3434
- Description: action.Description,
3435
- // Parameters with detailed information
3436
- Parameters: {
3437
- Input: action.Params
3438
- .filter(p => p.Type.trim().toLowerCase() === 'input' || p.Type.trim().toLowerCase() === 'both')
3439
- .map(param => this.formatActionParameter(param)),
3440
- Output: action.Params
3441
- .filter(p => p.Type.trim().toLowerCase() === 'output' || p.Type.trim().toLowerCase() === 'both')
3442
- .map(param => this.formatActionParameter(param))
3443
- },
3444
- // Result codes with detailed information
3445
- ResultCodes: action.ResultCodes.map(rc => ({
3446
- Code: rc.ResultCode,
3447
- IsSuccess: rc.IsSuccess,
3448
- Description: rc.Description || 'No description provided',
3449
- })),
3450
- // Additional metadata
3451
- Category: action.CategoryID ? this.getActionCategoryName(action.CategoryID) : null,
3452
- Status: action.Status
3453
- })), null, 2);
3446
+ return actions.map(action => {
3447
+ const lines = [];
3448
+ lines.push(`### ${action.Name}`);
3449
+ lines.push(action.Description);
3450
+ const inputParams = action.Params
3451
+ .filter(p => {
3452
+ const t = p.Type.trim().toLowerCase();
3453
+ return t === 'input' || t === 'both';
3454
+ });
3455
+ const outputParams = action.Params
3456
+ .filter(p => {
3457
+ const t = p.Type.trim().toLowerCase();
3458
+ return t === 'output' || t === 'both';
3459
+ });
3460
+ if (inputParams.length > 0) {
3461
+ lines.push(`**Input:** ${inputParams.map(p => this.formatActionParameter(p)).join(', ')}`);
3462
+ }
3463
+ if (outputParams.length > 0) {
3464
+ lines.push(`**Output:** ${outputParams.map(p => this.formatActionParameter(p)).join(', ')}`);
3465
+ }
3466
+ if (action.ResultCodes.length > 0) {
3467
+ const rcParts = action.ResultCodes.map(rc => {
3468
+ const marker = rc.IsSuccess ? '✓' : '✗';
3469
+ const desc = rc.Description && rc.Description.toLowerCase() !== rc.ResultCode.toLowerCase()
3470
+ ? ` ${rc.Description}`
3471
+ : '';
3472
+ return `${rc.ResultCode} ${marker}${desc}`;
3473
+ });
3474
+ lines.push(`**Results:** ${rcParts.join(' · ')}`);
3475
+ }
3476
+ return lines.join('\n');
3477
+ }).join('\n\n');
3454
3478
  }
3455
3479
  /**
3456
- * Formats a single action parameter for display.
3480
+ * Formats a single action parameter as a compact inline string.
3481
+ * Uses \* suffix for required, (array) when IsArray, and only shows
3482
+ * ValueType when it is not Scalar/Other.
3457
3483
  *
3458
- * @param {any} param - The action parameter to format
3459
- * @returns {object} Formatted parameter object
3484
+ * @param {MJActionParamEntity} param - The action parameter to format
3485
+ * @returns {string} Compact inline string, e.g. `` `To`\* — Email address ``
3460
3486
  * @private
3461
3487
  */
3462
3488
  formatActionParameter(param) {
3463
- return {
3464
- Name: param.Name,
3465
- Type: param.Type,
3466
- IsRequired: param.IsRequired,
3467
- IsArray: param.IsArray,
3468
- DefaultValue: param.DefaultValue,
3469
- Description: param.Description,
3470
- ValueType: param.ValueType
3471
- };
3489
+ const requiredMarker = param.IsRequired ? '\\*' : '';
3490
+ const parts = [];
3491
+ if (param.IsArray) {
3492
+ parts.push('array');
3493
+ }
3494
+ const vt = param.ValueType?.trim();
3495
+ if (vt && vt !== 'Scalar' && vt !== 'Other') {
3496
+ parts.push(vt);
3497
+ }
3498
+ const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '';
3499
+ let defaultStr = '';
3500
+ if (param.DefaultValue != null && param.DefaultValue !== '') {
3501
+ defaultStr = ` (default: ${JSON.stringify(param.DefaultValue)})`;
3502
+ }
3503
+ const desc = param.Description ? ` — ${param.Description}` : '';
3504
+ return `\`${param.Name}\`${requiredMarker}${suffix}${desc}${defaultStr}`;
3505
+ }
3506
+ /**
3507
+ * Formats an array of action result summaries as compact markdown instead of
3508
+ * pretty-printed JSON. This typically saves 60-70 % of the tokens that the
3509
+ * old `JSON.stringify(actionSummaries, null, 2)` approach consumed, while
3510
+ * remaining equally parseable by LLMs.
3511
+ *
3512
+ * Format per action:
3513
+ * ```
3514
+ * ## ActionName ✓ (or ✗ for failure)
3515
+ * **Result:** RESULT_CODE — human message
3516
+ * **Output:**
3517
+ * • `ParamName`: value
3518
+ * ```
3519
+ *
3520
+ * AI directives are intentionally omitted here because they are surfaced
3521
+ * as a separate high-priority message (see the caller).
3522
+ *
3523
+ * @param actionSummaries - The action summary objects built by executeActionStep
3524
+ * @returns Compact markdown string
3525
+ * @private
3526
+ */
3527
+ formatActionResultsAsMarkdown(actionSummaries) {
3528
+ return actionSummaries.map(a => {
3529
+ const marker = a.success ? '✓' : '✗';
3530
+ const lines = [];
3531
+ lines.push(`## ${a.actionName} ${marker}`);
3532
+ lines.push(`**Result:** ${a.resultCode} — ${a.message || '(no message)'}`);
3533
+ // Format output params as bullet list
3534
+ if (a.params && a.params.length > 0) {
3535
+ lines.push('**Output:**');
3536
+ for (const p of a.params) {
3537
+ lines.push(`• \`${p.Name}\`: ${this.formatParamValueForResult(p.Value)}`);
3538
+ }
3539
+ }
3540
+ return lines.join('\n');
3541
+ }).join('\n\n');
3542
+ }
3543
+ /**
3544
+ * Formats an ExecuteAgentResult as compact markdown for inclusion in
3545
+ * conversation messages. Extracts only the fields that are meaningful
3546
+ * to the calling agent (success, status, error, and payload) and
3547
+ * presents them in a concise format.
3548
+ *
3549
+ * @param subAgentName - Display name of the sub-agent
3550
+ * @param result - The execution result from the sub-agent
3551
+ * @returns Compact markdown string
3552
+ * @private
3553
+ */
3554
+ formatSubAgentResultAsMarkdown(subAgentName, result) {
3555
+ const marker = result.success ? '✓' : '✗';
3556
+ const lines = [];
3557
+ lines.push(`## Sub-agent: ${subAgentName} ${marker}`);
3558
+ // Status / error from the agent run
3559
+ const status = result.agentRun?.Status || (result.success ? 'Completed' : 'Failed');
3560
+ lines.push(`**Status:** ${status}`);
3561
+ if (!result.success && result.agentRun?.ErrorMessage) {
3562
+ lines.push(`**Error:** ${result.agentRun.ErrorMessage}`);
3563
+ }
3564
+ // Payload — the actual result data the parent agent cares about
3565
+ if (result.payload != null) {
3566
+ const payloadStr = typeof result.payload === 'string'
3567
+ ? result.payload
3568
+ : JSON.stringify(result.payload);
3569
+ // For very large payloads, truncate to keep context manageable
3570
+ if (payloadStr.length > 4000) {
3571
+ lines.push(`**Payload** (truncated):\n${payloadStr.substring(0, 4000)}…`);
3572
+ }
3573
+ else {
3574
+ lines.push(`**Payload:**\n${payloadStr}`);
3575
+ }
3576
+ }
3577
+ return lines.join('\n');
3578
+ }
3579
+ /**
3580
+ * Formats a single output parameter value for inclusion in action result
3581
+ * markdown. Scalars are shown inline with backtick formatting; objects and
3582
+ * arrays use compact (single-line) JSON to avoid the indentation overhead
3583
+ * of pretty-printed JSON. Very long values are truncated.
3584
+ *
3585
+ * @param value - The parameter value (any type)
3586
+ * @param maxLength - Maximum character length before truncation (default 500)
3587
+ * @returns Formatted string
3588
+ * @private
3589
+ */
3590
+ formatParamValueForResult(value, maxLength = 500) {
3591
+ if (value === null || value === undefined) {
3592
+ return '`null`';
3593
+ }
3594
+ if (typeof value === 'boolean' || typeof value === 'number') {
3595
+ return `\`${String(value)}\``;
3596
+ }
3597
+ let stringValue;
3598
+ if (typeof value === 'string') {
3599
+ stringValue = value;
3600
+ }
3601
+ else {
3602
+ // Compact JSON (no pretty-printing) for objects/arrays
3603
+ stringValue = JSON.stringify(value);
3604
+ }
3605
+ if (stringValue.length > maxLength) {
3606
+ return `${stringValue.substring(0, maxLength)}…`;
3607
+ }
3608
+ return stringValue;
3472
3609
  }
3473
3610
  /**
3474
3611
  * Formats a parameter value for display in action execution messages.
@@ -4203,7 +4340,8 @@ The context is now within limits. Please retry your request with the recovered c
4203
4340
  const promptToUse = await this.AgentTypeInstance.GetPromptForStep(params, config, previousDecision?.newPayload || previousDecision?.previousPayload, this.AgentTypeState, previousDecision);
4204
4341
  const promptId = promptToUse?.ID;
4205
4342
  const promptName = promptToUse?.Name;
4206
- // Prepare input data for the step
4343
+ // Prepare input data for the step (includes scratchpad snapshot before LLM response)
4344
+ const scratchpadSnapshotBeforeStep = this._scratchpadManager.HasContent() ? this._scratchpadManager.ToJSON() : undefined;
4207
4345
  const inputData = {
4208
4346
  promptId: promptId,
4209
4347
  promptName: promptName,
@@ -4213,6 +4351,7 @@ The context is now within limits. Please retry your request with the recovered c
4213
4351
  instructions: previousDecision.retryInstructions
4214
4352
  } : undefined,
4215
4353
  conversationMessages: params.conversationMessages,
4354
+ ...(scratchpadSnapshotBeforeStep && { scratchpad: scratchpadSnapshotBeforeStep }),
4216
4355
  };
4217
4356
  // Prepare prompt parameters
4218
4357
  const payload = previousDecision?.newPayload || params.payload;
@@ -4376,6 +4515,18 @@ The context is now within limits. Please retry your request with the recovered c
4376
4515
  // Set the final payload - the changeResult already respects the allowed paths
4377
4516
  finalPayload = changeResult.result;
4378
4517
  }
4518
+ // Apply scratchpad changes if provided (zero turn cost — processed inline)
4519
+ if (initialNextStep.scratchpad) {
4520
+ this._scratchpadManager.ApplyScratchpadChanges(initialNextStep.scratchpad);
4521
+ // Enforce task limit using the default (50) — the prompt params were already
4522
+ // used during prompt preparation. We use a simple default here to avoid
4523
+ // re-fetching prompt params just for this value.
4524
+ const maxTasks = 50;
4525
+ const pruned = this._scratchpadManager.EnforceTaskLimit(maxTasks);
4526
+ if (pruned > 0 && (params.verbose === true || IsVerboseLoggingEnabled())) {
4527
+ LogStatus(`Scratchpad: pruned ${pruned} completed tasks (limit: ${maxTasks})`);
4528
+ }
4529
+ }
4379
4530
  // now that we have processed the payload, we can process the next step which does validation and changes the next step if
4380
4531
  // validation fails
4381
4532
  const updatedNextStep = await this.processNextStep(initialNextStep, params, config.agentType, promptResult, finalPayload, stepEntity);
@@ -4394,6 +4545,10 @@ The context is now within limits. Please retry your request with the recovered c
4394
4545
  ...(currentStepPayloadChangeResult && {
4395
4546
  payloadChangeResult: currentStepPayloadChangeResult
4396
4547
  }),
4548
+ // Include scratchpad snapshot after changes for audit/training data
4549
+ ...(this._scratchpadManager.HasContent() && {
4550
+ scratchpad: this._scratchpadManager.ToJSON()
4551
+ }),
4397
4552
  // Include memory attribution for observability
4398
4553
  // This tracks which notes/examples were injected and influenced this step
4399
4554
  ...(this._injectedMemory && (this._injectedMemory.notes.length > 0 || this._injectedMemory.examples.length > 0) && {
@@ -5483,14 +5638,17 @@ The context is now within limits. Please retry your request with the recovered c
5483
5638
  success: result.success,
5484
5639
  params: sanitizedParams,
5485
5640
  resultCode: actionResult?.Result?.ResultCode || (result.success ? 'SUCCESS' : 'ERROR'),
5486
- message: result.success ? actionResult?.Message || 'Action completed' : result.error,
5641
+ message: result.success ? actionResult?.Message || 'Action completed' : result.error || 'Unknown error',
5487
5642
  aiDirectives: result.success ? actionResult?.AIDirectives : undefined
5488
5643
  };
5489
5644
  });
5490
5645
  // Check if any actions failed
5491
5646
  const failedActions = actionSummaries.filter(a => !a.success);
5492
- // Add user message with the results
5493
- const resultsMessage = (failedActions.length > 0 ? `${failedActions.length} of ${actionSummaries.length} failed:` : `Action results:`) + `\n${JSON.stringify(actionSummaries, null, 2)}`;
5647
+ // Add user message with the results — compact markdown instead of JSON
5648
+ const header = failedActions.length > 0
5649
+ ? `${failedActions.length} of ${actionSummaries.length} action(s) failed:`
5650
+ : `Action results:`;
5651
+ const resultsMessage = `${header}\n${this.formatActionResultsAsMarkdown(actionSummaries)}`;
5494
5652
  // Build metadata from AI Agent Actions configuration
5495
5653
  // If multiple actions, use the most restrictive (shortest) expiration settings
5496
5654
  let metadata;
@@ -7046,8 +7204,16 @@ The context is now within limits. Please retry your request with the recovered c
7046
7204
  // and we trim whitespace first
7047
7205
  const trimmedValue = value.trim();
7048
7206
  if (trimmedValue.startsWith('{{') && trimmedValue.endsWith('}}')) {
7207
+ // Single-variable case: "{{city.name}}" — strip wrappers, fall through to
7208
+ // single-variable resolution below which can return non-string types (objects, numbers)
7049
7209
  value = trimmedValue.substring(2, trimmedValue.length - 2).trim();
7050
7210
  }
7211
+ else if (trimmedValue.includes('{{')) {
7212
+ // Inline template interpolation: "text {{var.prop}} more {{var2.field}}"
7213
+ // The string contains embedded {{}} expressions mixed with literal text.
7214
+ // Each expression is resolved individually and stringified back into the template.
7215
+ return this.resolveInlineTemplateExpressions(trimmedValue, context, itemVariable);
7216
+ }
7051
7217
  // Check itemVariable reference (the custom loop variable name like "entityName" or "user")
7052
7218
  // This handles both direct references and path-based property access
7053
7219
  const ivToLower = itemVariable?.trim().toLowerCase();
@@ -7079,6 +7245,32 @@ The context is now within limits. Please retry your request with the recovered c
7079
7245
  // Static value - no variable reference found, return as literal string
7080
7246
  return value;
7081
7247
  }
7248
+ /**
7249
+ * Resolves multiple {{expression}} placeholders embedded within a literal string.
7250
+ *
7251
+ * Unlike the single-variable path (which can return objects/numbers), this always
7252
+ * returns a string because the resolved values are interpolated back into surrounding text.
7253
+ *
7254
+ * @example
7255
+ * // Given itemVariable="cityInfo", context.item={city:"Tokyo", country:"Japan"}
7256
+ * resolveInlineTemplateExpressions(
7257
+ * "largest company in {{cityInfo.city}} {{cityInfo.country}}",
7258
+ * context, "cityInfo"
7259
+ * )
7260
+ * // → "largest company in Tokyo Japan"
7261
+ */
7262
+ resolveInlineTemplateExpressions(template, context, itemVariable) {
7263
+ const expressionPattern = /\{\{\s*([^}]+?)\s*\}\}/g;
7264
+ return template.replace(expressionPattern, (_match, expr) => {
7265
+ // Resolve each expression using the single-variable path (without {{ }} wrappers)
7266
+ const resolved = this.resolveValueFromContext(expr.trim(), context, itemVariable);
7267
+ // If the expression didn't resolve (returned unchanged), keep original {{ }} for transparency
7268
+ if (resolved === expr.trim()) {
7269
+ return _match;
7270
+ }
7271
+ return String(resolved ?? '');
7272
+ });
7273
+ }
7082
7274
  /**
7083
7275
  * Helper to get value from nested object path - extracts from LoopAgentType
7084
7276
  */