@memberjunction/ai-agents 6.1.0-edge.6 → 6.1.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.
Files changed (67) hide show
  1. package/dist/SkillImportExportService.d.ts +17 -1
  2. package/dist/SkillImportExportService.d.ts.map +1 -1
  3. package/dist/SkillImportExportService.js +57 -5
  4. package/dist/SkillImportExportService.js.map +1 -1
  5. package/dist/SkillMarkdownConverter.d.ts +17 -1
  6. package/dist/SkillMarkdownConverter.d.ts.map +1 -1
  7. package/dist/SkillMarkdownConverter.js +40 -5
  8. package/dist/SkillMarkdownConverter.js.map +1 -1
  9. package/dist/agent-types/base-agent-type.d.ts +26 -1
  10. package/dist/agent-types/base-agent-type.d.ts.map +1 -1
  11. package/dist/agent-types/base-agent-type.js +17 -0
  12. package/dist/agent-types/base-agent-type.js.map +1 -1
  13. package/dist/agent-types/loop-agent-response-type.d.ts +16 -0
  14. package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
  15. package/dist/agent-types/loop-agent-response-type.js +19 -1
  16. package/dist/agent-types/loop-agent-response-type.js.map +1 -1
  17. package/dist/agent-types/loop-agent-type.d.ts +32 -1
  18. package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
  19. package/dist/agent-types/loop-agent-type.js +190 -3
  20. package/dist/agent-types/loop-agent-type.js.map +1 -1
  21. package/dist/base-agent.d.ts +186 -3
  22. package/dist/base-agent.d.ts.map +1 -1
  23. package/dist/base-agent.js +564 -53
  24. package/dist/base-agent.js.map +1 -1
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/native-tools/action-tool-builder.d.ts +92 -0
  30. package/dist/native-tools/action-tool-builder.d.ts.map +1 -0
  31. package/dist/native-tools/action-tool-builder.js +226 -0
  32. package/dist/native-tools/action-tool-builder.js.map +1 -0
  33. package/dist/native-tools/control-tools.d.ts +52 -0
  34. package/dist/native-tools/control-tools.d.ts.map +1 -0
  35. package/dist/native-tools/control-tools.js +113 -0
  36. package/dist/native-tools/control-tools.js.map +1 -0
  37. package/dist/native-tools/dual-channel.d.ts +16 -0
  38. package/dist/native-tools/dual-channel.d.ts.map +1 -0
  39. package/dist/native-tools/dual-channel.js +44 -0
  40. package/dist/native-tools/dual-channel.js.map +1 -0
  41. package/dist/native-tools/tool-result-turns.d.ts +32 -0
  42. package/dist/native-tools/tool-result-turns.d.ts.map +1 -0
  43. package/dist/native-tools/tool-result-turns.js +29 -0
  44. package/dist/native-tools/tool-result-turns.js.map +1 -0
  45. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
  46. package/dist/realtime/bridge-realtime-session-factory.js +22 -2
  47. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
  48. package/dist/realtime/realtime-client-session-service.d.ts +104 -17
  49. package/dist/realtime/realtime-client-session-service.d.ts.map +1 -1
  50. package/dist/realtime/realtime-client-session-service.js +325 -28
  51. package/dist/realtime/realtime-client-session-service.js.map +1 -1
  52. package/dist/realtime/realtime-coagent-config.d.ts +61 -1
  53. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  54. package/dist/realtime/realtime-coagent-config.js +93 -1
  55. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  56. package/dist/realtime/realtime-session-runner.d.ts +10 -8
  57. package/dist/realtime/realtime-session-runner.d.ts.map +1 -1
  58. package/dist/realtime/realtime-session-runner.js +11 -9
  59. package/dist/realtime/realtime-session-runner.js.map +1 -1
  60. package/dist/realtime/realtime-tool-broker.d.ts +11 -5
  61. package/dist/realtime/realtime-tool-broker.d.ts.map +1 -1
  62. package/dist/realtime/realtime-tool-broker.js +19 -7
  63. package/dist/realtime/realtime-tool-broker.js.map +1 -1
  64. package/dist/scoped-prompt-config-resolver.d.ts +6 -1
  65. package/dist/scoped-prompt-config-resolver.d.ts.map +1 -1
  66. package/dist/scoped-prompt-config-resolver.js.map +1 -1
  67. package/package.json +18 -18
@@ -11,9 +11,13 @@
11
11
  * @since 2.49.0
12
12
  */
13
13
  import { FileStorageEngineBase, MJEnvironmentEntityExtended } from '@memberjunction/core-entities';
14
+ import { buildActionToolSet, filterDeclarableActions, sanitizeToolName } from './native-tools/action-tool-builder.js';
15
+ import { buildNativeToolSet, SUB_AGENT_TOOL_PREFIX } from './native-tools/control-tools.js';
16
+ import { buildAssistantToolCallTurn, buildToolResultTurn, compactToolResultContent } from './native-tools/tool-result-turns.js';
17
+ import { looksLikeLoopEnvelope } from './native-tools/dual-channel.js';
14
18
  import { Metadata, RunView, LogStatus, LogStatusEx, LogError, LogErrorEx, IsVerboseLoggingEnabled, DatabaseProviderBase } from '@memberjunction/core';
15
19
  import { AgentRunWatchdog } from './agent-run-watchdog.js';
16
- import { AIPromptRunner } from '@memberjunction/ai-prompts';
20
+ import { AIPromptRunner, GetToolCallingDecision } from '@memberjunction/ai-prompts';
17
21
  import { BaseRealtimeModel, GetAIAPIKey } from '@memberjunction/ai';
18
22
  import { BaseAgentType } from './agent-types/base-agent-type.js';
19
23
  import { CopyScalarsAndArrays, JSONValidator, MJGlobal, SafeExpressionEvaluator, UUIDsEqual, EscapeSQLString } from '@memberjunction/global';
@@ -143,10 +147,6 @@ export class BaseAgent {
143
147
  * @private
144
148
  */
145
149
  this._generalValidationRetryCount = 0;
146
- /**
147
- * Current agent run entity.
148
- * @private
149
- */
150
150
  this._agentRun = null;
151
151
  /**
152
152
  * The task graph this run submitted and is now waiting on, or null.
@@ -480,6 +480,21 @@ export class BaseAgent {
480
480
  get AgentTypeInstance() {
481
481
  return this._agentTypeInstance;
482
482
  }
483
+ /**
484
+ * Current agent run entity.
485
+ * @private
486
+ */
487
+ /**
488
+ * System-wide safety net on prompt iterations, overridable per run via
489
+ * `ExecuteAgentParams.absoluteMaxIterations`.
490
+ *
491
+ * A static rather than a local const because two places now depend on the same number: the
492
+ * limit check that STOPS a run, and {@link isFinalPermittedIteration}, which has to predict
493
+ * that stop one turn ahead. Two copies of 5000 would be a silent mismatch the moment either
494
+ * moved — the gate would force `tool_choice: 'none'` on the wrong turn, or fail to force it
495
+ * on the right one, and neither shows up as an error.
496
+ */
497
+ static { this.DEFAULT_ABSOLUTE_MAX_ITERATIONS = 5000; }
483
498
  /**
484
499
  * The resolved FileStorageAccount ID for this agent run. Set during Execute()
485
500
  * via the hierarchical resolution chain (Runtime → Agent → Category → Type → fallback).
@@ -2923,6 +2938,296 @@ export class BaseAgent {
2923
2938
  * @returns {Promise<AIPromptParams>} Configured prompt parameters
2924
2939
  * @protected
2925
2940
  */
2941
+ /**
2942
+ * Declares the agent's Actions as native tools on the outgoing request (plan §8.1/§8.3).
2943
+ *
2944
+ * **Supplying tools does not turn native mode on.** It satisfies one of three gate terms; the
2945
+ * prompt runner still requires the model to declare the capability and the configuration to
2946
+ * want it (`ResolveNativeToolCalling`). Because no catalog row declares the capability today,
2947
+ * this is inert — the tools are built, the gate says no, and the run takes the envelope path
2948
+ * exactly as before. That is deliberate: the switch is a metadata change, not a code change.
2949
+ *
2950
+ * `tool_choice` is `'auto'` on a normal turn. §8.3 also specifies `'none'` when the framework
2951
+ * needs a control-flow decision rather than an action — the model must produce the envelope
2952
+ * then, and forcing it is the only way to be sure it can.
2953
+ */
2954
+ /**
2955
+ * Stamps the step with which tool-calling path it took and how much the model used it (§8.5).
2956
+ *
2957
+ * The mode is derivable through `TargetLogID` -> `AIPromptRun.ToolCallingMode`, but only for
2958
+ * steps whose target is a prompt run and only through a join that returns nothing for every
2959
+ * other step type. The call count is not derivable at all — it lives in the provider response,
2960
+ * which is not persisted per step. Both are recorded here so a comparison of the two paths can
2961
+ * group by them directly instead of reconstructing its own independent variable.
2962
+ *
2963
+ * Instrumentation must never fail a run, so every field is best-effort.
2964
+ */
2965
+ recordToolCallingInstrumentation(stepEntity, promptResult) {
2966
+ try {
2967
+ const mode = promptResult?.promptRun?.ToolCallingMode;
2968
+ if (mode) {
2969
+ stepEntity.ToolCallingMode = mode;
2970
+ }
2971
+ // Counted only on the native path: null means "envelope", which is different from a
2972
+ // native turn where the model chose not to call anything (0).
2973
+ if (mode === 'Native' || mode === 'NativeFallback' || mode === 'NativeImplicit') {
2974
+ const message = promptResult?.chatResult?.data?.choices?.[0]?.message;
2975
+ const callCount = message?.toolCalls?.length ?? 0;
2976
+ stepEntity.NativeToolCallCount = callCount;
2977
+ // A tool call wins, but a turn that ALSO carried a valid
2978
+ // envelope gave two answers, and the one we discard has to be counted somewhere.
2979
+ stepEntity.NativeDualChannel = callCount > 0 ? looksLikeLoopEnvelope(message?.content) : null;
2980
+ // whether this step's results went back as native tool-result turns.
2981
+ stepEntity.NativeToolResultsSent = GetToolCallingDecision(promptResult?.chatResult)?.toolResults === true;
2982
+ if (stepEntity.NativeDualChannel) {
2983
+ LogStatus(`Agent step answered on both channels: ${callCount} tool call(s) plus a JSON envelope; the envelope was discarded (tool call wins).`);
2984
+ }
2985
+ }
2986
+ if (mode) {
2987
+ this.queueStepSave(stepEntity, (st) => {
2988
+ st.ToolCallingMode = stepEntity.ToolCallingMode;
2989
+ st.NativeToolCallCount = stepEntity.NativeToolCallCount;
2990
+ st.NativeDualChannel = stepEntity.NativeDualChannel;
2991
+ st.NativeToolResultsSent = stepEntity.NativeToolResultsSent;
2992
+ });
2993
+ }
2994
+ }
2995
+ catch (error) {
2996
+ LogError(`Could not record tool-calling instrumentation on agent run step: ${error instanceof Error ? error.message : String(error)}`);
2997
+ }
2998
+ }
2999
+ /**
3000
+ * replays the model's own tool-call turn into history once per turn, so the tool-result
3001
+ * turns that follow have a call to answer (every provider requires it; BaseLLM validates it).
3002
+ * A no-op when the turn's results go back as the markdown user message.
3003
+ */
3004
+ appendNativeAssistantTurn(params, step) {
3005
+ const turn = step.nativeTurn;
3006
+ if (!turn?.sendResultsNatively || this._lastNativeTurnAppended === turn) {
3007
+ return;
3008
+ }
3009
+ params.conversationMessages.push(buildAssistantToolCallTurn(turn));
3010
+ this._lastNativeTurnAppended = turn;
3011
+ }
3012
+ /**
3013
+ * The tool_result answering a `payload_change_request` the model made on the SAME turn as its
3014
+ * actions or its delegation.
3015
+ *
3016
+ * The framework applies that change before the rest of the turn runs, so its call has to be
3017
+ * answered alongside them — and inside the same tool turn, since Anthropic requires every
3018
+ * tool_result for an assistant turn to sit in the one message that follows it. Returns an empty
3019
+ * array when the step carried no payload call, so callers can always spread it.
3020
+ */
3021
+ payloadToolResult(previousDecision) {
3022
+ if (!previousDecision?.payloadToolCallId) {
3023
+ return [];
3024
+ }
3025
+ return [{
3026
+ toolCallId: previousDecision.payloadToolCallId,
3027
+ toolName: 'payload_change_request',
3028
+ content: 'Payload change applied.',
3029
+ isError: false
3030
+ }];
3031
+ }
3032
+ /**
3033
+ * action results as ONE tool turn — a tool_result block per call, paired by id — when the
3034
+ * turn's results go back natively; otherwise the markdown user message as before. An action that
3035
+ * cannot be paired with a call id keeps the markdown message for itself.
3036
+ */
3037
+ appendActionResults(params, summaries, resultsMessage, metadata, previousDecision) {
3038
+ if (!previousDecision.nativeTurn?.sendResultsNatively) {
3039
+ params.conversationMessages.push({ role: 'user', content: resultsMessage, metadata });
3040
+ return;
3041
+ }
3042
+ const unpaired = [...(previousDecision.actions ?? [])];
3043
+ const results = [...this.payloadToolResult(previousDecision)];
3044
+ const orphans = [];
3045
+ for (const summary of summaries) {
3046
+ const at = unpaired.findIndex((a) => a.name === summary.actionName && !!a.toolCallId);
3047
+ const action = at >= 0 ? unpaired.splice(at, 1)[0] : undefined;
3048
+ if (!action?.toolCallId) {
3049
+ orphans.push(summary);
3050
+ continue;
3051
+ }
3052
+ results.push({
3053
+ toolCallId: action.toolCallId,
3054
+ toolName: sanitizeToolName(summary.actionName),
3055
+ content: this.formatActionResultsAsMarkdown([summary]),
3056
+ isError: !summary.success
3057
+ });
3058
+ }
3059
+ if (results.length > 0) {
3060
+ params.conversationMessages.push(buildToolResultTurn(results, metadata));
3061
+ }
3062
+ if (orphans.length > 0) {
3063
+ params.conversationMessages.push({ role: 'user', content: `Action results:\n${this.formatActionResultsAsMarkdown(orphans)}`, metadata });
3064
+ }
3065
+ }
3066
+ /**
3067
+ * Answers any tool call the turn's own result path left dangling, immediately before the
3068
+ * history goes back to the model.
3069
+ *
3070
+ * `appendNativeAssistantTurn` replays the model's call turn for EVERY step that carries one,
3071
+ * but only Actions, Sub-Agents and the payload-only Retry append results for it. An
3072
+ * unknown-tool Retry, a protocol-violation Retry, an `ask_user` Chat, or a parallel dispatch
3073
+ * that could not pair one of its ids therefore leaves calls unanswered — which Anthropic
3074
+ * ("Each `tool_use` block must have a corresponding `tool_result` block in the next message"),
3075
+ * OpenAI ("an assistant message with `tool_calls` must be followed by tool messages
3076
+ * responding to each `tool_call_id`") and Gemini all reject outright. `validateToolConversation`
3077
+ * in BaseLLM cannot catch it: it validates results→calls, never calls→results.
3078
+ *
3079
+ * Reconciling here rather than in each branch means a new step type cannot reintroduce the bug.
3080
+ * The synthetic result states only that the call did not run; the reason travels in whatever
3081
+ * message the branch itself appended (the retry instructions, the chat question).
3082
+ */
3083
+ reconcileUnansweredToolCalls(params) {
3084
+ const messages = params.conversationMessages;
3085
+ if (!messages?.length) {
3086
+ return;
3087
+ }
3088
+ // Only the most recent assistant call turn can still be open — anything earlier was
3089
+ // answered by its own branch or closed by a previous pass through here.
3090
+ let at = -1;
3091
+ for (let i = messages.length - 1; i >= 0; i--) {
3092
+ const candidate = messages[i];
3093
+ if (candidate.role === 'assistant' && candidate.toolCalls?.length) {
3094
+ at = i;
3095
+ break;
3096
+ }
3097
+ }
3098
+ if (at < 0) {
3099
+ return;
3100
+ }
3101
+ const answered = new Set();
3102
+ for (let i = at + 1; i < messages.length; i++) {
3103
+ const content = messages[i].content;
3104
+ if (!Array.isArray(content)) {
3105
+ continue;
3106
+ }
3107
+ for (const block of content) {
3108
+ if (block.type === 'tool_result' && block.toolCallId) {
3109
+ answered.add(block.toolCallId);
3110
+ }
3111
+ }
3112
+ }
3113
+ // A call with no id cannot be paired by any provider, so it cannot be answered here
3114
+ // either — `extractOpenAICompatibleToolCalls` substitutes '' when a host omits the id.
3115
+ const unanswered = (messages[at].toolCalls ?? [])
3116
+ .filter((call) => !!call.id && !answered.has(call.id));
3117
+ if (unanswered.length === 0) {
3118
+ return;
3119
+ }
3120
+ params.conversationMessages.push(buildToolResultTurn(unanswered.map((call) => ({
3121
+ toolCallId: call.id,
3122
+ toolName: call.name,
3123
+ content: 'Not executed — the agent did not run this call on this turn. See the message that follows.',
3124
+ isError: true
3125
+ })), { turnAdded: this._promptTurnCount, messageType: 'action-result' }));
3126
+ }
3127
+ /**
3128
+ * a tool turn can never be REMOVED from history — that orphans the assistant call it
3129
+ * answers, which every provider rejects. Expiry and recovery stub its blocks instead.
3130
+ */
3131
+ stubToolTurn(message, note) {
3132
+ return compactToolResultContent(message, () => note);
3133
+ }
3134
+ applyNativeTools(promptParams, params) {
3135
+ this._nativeToolBindings = undefined;
3136
+ // Only a type that can read the call back may be offered tools — see
3137
+ // BaseAgentType.SupportsNativeToolCalls for what goes wrong otherwise. With no declarations
3138
+ // the gate's `toolsProvided` term is false and the run takes the envelope path unchanged,
3139
+ // whatever the catalog or prompt asked for.
3140
+ if (!this._agentTypeInstance?.SupportsNativeToolCalls) {
3141
+ return;
3142
+ }
3143
+ // A per-AGENT gate on declaration, one level below the agent-type
3144
+ // gate above. A coordinator whose prompt says it never does work itself keeps its Actions in
3145
+ // the prose catalog; with nothing declared the runner's gate resolves Envelope for this run.
3146
+ if (params.agent?.DeclareActionsAsNativeTools === false) {
3147
+ return;
3148
+ }
3149
+ // ...and a per-agent-ACTION gate: rows that opt out are removed before the tool set is built.
3150
+ const actions = filterDeclarableActions(this.getEffectiveActionsForValidation(params.agent.ID), AIEngine.Instance.AgentActions.filter((aa) => UUIDsEqual(aa.AgentID, params.agent.ID)));
3151
+ const subAgents = this.getEffectiveSubAgentsForValidation(params.agent.ID);
3152
+ if (actions.length === 0 && subAgents.length === 0) {
3153
+ return;
3154
+ }
3155
+ try {
3156
+ const actionSet = buildActionToolSet(actions, new Map(actions.map((a) => [a.ID, a.Params.Items])));
3157
+ // Under implicit control flow the agent cannot know which model will answer, so it declares the full
3158
+ // set — Actions plus the control-flow tools (one per sub-agent, payload_change_request,
3159
+ // ask_user) — and NAMES the control ones. The runner keeps them only when the selected
3160
+ // model's LLM.NativeControlFlow resolves to 'implicit'; a hybrid model never sees them.
3161
+ const toolSet = buildNativeToolSet(actionSet, subAgents);
3162
+ promptParams.tools = toolSet.tools;
3163
+ promptParams.controlFlowToolNames = toolSet.controlToolNames;
3164
+ promptParams.toolChoice = this.resolveToolChoiceForTurn(params);
3165
+ this._nativeToolBindings = toolSet.byToolName;
3166
+ }
3167
+ catch (error) {
3168
+ // A tool-name collision is a metadata problem and a hard error at build time — but it
3169
+ // must not take down a run that would otherwise work on the envelope path, which every
3170
+ // agent still does today. Surface it loudly and continue without tools.
3171
+ LogError(`Agent '${params.agent.Name}': could not declare native tools — ${error instanceof Error ? error.message : String(error)}`);
3172
+ }
3173
+ }
3174
+ /**
3175
+ * The `tool_choice` for this turn (§8.3).
3176
+ *
3177
+ * `'auto'` normally, `'none'` on the last turn this run will be allowed.
3178
+ *
3179
+ * **Why the final turn is special.** A tool call is a request to continue: the framework runs
3180
+ * the action, feeds the result back, and the model decides again. On the last permitted
3181
+ * iteration there is no "again" — the limit check fires the moment the turn returns, so the
3182
+ * action is executed, paid for, and its result discarded, and the run ends with no answer for
3183
+ * the user because the model spent its last turn asking a question instead of answering one.
3184
+ * Forcing `'none'` converts that turn into what the framework actually needs from it: a
3185
+ * terminal envelope.
3186
+ *
3187
+ * **What this does NOT fix.** Models call a tool on a measurable share of turns whose right
3188
+ * answer was chat, completion or delegation. Those are not predictable
3189
+ * from framework state — only the model knows the task is finished — so no `tool_choice` can
3190
+ * address them. That belongs to the prompt, and is why the native-mode Actions section names
3191
+ * the cases explicitly.
3192
+ *
3193
+ * Subclasses may narrow this further; the base contract is that a forced `'none'` must never be
3194
+ * relaxed to `'auto'` on a turn the framework has already decided is terminal.
3195
+ *
3196
+ * @param params The run parameters, for the per-run iteration override
3197
+ * @returns `'none'` on the final permitted iteration, otherwise `'auto'`
3198
+ */
3199
+ resolveToolChoiceForTurn(params) {
3200
+ return this.isFinalPermittedIteration(params) ? 'none' : 'auto';
3201
+ }
3202
+ /**
3203
+ * Whether the turn currently being prepared is the last one this run will be allowed.
3204
+ *
3205
+ * Reads `TotalPromptIterations`, which the loop increments immediately BEFORE composing the
3206
+ * prompt — so by the time this runs the counter already includes the turn about to go out, and
3207
+ * `iterations >= limit` means "this turn is the last", not "the last one already happened".
3208
+ * That off-by-one is the whole subtlety and is why this lives beside the limit check it mirrors.
3209
+ *
3210
+ * Returns false when no run is in flight: the eval harness composes parameters through
3211
+ * `BaseAgent` without executing a loop, and a composed-but-never-run turn has no iteration
3212
+ * budget to be at the end of.
3213
+ *
3214
+ * Only the ITERATION limits are predicted. Cost, token and time limits also stop a run, but
3215
+ * none can be known before the turn that crosses them — guessing would force `'none'` on turns
3216
+ * that had budget left, which silently disables native tool calling rather than bounding it.
3217
+ *
3218
+ * @param params The run parameters, for `absoluteMaxIterations`
3219
+ * @returns true when the framework will stop the run after this turn
3220
+ */
3221
+ isFinalPermittedIteration(params) {
3222
+ const iterations = this._agentRun?.TotalPromptIterations;
3223
+ if (!iterations) {
3224
+ return false;
3225
+ }
3226
+ const perAgent = params.agent?.MaxIterationsPerRun;
3227
+ const absolute = params.absoluteMaxIterations ?? BaseAgent.DEFAULT_ABSOLUTE_MAX_ITERATIONS;
3228
+ return (typeof perAgent === 'number' && perAgent > 0 && iterations >= perAgent)
3229
+ || (absolute > 0 && iterations >= absolute);
3230
+ }
2926
3231
  async preparePromptParams(config, payload, params) {
2927
3232
  const agentType = config.agentType;
2928
3233
  const systemPrompt = config.systemPrompt;
@@ -2946,8 +3251,11 @@ export class BaseAgent {
2946
3251
  }
2947
3252
  promptParams.data = promptTemplateData;
2948
3253
  promptParams.contextUser = params.contextUser;
3254
+ // Last gate before the history goes back to the model: no tool call may be left dangling.
3255
+ this.reconcileUnansweredToolCalls(params);
2949
3256
  promptParams.conversationMessages = params.conversationMessages;
2950
3257
  promptParams.verbose = params.verbose; // Pass through verbose flag
3258
+ this.applyNativeTools(promptParams, params);
2951
3259
  // Apply effortLevel with precedence hierarchy
2952
3260
  // 1. params.effortLevel (ExecuteAgentParams - highest priority)
2953
3261
  // 2. agent.DefaultPromptEffortLevel (agent default - medium priority)
@@ -3152,7 +3460,7 @@ export class BaseAgent {
3152
3460
  async determineNextStep(params, agentType, promptResult, currentPayload) {
3153
3461
  // Let the agent type determine the next step
3154
3462
  this.logStatus(`🎯 Agent type '${agentType.Name}' determining next step`, true, params);
3155
- const nextStep = await this.AgentTypeInstance.DetermineNextStep(promptResult, params, currentPayload, this.AgentTypeState);
3463
+ const nextStep = await this.AgentTypeInstance.DetermineNextStep(promptResult, params, currentPayload, this.AgentTypeState, this._nativeToolBindings);
3156
3464
  return nextStep;
3157
3465
  }
3158
3466
  /**
@@ -3902,8 +4210,7 @@ export class BaseAgent {
3902
4210
  this.applyTokenStatsToRun(agentRun, this.calculateTokenStats());
3903
4211
  }
3904
4212
  // Check absolute maximum iterations (safety net to prevent infinite loops)
3905
- const DEFAULT_ABSOLUTE_MAX_ITERATIONS = 5000;
3906
- const absoluteMaxIterations = params.absoluteMaxIterations ?? DEFAULT_ABSOLUTE_MAX_ITERATIONS;
4213
+ const absoluteMaxIterations = params.absoluteMaxIterations ?? BaseAgent.DEFAULT_ABSOLUTE_MAX_ITERATIONS;
3907
4214
  if (agentRun.TotalPromptIterations && agentRun.TotalPromptIterations >= absoluteMaxIterations) {
3908
4215
  return {
3909
4216
  exceeded: true,
@@ -4355,6 +4662,20 @@ export class BaseAgent {
4355
4662
  }
4356
4663
  // Remove in reverse order to maintain indices
4357
4664
  removedIndices.sort((a, b) => b - a).forEach(index => {
4665
+ const target = params.conversationMessages[index];
4666
+ if (target.role === 'tool') {
4667
+ // a tool turn cannot be removed without orphaning the call it answers; stub it.
4668
+ params.conversationMessages[index] = this.stubToolTurn(target, '[result expired — stubbed to recover context]');
4669
+ this.emitMessageLifecycleEvent({
4670
+ type: 'message-compacted',
4671
+ turn: currentStepCount,
4672
+ messageIndex: index,
4673
+ message: params.conversationMessages[index],
4674
+ reason: 'Context recovery - tool turn stubbed (a tool turn cannot be removed)',
4675
+ tokensSaved: this.estimateTokens(target.content)
4676
+ });
4677
+ return;
4678
+ }
4358
4679
  const removed = params.conversationMessages.splice(index, 1)[0];
4359
4680
  // Emit lifecycle event
4360
4681
  this.emitMessageLifecycleEvent({
@@ -4407,6 +4728,20 @@ export class BaseAgent {
4407
4728
  const originalContent = typeof originalMessage.content === 'string'
4408
4729
  ? originalMessage.content
4409
4730
  : JSON.stringify(originalMessage.content);
4731
+ if (originalMessage.role === 'tool') {
4732
+ // compact each tool_result block's text; the block structure is what the provider needs.
4733
+ const compacted = compactToolResultContent(originalMessage, (t) => (t.length > 500 ? `${t.slice(0, 500)}… [compacted from ${t.length} chars]` : t));
4734
+ const saved = originalTokens - this.estimateTokens(compacted.content);
4735
+ if (saved > 0) {
4736
+ params.conversationMessages[candidate.index] = {
4737
+ ...compacted,
4738
+ metadata: { ...originalMessage.metadata, wasCompacted: true, originalLength: originalContent.length, tokensSaved: saved }
4739
+ };
4740
+ tokensSaved += saved;
4741
+ compactedCount++;
4742
+ }
4743
+ continue;
4744
+ }
4410
4745
  // Use smart trim (faster than AI summary, no API cost)
4411
4746
  const compactedContent = await this.compactMessage(originalMessage, {
4412
4747
  compactMode: 'First N Chars',
@@ -4648,8 +4983,19 @@ The context is now within limits. Please retry your request with the recovered c
4648
4983
  // Check guardrails if next step would continue execution
4649
4984
  const guardrailCheckedStep = await this.checkExecutionGuardrails(params, validatedNextStep, currentPayload, this._agentRun, currentStep);
4650
4985
  this.logStatus(`📌 Next step determined: ${guardrailCheckedStep.step}${guardrailCheckedStep.terminate ? ' (terminating)' : ''}`, true, params);
4986
+ // the model's own call turn goes into history before anything answers it.
4987
+ this.appendNativeAssistantTurn(params, guardrailCheckedStep);
4651
4988
  // if we need to retry make sure we add the retry message to the conversation messages
4652
- if (guardrailCheckedStep.step === 'Retry' && (guardrailCheckedStep.message || guardrailCheckedStep.errorMessage || guardrailCheckedStep.retryInstructions)) {
4989
+ if (guardrailCheckedStep.step === 'Retry' && guardrailCheckedStep.payloadToolCallId && guardrailCheckedStep.nativeTurn?.sendResultsNatively) {
4990
+ // the payload-only turn is answered as a tool result for the payload_change_request call.
4991
+ params.conversationMessages.push(buildToolResultTurn([{
4992
+ toolCallId: guardrailCheckedStep.payloadToolCallId,
4993
+ toolName: 'payload_change_request',
4994
+ content: guardrailCheckedStep.retryInstructions || 'Payload change applied.',
4995
+ isError: false
4996
+ }], { turnAdded: this._promptTurnCount, messageType: 'action-result' }));
4997
+ }
4998
+ else if (guardrailCheckedStep.step === 'Retry' && (guardrailCheckedStep.message || guardrailCheckedStep.errorMessage || guardrailCheckedStep.retryInstructions)) {
4653
4999
  params.conversationMessages.push({
4654
5000
  role: 'user',
4655
5001
  content: `Retrying due to: ${guardrailCheckedStep.retryInstructions || guardrailCheckedStep.message || guardrailCheckedStep.errorMessage}`
@@ -5840,6 +6186,60 @@ The context is now within limits. Please retry your request with the recovered c
5840
6186
  throw new Error(`Error executing actions: ${error.message}`);
5841
6187
  }
5842
6188
  }
6189
+ /**
6190
+ * Makes a SLICED conversation window safe to send on its own.
6191
+ *
6192
+ * A tool turn is only valid immediately after the assistant turn that declared its call ids, so
6193
+ * cutting the parent's history at an arbitrary index can strand either half of a pair. Keeping
6194
+ * the result half throws in `validateToolConversation` before the request is even built; keeping
6195
+ * the call half is accepted there but rejected by every provider. Both halves are repaired here
6196
+ * so the caller's window is internally consistent whatever index it happened to cut on:
6197
+ * an unpairable tool turn is dropped, and an assistant turn whose calls nothing in the window
6198
+ * answers keeps its prose but loses the calls.
6199
+ *
6200
+ * Only the slicing modes need this — 'All' is self-consistent by construction.
6201
+ */
6202
+ makeToolTurnsSelfConsistent(window) {
6203
+ const declared = new Set();
6204
+ const resolved = new Set();
6205
+ for (const message of window) {
6206
+ const typed = message;
6207
+ if (typed.role === 'assistant') {
6208
+ for (const call of typed.toolCalls ?? []) {
6209
+ if (call.id)
6210
+ declared.add(call.id);
6211
+ }
6212
+ }
6213
+ if (typed.role === 'tool' && Array.isArray(typed.content)) {
6214
+ for (const block of typed.content) {
6215
+ if (block.type === 'tool_result' && block.toolCallId)
6216
+ resolved.add(block.toolCallId);
6217
+ }
6218
+ }
6219
+ }
6220
+ const kept = [];
6221
+ for (const message of window) {
6222
+ const typed = message;
6223
+ if (typed.role === 'tool') {
6224
+ const blocks = Array.isArray(typed.content) ? typed.content : [];
6225
+ // Its assistant turn was cut away — nothing in this window declares these calls.
6226
+ const pairable = blocks.some((b) => b.type === 'tool_result' && b.toolCallId && declared.has(b.toolCallId));
6227
+ if (!pairable)
6228
+ continue;
6229
+ }
6230
+ if (typed.role === 'assistant' && typed.toolCalls?.length) {
6231
+ const answered = typed.toolCalls.every((call) => call.id && resolved.has(call.id));
6232
+ if (!answered) {
6233
+ // Demote to prose rather than send a call this window never answers.
6234
+ const { toolCalls: _dropped, ...rest } = typed;
6235
+ kept.push({ ...rest, content: typed.content || '[tool call omitted for context management]' });
6236
+ continue;
6237
+ }
6238
+ }
6239
+ kept.push(message);
6240
+ }
6241
+ return kept;
6242
+ }
5843
6243
  /**
5844
6244
  * Prepares conversation messages for sub-agent execution based on database-configured message mode.
5845
6245
  *
@@ -5888,7 +6288,7 @@ The context is now within limits. Please retry your request with the recovered c
5888
6288
  case 'Latest':
5889
6289
  // Pass most recent N messages
5890
6290
  if (maxMessages && maxMessages > 0) {
5891
- messages = params.conversationMessages.slice(-maxMessages);
6291
+ messages = this.makeToolTurnsSelfConsistent(params.conversationMessages.slice(-maxMessages));
5892
6292
  }
5893
6293
  else {
5894
6294
  messages = [...params.conversationMessages];
@@ -5900,14 +6300,14 @@ The context is now within limits. Please retry your request with the recovered c
5900
6300
  const firstTwo = params.conversationMessages.slice(0, 2);
5901
6301
  const remaining = params.conversationMessages.slice(-(maxMessages - 2));
5902
6302
  const omittedCount = params.conversationMessages.length - maxMessages;
5903
- messages = [
6303
+ messages = this.makeToolTurnsSelfConsistent([
5904
6304
  ...firstTwo,
5905
6305
  {
5906
6306
  role: 'system',
5907
6307
  content: `[${omittedCount} messages omitted for context management]`
5908
6308
  },
5909
6309
  ...remaining
5910
- ];
6310
+ ]);
5911
6311
  }
5912
6312
  else {
5913
6313
  messages = [...params.conversationMessages];
@@ -6994,7 +7394,7 @@ The context is now within limits. Please retry your request with the recovered c
6994
7394
  AgentRunID: this._agentRun.ID,
6995
7395
  StepNumber: stepNumber,
6996
7396
  StepType: params.stepType,
6997
- StepName: this.formatHierarchicalMessage(params.stepName), // include hierarchy breadcrumb
7397
+ StepName: this.fitStepName(stepEntity, this.formatHierarchicalMessage(params.stepName)), // breadcrumb, trimmed to the column
6998
7398
  TargetID: params.targetId,
6999
7399
  TargetLogID: params.targetLogId,
7000
7400
  ParentID: params.parentId, // Link to parent step (e.g., loop step)
@@ -7226,6 +7626,21 @@ The context is now within limits. Please retry your request with the recovered c
7226
7626
  }
7227
7627
  return baseMessage;
7228
7628
  }
7629
+ /**
7630
+ * Trims a step name to what `AIAgentRunStep.StepName` can hold. Step names are built from free
7631
+ * text — a termination message quoting a provider's error, a tool name with its arguments, the
7632
+ * hierarchy breadcrumb in front of either — and one longer than the column made the whole row
7633
+ * unsaveable, so the step vanished from the run ("2 step record save(s) failed" — a
7634
+ * 327-character failure reason). The width comes from the entity's field metadata
7635
+ * when the instance carries it, else the column's declared 255 characters.
7636
+ *
7637
+ * @protected
7638
+ */
7639
+ fitStepName(stepEntity, name) {
7640
+ const declared = stepEntity.EntityInfo?.Fields?.find((f) => f.Name === 'StepName')?.MaxLength;
7641
+ const limit = declared && declared > 0 ? declared : 255;
7642
+ return name.length > limit ? `${name.slice(0, limit - 1)}…` : name;
7643
+ }
7229
7644
  /**
7230
7645
  * Builds hierarchical step string from parent and current step counts.
7231
7646
  *
@@ -7563,6 +7978,7 @@ The context is now within limits. Please retry your request with the recovered c
7563
7978
  };
7564
7979
  // Execute the prompt
7565
7980
  const promptResult = await this.executePrompt(promptParams);
7981
+ this.recordToolCallingInstrumentation(stepEntity, promptResult);
7566
7982
  // Increment prompt-specific turn counter (used for expiration age calculations)
7567
7983
  this._promptTurnCount++;
7568
7984
  // Loop and sub-agent results now use the standard expiration/compaction lifecycle
@@ -7981,10 +8397,14 @@ The context is now within limits. Please retry your request with the recovered c
7981
8397
  // reason as the action record above: the model's real output is the JSON envelope, and
7982
8398
  // storing framework prose as an `assistant` turn trains strong in-context models to imitate
7983
8399
  // the prose and drift off the required JSON format. See the note at the action-record push.
7984
- params.conversationMessages.push({
7985
- role: 'user',
7986
- content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
7987
- });
8400
+ // a natively-called delegate_to_* is already in the history as the assistant's call turn and
8401
+ // will be answered by a `tool` turn; a user turn in between violates the provider contracts.
8402
+ if (!(previousDecision?.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId)) {
8403
+ params.conversationMessages.push({
8404
+ role: 'user',
8405
+ content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
8406
+ });
8407
+ }
7988
8408
  // Prepare input data for the step
7989
8409
  const inputData = {
7990
8410
  agentName: params.agent.Name,
@@ -8206,11 +8626,18 @@ The context is now within limits. Please retry your request with the recovered c
8206
8626
  subAgentMetadata.compactPromptId = override.compactPromptId;
8207
8627
  }
8208
8628
  }
8209
- params.conversationMessages.push({
8210
- role: 'user',
8211
- content: resultMessage,
8212
- metadata: subAgentMetadata
8213
- });
8629
+ if (previousDecision?.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
8630
+ // the delegate_to_* call is answered as a tool result.
8631
+ params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
8632
+ toolCallId: subAgentRequest.toolCallId,
8633
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
8634
+ content: resultMessage,
8635
+ isError: !subAgentResult.success
8636
+ }], subAgentMetadata));
8637
+ }
8638
+ else {
8639
+ params.conversationMessages.push({ role: 'user', content: resultMessage, metadata: subAgentMetadata });
8640
+ }
8214
8641
  // Set PayloadAtEnd with the merged payload
8215
8642
  if (stepEntity) {
8216
8643
  stepEntity.PayloadAtEnd = this.serializePayloadAtEnd(mergedPayload);
@@ -8405,7 +8832,7 @@ The context is now within limits. Please retry your request with the recovered c
8405
8832
  *
8406
8833
  * @private
8407
8834
  */
8408
- prepareParallelSubAgentDispatch(params, request, stepCount) {
8835
+ prepareParallelSubAgentDispatch(params, request, stepCount, nativeResults = false) {
8409
8836
  const resolved = this.resolveSubAgentByName(params, request.name);
8410
8837
  if (!resolved) {
8411
8838
  this.logError(`Sub-agent '${request.name}' not found or not active for agent '${params.agent.Name}'`, {
@@ -8429,10 +8856,14 @@ The context is now within limits. Please retry your request with the recovered c
8429
8856
  });
8430
8857
  // `user`-role environment annotation (not an `assistant` turn) — see the note on the
8431
8858
  // single-delegation push above for why framework prose must not be stored as assistant turns.
8432
- params.conversationMessages.push({
8433
- role: 'user',
8434
- content: `[You delegated this task to the parallel sub-agent "${request.name}". Reason: ${request.message}]`
8435
- });
8859
+ // skipped when the call is natively recorded and will be answered by a tool result (see the
8860
+ // single-delegation push for why a user turn between call and result must not be inserted).
8861
+ if (!(nativeResults && request.toolCallId)) {
8862
+ params.conversationMessages.push({
8863
+ role: 'user',
8864
+ content: `[You delegated this task to the parallel sub-agent "${request.name}". Reason: ${request.message}]`
8865
+ });
8866
+ }
8436
8867
  return { request: request, subAgentEntity, relationship };
8437
8868
  }
8438
8869
  /**
@@ -8675,16 +9106,38 @@ The context is now within limits. Please retry your request with the recovered c
8675
9106
  async executeParallelSubAgents(params, subAgentRequests, previousDecision, parentStepId, subAgentPayloadOverride, stepCount = 0) {
8676
9107
  const currentPayload = previousDecision.newPayload;
8677
9108
  // Synchronous pre-flight — order-stable transcript + progress events.
8678
- const dispatches = subAgentRequests.map(req => this.prepareParallelSubAgentDispatch(params, req, stepCount));
9109
+ const nativeResults = previousDecision.nativeTurn?.sendResultsNatively === true;
9110
+ const dispatches = subAgentRequests.map(req => this.prepareParallelSubAgentDispatch(params, req, stepCount, nativeResults));
8679
9111
  // Bounded parallel dispatch.
8680
9112
  const executions = await this.mapWithConcurrency(dispatches, PARALLEL_SUBAGENT_CONCURRENCY_LIMIT, (dispatch) => this.runSingleParallelSubAgent(params, dispatch, previousDecision, currentPayload, parentStepId, subAgentPayloadOverride, stepCount));
8681
9113
  // Sequential merge + per-sibling step finalization.
8682
9114
  const { mergedPayload, anyFailure, allExecutions } = await this.mergeParallelExecutionsIntoParent(params, subAgentRequests, executions, currentPayload);
8683
- // Aggregated summary appended to the parent transcript.
8684
- params.conversationMessages.push({
8685
- role: 'user',
8686
- content: `Parallel Sub-Agents Completed:\n\n${this.buildParallelSubAgentSummary(allExecutions)}`
8687
- });
9115
+ // Aggregated summary appended to the parent transcript — or, with native tool results, one `tool` turn whose
9116
+ // tool_result blocks answer each delegate_to_* call by id (every provider requires every call
9117
+ // of an assistant turn to be answered before the next turn).
9118
+ // Pair per execution rather than all-or-nothing: one dispatch that lost its id must not
9119
+ // discard the pairing for the calls that have one, or those calls go unanswered and the
9120
+ // reconciler has to report completed sub-agents as "not executed". Anything unpairable
9121
+ // keeps the markdown user message for itself, as the Actions path already does.
9122
+ const pairable = nativeResults ? allExecutions.filter((e) => !!e.request.toolCallId) : [];
9123
+ const unpairable = allExecutions.filter((e) => !pairable.includes(e));
9124
+ if (pairable.length > 0) {
9125
+ params.conversationMessages.push(buildToolResultTurn([
9126
+ ...this.payloadToolResult(previousDecision),
9127
+ ...pairable.map((e) => ({
9128
+ toolCallId: e.request.toolCallId,
9129
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(e.request.name)}`,
9130
+ content: this.buildParallelSubAgentSummary([e]),
9131
+ isError: !e.result.success
9132
+ }))
9133
+ ], undefined));
9134
+ }
9135
+ if (unpairable.length > 0) {
9136
+ params.conversationMessages.push({
9137
+ role: 'user',
9138
+ content: `Parallel Sub-Agents Completed:\n\n${this.buildParallelSubAgentSummary(unpairable)}`
9139
+ });
9140
+ }
8688
9141
  // Termination semantics: matches the single sub-agent path —
8689
9142
  // `terminateAfter` triggers parent termination regardless of the child's
8690
9143
  // success/failure. The parent's step reflects whether any child failed:
@@ -8730,10 +9183,14 @@ The context is now within limits. Please retry your request with the recovered c
8730
9183
  // reason as the action record above: the model's real output is the JSON envelope, and
8731
9184
  // storing framework prose as an `assistant` turn trains strong in-context models to imitate
8732
9185
  // the prose and drift off the required JSON format. See the note at the action-record push.
8733
- params.conversationMessages.push({
8734
- role: 'user',
8735
- content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
8736
- });
9186
+ // a natively-called delegate_to_* is already in the history as the assistant's call turn and
9187
+ // will be answered by a `tool` turn; a user turn in between violates the provider contracts.
9188
+ if (!(previousDecision?.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId)) {
9189
+ params.conversationMessages.push({
9190
+ role: 'user',
9191
+ content: `[You delegated this task to the "${subAgentRequest.name}" agent. Reason: ${subAgentRequest.message}]`
9192
+ });
9193
+ }
8737
9194
  // Prepare input data for the step
8738
9195
  const inputData = {
8739
9196
  agentName: params.agent.Name,
@@ -8891,11 +9348,22 @@ The context is now within limits. Please retry your request with the recovered c
8891
9348
  relatedMetadata.compactPromptId = override.compactPromptId;
8892
9349
  }
8893
9350
  }
8894
- params.conversationMessages.push({
8895
- role: 'user',
8896
- content: relatedResultMessage,
8897
- metadata: relatedMetadata
8898
- });
9351
+ if (previousDecision.nativeTurn?.sendResultsNatively && subAgentRequest.toolCallId) {
9352
+ // the delegate_to_* call is answered as a tool result (same as the child path).
9353
+ params.conversationMessages.push(buildToolResultTurn([...this.payloadToolResult(previousDecision), {
9354
+ toolCallId: subAgentRequest.toolCallId,
9355
+ toolName: `${SUB_AGENT_TOOL_PREFIX}${sanitizeToolName(subAgentRequest.name)}`,
9356
+ content: relatedResultMessage,
9357
+ isError: !subAgentResult.success
9358
+ }], relatedMetadata));
9359
+ }
9360
+ else {
9361
+ params.conversationMessages.push({
9362
+ role: 'user',
9363
+ content: relatedResultMessage,
9364
+ metadata: relatedMetadata
9365
+ });
9366
+ }
8899
9367
  // Update the agent run's current payload
8900
9368
  if (this._agentRun) {
8901
9369
  this._agentRun.FinalPayloadObject = mergedPayload;
@@ -9243,10 +9711,17 @@ The context is now within limits. Please retry your request with the recovered c
9243
9711
  if (addConversationMessage) {
9244
9712
  // Record as a `user`-role environment annotation (no metadata - permanent record).
9245
9713
  // See the note above on why this is NOT an `assistant` turn.
9246
- params.conversationMessages.push({
9247
- role: 'user',
9248
- content: actionMessage
9249
- });
9714
+ //
9715
+ // Exception: when the model's own tool-call turn is already in the history (native
9716
+ // tool results), the call and its arguments are the record, and a user turn between that
9717
+ // call and its `tool` result breaks the OpenAI and Anthropic contracts — results must
9718
+ // immediately follow the call. Skip it there.
9719
+ if (!previousDecision.nativeTurn?.sendResultsNatively) {
9720
+ params.conversationMessages.push({
9721
+ role: 'user',
9722
+ content: actionMessage
9723
+ });
9724
+ }
9250
9725
  }
9251
9726
  const actionEngine = ActionEngineServer.Instance;
9252
9727
  // Get the AIAgentAction metadata records for this agent (used for expiration settings)
@@ -9393,11 +9868,7 @@ The context is now within limits. Please retry your request with the recovered c
9393
9868
  }
9394
9869
  if (addConversationMessage) {
9395
9870
  // Add user message with results and optional metadata
9396
- params.conversationMessages.push({
9397
- role: 'user',
9398
- content: resultsMessage,
9399
- metadata: metadata
9400
- });
9871
+ this.appendActionResults(params, actionSummaries, resultsMessage, metadata, previousDecision); // results as a tool turn or the markdown user message
9401
9872
  // Surface explicit AI directives from action results as a separate instruction message.
9402
9873
  // Actions that need the AI to follow specific instructions (not just acknowledge data)
9403
9874
  // populate AIDirectives on their ActionResultSimple return value.
@@ -9992,6 +10463,13 @@ The context is now within limits. Please retry your request with the recovered c
9992
10463
  * Because `params` is the same object reference used for the rest of this run, every subsequent
9993
10464
  * turn's `gatherPromptTemplateData()` call picks up the change automatically — no extra plumbing.
9994
10465
  *
10466
+ * Which actions: the skill's `ExposeToModel` rows only ({@link AIEngine.GetSkillExposedActionIDs}).
10467
+ * A bundled action with the flag off is left OUT of the run: the effective action set is both the
10468
+ * prompt's tool surface and the execution allow-list, so the model neither sees it nor can call it
10469
+ * by name, and no agent path (PreProcessActionStep, a loop's action lookup) can reach it either.
10470
+ * It stays bundled for SKILL.md export and tooling; application code invokes it through the
10471
+ * Actions API. Skill attribution therefore never applies to it — the agent never runs it.
10472
+ *
9995
10473
  * Override to change propagation scope (e.g. a subclass that wants skill-granted capabilities
9996
10474
  * to cascade to sub-agents could push `scope: 'all-subagents'` instead).
9997
10475
  *
@@ -9999,7 +10477,10 @@ The context is now within limits. Please retry your request with the recovered c
9999
10477
  */
10000
10478
  enableSkillCapabilities(skill, params) {
10001
10479
  const activatingAgentIds = [params.agent.ID];
10002
- const actionIds = AIEngine.Instance.GetSkillActionIDs(skill.ID);
10480
+ // Only the actions the skill exposes (AISkillAction.ExposeToModel) join the run. A bundled
10481
+ // action with the flag off is not described to the model and not executable by the agent;
10482
+ // the application invokes it (a menu button in the skill's reply) through the Actions API.
10483
+ const actionIds = AIEngine.Instance.GetSkillExposedActionIDs(skill.ID);
10003
10484
  if (actionIds.length > 0) {
10004
10485
  if (!params.actionChanges) {
10005
10486
  params.actionChanges = [];
@@ -11620,7 +12101,18 @@ The context is now within limits. Please retry your request with the recovered c
11620
12101
  continue;
11621
12102
  }
11622
12103
  msg.metadata.isExpired = true;
11623
- if (msg.metadata.expirationMode === 'Remove') {
12104
+ if (msg.metadata.expirationMode === 'Remove' && msg.role === 'tool') {
12105
+ // a tool turn cannot be removed (it answers an assistant call); stub its blocks.
12106
+ params.conversationMessages[i] = this.stubToolTurn(msg, `[result expired after ${turnsAlive} turns]`);
12107
+ this.emitMessageLifecycleEvent({
12108
+ type: 'message-expired',
12109
+ turn: currentTurn,
12110
+ messageIndex: i,
12111
+ message: params.conversationMessages[i],
12112
+ reason: `Expired after ${turnsAlive} turns (limit: ${msg.metadata.expirationTurns}); tool turn stubbed, not removed`
12113
+ });
12114
+ }
12115
+ else if (msg.metadata.expirationMode === 'Remove') {
11624
12116
  messagesToRemove.push(i);
11625
12117
  this.emitMessageLifecycleEvent({
11626
12118
  type: 'message-expired',
@@ -11653,6 +12145,25 @@ The context is now within limits. Please retry your request with the recovered c
11653
12145
  const preserveOriginal = params.messageExpirationOverride?.preserveOriginalContent !== false;
11654
12146
  for (const item of messagesToCompact) {
11655
12147
  const originalContent = item.message.content;
12148
+ if (item.message.role === 'tool') {
12149
+ // compact per block so the tool turn keeps answering its call.
12150
+ const limit = item.metadata.compactLength || 500;
12151
+ const compactedTurn = compactToolResultContent(item.message, (t) => (t.length > limit ? `${t.slice(0, limit)}… [compacted from ${t.length} chars]` : t));
12152
+ const saved = this.estimateTokens(originalContent) - this.estimateTokens(compactedTurn.content);
12153
+ params.conversationMessages[item.index] = {
12154
+ ...compactedTurn,
12155
+ metadata: { ...item.message.metadata, wasCompacted: true, originalContent: preserveOriginal ? originalContent : undefined, originalLength: item.metadata.originalLength, tokensSaved: saved, canExpand: preserveOriginal }
12156
+ };
12157
+ this.emitMessageLifecycleEvent({
12158
+ type: 'message-compacted',
12159
+ turn: currentTurn,
12160
+ messageIndex: item.index,
12161
+ message: params.conversationMessages[item.index],
12162
+ reason: `Compacted tool turn per block (saved ${saved} tokens)`,
12163
+ tokensSaved: saved
12164
+ });
12165
+ continue;
12166
+ }
11656
12167
  const compacted = await this.compactMessage(item.message, item.metadata, params);
11657
12168
  // Calculate token savings
11658
12169
  const originalTokens = this.estimateTokens(originalContent);