@pentoshi/clai 3.11.29 → 3.11.33

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 (43) hide show
  1. package/dist/agent/compaction-summary.js +1 -1
  2. package/dist/agent/compaction-summary.js.map +1 -1
  3. package/dist/agent/continue-orient.js +1 -1
  4. package/dist/agent/continue-orient.js.map +1 -1
  5. package/dist/agent/finalize-gate.d.ts +0 -3
  6. package/dist/agent/finalize-gate.js +2 -10
  7. package/dist/agent/finalize-gate.js.map +1 -1
  8. package/dist/agent/loop-guard.d.ts +12 -3
  9. package/dist/agent/loop-guard.js +237 -41
  10. package/dist/agent/loop-guard.js.map +1 -1
  11. package/dist/agent/must-continue.d.ts +1 -3
  12. package/dist/agent/must-continue.js +0 -16
  13. package/dist/agent/must-continue.js.map +1 -1
  14. package/dist/agent/plan-tool.js +1 -1
  15. package/dist/agent/plan-tool.js.map +1 -1
  16. package/dist/agent/runner.js +73 -139
  17. package/dist/agent/runner.js.map +1 -1
  18. package/dist/agent/task-analyzer.js +1 -1
  19. package/dist/agent/task-analyzer.js.map +1 -1
  20. package/dist/agent/tool-call-parser.d.ts +2 -4
  21. package/dist/agent/tool-call-parser.js +109 -59
  22. package/dist/agent/tool-call-parser.js.map +1 -1
  23. package/dist/agent/workspace-orient.js +1 -1
  24. package/dist/agent/workspace-orient.js.map +1 -1
  25. package/dist/llm/capabilities.d.ts +2 -2
  26. package/dist/llm/capabilities.js +9 -11
  27. package/dist/llm/capabilities.js.map +1 -1
  28. package/dist/llm/http.js +3 -1
  29. package/dist/llm/http.js.map +1 -1
  30. package/dist/llm/router.js +9 -5
  31. package/dist/llm/router.js.map +1 -1
  32. package/dist/prompts/embedded.js +1 -1
  33. package/dist/prompts/embedded.js.map +1 -1
  34. package/dist/prompts/index.js +13 -6
  35. package/dist/prompts/index.js.map +1 -1
  36. package/dist/prompts/system.agent.md +6 -4
  37. package/dist/tui-v2/rendering/incremental-strip.js +1 -1
  38. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -1
  39. package/dist/tui-v2/rendering/strip-tool-surfaces.js +10 -0
  40. package/dist/tui-v2/rendering/strip-tool-surfaces.js.map +1 -1
  41. package/dist/version.generated.d.ts +2 -2
  42. package/dist/version.generated.js +2 -2
  43. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, creat
6
6
  import { modelSupportsVision, resolveToolDialect } from "../llm/capabilities.js";
7
7
  import { syntheticToolCallId, isTextOnlyModel, markTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
8
8
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
9
- import { randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
  import { jobManager, } from "../tools/jobs.js";
11
11
  import { isResponderResultLedgerMessage, responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
12
12
  import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, renderRequestEnvironmentContext, scratchDirFor, toolNudge, } from "../prompts/index.js";
@@ -63,14 +63,14 @@ import { CompactionAttemptLedger, compactionAttemptKey, } from "./compaction-att
63
63
  import { loadPlan, mutatePlan, markTask, appendPlanTask, readyPlanTasks, foregroundRemaining, responderOpenTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
64
64
  import { stat } from "node:fs/promises";
65
65
  import { isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
66
- import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, localHttpProbeIsFailure, localHttpProbeIsSuccess, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
66
+ import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, localHttpProbeIsFailure, localHttpProbeIsSuccess, buildWorkflowDirective, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
67
67
  import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
68
68
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
69
69
  import { codingSessionFromContext, isProtocolPlaceholderOutput, progressPauseMode, } from "./progress-pause-policy.js";
70
70
  import { renderPlanForTerminal, planContextMessage, upsertPlanContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
71
71
  import { readDeclaredParentTaskId, resolveResponderParent, isExplicitResponderDelegation, delegationTaskTitle, } from "./responder-parent.js";
72
72
  import { readTaskUpdateArgs, distinctAdvancingTaskIds, isSimultaneousTaskAdvance, batchUpdateSignature, buildMultiUpdateReminder, buildMultiOpenRejection, multiUpdateToast, multiOpenToast, openingTaskIds, } from "./task-sync.js";
73
- import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
73
+ import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteReconToolCall, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
74
74
  import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
75
75
  import { buildContinueOrientation, looksLikeContinueOrResumePrompt, } from "./continue-orient.js";
76
76
  import { detectPackageManager } from "./workspace-orient.js";
@@ -86,7 +86,7 @@ import { buildRichStopSummary } from "./stop-summary.js";
86
86
  import { composeAgentSystemPrompt } from "./prompt-composer.js";
87
87
  import { createGovernorState, governProgress, } from "./evidence-governor.js";
88
88
  import { createTurnState, transitionTurn, } from "./turn-state.js";
89
- import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
89
+ import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, completedOperationObservationDigest, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
90
90
  import { createTurnOutcome, normalizeTurnOutcomeInput, renderTurnOutcome, } from "./turn-outcome.js";
91
91
  import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
92
92
  export * from "./tool-call-parser.js";
@@ -442,19 +442,12 @@ export async function runAgentTurn(prompt, options = {}) {
442
442
  // brand-new plan (the exact failure where "what do u know till now"
443
443
  // triggered explore→plan and created an unrelated "Enhance clai" plan).
444
444
  const informationalQuery = looksLikeInformationalQuery(prompt);
445
- // Greetings / thanks / short acks must never force tools, plans, or
446
- // freshness retries — a false "act don't narrate" path burned tokens on
447
- // web.search recovery loops after a simple "hi".
445
+ // Greetings / thanks / short acks must never force tools or plans.
448
446
  const idleOrSocialPrompt = looksLikeIdleOrSocialPrompt(prompt);
449
447
  suppressOutcomeDiagnostics =
450
448
  informationalQuery ||
451
449
  idleOrSocialPrompt ||
452
450
  looksLikeContinueOrResumePrompt(prompt);
453
- const freshWebSearchRequired = !buildLikeTurn &&
454
- !pentestLikeTurn &&
455
- !idleOrSocialPrompt &&
456
- toolNames.includes("web.search") &&
457
- requiresFreshWebSearch(prompt);
458
451
  let provider = initialProvider;
459
452
  await ensureProviderConfigured(provider);
460
453
  let model = initialModel;
@@ -608,9 +601,6 @@ export async function runAgentTurn(prompt, options = {}) {
608
601
  orientInput.candidateProject = candidate;
609
602
  systemSections.push(buildWorkspaceOrientation(orientInput));
610
603
  }
611
- if (freshWebSearchRequired) {
612
- systemSections.push(freshnessGuardMessage());
613
- }
614
604
  // The live plan is mutable state: it is injected once as a keyed request
615
605
  // suffix (upsertPlanContextMessage) instead of being frozen into the stable
616
606
  // system prefix, so the model never sees a stale and a fresh plan together.
@@ -719,7 +709,6 @@ export async function runAgentTurn(prompt, options = {}) {
719
709
  content.startsWith("ACTIVE PROJECT ROOT:") ||
720
710
  content.startsWith("USER DESTINATION:") ||
721
711
  content.startsWith("WORKSPACE STATUS") ||
722
- content.startsWith("Freshness guard for this turn:") ||
723
712
  content.includes("MODE") ||
724
713
  content.includes("OUTCOME"),
725
714
  }));
@@ -856,6 +845,17 @@ export async function runAgentTurn(prompt, options = {}) {
856
845
  }
857
846
  return message;
858
847
  };
848
+ const upsertActionCycleRecovery = (content) => {
849
+ const prefix = "[ACTION CYCLE RECOVERY] ";
850
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
851
+ const message = messages[index];
852
+ if (message.role === "user" && message.internal && message.content.startsWith(prefix)) {
853
+ messages.splice(index, 1);
854
+ break;
855
+ }
856
+ }
857
+ messages.push(recoveryUserMessage(prefix + content));
858
+ };
859
859
  const recoveryProse = (content) => {
860
860
  const text = textBeforeToolCall(stripSentinelTokens(content)).trim();
861
861
  if (!text ||
@@ -928,7 +928,6 @@ export async function runAgentTurn(prompt, options = {}) {
928
928
  // (e.g. malformed extra/missing braces that are NOT simple truncation). We
929
929
  // retry instead of leaking the raw block as the final answer.
930
930
  let malformedFenceRetries = 0;
931
- let sawFreshWebSearch = false;
932
931
  const recovery = createRecoveryBudgets();
933
932
  let sawServerStart = false;
934
933
  let sawPlanCreateOk = false;
@@ -1000,70 +999,6 @@ export async function runAgentTurn(prompt, options = {}) {
1000
999
  remoteWorkVerified: planHasVerifiedRemoteWork(plan),
1001
1000
  });
1002
1001
  };
1003
- const reconcileOpenTaskBeforeFinalizing = async () => {
1004
- let plan = await loadPlan(session.sessionId).catch(() => undefined);
1005
- const open = plan?.tasks.find((task) => task.state === "in_progress" && !task.responderOwned);
1006
- if (!plan || !open)
1007
- return plan;
1008
- const gate = completionGateForTask(plan, open.id);
1009
- if (!gate.ok)
1010
- return plan;
1011
- const reconciledTaskIds = [open.id];
1012
- const working = plan;
1013
- markTask(working, open.id, "done", "Completion reconciled from verified task evidence.");
1014
- while (true) {
1015
- const observation = readyPlanTasks(working).find((task) => isRuntimeObservationTask(task.title) ||
1016
- (working.kind === "pentest" && isRemoteObservationTask(task.title)));
1017
- if (!observation)
1018
- break;
1019
- const observationGate = completionGateForTask(working, observation.id);
1020
- if (!observationGate.ok)
1021
- break;
1022
- markTask(working, observation.id, "done", working.kind === "pentest"
1023
- ? "Satisfied by verified remote evidence from the preceding task."
1024
- : "Satisfied by the verified runtime evidence from the preceding task.");
1025
- reconciledTaskIds.push(observation.id);
1026
- }
1027
- if (working.status === "draft" || working.status === "approved") {
1028
- working.status = "in_progress";
1029
- }
1030
- if (isPlanTerminal(working)) {
1031
- working.status = isPlanSuccessful(working) ? "completed" : "abandoned";
1032
- }
1033
- // Replay the same reconciliation as a reducer so a concurrent
1034
- // responder settlement is preserved instead of overwritten.
1035
- const reconciledNotes = new Map(reconciledTaskIds.map((id) => [
1036
- id,
1037
- working.tasks.find((task) => task.id === id)?.note,
1038
- ]));
1039
- const reconcileResult = await mutatePlan(session.sessionId, (draft) => {
1040
- let changed = false;
1041
- for (const [id, note] of reconciledNotes) {
1042
- const task = draft.tasks.find((candidate) => candidate.id === id);
1043
- if (!task || task.state === "done")
1044
- continue;
1045
- task.state = "done";
1046
- if (note !== undefined)
1047
- task.note = note;
1048
- changed = true;
1049
- }
1050
- if (draft.status === "draft" || draft.status === "approved") {
1051
- draft.status = "in_progress";
1052
- changed = true;
1053
- }
1054
- if (isPlanTerminal(draft)) {
1055
- draft.status = isPlanSuccessful(draft) ? "completed" : "abandoned";
1056
- changed = true;
1057
- }
1058
- return changed;
1059
- }).catch(() => undefined);
1060
- if (reconcileResult?.ok && reconcileResult.plan)
1061
- plan = reconcileResult.plan;
1062
- writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
1063
- writeNotice("info", `reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence`, chalk.dim(` ℹ reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence — no duplicate verification\n`));
1064
- taskWorkLedger = null;
1065
- return plan;
1066
- };
1067
1002
  async function persistProjectRootOnPlan(root) {
1068
1003
  const pm = detectPackageManager(root);
1069
1004
  // Metadata patches go through the transactional boundary so a
@@ -1376,8 +1311,14 @@ export async function runAgentTurn(prompt, options = {}) {
1376
1311
  ...(retryReason ? { retryReason } : {}),
1377
1312
  });
1378
1313
  if (loopCheck.block) {
1379
- const reason = loopCheck.reason ??
1314
+ const baseReason = loopCheck.reason ??
1380
1315
  `${call.name} previously failed with identical arguments. Change the command/args and retry.`;
1316
+ const priorObservation = loopCheck.kind === "unchanged-success"
1317
+ ? loopGuard.getPriorObservation(call.name, call.args)
1318
+ : undefined;
1319
+ const reason = priorObservation
1320
+ ? `${baseReason}\n\nPrior successful result (reuse this; it is the result of the requested call):\n${priorObservation}`
1321
+ : baseReason;
1381
1322
  if (loopCheck.kind === "unchanged-success") {
1382
1323
  const result = { ok: true, output: reason, exitCode: 0 };
1383
1324
  emitVisibleSyntheticReceipt(result, reason);
@@ -1789,9 +1730,6 @@ export async function runAgentTurn(prompt, options = {}) {
1789
1730
  };
1790
1731
  }
1791
1732
  }
1792
- if (call.name === "web.search") {
1793
- sawFreshWebSearch = true;
1794
- }
1795
1733
  if (!alreadyPrintedIds.has(toolEventId)) {
1796
1734
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1797
1735
  writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
@@ -3257,9 +3195,7 @@ export async function runAgentTurn(prompt, options = {}) {
3257
3195
  ...(toolsAttached
3258
3196
  ? {
3259
3197
  tools: turnTools,
3260
- toolChoice: freshWebSearchRequired && !sawFreshWebSearch
3261
- ? { type: "function", name: "web.search" }
3262
- : "auto",
3198
+ toolChoice: "auto",
3263
3199
  parallelToolCalls: true,
3264
3200
  // P2-3: emit tool cards as soon as the function name arrives.
3265
3201
  onToolCallDelta: (delta) => {
@@ -3692,12 +3628,15 @@ export async function runAgentTurn(prompt, options = {}) {
3692
3628
  }
3693
3629
  if (nativeToolCalls.length) {
3694
3630
  const first = nativeToolCalls[0];
3695
- if (first.args?._parseError) {
3696
- call = undefined;
3697
- }
3698
- else {
3699
- call = normalizeToolCall({ name: first.name, args: first.args });
3700
- }
3631
+ call = first.args?._parseError
3632
+ ? {
3633
+ name: first.name || "unknown",
3634
+ args: {
3635
+ __nativeParseError: true,
3636
+ _raw: first.args._raw,
3637
+ },
3638
+ }
3639
+ : normalizeToolCall({ name: first.name, args: first.args });
3701
3640
  }
3702
3641
  else {
3703
3642
  call = parseToolCall(assistantText.visible, {
@@ -3804,19 +3743,15 @@ export async function runAgentTurn(prompt, options = {}) {
3804
3743
  retryWithoutThinking = true;
3805
3744
  commitAssistantRetry(assistantText.visible);
3806
3745
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
3807
- const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
3746
+ const buildNudge = isPlanMode && !activePlan
3808
3747
  ? toolsAttached
3809
- ? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
3810
- : "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
3811
- : isPlanMode && !activePlan
3812
- ? toolsAttached
3813
- ? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
3814
- : "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
3815
- "Do NOT hide tool calls in <think> tags — put them in the visible response."
3816
- : toolsAttached
3817
- ? "No visible output. " + toolNudge(true)
3818
- : "No visible output. Emit a ```tool block or give your final answer. " +
3819
- "Do NOT hide tool calls in <think> tags — put them in the visible response.";
3748
+ ? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
3749
+ : "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
3750
+ "Do NOT hide tool calls in <think> tags — put them in the visible response."
3751
+ : toolsAttached
3752
+ ? "No visible output. " + toolNudge(true)
3753
+ : "No visible output. Emit a ```tool block or give your final answer. " +
3754
+ "Do NOT hide tool calls in <think> tags — put them in the visible response.";
3820
3755
  messages.push(recoveryUserMessage(buildNudge));
3821
3756
  continue;
3822
3757
  }
@@ -3884,7 +3819,7 @@ export async function runAgentTurn(prompt, options = {}) {
3884
3819
  }
3885
3820
  // Exhausted retries — fall through to the normal answer path.
3886
3821
  }
3887
- if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
3822
+ if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>|<[||]DSML[||](?:tool_calls|invoke|parameter)\b/i.test(assistantText.visible)) {
3888
3823
  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"));
3889
3824
  commitAssistantRetry(assistantText.visible);
3890
3825
  messages.push(recoveryUserMessage(toolsAttached
@@ -4012,15 +3947,12 @@ export async function runAgentTurn(prompt, options = {}) {
4012
3947
  const cleaned = collapseRepeatedText(stripSentinelTokens(canonicalAssistantVisible));
4013
3948
  const narratedAction = looksLikeActionNarration(cleaned);
4014
3949
  const narratedWebAction = looksLikeWebActionNarration(cleaned);
4015
- const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
4016
- const livePlanAtCompletion = reconciledPlanAtCompletion ??
4017
- (await loadPlan(session.sessionId).catch(() => undefined));
3950
+ const livePlanAtCompletion = await loadPlan(session.sessionId).catch(() => undefined);
4018
3951
  const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
4019
3952
  const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
4020
3953
  planStatusAtCompletion === "completed";
4021
3954
  const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
4022
- const userExpectsWork = freshWebSearchRequired ||
4023
- (planHasOpenWorkNow && session.planApproved.value) ||
3955
+ const userExpectsWork = (planHasOpenWorkNow && session.planApproved.value) ||
4024
3956
  (!informationalQuery &&
4025
3957
  !idleOrSocialPrompt &&
4026
3958
  (buildLikeTurn || pentestLikeTurn));
@@ -4088,11 +4020,6 @@ export async function runAgentTurn(prompt, options = {}) {
4088
4020
  pentestSession,
4089
4021
  informationalQuery,
4090
4022
  idleOrSocialPrompt,
4091
- freshWebSearchRequired,
4092
- freshnessGuardText: freshWebSearchRequired
4093
- ? freshnessGuardMessage()
4094
- : "",
4095
- sawFreshWebSearch,
4096
4023
  sawPlanCreateOk,
4097
4024
  sawFeatureImplWrite,
4098
4025
  sawScaffoldOk,
@@ -4291,11 +4218,20 @@ export async function runAgentTurn(prompt, options = {}) {
4291
4218
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
4292
4219
  const suppressedResults = bound.map((b) => {
4293
4220
  const duplicate = runIds.has(b.id);
4294
- const resultReason = duplicate ? reason : deferReason;
4221
+ const priorObservation = duplicate
4222
+ ? loopGuard.getPriorObservation(b.call.name, b.call.args)
4223
+ : undefined;
4224
+ const resultReason = duplicate
4225
+ ? reason +
4226
+ (priorObservation
4227
+ ? `\n\nPrior successful result for ${b.call.name}:\n${priorObservation}`
4228
+ : "")
4229
+ : deferReason;
4295
4230
  const result = {
4296
- ok: false,
4231
+ ok: duplicate,
4297
4232
  output: resultReason,
4298
- exitCode: duplicate ? 409 : 130,
4233
+ exitCode: duplicate ? 0 : 130,
4234
+ ...(duplicate ? { suppressedRepeat: true } : {}),
4299
4235
  };
4300
4236
  return { b, resultReason, result };
4301
4237
  });
@@ -4316,22 +4252,6 @@ export async function runAgentTurn(prompt, options = {}) {
4316
4252
  writeToolOutput(eventId, output, chalk.dim(` ${output}`));
4317
4253
  emitToolResult(eventId, result, resultReason);
4318
4254
  }
4319
- if (historyNativeCalls.length) {
4320
- appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock ??
4321
- (assistantText.hasThinking && assistantText.thinkContent
4322
- ? { text: assistantText.thinkContent }
4323
- : undefined));
4324
- for (const { b, resultReason, result } of suppressedResults) {
4325
- appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=${result.exitCode}, ok=false):\n${resultReason}`, b.call.name, false);
4326
- }
4327
- }
4328
- else {
4329
- const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
4330
- allCalls
4331
- .map((candidate) => `\`\`\`tool\n${JSON.stringify(candidate)}\n\`\`\``)
4332
- .join("\n\n");
4333
- pushAssistantHistory(standardizedContent);
4334
- }
4335
4255
  if (sequenceDecision.terminal) {
4336
4256
  const remainingCriteria = unreadResponderNotificationIds.size > 0
4337
4257
  ? ["Analyze and acknowledge the delivered Responder result without repeating completed foreground work."]
@@ -4341,10 +4261,10 @@ export async function runAgentTurn(prompt, options = {}) {
4341
4261
  moveTurn("partial", "repeated identical action sequence");
4342
4262
  return finishTurn("Stopped an identical action cycle before it could execute again.", productiveSteps, "partial", remainingCriteria, "The model repeated an identical action sequence without a new premise or state change.");
4343
4263
  }
4344
- messages.push(recoveryUserMessage(reason +
4264
+ upsertActionCycleRecovery(reason +
4345
4265
  (unreadResponderNotificationIds.size > 0
4346
4266
  ? " A delivered Responder result is still unread: analyze the available result, gather only genuinely necessary bounded evidence, then call job.read before returning to foreground work."
4347
- : " Reassess the evidence and select the next action yourself; do not replay completed work.")));
4267
+ : " The original successful tool result remains in context. Reassess that evidence and either finish or select a materially different action; do not replay completed work."));
4348
4268
  continue;
4349
4269
  }
4350
4270
  if (sequenceDecision.warn && sequenceDecision.warnMessage) {
@@ -4408,6 +4328,7 @@ export async function runAgentTurn(prompt, options = {}) {
4408
4328
  let planCreatedThisTurn = Boolean(activePlan && activePlan.tasks.length > 0);
4409
4329
  let actionSequenceExecuted = 0;
4410
4330
  let actionSequenceEligible = allCalls.length > 0;
4331
+ const actionSequenceOutcomes = new Map();
4411
4332
  /**
4412
4333
  * Record a tool result into history. Failures / user declines are
4413
4334
  * always returned to the model — we never cancel later siblings or
@@ -4418,6 +4339,15 @@ export async function runAgentTurn(prompt, options = {}) {
4418
4339
  consecutiveModelOnlyRounds = 0;
4419
4340
  recordedNativeIds.add(boundCall.id);
4420
4341
  actionSequenceExecuted += 1;
4342
+ const sequenceObservation = res.suppressedRepeat
4343
+ ? loopGuard.getPriorObservation(res.call.name, res.call.args) ??
4344
+ res.contextOutput
4345
+ : res.result.output ?? res.contextOutput;
4346
+ actionSequenceOutcomes.set(boundCall.id, JSON.stringify({
4347
+ ok: res.ok,
4348
+ exitCode: res.result.exitCode ?? null,
4349
+ digest: completedOperationObservationDigest(res.call.name, sequenceObservation),
4350
+ }));
4421
4351
  // A policy-suppressed call is deterministic: replaying it verbatim
4422
4352
  // returns the identical receipt. It must therefore keep the sequence
4423
4353
  // eligible, otherwise the tool-level suppression and the sequence
@@ -4757,12 +4687,16 @@ export async function runAgentTurn(prompt, options = {}) {
4757
4687
  }
4758
4688
  fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
4759
4689
  }
4690
+ const actionSequenceOutcome = createHash("sha256")
4691
+ .update(JSON.stringify(bound.map((entry) => actionSequenceOutcomes.get(entry.id) ?? null)))
4692
+ .digest("hex")
4693
+ .slice(0, 24);
4760
4694
  loopGuard.completeActionSequence(actionSequenceCalls, actionSequenceEligible &&
4761
4695
  toRun.length === bound.length &&
4762
4696
  actionSequenceExecuted === allCalls.length &&
4763
4697
  !aborted &&
4764
4698
  !awaitingPlanApproval &&
4765
- !governorPauseReason);
4699
+ !governorPauseReason, actionSequenceOutcome);
4766
4700
  // Keep ledger system rows outside the native assistant→tool group so
4767
4701
  // protocol repair preserves the real successful job.read body.
4768
4702
  for (const notification of deferredResponderLedgerNotifications.splice(0)) {