@pentoshi/clai 3.11.0 → 3.11.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.
@@ -4,7 +4,7 @@ import { completeWithProvider, streamWithProvider } from "../llm/router.js";
4
4
  import { streamAlreadyEmitted } from "../llm/stream-progress.js";
5
5
  import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
6
6
  import { resolveToolDialect } from "../llm/capabilities.js";
7
- import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
7
+ import { syntheticToolCallId, isTextOnlyModel, markTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
8
8
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { jobManager, } from "../tools/jobs.js";
@@ -1154,6 +1154,7 @@ export async function runAgentTurn(prompt, options = {}) {
1154
1154
  // normal continuation is governed by evidence and resource deltas above.
1155
1155
  const maxIterations = Math.max(210, computeMaxIterations(stepBudget));
1156
1156
  let productiveSteps = 0;
1157
+ let consecutiveModelOnlyRounds = 0;
1157
1158
  /** Successful file mutation this turn — kills false "error diagnosed but not fixed". */
1158
1159
  let sawSuccessfulMutation = false;
1159
1160
  let step = -1;
@@ -3137,7 +3138,7 @@ export async function runAgentTurn(prompt, options = {}) {
3137
3138
  return;
3138
3139
  const name = fromWireName(delta.name) ?? delta.name;
3139
3140
  const existing = deferredToolCalls[delta.index];
3140
- if (existing) {
3141
+ if (existing && existing.call.name !== "…") {
3141
3142
  if (delta.argumentsBytes &&
3142
3143
  delta.argumentsBytes >= 4096 &&
3143
3144
  !writesDirectly) {
@@ -3148,20 +3149,23 @@ export async function runAgentTurn(prompt, options = {}) {
3148
3149
  }
3149
3150
  return;
3150
3151
  }
3151
- // Ensure slots are dense so index maps to deferredToolCalls[i].
3152
3152
  while (deferredToolCalls.length < delta.index) {
3153
+ const slot = deferredToolCalls.length;
3154
+ const placeholderId = `tool-${++nextToolEventId}`;
3155
+ callIds[slot] = placeholderId;
3153
3156
  deferredToolCalls.push({
3154
- eventId: `tool-${++nextToolEventId}`,
3157
+ eventId: placeholderId,
3155
3158
  call: { name: "…", args: {} },
3156
3159
  rendered: "",
3160
+ shown: false,
3157
3161
  });
3158
3162
  }
3159
3163
  const call = normalizeToolCall({
3160
3164
  name,
3161
3165
  args: {},
3162
3166
  });
3163
- const eventId = `tool-${++nextToolEventId}`;
3164
- callIds.push(eventId);
3167
+ const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
3168
+ callIds[delta.index] = eventId;
3165
3169
  alreadyPrintedIds.add(eventId);
3166
3170
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
3167
3171
  chalk.gray(` ${formatToolArgs(call)}`);
@@ -3169,6 +3173,7 @@ export async function runAgentTurn(prompt, options = {}) {
3169
3173
  eventId,
3170
3174
  call,
3171
3175
  rendered: styleToolChatter(call, toolCallLine) + "\n",
3176
+ shown: false,
3172
3177
  };
3173
3178
  if (deferredToolCalls.length === delta.index) {
3174
3179
  deferredToolCalls.push(entry);
@@ -3178,6 +3183,8 @@ export async function runAgentTurn(prompt, options = {}) {
3178
3183
  }
3179
3184
  streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
3180
3185
  if (!writesDirectly) {
3186
+ writeToolCall(eventId, call, entry.rendered);
3187
+ entry.shown = true;
3181
3188
  emit({ type: "status", text: call.name });
3182
3189
  }
3183
3190
  else {
@@ -3202,16 +3209,20 @@ export async function runAgentTurn(prompt, options = {}) {
3202
3209
  while (streamedCallsCount < parsedCalls.length) {
3203
3210
  const call = parsedCalls[streamedCallsCount];
3204
3211
  const eventId = `tool-${++nextToolEventId}`;
3205
- callIds.push(eventId);
3212
+ callIds[streamedCallsCount] = eventId;
3206
3213
  alreadyPrintedIds.add(eventId);
3207
3214
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
3208
3215
  chalk.gray(` ${formatToolArgs(call)}`);
3209
- deferredToolCalls.push({
3216
+ const entry = {
3210
3217
  eventId,
3211
3218
  call,
3212
3219
  rendered: styleToolChatter(call, toolCallLine) + "\n",
3213
- });
3220
+ shown: false,
3221
+ };
3222
+ deferredToolCalls.push(entry);
3214
3223
  if (!writesDirectly) {
3224
+ writeToolCall(eventId, call, entry.rendered);
3225
+ entry.shown = true;
3215
3226
  emit({ type: "status", text: call.name });
3216
3227
  }
3217
3228
  streamedCallsCount += 1;
@@ -3293,17 +3304,42 @@ export async function runAgentTurn(prompt, options = {}) {
3293
3304
  kind: failureKind,
3294
3305
  state: recoveryState,
3295
3306
  });
3307
+ const partialStream = streamAlreadyEmitted(streamError) || accumulatedText.length > 0;
3308
+ let continuationNudge = "";
3309
+ if (partialStream) {
3310
+ spinner.stop();
3311
+ deltaParser?.finish();
3312
+ const partial = rememberThinkingFromText(accumulatedText);
3313
+ const hasShownToolCall = deferredToolCalls.some((entry) => entry.shown);
3314
+ const partialVisible = textBeforeToolCall(stripThinking(collapseRepeatedText(accumulatedText)).visible).trim();
3315
+ if (partialVisible) {
3316
+ if (!hasShownToolCall) {
3317
+ writeAssistantMessage(partialVisible);
3318
+ }
3319
+ pushAssistantHistory(partialVisible);
3320
+ }
3321
+ else if (!writesDirectly && !hasShownToolCall) {
3322
+ emit({ type: "assistant-message", text: partial.visible });
3323
+ }
3324
+ if (partial.hasThinking && !hasShownToolCall) {
3325
+ writeThinkingBlock(partial.thinkContent);
3326
+ }
3327
+ for (const deferred of deferredToolCalls) {
3328
+ if (!deferred.shown || deferred.call.name === "…")
3329
+ continue;
3330
+ writeToolBlocked(deferred.eventId, deferred.call.name, "Incomplete tool call discarded after the provider stream was interrupted.", chalk.yellow(" ⚠ incomplete tool call discarded after stream interruption\n"));
3331
+ }
3332
+ continuationNudge =
3333
+ "The provider stream was interrupted after partial output. Continue from the exact stopping point without repeating prior text. Any incomplete tool call was discarded and must be reissued in full.";
3334
+ const restartNotice = plan.action === "give-up"
3335
+ ? "partial response preserved before terminal provider failure"
3336
+ : "partial response preserved — resuming from the interruption";
3337
+ writeNotice("warn", restartNotice, chalk.yellow(` ⚠ ${restartNotice}\n`));
3338
+ }
3296
3339
  if (plan.action === "give-up") {
3297
3340
  throw streamError;
3298
3341
  }
3299
3342
  recordRecoveryAttempt(recoveryState, failureKind);
3300
- // The router refuses transparent retries after emission,
3301
- // but the recovery ladder may still re-run the step. Say so, since
3302
- // the visible answer restarts from scratch.
3303
- if (streamAlreadyEmitted(streamError)) {
3304
- const restartNotice = "partial answer discarded after a mid-stream failure — the reply restarts below";
3305
- writeNotice("warn", restartNotice, chalk.yellow(` ⚠ ${restartNotice}\n`));
3306
- }
3307
3343
  if (plan.notice) {
3308
3344
  writeNotice("warn", plan.notice, chalk.yellow(` ⚠ ${plan.notice}\n`));
3309
3345
  }
@@ -3314,8 +3350,11 @@ export async function runAgentTurn(prompt, options = {}) {
3314
3350
  if (plan.forceCompact) {
3315
3351
  await maybeAutoCompact(`stream-recovery:${failureKind}`, true);
3316
3352
  }
3317
- if (plan.nudge) {
3318
- messages.push(recoveryUserMessage(plan.nudge));
3353
+ const recoveryNudge = [continuationNudge, plan.nudge]
3354
+ .filter((part) => Boolean(part))
3355
+ .join("\n\n");
3356
+ if (recoveryNudge) {
3357
+ messages.push(recoveryUserMessage(recoveryNudge));
3319
3358
  }
3320
3359
  if (plan.delayMs > 0) {
3321
3360
  emit({
@@ -3374,6 +3413,25 @@ export async function runAgentTurn(prompt, options = {}) {
3374
3413
  (toolsAttached && !isTextOnlyModel(provider, model));
3375
3414
  const assistantTextResult = rememberThinkingFromText(completion.text);
3376
3415
  assistantText = assistantTextResult;
3416
+ const commitAssistantRetry = (historyText) => {
3417
+ const hasShownToolCall = deferredToolCalls.some((entry) => entry.shown);
3418
+ if (!hasShownToolCall) {
3419
+ const displayText = textBeforeToolCall(stripThinking(collapseRepeatedText(completion.text)).visible).trim();
3420
+ if (displayText) {
3421
+ writeAssistantMessage(displayText);
3422
+ }
3423
+ else if (deltaParser) {
3424
+ emit({ type: "assistant-message", text: "" });
3425
+ }
3426
+ if (assistantText.hasThinking && deltaParser) {
3427
+ emit({
3428
+ type: "thinking-block",
3429
+ content: assistantText.thinkContent,
3430
+ });
3431
+ }
3432
+ }
3433
+ pushAssistantHistory(historyText);
3434
+ };
3377
3435
  // Only emit a thinking-block event when the classic renderer is
3378
3436
  // active (writesDirectly / no deltaParser). In TUI v2 the
3379
3437
  // deltaParser already streamed thinking-delta events that created
@@ -3392,13 +3450,14 @@ export async function runAgentTurn(prompt, options = {}) {
3392
3450
  // otherwise create cards now (non-streaming / name-after-done providers).
3393
3451
  if (nativeToolCalls.length) {
3394
3452
  if (deferredToolCalls.length === 0) {
3395
- for (const tc of nativeToolCalls) {
3453
+ for (let i = 0; i < nativeToolCalls.length; i += 1) {
3454
+ const tc = nativeToolCalls[i];
3396
3455
  const normalized = normalizeToolCall({
3397
3456
  name: tc.name,
3398
3457
  args: tc.args,
3399
3458
  });
3400
3459
  const eventId = `tool-${++nextToolEventId}`;
3401
- callIds.push(eventId);
3460
+ callIds[i] = eventId;
3402
3461
  alreadyPrintedIds.add(eventId);
3403
3462
  const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
3404
3463
  chalk.gray(` ${formatToolArgs(normalized)}`);
@@ -3406,6 +3465,7 @@ export async function runAgentTurn(prompt, options = {}) {
3406
3465
  eventId,
3407
3466
  call: normalized,
3408
3467
  rendered: styleToolChatter(normalized, toolCallLine) + "\n",
3468
+ shown: false,
3409
3469
  });
3410
3470
  }
3411
3471
  }
@@ -3418,6 +3478,7 @@ export async function runAgentTurn(prompt, options = {}) {
3418
3478
  });
3419
3479
  const existing = deferredToolCalls[i];
3420
3480
  if (existing && existing.call.name !== "…") {
3481
+ callIds[i] = existing.eventId;
3421
3482
  existing.call = normalized;
3422
3483
  const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
3423
3484
  chalk.gray(` ${formatToolArgs(normalized)}`);
@@ -3426,16 +3487,15 @@ export async function runAgentTurn(prompt, options = {}) {
3426
3487
  }
3427
3488
  else if (!existing || existing.call.name === "…") {
3428
3489
  const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
3429
- if (!existing) {
3430
- callIds.push(eventId);
3431
- alreadyPrintedIds.add(eventId);
3432
- }
3490
+ callIds[i] = eventId;
3491
+ alreadyPrintedIds.add(eventId);
3433
3492
  const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
3434
3493
  chalk.gray(` ${formatToolArgs(normalized)}`);
3435
3494
  const entry = {
3436
3495
  eventId,
3437
3496
  call: normalized,
3438
3497
  rendered: styleToolChatter(normalized, toolCallLine) + "\n",
3498
+ shown: existing?.shown ?? false,
3439
3499
  };
3440
3500
  if (existing)
3441
3501
  deferredToolCalls[i] = entry;
@@ -3551,7 +3611,7 @@ export async function runAgentTurn(prompt, options = {}) {
3551
3611
  }
3552
3612
  if (assistantText.hasThinking)
3553
3613
  retryWithoutThinking = true;
3554
- pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
3614
+ commitAssistantRetry(stripThinking(collapseRepeatedText(completion.text)).visible);
3555
3615
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
3556
3616
  const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
3557
3617
  ? toolsAttached
@@ -3603,6 +3663,7 @@ export async function runAgentTurn(prompt, options = {}) {
3603
3663
  }
3604
3664
  }
3605
3665
  if (!call) {
3666
+ consecutiveModelOnlyRounds += 1;
3606
3667
  if (bareArgsOnly) {
3607
3668
  bareToolJsonRetries += 1;
3608
3669
  if (bareToolJsonRetries <= 3) {
@@ -3611,7 +3672,7 @@ export async function runAgentTurn(prompt, options = {}) {
3611
3672
  : "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(toolsAttached
3612
3673
  ? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
3613
3674
  : " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
3614
- pushAssistantHistory(assistantText.visible);
3675
+ commitAssistantRetry(assistantText.visible);
3615
3676
  messages.push(recoveryUserMessage(isPlanMode && !activePlan
3616
3677
  ? toolsAttached
3617
3678
  ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
@@ -3634,7 +3695,7 @@ export async function runAgentTurn(prompt, options = {}) {
3634
3695
  }
3635
3696
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
3636
3697
  writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
3637
- pushAssistantHistory(assistantText.visible);
3698
+ commitAssistantRetry(assistantText.visible);
3638
3699
  messages.push(recoveryUserMessage(toolsAttached
3639
3700
  ? "Your previous tool call was malformed or truncated. " +
3640
3701
  toolNudge(true) +
@@ -3656,7 +3717,7 @@ export async function runAgentTurn(prompt, options = {}) {
3656
3717
  if (writeResult.ok) {
3657
3718
  const lineCount = salvaged.content.split("\n").length;
3658
3719
  writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
3659
- pushAssistantHistory(stripThinking(assistantText.visible).visible);
3720
+ commitAssistantRetry(stripThinking(assistantText.visible).visible);
3660
3721
  const priorBytes = writeResult.bytesOnDisk;
3661
3722
  const salvagedToolName = salvaged.operation === "append" ? "fs.append" : "fs.write";
3662
3723
  messages.push({
@@ -3685,7 +3746,7 @@ export async function runAgentTurn(prompt, options = {}) {
3685
3746
  }
3686
3747
  if (truncatedToolRetries <= 3) {
3687
3748
  writeNotice("warn", "tool call was cut off (output too long) — asking the model to retry safely", chalk.yellow(" ⚠ tool call was cut off (output too long) — asking the model to retry safely\n"));
3688
- pushAssistantHistory(stripThinking(assistantText.visible).visible);
3749
+ commitAssistantRetry(stripThinking(assistantText.visible).visible);
3689
3750
  messages.push({
3690
3751
  role: "user",
3691
3752
  content: toolsAttached
@@ -3718,7 +3779,7 @@ export async function runAgentTurn(prompt, options = {}) {
3718
3779
  if (writeResult.ok) {
3719
3780
  const lineCount = salvaged.content.split("\n").length;
3720
3781
  writeNotice("info", `malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}`, chalk.cyan(` ℹ malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}\n`));
3721
- pushAssistantHistory(stripThinking(assistantText.visible).visible);
3782
+ commitAssistantRetry(stripThinking(assistantText.visible).visible);
3722
3783
  messages.push({
3723
3784
  role: "user",
3724
3785
  content: `The system extracted and wrote ${lineCount} lines to ${salvaged.path} from your malformed tool call. ` +
@@ -3736,7 +3797,7 @@ export async function runAgentTurn(prompt, options = {}) {
3736
3797
  malformedFenceRetries += 1;
3737
3798
  if (malformedFenceRetries <= 3) {
3738
3799
  writeNotice("warn", "tool block present but its JSON didn't parse — asking the model to re-emit valid JSON", chalk.yellow(" ⚠ tool block present but its JSON didn't parse — asking the model to re-emit valid JSON\n"));
3739
- pushAssistantHistory(stripThinking(assistantText.visible).visible);
3800
+ commitAssistantRetry(stripThinking(assistantText.visible).visible);
3740
3801
  messages.push({
3741
3802
  role: "user",
3742
3803
  content: toolsAttached
@@ -3756,14 +3817,7 @@ export async function runAgentTurn(prompt, options = {}) {
3756
3817
  }
3757
3818
  // Exhausted retries — fall through to the normal path.
3758
3819
  }
3759
- const cleaned = stripSentinelTokens(assistantText.visible);
3760
- if (unreadResponderNotificationIds.size > 0) {
3761
- const unread = [...unreadResponderNotificationIds];
3762
- pushAssistantHistory(assistantText.visible);
3763
- messages.push(recoveryUserMessage(`You have ${unread.length} delivered Responder result(s) that remain unread: ${unread.join(", ")}. ` +
3764
- "If analysis is incomplete, call only the bounded evidence tool needed now. If each result has been analyzed and is satisfactory, you MUST call job.read with its jobId or exact notificationId before giving a final response. job.read does not require an active plan; do not create or update a plan merely to acknowledge a result."));
3765
- continue;
3766
- }
3820
+ const cleaned = collapseRepeatedText(stripSentinelTokens(assistantText.visible));
3767
3821
  const narratedAction = looksLikeActionNarration(cleaned);
3768
3822
  const narratedWebAction = looksLikeWebActionNarration(cleaned);
3769
3823
  const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
@@ -3778,11 +3832,47 @@ export async function runAgentTurn(prompt, options = {}) {
3778
3832
  (!informationalQuery &&
3779
3833
  !idleOrSocialPrompt &&
3780
3834
  (buildLikeTurn || pentestLikeTurn));
3835
+ const unreadResponderResults = unreadResponderNotificationIds.size > 0;
3781
3836
  const wantsAction = !completedPlanDuringThisTurn &&
3782
3837
  !idleOrSocialPrompt &&
3783
3838
  (userExpectsWork ||
3784
3839
  (narratedAction && !informationalQuery) ||
3785
3840
  (narratedWebAction && !informationalQuery));
3841
+ if ((wantsAction || unreadResponderResults) &&
3842
+ toolsAttached &&
3843
+ consecutiveModelOnlyRounds === 2) {
3844
+ markTextOnlyModel(provider, model);
3845
+ commitAssistantRetry(assistantText.visible);
3846
+ writeNotice("warn", "model repeatedly returned prose instead of a native tool call — switching this model to the text tool protocol", chalk.yellow(" ⚠ switching this model to the text tool protocol after repeated non-actionable responses\n"));
3847
+ messages.push(recoveryUserMessage("Native tool calling did not produce an executable call. Continue now with exactly one complete fenced ```tool block. Do not repeat the prior narration."));
3848
+ continue;
3849
+ }
3850
+ if ((wantsAction || unreadResponderResults) &&
3851
+ consecutiveModelOnlyRounds >= 6) {
3852
+ commitAssistantRetry(assistantText.visible);
3853
+ const stalledMessage = "Stopped a repeated model-only retry cycle after the model returned no executable tool call. Completed work and transcript output were preserved.";
3854
+ writeAssistantMessage(stalledMessage);
3855
+ const remainingCriteria = livePlanAtCompletion
3856
+ ? foregroundRemaining(livePlanAtCompletion).map((task) => `[${task.id}] ${task.title}`)
3857
+ : [];
3858
+ if (unreadResponderResults) {
3859
+ remainingCriteria.push("Analyze and acknowledge each delivered Responder result.");
3860
+ }
3861
+ if (remainingCriteria.length === 0) {
3862
+ remainingCriteria.push("Continue the unfinished work with an executable tool call.");
3863
+ }
3864
+ outcomeState.outcome.status = "partial";
3865
+ await saveOutcomeState(outcomeState);
3866
+ moveTurn("partial", "repeated model-only responses");
3867
+ return finishTurn(stalledMessage, productiveSteps, "partial", remainingCriteria, "The model returned six consecutive responses without executing a tool.");
3868
+ }
3869
+ if (unreadResponderResults) {
3870
+ const unread = [...unreadResponderNotificationIds];
3871
+ commitAssistantRetry(assistantText.visible);
3872
+ messages.push(recoveryUserMessage(`You have ${unread.length} delivered Responder result(s) that remain unread: ${unread.join(", ")}. ` +
3873
+ "If analysis is incomplete, call only the bounded evidence tool needed now. If each result has been analyzed and is satisfactory, you MUST call job.read with its jobId or exact notificationId before giving a final response. job.read does not require an active plan; do not create or update a plan merely to acknowledge a result."));
3874
+ continue;
3875
+ }
3786
3876
  const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
3787
3877
  !activePlan &&
3788
3878
  looksLikePlanNarration(cleaned);
@@ -3834,7 +3924,7 @@ export async function runAgentTurn(prompt, options = {}) {
3834
3924
  }
3835
3925
  if (action) {
3836
3926
  consumeBudget(recovery, action.budgetKey);
3837
- pushAssistantHistory(assistantText.visible);
3927
+ commitAssistantRetry(assistantText.visible);
3838
3928
  messages.push(recoveryUserMessage(action.message));
3839
3929
  continue;
3840
3930
  }
@@ -3847,7 +3937,7 @@ export async function runAgentTurn(prompt, options = {}) {
3847
3937
  ? " Call the web_search tool now."
3848
3938
  : " Reply with ONLY a fenced ```tool block for web.search now."));
3849
3939
  consumeBudget(recovery, action.budgetKey);
3850
- pushAssistantHistory(assistantText.visible);
3940
+ commitAssistantRetry(assistantText.visible);
3851
3941
  messages.push(recoveryUserMessage(action.message));
3852
3942
  continue;
3853
3943
  }
@@ -3859,7 +3949,7 @@ export async function runAgentTurn(prompt, options = {}) {
3859
3949
  if (!planAtEnd && !sawPlanCreateOk) {
3860
3950
  const action = recoveryForMissingPlan(toolsAttached);
3861
3951
  consumeBudget(recovery, action.budgetKey);
3862
- pushAssistantHistory(assistantText.visible);
3952
+ commitAssistantRetry(assistantText.visible);
3863
3953
  messages.push(recoveryUserMessage(action.message));
3864
3954
  continue;
3865
3955
  }
@@ -3875,7 +3965,7 @@ export async function runAgentTurn(prompt, options = {}) {
3875
3965
  budgetRemaining(recovery, "featureImpl")) {
3876
3966
  const action = recoveryForMissingFeature(getActiveProjectRoot());
3877
3967
  consumeBudget(recovery, action.budgetKey);
3878
- pushAssistantHistory(assistantText.visible);
3968
+ commitAssistantRetry(assistantText.visible);
3879
3969
  messages.push(recoveryUserMessage(action.message));
3880
3970
  continue;
3881
3971
  }
@@ -3905,7 +3995,7 @@ export async function runAgentTurn(prompt, options = {}) {
3905
3995
  if (codingPlanFinished || freestyleLocalAppDone) {
3906
3996
  const action = recoveryForRuntimeVerify(getActiveProjectRoot());
3907
3997
  consumeBudget(recovery, action.budgetKey);
3908
- pushAssistantHistory(assistantText.visible);
3998
+ commitAssistantRetry(assistantText.visible);
3909
3999
  messages.push(recoveryUserMessage(action.message));
3910
4000
  continue;
3911
4001
  }
@@ -3920,7 +4010,7 @@ export async function runAgentTurn(prompt, options = {}) {
3920
4010
  cleaned.trim().length > 0) {
3921
4011
  const action = recoveryForFailedProbe();
3922
4012
  consumeBudget(recovery, action.budgetKey);
3923
- pushAssistantHistory(assistantText.visible);
4013
+ commitAssistantRetry(assistantText.visible);
3924
4014
  messages.push(recoveryUserMessage(action.message));
3925
4015
  continue;
3926
4016
  }
@@ -3932,7 +4022,7 @@ export async function runAgentTurn(prompt, options = {}) {
3932
4022
  })) {
3933
4023
  const action = recoveryForShallowPentest();
3934
4024
  consumeBudget(recovery, action.budgetKey);
3935
- pushAssistantHistory(assistantText.visible);
4025
+ commitAssistantRetry(assistantText.visible);
3936
4026
  messages.push(recoveryUserMessage(action.message));
3937
4027
  continue;
3938
4028
  }
@@ -3954,7 +4044,7 @@ export async function runAgentTurn(prompt, options = {}) {
3954
4044
  errorFix: errorFixNarration,
3955
4045
  });
3956
4046
  consumeBudget(recovery, action.budgetKey);
3957
- pushAssistantHistory(assistantText.visible);
4047
+ commitAssistantRetry(assistantText.visible);
3958
4048
  messages.push(recoveryUserMessage(action.message));
3959
4049
  continue;
3960
4050
  }
@@ -3999,6 +4089,7 @@ export async function runAgentTurn(prompt, options = {}) {
3999
4089
  outcomeStatus,
4000
4090
  remainingCriteria,
4001
4091
  });
4092
+ writeAssistantMessage(cleaned);
4002
4093
  lastAnswer = cleaned;
4003
4094
  return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
4004
4095
  ? "One or more required plan tasks failed."
@@ -4013,7 +4104,8 @@ export async function runAgentTurn(prompt, options = {}) {
4013
4104
  : nativeToolCalls.length
4014
4105
  ? assistantText.visible.trim()
4015
4106
  : textBeforeToolCall(assistantText.visible);
4016
- if (beforeTool) {
4107
+ if (beforeTool &&
4108
+ !deferredToolCalls.some((entry) => entry.shown)) {
4017
4109
  writeAssistantMessage(beforeTool);
4018
4110
  }
4019
4111
  let bound = [];
@@ -4182,7 +4274,10 @@ export async function runAgentTurn(prompt, options = {}) {
4182
4274
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
4183
4275
  if (!deferred.call.name || deferred.call.name === "…")
4184
4276
  continue;
4185
- writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
4277
+ if (!deferred.shown || !writesDirectly) {
4278
+ writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
4279
+ deferred.shown = true;
4280
+ }
4186
4281
  }
4187
4282
  if (historyNativeCalls.length) {
4188
4283
  appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock);
@@ -4232,6 +4327,7 @@ export async function runAgentTurn(prompt, options = {}) {
4232
4327
  * explicit user abort stops remaining calls.
4233
4328
  */
4234
4329
  const recordResult = (boundCall, res) => {
4330
+ consecutiveModelOnlyRounds = 0;
4235
4331
  recordedNativeIds.add(boundCall.id);
4236
4332
  actionSequenceExecuted += 1;
4237
4333
  // A policy-suppressed call is deterministic: replaying it verbatim