agent-nuvira 3.3.10 → 3.3.12

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 (234) hide show
  1. package/README.md +32 -1
  2. package/dist/agents/orchestrator.d.ts.map +1 -1
  3. package/dist/agents/orchestrator.js +34 -12
  4. package/dist/agents/orchestrator.js.map +1 -1
  5. package/dist/cli/chat.d.ts +36 -0
  6. package/dist/cli/chat.d.ts.map +1 -1
  7. package/dist/cli/chat.js +440 -44
  8. package/dist/cli/chat.js.map +1 -1
  9. package/dist/cli/config.d.ts +28 -0
  10. package/dist/cli/config.d.ts.map +1 -1
  11. package/dist/cli/config.js +169 -1
  12. package/dist/cli/config.js.map +1 -1
  13. package/dist/cli/eval.d.ts +8 -0
  14. package/dist/cli/eval.d.ts.map +1 -1
  15. package/dist/cli/eval.js +64 -0
  16. package/dist/cli/eval.js.map +1 -1
  17. package/dist/cli/execute.d.ts.map +1 -1
  18. package/dist/cli/execute.js +8 -0
  19. package/dist/cli/execute.js.map +1 -1
  20. package/dist/cli/loop-executor.d.ts +13 -0
  21. package/dist/cli/loop-executor.d.ts.map +1 -1
  22. package/dist/cli/loop-executor.js +27 -18
  23. package/dist/cli/loop-executor.js.map +1 -1
  24. package/dist/cli/tool-install-prompt.d.ts +58 -0
  25. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  26. package/dist/cli/tool-install-prompt.js +137 -0
  27. package/dist/cli/tool-install-prompt.js.map +1 -1
  28. package/dist/cli/weak-model-prompt.d.ts +16 -7
  29. package/dist/cli/weak-model-prompt.d.ts.map +1 -1
  30. package/dist/cli/weak-model-prompt.js +24 -8
  31. package/dist/cli/weak-model-prompt.js.map +1 -1
  32. package/dist/config/capability-mode.d.ts +122 -0
  33. package/dist/config/capability-mode.d.ts.map +1 -0
  34. package/dist/config/capability-mode.js +132 -0
  35. package/dist/config/capability-mode.js.map +1 -0
  36. package/dist/config/limits.d.ts +47 -0
  37. package/dist/config/limits.d.ts.map +1 -0
  38. package/dist/config/limits.js +64 -0
  39. package/dist/config/limits.js.map +1 -0
  40. package/dist/config/process-env.d.ts.map +1 -1
  41. package/dist/config/process-env.js +44 -0
  42. package/dist/config/process-env.js.map +1 -1
  43. package/dist/config/types.d.ts +113 -0
  44. package/dist/config/types.d.ts.map +1 -1
  45. package/dist/config/work-digest.d.ts +46 -0
  46. package/dist/config/work-digest.d.ts.map +1 -0
  47. package/dist/config/work-digest.js +80 -0
  48. package/dist/config/work-digest.js.map +1 -0
  49. package/dist/context/cache.d.ts +35 -1
  50. package/dist/context/cache.d.ts.map +1 -1
  51. package/dist/context/cache.js +31 -2
  52. package/dist/context/cache.js.map +1 -1
  53. package/dist/gateway/inbound-media.d.ts +18 -0
  54. package/dist/gateway/inbound-media.d.ts.map +1 -1
  55. package/dist/gateway/inbound-media.js +96 -2
  56. package/dist/gateway/inbound-media.js.map +1 -1
  57. package/dist/inference/anthropic-adapter.d.ts +6 -0
  58. package/dist/inference/anthropic-adapter.d.ts.map +1 -1
  59. package/dist/inference/anthropic-adapter.js +79 -8
  60. package/dist/inference/anthropic-adapter.js.map +1 -1
  61. package/dist/inference/gemini-adapter.d.ts +6 -0
  62. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  63. package/dist/inference/gemini-adapter.js +73 -8
  64. package/dist/inference/gemini-adapter.js.map +1 -1
  65. package/dist/inference/groq-adapter.d.ts +3 -0
  66. package/dist/inference/groq-adapter.d.ts.map +1 -1
  67. package/dist/inference/groq-adapter.js +83 -40
  68. package/dist/inference/groq-adapter.js.map +1 -1
  69. package/dist/inference/interface.d.ts +7 -0
  70. package/dist/inference/interface.d.ts.map +1 -1
  71. package/dist/inference/model-catalog.d.ts +17 -0
  72. package/dist/inference/model-catalog.d.ts.map +1 -1
  73. package/dist/inference/model-catalog.js +42 -7
  74. package/dist/inference/model-catalog.js.map +1 -1
  75. package/dist/inference/model-probe.d.ts +17 -0
  76. package/dist/inference/model-probe.d.ts.map +1 -1
  77. package/dist/inference/model-probe.js +58 -1
  78. package/dist/inference/model-probe.js.map +1 -1
  79. package/dist/inference/model-validator.d.ts +16 -1
  80. package/dist/inference/model-validator.d.ts.map +1 -1
  81. package/dist/inference/model-validator.js +83 -2
  82. package/dist/inference/model-validator.js.map +1 -1
  83. package/dist/inference/nim-adapter.js +4 -4
  84. package/dist/inference/nim-adapter.js.map +1 -1
  85. package/dist/inference/openai-compat-adapter.d.ts +9 -0
  86. package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
  87. package/dist/inference/openai-compat-adapter.js +101 -51
  88. package/dist/inference/openai-compat-adapter.js.map +1 -1
  89. package/dist/inference/openrouter-adapter.d.ts +3 -0
  90. package/dist/inference/openrouter-adapter.d.ts.map +1 -1
  91. package/dist/inference/openrouter-adapter.js +134 -57
  92. package/dist/inference/openrouter-adapter.js.map +1 -1
  93. package/dist/inference/reasoning-effort.d.ts +130 -0
  94. package/dist/inference/reasoning-effort.d.ts.map +1 -0
  95. package/dist/inference/reasoning-effort.js +237 -0
  96. package/dist/inference/reasoning-effort.js.map +1 -0
  97. package/dist/inference/route-resolver.d.ts +7 -0
  98. package/dist/inference/route-resolver.d.ts.map +1 -1
  99. package/dist/inference/route-resolver.js +2 -1
  100. package/dist/inference/route-resolver.js.map +1 -1
  101. package/dist/inference/sse.d.ts +6 -0
  102. package/dist/inference/sse.d.ts.map +1 -1
  103. package/dist/inference/sse.js +1 -0
  104. package/dist/inference/sse.js.map +1 -1
  105. package/dist/inference/tool-call-utils.d.ts +4 -3
  106. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  107. package/dist/inference/tool-call-utils.js +16 -4
  108. package/dist/inference/tool-call-utils.js.map +1 -1
  109. package/dist/inference/tools.d.ts +13 -0
  110. package/dist/inference/tools.d.ts.map +1 -1
  111. package/dist/inference/tools.js +20 -2
  112. package/dist/inference/tools.js.map +1 -1
  113. package/dist/learning/agentic-route-gate.d.ts +104 -0
  114. package/dist/learning/agentic-route-gate.d.ts.map +1 -0
  115. package/dist/learning/agentic-route-gate.js +125 -0
  116. package/dist/learning/agentic-route-gate.js.map +1 -0
  117. package/dist/learning/auto-router.d.ts +36 -0
  118. package/dist/learning/auto-router.d.ts.map +1 -1
  119. package/dist/learning/auto-router.js +115 -8
  120. package/dist/learning/auto-router.js.map +1 -1
  121. package/dist/learning/autonomy-policy.d.ts +8 -0
  122. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  123. package/dist/learning/autonomy-policy.js +34 -0
  124. package/dist/learning/autonomy-policy.js.map +1 -1
  125. package/dist/learning/build-prerequisites.d.ts +68 -0
  126. package/dist/learning/build-prerequisites.d.ts.map +1 -0
  127. package/dist/learning/build-prerequisites.js +267 -0
  128. package/dist/learning/build-prerequisites.js.map +1 -0
  129. package/dist/learning/capability-parity.d.ts +108 -0
  130. package/dist/learning/capability-parity.d.ts.map +1 -0
  131. package/dist/learning/capability-parity.js +154 -0
  132. package/dist/learning/capability-parity.js.map +1 -0
  133. package/dist/learning/continuation-intent.d.ts +43 -0
  134. package/dist/learning/continuation-intent.d.ts.map +1 -0
  135. package/dist/learning/continuation-intent.js +82 -0
  136. package/dist/learning/continuation-intent.js.map +1 -0
  137. package/dist/learning/cost-tracker.d.ts +28 -4
  138. package/dist/learning/cost-tracker.d.ts.map +1 -1
  139. package/dist/learning/cost-tracker.js +58 -8
  140. package/dist/learning/cost-tracker.js.map +1 -1
  141. package/dist/learning/eval-framework.d.ts +17 -0
  142. package/dist/learning/eval-framework.d.ts.map +1 -1
  143. package/dist/learning/eval-framework.js +63 -2
  144. package/dist/learning/eval-framework.js.map +1 -1
  145. package/dist/learning/model-capability.d.ts +94 -0
  146. package/dist/learning/model-capability.d.ts.map +1 -0
  147. package/dist/learning/model-capability.js +172 -0
  148. package/dist/learning/model-capability.js.map +1 -0
  149. package/dist/learning/model-harness.d.ts +19 -0
  150. package/dist/learning/model-harness.d.ts.map +1 -1
  151. package/dist/learning/model-harness.js +27 -0
  152. package/dist/learning/model-harness.js.map +1 -1
  153. package/dist/learning/model-registry.d.ts +33 -0
  154. package/dist/learning/model-registry.d.ts.map +1 -1
  155. package/dist/learning/model-registry.js +79 -0
  156. package/dist/learning/model-registry.js.map +1 -1
  157. package/dist/learning/prompt-budget.d.ts +91 -0
  158. package/dist/learning/prompt-budget.d.ts.map +1 -0
  159. package/dist/learning/prompt-budget.js +99 -0
  160. package/dist/learning/prompt-budget.js.map +1 -0
  161. package/dist/learning/reasoning-trace.d.ts +55 -2
  162. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  163. package/dist/learning/reasoning-trace.js +48 -0
  164. package/dist/learning/reasoning-trace.js.map +1 -1
  165. package/dist/learning/resilient-call.d.ts.map +1 -1
  166. package/dist/learning/resilient-call.js +16 -3
  167. package/dist/learning/resilient-call.js.map +1 -1
  168. package/dist/learning/resolve-options.d.ts.map +1 -1
  169. package/dist/learning/resolve-options.js +16 -3
  170. package/dist/learning/resolve-options.js.map +1 -1
  171. package/dist/learning/routing-history.d.ts +13 -0
  172. package/dist/learning/routing-history.d.ts.map +1 -1
  173. package/dist/learning/routing-history.js.map +1 -1
  174. package/dist/learning/run-trace.d.ts +68 -0
  175. package/dist/learning/run-trace.d.ts.map +1 -1
  176. package/dist/learning/run-trace.js +101 -0
  177. package/dist/learning/run-trace.js.map +1 -1
  178. package/dist/learning/turn-report.d.ts +78 -0
  179. package/dist/learning/turn-report.d.ts.map +1 -0
  180. package/dist/learning/turn-report.js +140 -0
  181. package/dist/learning/turn-report.js.map +1 -0
  182. package/dist/tools/edit-verification.d.ts +2 -11
  183. package/dist/tools/edit-verification.d.ts.map +1 -1
  184. package/dist/tools/edit-verification.js +33 -1
  185. package/dist/tools/edit-verification.js.map +1 -1
  186. package/dist/tools/loop-skill-hint.d.ts +78 -0
  187. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  188. package/dist/tools/loop-skill-hint.js +274 -5
  189. package/dist/tools/loop-skill-hint.js.map +1 -1
  190. package/dist/tools/plan-store.d.ts +104 -3
  191. package/dist/tools/plan-store.d.ts.map +1 -1
  192. package/dist/tools/plan-store.js +250 -6
  193. package/dist/tools/plan-store.js.map +1 -1
  194. package/dist/tools/read-extract.d.ts +9 -3
  195. package/dist/tools/read-extract.d.ts.map +1 -1
  196. package/dist/tools/read-extract.js +70 -25
  197. package/dist/tools/read-extract.js.map +1 -1
  198. package/dist/tools/registry.d.ts +20 -1
  199. package/dist/tools/registry.d.ts.map +1 -1
  200. package/dist/tools/registry.js +17 -4
  201. package/dist/tools/registry.js.map +1 -1
  202. package/dist/tools/run-terminal.d.ts.map +1 -1
  203. package/dist/tools/run-terminal.js +34 -0
  204. package/dist/tools/run-terminal.js.map +1 -1
  205. package/dist/tools/tool-loop.d.ts +149 -0
  206. package/dist/tools/tool-loop.d.ts.map +1 -1
  207. package/dist/tools/tool-loop.js +706 -13
  208. package/dist/tools/tool-loop.js.map +1 -1
  209. package/dist/utils/effect-verification.js +1 -1
  210. package/dist/utils/effect-verification.js.map +1 -1
  211. package/dist/web-dashboard/attachment-extract.d.ts +8 -1
  212. package/dist/web-dashboard/attachment-extract.d.ts.map +1 -1
  213. package/dist/web-dashboard/attachment-extract.js +14 -3
  214. package/dist/web-dashboard/attachment-extract.js.map +1 -1
  215. package/dist/web-dashboard/chat-console.d.ts +21 -1
  216. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  217. package/dist/web-dashboard/chat-console.js +33 -5
  218. package/dist/web-dashboard/chat-console.js.map +1 -1
  219. package/dist/web-dashboard/server.d.ts.map +1 -1
  220. package/dist/web-dashboard/server.js +198 -21
  221. package/dist/web-dashboard/server.js.map +1 -1
  222. package/dist/web-dashboard/src/types.d.ts +97 -0
  223. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  224. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  225. package/dist/web-dashboard/workspace-guard.js +44 -4
  226. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  227. package/package.json +4 -1
  228. package/src/web-dashboard/public/assets/index-BrZcYhy6.js +207 -0
  229. package/src/web-dashboard/public/assets/index-BrZcYhy6.js.map +1 -0
  230. package/src/web-dashboard/public/assets/index-DsWczTa6.css +1 -0
  231. package/src/web-dashboard/public/index.html +2 -2
  232. package/src/web-dashboard/public/assets/index-Beportyl.js +0 -207
  233. package/src/web-dashboard/public/assets/index-Beportyl.js.map +0 -1
  234. package/src/web-dashboard/public/assets/index-gtQyg9nm.css +0 -1
@@ -33,15 +33,24 @@ import { stepDigest } from '../learning/step-checkpoint.js';
33
33
  // process declared no fault (`NUVIRA_INJECT_FAULT`), so an ordinary turn is
34
34
  // unaffected.
35
35
  import { faultAt } from '../runtime/fault-injection.js';
36
- import { detectPermissionSeeking, isAffirmativeReply, replyAsksTheReader, requestAuthorizesWrites, stripTrailingPermissionSeek, } from '../learning/autonomy-policy.js';
36
+ import { detectPermissionSeeking, isAffirmativeReply, replyAsksTheReader, requestAuthorizesWrites, requestForbidsWrites, stripTrailingPermissionSeek, } from '../learning/autonomy-policy.js';
37
37
  import { envelopeFromPlan, envelopeFromRequest, getEnvelope, grantEnvelope, isEnvelopeKey, } from '../learning/intent-envelope.js';
38
- import { detectProcessComplaint, isTraceKey, repeatNudge, runTraceFor, RunTrace, } from '../learning/run-trace.js';
38
+ import { detectProcessComplaint, isTraceKey, noProgressNudge, repeatNudge, repeatedFailureNudge, runTraceFor, RunTrace, } from '../learning/run-trace.js';
39
39
  import { wantsAuthoredArtifact } from '../learning/deliverable-class.js';
40
40
  import { normalizeFollowups } from './followup-utils.js';
41
+ // The capability mode (balanced | max) — `max` widens the loop's own reasoning
42
+ // budget as well as routing, so "cost is not a concern" means the agent may
43
+ // keep working through a long build instead of stopping at the default bound.
44
+ import { isMaxCapability } from '../config/capability-mode.js';
45
+ // The digest's scope (all | max | off) — a user-facing control over whether the
46
+ // compaction digest is injected in every mode, only under `max`, or never.
47
+ import { isWorkDigestEnabled } from '../config/work-digest.js';
41
48
  import { assessEditActivity, detectUnverifiedEditClaim, isVerificationTool, verificationNudgeFor, THINK_ONLY_ESCALATION, AUTHORIZED_WORK_NUDGE, deliverableNudge, } from './edit-verification.js';
42
49
  import { effectiveToolJsonSchemas, coreToolJsonSchemas, isToolEnabled, toolsetForTool } from './toolsets.js';
43
50
  import { deliverablesNamedIn, recordStepHandoff } from '../agents/step-handoff.js';
44
51
  import { fenceUntrustedToolOutput } from './untrusted-content.js';
52
+ import { installableToolsFromFailure, resolveInstallCommand, toolTakeoverInstruction, } from '../cli/tool-install-prompt.js';
53
+ import { matchPrerequisiteSignatures, prerequisiteTakeoverInstruction, checkProjectPrerequisites, createNodePrereqFs, formatPreflightFindings, } from '../learning/build-prerequisites.js';
45
54
  /**
46
55
  * Tools that MUTATE the workspace. A refusal of one of these is the only
47
56
  * refusal that leaves work undone — a declined `read_file` costs a step, a
@@ -54,12 +63,41 @@ const MUTATING_TOOL_NAMES = new Set([
54
63
  'run_terminal',
55
64
  'run_cli',
56
65
  ]);
66
+ /**
67
+ * The subset of mutating tools the PLAN gate HARD-BLOCKS on the first call —
68
+ * the ones that change the workspace's FILES. `run_terminal` / `run_cli` are
69
+ * deliberately excluded from the BLOCK (they still trigger the nudge): a
70
+ * terminal command is as often a test, a build or an inspection as it is an
71
+ * edit, and refusing it outright would stop the very step a plan is meant to
72
+ * reach. The nudge still tells the model to plan first.
73
+ */
74
+ const PLAN_GATED_TOOL_NAMES = new Set([
75
+ 'write_file',
76
+ 'edit_file',
77
+ 'propose_change',
78
+ ]);
57
79
  /** The path a mutating call was aimed at, when it named one. */
58
80
  function mutatedPathOf(args) {
59
81
  const a = args;
60
82
  const p = a?.path ?? a?.file_path ?? a?.file;
61
83
  return typeof p === 'string' && p.trim() ? p.trim() : undefined;
62
84
  }
85
+ /**
86
+ * The ACTION signature of a failed call — what it tried to do, so the identical
87
+ * retry is recognizable. Prefers the shell `command`, then the file `path`,
88
+ * then the tool name (a call that named neither still repeats as the same tool).
89
+ * Used by the self-diagnosis gate (see RunTrace.repeatedFailure).
90
+ */
91
+ function failureActionOf(call) {
92
+ const a = call.arguments;
93
+ const command = typeof a?.command === 'string' && a.command.trim() ? a.command.trim() : undefined;
94
+ if (command)
95
+ return command;
96
+ const path = mutatedPathOf(call.arguments);
97
+ if (path)
98
+ return path;
99
+ return call.name;
100
+ }
63
101
  /**
64
102
  * Append what a tool call actually did to the turn's provenance ledger.
65
103
  *
@@ -396,10 +434,47 @@ const MAX_PARALLEL_READS = 4;
396
434
  * were well able to do (see `THINK_ONLY_ESCALATION`).
397
435
  */
398
436
  export const MAX_THINK_CONTINUES = 3;
437
+ /**
438
+ * Consecutive steps that RAN tools and had NO success before the loop asks the
439
+ * model to diagnose the stall. This generalises {@link RunTrace.repeatedFailure}
440
+ * (the identical action retried): a weak model also loops by substituting a
441
+ * different command each step, every one failing the same underlying way — none
442
+ * of them repeats exactly, so the same-action gate never fires.
443
+ *
444
+ * The threshold is deliberately ABOVE the handful of distinct checks ordinary
445
+ * iteration tries (`npm test` → `npm run build` → `npm run lint`), so a short run
446
+ * of unrelated failures is left alone; only a sustained all-fail streak is a stall.
447
+ */
448
+ export const NO_PROGRESS_STALL_STEPS = 4;
449
+ /**
450
+ * How substantial a turn must be before the SELF-REVIEW gate may fire (see
451
+ * `selfReviewNudge`). Short, single-edit turns already end with an obvious
452
+ * result the model just looked at; a long turn is where scope drift hides, and
453
+ * where an extra pass earns its latency.
454
+ */
455
+ export const SELF_REVIEW_MIN_STEPS = 6;
456
+ /**
457
+ * S5 — how many `plan_todo` UPDATE calls one turn may make. The guard refuses
458
+ * repeated CREATES (the observed planner loop) but must allow updates, because
459
+ * an update is how the user's checklist advances; this cap only stops a model
460
+ * that replaces doing the work with spamming status changes. It sits well above
461
+ * a genuine multi-step job (the largest real plans in the tree are single
462
+ * digits) and far below a loop.
463
+ */
464
+ export const PLAN_TODO_UPDATE_CAP = 64;
399
465
  /** Default continuations granted per turn when the option is omitted. */
400
466
  export const DEFAULT_MAX_CONTINUATIONS = 2;
401
467
  /** Default extra steps granted per continuation. */
402
468
  export const DEFAULT_CONTINUATION_STEPS = 8;
469
+ /**
470
+ * `max` capability mode — the loop's longest bounded reasoning budget. The
471
+ * defaults above are sized to keep an ordinary turn responsive; under `max` the
472
+ * user has said cost is not a concern, so the same session is allowed to run a
473
+ * long build / many-file edit to completion instead of stopping at the default
474
+ * bound. Still finite: the hard cap is `maxSteps + 4 * 16` extra steps.
475
+ */
476
+ export const MAX_CAPABILITY_MAX_CONTINUATIONS = 4;
477
+ export const MAX_CAPABILITY_CONTINUATION_STEPS = 16;
403
478
  /** Pause before re-attempting a failed step (lets a transient outage clear). */
404
479
  export const CONTINUATION_DELAY_MS = 1_500;
405
480
  /**
@@ -425,8 +500,12 @@ function sleep(ms) {
425
500
  async function runToolLoopInner(opts, progress) {
426
501
  const { messages, tools: toolNames, maxSteps = 16, context, deps } = opts;
427
502
  // Bounded auto-continuation state (see ToolLoopOptions.maxContinuations).
428
- const maxContinuations = Math.max(0, opts.maxContinuations ?? DEFAULT_MAX_CONTINUATIONS);
429
- const continuationSteps = Math.max(1, opts.continuationSteps ?? DEFAULT_CONTINUATION_STEPS);
503
+ // `max` capability mode raises the DEFAULT budget (an explicit option still
504
+ // wins, so callers that pin a bound keep it). Read once per turn from the
505
+ // config, exactly like routing, so a mode change applies to the next turn.
506
+ const maxCapability = isMaxCapability(context.configManager);
507
+ const maxContinuations = Math.max(0, opts.maxContinuations ?? (maxCapability ? MAX_CAPABILITY_MAX_CONTINUATIONS : DEFAULT_MAX_CONTINUATIONS));
508
+ const continuationSteps = Math.max(1, opts.continuationSteps ?? (maxCapability ? MAX_CAPABILITY_CONTINUATION_STEPS : DEFAULT_CONTINUATION_STEPS));
430
509
  let continuations = 0;
431
510
  // Bounded dangling-promise nudges spent this turn (see INTENT_PROMISE_RE).
432
511
  let intentNudges = 0;
@@ -440,12 +519,26 @@ async function runToolLoopInner(opts, progress) {
440
519
  // asks the model to PROCEED, this one asks it to stop REPEATING, and the two
441
520
  // fire on independent evidence.
442
521
  let repeatNudges = 0;
522
+ // The loop's SELF-DIAGNOSIS nudge: bounded once per turn, fired when the SAME
523
+ // action has failed more than once (see RunTrace.repeatedFailure). This is the
524
+ // capability that turns "retry the identical failing command 10 times" into
525
+ // "state the root cause and change the approach" — the live macOS-build turn
526
+ // re-issued `npx tauri build` again and again against the same missing Cargo.
527
+ let diagnosisNudges = 0;
528
+ // Stage 2 — consecutive tool-running steps with NO success (the generalized
529
+ // stall signal, see NO_PROGRESS_STALL_STEPS). Reset by any successful tool.
530
+ let failedToolSteps = 0;
531
+ // Stage 4 — bounded SELF-REVIEW nudge (see requireSelfReview / selfReviewNudge).
532
+ let selfReviewNudges = 0;
443
533
  // Bounded think-only continuation (see THINK_ONLY_ESCALATION). Counts the
444
534
  // consecutive reasoning-only steps so the loop cannot spin on them.
445
535
  let thinkContinues = 0;
446
536
  // G13b — bounded "the request asked for a file and none was written" nudges
447
537
  // (see wantsAuthoredArtifact).
448
538
  let deliverableNudges = 0;
539
+ // ZERO-ACTION — bounded "the request directed work and NOTHING was done"
540
+ // nudges (see requireAction / zeroActionGateApplies).
541
+ let actionNudges = 0;
449
542
  /**
450
543
  * G18 — the sink, wrapped so an observability failure can never become a
451
544
  * turn failure (a recorder that throws is a bug in the instrument, not in
@@ -468,6 +561,12 @@ async function runToolLoopInner(opts, progress) {
468
561
  const maxParallelReads = Math.max(1, opts.maxParallelReads ?? MAX_PARALLEL_READS);
469
562
  const followups = [];
470
563
  const toolCallsRun = [];
564
+ // S5 — the plan_todo loop guard is split by ACTION (see the guard below):
565
+ // repeated CREATES were the observed planner loop; UPDATES are the tracking
566
+ // that keeps the user's checklist honest. Counted across the whole turn, like
567
+ // `toolCallsRun`, because a multi-step job runs many steps in one turn.
568
+ let planTodoCreates = 0;
569
+ let planTodoUpdates = 0;
471
570
  // Every collected suggestion passes through the shared normalizer, so the
472
571
  // loop's output is ALWAYS clean + structured (1–3 items, deduped, no leaked
473
572
  // tool JSON, capped prompt/label) regardless of what the model emitted —
@@ -646,6 +745,39 @@ async function runToolLoopInner(opts, progress) {
646
745
  // with an empty/bounded response (the "where is the essay?" bug).
647
746
  let lastContent = '';
648
747
  let bounded = false;
748
+ // D2.2 — has this turn already pre-flighted the project's build prerequisites?
749
+ // Checked once, before the FIRST build command the turn plans to run.
750
+ let prerequisitesPreflighted = false;
751
+ // E2 — has this turn already spent its one bounded "declare a plan first"
752
+ // nudge? Bounded once, like every other gate.
753
+ let planNudged = false;
754
+ // E2 (hard) — has this turn already spent its one bounded BLOCK of the first
755
+ // mutation? The plan gate is a hard requirement the first time it fires and a
756
+ // pure nudge after that: the first mutating batch is refused (see the gate
757
+ // below), and the second attempt runs even without a plan — a bounded block,
758
+ // never a wall.
759
+ let planBlocked = false;
760
+ /**
761
+ * Should the SELF-REVIEW gate fire now? Returns the correction to inject, or
762
+ * null. Extracted so the two end-of-turn exits ask the question identically —
763
+ * nothing here is a function of which branch is ending.
764
+ */
765
+ const selfReviewCorrection = () => {
766
+ if (selfReviewNudges >= 1)
767
+ return null;
768
+ if (opts.requireSelfReview === false)
769
+ return null;
770
+ if (steps < SELF_REVIEW_MIN_STEPS)
771
+ return null;
772
+ if (progress.mutatedPaths.length === 0)
773
+ return null;
774
+ // The verification gate owns "did you check it"; self-review only runs once
775
+ // that question is settled, so the two can never both fire in one turn.
776
+ const activity = assessEditActivity(progress.successfulToolCalls, progress.verificationEvidence, progress.mutatedPaths);
777
+ if (activity.needsVerification)
778
+ return null;
779
+ return selfReviewNudge(currentAsk(opts));
780
+ };
649
781
  for (;;) {
650
782
  // ── Bounded auto-continuation on the STEP BOUND ────────────────────────
651
783
  // The model still wanted to act when the budget ran out (a long build, a
@@ -675,6 +807,19 @@ async function runToolLoopInner(opts, progress) {
675
807
  if (trimmedResult.trimmed > 0) {
676
808
  thread.length = 0;
677
809
  thread.push(...trimmedResult.thread);
810
+ // Keep a memory of what the turn DID after its raw outputs are gone:
811
+ // without this, a long turn loses the VERDICTS (did the build pass?) and
812
+ // re-runs work it already finished. Deterministic and bounded — the same
813
+ // philosophy as trimThreadBudget itself (facts, no summarizer, no drift).
814
+ if (isWorkDigestEnabled(context.configManager)) {
815
+ const digest = buildWorkDigest({
816
+ successfulTools: progress.successfulToolCalls,
817
+ mutatedPaths: progress.mutatedPaths,
818
+ executedActions: progress.executedActions,
819
+ });
820
+ if (digest)
821
+ upsertWorkDigest(thread, digest);
822
+ }
678
823
  deps.onEvent?.(` ✂️ ${trimmedResult.trimmed} old tool result(s) trimmed to fit the ${Math.round(budgetChars / 1000)}K-char context budget.`);
679
824
  }
680
825
  }
@@ -999,6 +1144,32 @@ async function runToolLoopInner(opts, progress) {
999
1144
  thread.push({ role: 'user', content: repeatNudge(closing, runTrace.priorAnswer(closing)) });
1000
1145
  continue;
1001
1146
  }
1147
+ // ── ZERO-ACTION gate (bounded, once) ────────────────────────────────
1148
+ // The request DIRECTED work on the workspace and the turn is ending
1149
+ // without having run a single tool: not a dropped promise, not a
1150
+ // permission question, not an authored file — just prose where the work
1151
+ // should be. The gates above all key on a positive shape in the reply and
1152
+ // so miss a plain non-answer; this one keys on the REQUEST and the
1153
+ // ABSENCE of action, which is exactly the shape that ended a fully
1154
+ // specified four-part coding ask with nothing done. Bounded once; the
1155
+ // residual is reported by `noActionTaken` below, whether or not the nudge
1156
+ // is enabled.
1157
+ if (actionNudges < 1 &&
1158
+ zeroActionGateApplies(opts, progress, schemas.length, requestText, authorization.authorized)) {
1159
+ actionNudges += 1;
1160
+ stepLimit += 1;
1161
+ deps.onEvent?.(' 🛠️ The request asked for work and nothing was done — telling the model to do it now.');
1162
+ traceEvent({
1163
+ kind: 'gate',
1164
+ gate: 'action',
1165
+ summary: 'the request directed work on the workspace and the turn performed none — one bounded nudge to carry it out',
1166
+ });
1167
+ // The non-answer must not become the delivered answer either way.
1168
+ lastContent = '';
1169
+ thread.push({ role: 'assistant', content: response.content });
1170
+ thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
1171
+ continue;
1172
+ }
1002
1173
  // S1 (both exits): the MOST SUBSTANTIVE content seen wins here too —
1003
1174
  // a short closing step ("Sent it to her! ✅") with no tool calls must
1004
1175
  // not clobber the deliverable (poem/essay) the model composed in an
@@ -1055,6 +1226,22 @@ async function runToolLoopInner(opts, progress) {
1055
1226
  });
1056
1227
  continue;
1057
1228
  }
1229
+ // SELF-REVIEW (no-tools exit) — a substantial, already-verified turn is
1230
+ // ending; spend ONE bounded pass to check the result against the ask.
1231
+ const review = selfReviewCorrection();
1232
+ if (review) {
1233
+ selfReviewNudges += 1;
1234
+ stepLimit += 1;
1235
+ deps.onEvent?.(' 🧭 Substantial turn ending — asking the model to review the result against the original ask.');
1236
+ traceEvent({
1237
+ kind: 'gate',
1238
+ gate: 'self-review',
1239
+ summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
1240
+ });
1241
+ thread.push({ role: 'assistant', content: response.content });
1242
+ thread.push({ role: 'user', content: review });
1243
+ continue;
1244
+ }
1058
1245
  return {
1059
1246
  content: response.content.length >= lastContent.length ? response.content : lastContent,
1060
1247
  followups,
@@ -1085,14 +1272,47 @@ async function runToolLoopInner(opts, progress) {
1085
1272
  const tool = getTool(call.name);
1086
1273
  const priorSameTool = toolCallsRun.filter((t) => t === call.name).length;
1087
1274
  toolCallsRun.push(call.name);
1088
- // S5 — PLANNER LOOP GUARD: allow the first plan of the turn, refuse
1089
- // repeats (the 6x planner loop observed in
1090
- // trace-1788059239352-k7zl03: 15.6K tokens, 2m25s, FAILED).
1091
- if (call.name === 'plan_todo' && priorSameTool >= 1) {
1092
- return {
1093
- call,
1094
- refuse: 'Error: plan_todo already called. You have a plan — now execute it. Do NOT call plan_todo again. Use write_file, run_terminal, or other execution tools to complete the work.',
1095
- };
1275
+ // S5 — PLANNER LOOP GUARD, now split by ACTION.
1276
+ //
1277
+ // The guard exists because of a real failure: the model called plan_todo
1278
+ // six times in one turn (trace-1788059239352-k7zl03 — 15.6K tokens,
1279
+ // 2m25s, FAILED) instead of doing the work. But `create` and `update` are
1280
+ // the SAME tool, and the original `priorSameTool >= 1` check refused BOTH
1281
+ // — so a plan could be declared once and then never advanced, and the
1282
+ // checklist the user watches went stale the moment the first step closed.
1283
+ // Only repeated CREATES are the loop; updates ARE the tracking. Anything
1284
+ // that is not an explicit update (absent/malformed action) is counted as
1285
+ // a create, so the original protection is unchanged for the loop case.
1286
+ if (call.name === 'plan_todo') {
1287
+ const action = (() => {
1288
+ try {
1289
+ const raw = call.arguments;
1290
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
1291
+ return obj && typeof obj === 'object'
1292
+ ? String(obj.action ?? '')
1293
+ : undefined;
1294
+ }
1295
+ catch {
1296
+ return undefined;
1297
+ }
1298
+ })();
1299
+ const isUpdate = action === 'update';
1300
+ if (!isUpdate && planTodoCreates >= 1) {
1301
+ return {
1302
+ call,
1303
+ refuse: 'Error: a plan already exists for this turn — do NOT declare it again. Advance it instead: call plan_todo with action "update", the step id and its status, then keep doing the work.',
1304
+ };
1305
+ }
1306
+ if (isUpdate && planTodoUpdates >= PLAN_TODO_UPDATE_CAP) {
1307
+ return {
1308
+ call,
1309
+ refuse: `Error: plan_todo has been updated ${PLAN_TODO_UPDATE_CAP} times this turn — stop updating the plan and finish the remaining work.`,
1310
+ };
1311
+ }
1312
+ if (isUpdate)
1313
+ planTodoUpdates += 1;
1314
+ else
1315
+ planTodoCreates += 1;
1096
1316
  }
1097
1317
  if (tool?.category === 'pipeline' && priorSameTool >= 1) {
1098
1318
  // The guard this replaces tested the literal name `pipeline`, which is
@@ -1286,6 +1506,118 @@ async function runToolLoopInner(opts, progress) {
1286
1506
  toolSpan?.end({ ok: false, message: 'tool outcome was never reported' });
1287
1507
  }
1288
1508
  };
1509
+ // ── PROJECT PREREQUISITE PRE-FLIGHT (D2.2) ──────────────────────────
1510
+ // Before the FIRST build of the turn, check the project's markers so a
1511
+ // missing prerequisite (build.rs, a Cargo feature, an icon) is handed over
1512
+ // BEFORE a failed build — not after ten identical retries. Best-effort and
1513
+ // bounded: once per turn, only when a build is actually planned.
1514
+ if (!prerequisitesPreflighted) {
1515
+ const buildPlan = plans.find((p) => {
1516
+ if (p.refuse !== undefined)
1517
+ return false;
1518
+ if (p.call.name !== 'run_terminal' && p.call.name !== 'terminal')
1519
+ return false;
1520
+ try {
1521
+ const raw = p.call.arguments;
1522
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
1523
+ const cmd = String(obj?.command ?? '');
1524
+ return isBuildCommand(cmd);
1525
+ }
1526
+ catch {
1527
+ return false;
1528
+ }
1529
+ });
1530
+ if (buildPlan) {
1531
+ prerequisitesPreflighted = true;
1532
+ try {
1533
+ const findings = checkProjectPrerequisites(createNodePrereqFs(opts.context?.cwd || process.cwd()));
1534
+ if (findings.length > 0) {
1535
+ thread.push({ role: 'user', content: formatPreflightFindings(findings) });
1536
+ deps.onEvent?.(` 🧱 pre-flight: ${findings.length} missing project prerequisite(s) — handed the exact fix before building.`);
1537
+ traceEvent({
1538
+ kind: 'gate',
1539
+ gate: 'prerequisite',
1540
+ summary: `pre-flight found ${findings.length} missing project prerequisite(s) before the build: ` +
1541
+ findings.map((f) => f.id).join(', '),
1542
+ });
1543
+ }
1544
+ }
1545
+ catch {
1546
+ // A pre-flight must never break the turn.
1547
+ }
1548
+ }
1549
+ }
1550
+ // ── PLAN GATE (E2) ──────────────────────────────────────────────────
1551
+ // A workspace-directing turn about to MUTATE without having declared a plan
1552
+ // is stopped ONCE: its first FILE mutation is refused and the model is told
1553
+ // to plan first (`plan_todo`). Terminal commands still trigger the nudge but
1554
+ // are not blocked (see PLAN_GATED_TOOL_NAMES) — a build or a test is a step
1555
+ // a plan is meant to reach, not a change to gate. Bounded by design: the
1556
+ // SECOND attempt runs even without a plan (see `planBlocked`), so this guides
1557
+ // the model into the plan → track → verify contract instead of walling the
1558
+ // turn off. A batch that DECLARES a plan itself is never blocked (the model
1559
+ // is already doing the right thing), and `requirePlan:false` leaves the gate
1560
+ // byte-identical to not existing.
1561
+ const declaresPlan = !opts.context?.planStore?.snapshot?.() && plans.some((p) => {
1562
+ if (p.call.name !== 'plan_todo')
1563
+ return false;
1564
+ try {
1565
+ const raw = p.call.arguments;
1566
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
1567
+ const action = obj && typeof obj === 'object' ? String(obj.action ?? '') : '';
1568
+ return action !== 'update';
1569
+ }
1570
+ catch {
1571
+ // Malformed args still count as a create attempt — the plan_todo guard
1572
+ // above already handles a repeated create, and a first one is honored.
1573
+ return true;
1574
+ }
1575
+ });
1576
+ if (!planNudged &&
1577
+ !declaresPlan &&
1578
+ opts.requirePlan !== false &&
1579
+ schemas.length > 0 &&
1580
+ !opts.context?.planStore?.snapshot?.() &&
1581
+ plans.some((p) => p.refuse === undefined && MUTATING_TOOL_NAMES.has(p.call.name)) &&
1582
+ requestRequiresWorkspaceAction(requestText, authorization.authorized)) {
1583
+ planNudged = true;
1584
+ // Hard requirement, spent ONCE: refuse the first batch's FILE mutations
1585
+ // so nothing is written before a plan exists. It bumps the step budget by
1586
+ // one because it forces a genuine re-answer (the model must declare the
1587
+ // plan and retry), exactly like the zero-action nudge.
1588
+ const blocked = [];
1589
+ if (!planBlocked) {
1590
+ planBlocked = true;
1591
+ for (const p of plans) {
1592
+ if (p.refuse === undefined && PLAN_GATED_TOOL_NAMES.has(p.call.name)) {
1593
+ p.refuse =
1594
+ 'Error: declare a plan first via plan_todo — call it with 2–6 ordered steps (action "create"), then retry this change. The plan is how the turn is tracked and verified.';
1595
+ blocked.push(p.call.name);
1596
+ }
1597
+ }
1598
+ if (blocked.length > 0)
1599
+ stepLimit += 1;
1600
+ for (const name of blocked) {
1601
+ traceEvent({
1602
+ kind: 'refusal',
1603
+ gate: 'plan',
1604
+ tool: name,
1605
+ summary: `refused ${name}: no plan declared — the first mutation is blocked once so the model plans before it changes files`,
1606
+ });
1607
+ }
1608
+ }
1609
+ deps.onEvent?.(blocked.length > 0
1610
+ ? ' 🗺️ No plan declared — blocking the first change until the model plans.'
1611
+ : ' 🗺️ No plan declared — asking the model to plan before it changes files.');
1612
+ traceEvent({
1613
+ kind: 'gate',
1614
+ gate: 'plan',
1615
+ summary: blocked.length > 0
1616
+ ? `a workspace-directing turn was about to mutate without a plan — the first mutation batch (${blocked.join(', ')}) was refused once and the model asked to declare a plan first`
1617
+ : 'a workspace-directing turn was about to mutate without a plan — one bounded nudge to declare one first',
1618
+ });
1619
+ thread.push({ role: 'user', content: planRequiredNudge(currentAsk(opts)) });
1620
+ }
1289
1621
  const executed = new Array(plans.length).fill('');
1290
1622
  for (let i = 0; i < plans.length;) {
1291
1623
  // A run of consecutive read-only calls is ONE bounded fan-out; any
@@ -1323,6 +1655,7 @@ async function runToolLoopInner(opts, progress) {
1323
1655
  // What each call actually delivered (post hint/tip decoration), kept in
1324
1656
  // call order for the endsAgentStep exit below.
1325
1657
  const delivered = new Array(plans.length).fill('');
1658
+ let stepAnySuccess = false;
1326
1659
  for (let i = 0; i < plans.length; i += 1) {
1327
1660
  const call = plans[i].call;
1328
1661
  const rawResult = executed[i];
@@ -1342,8 +1675,10 @@ async function runToolLoopInner(opts, progress) {
1342
1675
  const ranOk = refusal === null &&
1343
1676
  !rawResult.startsWith('Error:') &&
1344
1677
  (!DELIVERY_TOOL_NAMES.has(call.name) || deliveryResultSucceeded(rawResult));
1345
- if (ranOk)
1678
+ if (ranOk) {
1346
1679
  progress.successfulToolCalls.push(call.name);
1680
+ stepAnySuccess = true;
1681
+ }
1347
1682
  if (call.name === 'gateway_send' && deliveryResultSucceeded(rawResult)) {
1348
1683
  progress.deliveryConfirmed = true;
1349
1684
  }
@@ -1367,6 +1702,17 @@ async function runToolLoopInner(opts, progress) {
1367
1702
  }
1368
1703
  }
1369
1704
  else if (refusal !== null || rawResult.startsWith('Error:')) {
1705
+ // SELF-DIAGNOSIS — record an action that RAN and FAILED (a non-zero
1706
+ // exit, a timeout, a tool error), keyed by the action itself (the
1707
+ // command / path). The gate below fires when the SAME action repeats:
1708
+ // a refusal was already remembered across runs (step-handoff), but a
1709
+ // command that RUNS and FAILS was not, so the live macOS-build turn
1710
+ // re-issued `npx tauri build` ~10 times against the same missing Cargo
1711
+ // and never once stopped to diagnose. Refusals are the other record
1712
+ // below; this one is for the calls that actually executed.
1713
+ if (refusal === null) {
1714
+ runTrace.recordFailure(call.name, failureActionOf(call), rawResult);
1715
+ }
1370
1716
  // Stage 2 — the other half of noticing a loop: the SAME refusal, twice.
1371
1717
  // The run records it with its reason, so a later step (or a self-report)
1372
1718
  // can see "blocked twice, identically" instead of rediscovering it — and
@@ -1422,6 +1768,54 @@ async function runToolLoopInner(opts, progress) {
1422
1768
  const parallelTip = parallel.note(call.name, !resultText.startsWith('Error:'));
1423
1769
  if (parallelTip)
1424
1770
  resultText = `${resultText}\n\n${parallelTip}`;
1771
+ // ── MISSING-PREREQUISITE TAKEOVER ──────────────────────────────────
1772
+ // A `command not found` (exit 127) for a KNOWN, installable system tool
1773
+ // is a STEP, not a wall — but a model can read it as an environment
1774
+ // limit and stop: "I cannot install system-level software on your host
1775
+ // machine — I am physically unable" (live: trace-1791127992452-qzgodi,
1776
+ // where a user who had explicitly granted terminal permission was handed
1777
+ // a manual `curl … | sh` step instead of the install being done). Inject
1778
+ // the exact install command and forbid the refusal, deterministically, so
1779
+ // the NEXT model step acts. Advisory text only — the loop never runs the
1780
+ // install itself; that stays the model's governed `run_terminal` call.
1781
+ if (!ranOk && (call.name === 'run_terminal' || call.name === 'terminal')) {
1782
+ const failedCommand = String(call.arguments?.command ?? '');
1783
+ // EVERY installable tool the failing line needs, not just the first —
1784
+ // a `cd app && cargo build && cmake .` turn should hand the model all
1785
+ // the prerequisites in one takeover instead of one per round-trip.
1786
+ const missing = installableToolsFromFailure(failedCommand, rawResult);
1787
+ if (missing.length > 0) {
1788
+ const installCommands = missing.map((t) => resolveInstallCommand(t));
1789
+ resultText = `${resultText}\n\n${toolTakeoverInstruction(missing, installCommands)}`;
1790
+ deps.onEvent?.(` 🛠️ ${missing.map((t) => `'${t}'`).join(', ')} missing and installable — handing the model the install command(s) to run.`);
1791
+ traceEvent({
1792
+ kind: 'gate',
1793
+ gate: 'tool-takeover',
1794
+ summary: `${missing.join(', ')} missing (command not found) and installable — the model is ` +
1795
+ 'told to install them itself instead of declaring itself unable',
1796
+ });
1797
+ }
1798
+ // ── PROJECT PREREQUISITE BACKSTOP (D2.3) ──────────────────────────
1799
+ // No missing BINARY, but this failure may be a missing project FILE/
1800
+ // FEATURE (the other half of the same wall): `src-tauri/build.rs`, a
1801
+ // Cargo `custom-protocol` feature, an icon — none of which is a tool to
1802
+ // install, so the takeover above never fired and the live macOS turn
1803
+ // re-ran `npx tauri build` ~10 times. Match the OUTPUT SIGNATURE, hand
1804
+ // the model the exact fix on the FIRST failure, and record it.
1805
+ if (missing.length === 0) {
1806
+ const rules = matchPrerequisiteSignatures(rawResult);
1807
+ if (rules.length > 0) {
1808
+ resultText = `${resultText}\n\n${prerequisiteTakeoverInstruction(rules)}`;
1809
+ deps.onEvent?.(` 🧱 build prerequisite signature matched (${rules.map((r) => r.id).join(', ')}) — handing the model the exact fix.`);
1810
+ traceEvent({
1811
+ kind: 'gate',
1812
+ gate: 'prerequisite',
1813
+ summary: `build failed on a missing PROJECT prerequisite (${rules.map((r) => r.id).join(', ')}) — ` +
1814
+ 'the model is given the exact fix instead of retrying the identical command',
1815
+ });
1816
+ }
1817
+ }
1818
+ }
1425
1819
  delivered[i] = resultText;
1426
1820
  // G18 — record what this call DID, in the model's own call order, with the
1427
1821
  // evidence a later reader needs: the args the gate saw, the first line of
@@ -1443,6 +1837,52 @@ async function runToolLoopInner(opts, progress) {
1443
1837
  });
1444
1838
  thread.push({ role: 'tool', toolCallId: call.id, content: resultText });
1445
1839
  }
1840
+ // Track the generalized stall: a step that RAN tools but succeeded at none
1841
+ // of them extends the streak; any success clears it. A text-only step (no
1842
+ // tools) leaves it as it was — the model is thinking, not failing.
1843
+ if (plans.length > 0)
1844
+ failedToolSteps = stepAnySuccess ? 0 : failedToolSteps + 1;
1845
+ // ── SELF-DIAGNOSIS nudge (bounded, once) ──────────────────────────────
1846
+ // The loop's missing introspection: when the SAME action has failed more
1847
+ // than once, re-issuing it is a loop, not progress. This is the exact
1848
+ // shape of the live macOS-build turn — `npx tauri build` was retried ~10
1849
+ // times against the same missing Rust/Cargo, each attempt rediscovering
1850
+ // the same wall, and the run never stopped to ask WHY. Unlike the
1851
+ // end-of-turn gates (permission/repeat/deliverable), which fire only when
1852
+ // the model stops calling tools, this one must fire MID-TURN while the
1853
+ // model is still looping on the failing call — so it is injected here,
1854
+ // right after the step's results are in the thread, and the next model
1855
+ // step sees it. Bounded once per turn and keyed on the action so the same
1856
+ // repeat cannot trigger it twice.
1857
+ if (diagnosisNudges < 1) {
1858
+ const repeated = runTrace.repeatedFailure();
1859
+ if (repeated && repeated.times >= 2) {
1860
+ diagnosisNudges += 1;
1861
+ stepLimit += 1;
1862
+ deps.onEvent?.(` 🩺 Same action failed ${repeated.times}× — asking the model to diagnose the cause instead of retrying.`);
1863
+ traceEvent({
1864
+ kind: 'gate',
1865
+ gate: 'diagnosis',
1866
+ summary: `the same action failed ${repeated.times} times (${repeated.tool}: ${repeated.action.slice(0, 120)}) — ` +
1867
+ 'one bounded nudge to diagnose the root cause and change the approach',
1868
+ });
1869
+ thread.push({ role: 'user', content: repeatedFailureNudge(repeated) });
1870
+ }
1871
+ else if (failedToolSteps >= NO_PROGRESS_STALL_STEPS) {
1872
+ // Stage 2 — no SINGLE action repeated, but the last N steps each ran
1873
+ // tools and none succeeded. Same diagnosis demanded, different evidence.
1874
+ diagnosisNudges += 1;
1875
+ stepLimit += 1;
1876
+ deps.onEvent?.(` 🩺 ${failedToolSteps} steps with no successful action — asking the model to diagnose the stall.`);
1877
+ traceEvent({
1878
+ kind: 'gate',
1879
+ gate: 'diagnosis',
1880
+ summary: `${failedToolSteps} consecutive steps ran tools and none succeeded — ` +
1881
+ 'one bounded nudge to diagnose the shared cause and change the approach',
1882
+ });
1883
+ thread.push({ role: 'user', content: noProgressNudge(runTrace.recentFailures(3), failedToolSteps) });
1884
+ }
1885
+ }
1446
1886
  // Tiered exposure: after EVERY executed tool call, union any newly
1447
1887
  // loaded toolset schemas into the live set so the NEXT model step can
1448
1888
  // call them natively (tool_search load → loadedExtraTools → here).
@@ -1571,6 +2011,40 @@ async function runToolLoopInner(opts, progress) {
1571
2011
  thread.push({ role: 'user', content: deliverableNudge(authorization.requestedPath) });
1572
2012
  continue;
1573
2013
  }
2014
+ // ZERO-ACTION gate (concluding exit) — the same bounded pass as the
2015
+ // no-tools exit, for a turn that concluded (suggest_followups) without
2016
+ // ever touching the workspace a directed request asked it to change.
2017
+ if (actionNudges < 1 &&
2018
+ zeroActionGateApplies(opts, progress, schemas.length, requestText, authorization.authorized)) {
2019
+ actionNudges += 1;
2020
+ stepLimit += 1;
2021
+ deps.onEvent?.(' 🛠️ The request asked for work and nothing was done — telling the model to do it now.');
2022
+ traceEvent({
2023
+ kind: 'gate',
2024
+ gate: 'action',
2025
+ summary: 'the request directed work on the workspace and the turn performed none — one bounded nudge to carry it out',
2026
+ });
2027
+ lastContent = '';
2028
+ thread.push({ role: 'assistant', content: response.content });
2029
+ thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
2030
+ continue;
2031
+ }
2032
+ // SELF-REVIEW (concluding exit) — same bounded pass, placed AFTER the
2033
+ // verification/deliverable gates so those settle their questions first.
2034
+ const review = selfReviewCorrection();
2035
+ if (review) {
2036
+ selfReviewNudges += 1;
2037
+ stepLimit += 1;
2038
+ deps.onEvent?.(' 🧭 Substantial turn ending — asking the model to review the result against the original ask.');
2039
+ traceEvent({
2040
+ kind: 'gate',
2041
+ gate: 'self-review',
2042
+ summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
2043
+ });
2044
+ thread.push({ role: 'assistant', content: response.content });
2045
+ thread.push({ role: 'user', content: review });
2046
+ continue;
2047
+ }
1574
2048
  const content = response.content.length >= lastContent.length ? response.content : lastContent;
1575
2049
  return {
1576
2050
  content,
@@ -1921,9 +2395,23 @@ export async function runToolLoop(opts) {
1921
2395
  if (!result.cancelled &&
1922
2396
  progress.mutatedPaths.length === 0 &&
1923
2397
  !replyAsksTheReader(result.content) &&
2398
+ // A request that forbade writes cannot have "failed to deliver" a file.
2399
+ !requestForbidsWrites(lastUserText(opts.messages)) &&
1924
2400
  wantsAuthoredArtifact(lastUserText(opts.messages))) {
1925
2401
  result.undeliveredArtifact = true;
1926
2402
  }
2403
+ // ZERO-ACTION honesty — a request that DIRECTED work on the workspace was
2404
+ // answered with nothing at all: no tool succeeded, nothing was written. The
2405
+ // flag is a function of what the turn DID, never of configuration, so a
2406
+ // caller can never read "completed" from a turn whose request it never
2407
+ // touched. Authored-artifact asks are left to `undeliveredArtifact` above so
2408
+ // one turn is never reported under two names.
2409
+ const askText = lastUserText(opts.messages);
2410
+ if (!hasProductiveAction(progress) &&
2411
+ progress.mutatedPaths.length === 0 &&
2412
+ requestRequiresWorkspaceAction(askText, requestAuthorizesWrites(askText).authorized)) {
2413
+ result.noActionTaken = true;
2414
+ }
1927
2415
  }
1928
2416
  return result;
1929
2417
  }
@@ -1961,8 +2449,125 @@ function deliverableGateApplies(opts, content, progress, schemaCount, requestTex
1961
2449
  return false;
1962
2450
  if (replyAsksTheReader(content))
1963
2451
  return false;
2452
+ // A negative instruction outranks every positive signal. "Do not write any
2453
+ // files — answer in chat" contains a creation verb and a file-shaped noun, so
2454
+ // without this the gate read it as an authored-artifact ask and wrote a file
2455
+ // against the user's explicit instruction.
2456
+ if (requestForbidsWrites(requestText))
2457
+ return false;
1964
2458
  return wantsAuthoredArtifact(requestText);
1965
2459
  }
2460
+ /**
2461
+ * Tools that do not count as having DONE anything on their own. `suggest_followups`
2462
+ * concludes a turn; it produces no work, so a turn that only called it has still
2463
+ * performed nothing the request asked for. Every other successful tool counts as
2464
+ * an action (a read is an action), which keeps the zero-action gate conservative.
2465
+ */
2466
+ /**
2467
+ * A token that NAMES a file (a real source/config extension). The strongest
2468
+ * signal that the request is about the WORKSPACE, not a chat answer.
2469
+ */
2470
+ const REQUEST_FILE_TOKEN_RE = /\b[\w./~-]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|cs|cpp|cxx|cc|c|h|hpp|json|ya?ml|toml|ini|cfg|conf|md|markdown|txt|csv|tsv|html?|css|scss|sass|less|sql|sh|bash|zsh|fish|env|lock|xml|gradle|properties|vue|svelte|php|lua|pl|swift|dart|scala)\b/i;
2471
+ /** A verb that asks for a change to the workspace. */
2472
+ const WORK_EDIT_VERB_RE = /\b(?:fix|repair|refactor|edit|modify|updat|chang|add|implement|creat|writ|delet|remov|renam|rewrit|migrat|correct|debug|patch|tweak|scaffold)\w*/i;
2473
+ /** A code/workspace noun that pairs with the verb above. */
2474
+ const CODE_NOUN_RE = /\b(?:file|files|function|functions|method|methods|class|classes|module|modules|script|scripts|component|components|test|tests|suite|api|endpoint|endpoints|route|routes|schema|schemas|migration|migrations|package|dependency|dependencies|import|imports|config|configuration|type|types|interface|interfaces|bug|bugs|repo|repository|codebase|project|source)\b/i;
2475
+ /**
2476
+ * Does this request DIRECT work on the workspace — the precondition for the
2477
+ * ZERO-ACTION gate?
2478
+ *
2479
+ * Deliberately narrower than "the request authorizes writes": the gate spends a
2480
+ * model step, so it must only fire on an ask that genuinely needs the tools.
2481
+ * `requestAuthorizesWrites` is true for a prose deliverable ("draft an
2482
+ * itinerary") or a review ("assess this project"), which are correctly answered
2483
+ * in chat — nudging those would turn one turn into two for no reason (found by
2484
+ * the existing suite). The workspace signal is therefore explicit: the request
2485
+ * either names a FILE, or pairs an edit verb with a code noun.
2486
+ */
2487
+ function requestRequiresWorkspaceAction(requestText, authorized) {
2488
+ const text = (requestText || '').trim();
2489
+ if (!text)
2490
+ return false;
2491
+ if (!authorized)
2492
+ return false;
2493
+ if (requestForbidsWrites(text))
2494
+ return false;
2495
+ if (wantsAuthoredArtifact(text))
2496
+ return false;
2497
+ if (REQUEST_FILE_TOKEN_RE.test(text))
2498
+ return true;
2499
+ return WORK_EDIT_VERB_RE.test(text) && CODE_NOUN_RE.test(text);
2500
+ }
2501
+ const NON_PRODUCTIVE_TOOLS = new Set(['suggest_followups']);
2502
+ /** True when the turn performed at least one action beyond merely concluding. */
2503
+ function hasProductiveAction(progress) {
2504
+ return progress.successfulToolCalls.some((name) => !NON_PRODUCTIVE_TOOLS.has(name));
2505
+ }
2506
+ /**
2507
+ * Does the ZERO-ACTION gate apply to this turn?
2508
+ *
2509
+ * Every term is an independent, checkable fact — the conjunction is what keeps
2510
+ * the gate from firing on turns that are legitimately answer-only:
2511
+ *
2512
+ * - the request AUTHORIZES writes (`authorized`) — a create/maintenance ask,
2513
+ * not a question (`requestAuthorizesWrites` already vetoes pure questions);
2514
+ * - NO tool call succeeded this turn (a turn that gathered context and then
2515
+ * answered is out of scope — it did something, even if it then stalled);
2516
+ * - NOTHING was written (`mutatedPaths` is the loop's own proof a write
2517
+ * landed, so a heredoc through `run_terminal` is not nudged);
2518
+ * - tools are actually available (a caller that exposed none cannot comply);
2519
+ * - the request did NOT forbid writes (a negative instruction outranks every
2520
+ * positive signal — see `requestForbidsWrites`);
2521
+ * - it is NOT an authored-artifact ask: `wantsAuthoredArtifact` requests are
2522
+ * the DELIVERABLE gate's job, with their own narrower message, and running
2523
+ * both would spend two nudges on one ask.
2524
+ */
2525
+ function zeroActionGateApplies(opts, progress, schemaCount, requestText, authorized) {
2526
+ if (opts.requireAction === false)
2527
+ return false;
2528
+ if (schemaCount === 0)
2529
+ return false;
2530
+ if (hasProductiveAction(progress))
2531
+ return false;
2532
+ if (progress.mutatedPaths.length > 0)
2533
+ return false;
2534
+ return requestRequiresWorkspaceAction(requestText, authorized);
2535
+ }
2536
+ /**
2537
+ * The bounded PLAN-REQUIRED nudge (E2) — one advisory step telling the model to
2538
+ * declare a short plan before it mutates.
2539
+ *
2540
+ * Names the ask, states the contract (plan first, then work), and closes the
2541
+ * failure mode the nudge exists for: planning that REPLACES the work; the last
2542
+ * line forbids exactly that dormancy.
2543
+ */
2544
+ export function planRequiredNudge(ask) {
2545
+ const quoted = (ask || '').trim().replace(/\s+/g, ' ').slice(0, 300);
2546
+ return ('Before you change anything, declare a short PLAN for this turn — call `plan_todo` with 2–6 ordered steps' +
2547
+ ' (create the plan once, then advance it with action "update" as you go).' +
2548
+ (quoted ? ` The request: "${quoted}".` : '') +
2549
+ '\nA plan is how the user can follow along and how the turn is verified — keep it accurate, do not pad it,' +
2550
+ ' and do NOT let planning replace the work: declare it, then do the first step now.');
2551
+ }
2552
+ /**
2553
+ * The bounded ZERO-ACTION correction — the loop telling the model, in one step,
2554
+ * that a directed request has not been touched yet.
2555
+ *
2556
+ * Names the ask (so the model cannot claim it did not know what was wanted),
2557
+ * states plainly that nothing ran and nothing changed, and closes the two escape
2558
+ * hatches that produced the observed non-actions: do not re-ask a request that is
2559
+ * already specified, and do not answer a work request with a plan, an apology or
2560
+ * a question. It still leaves a REAL blocker as a legitimate way out — the goal
2561
+ * is the work, not compliance theatre.
2562
+ */
2563
+ export function zeroActionNudge(ask) {
2564
+ const quoted = (ask || '').trim().replace(/\s+/g, ' ').slice(0, 300);
2565
+ return ('Nothing has been done yet: you have not called a single tool this turn, so no file was changed and nothing was checked.' +
2566
+ (quoted ? ` The request already asked for this work: "${quoted}".` : '') +
2567
+ '\nYou have the tools to do it — read what you need, make the change, and run the check NOW.' +
2568
+ ' Do NOT reply with a plan, a summary of what you would do, an apology, or a request for the user to restate or confirm' +
2569
+ ' a request that is already complete. If something genuinely blocks you, say exactly what it is and why; otherwise do the work.');
2570
+ }
1966
2571
  /**
1967
2572
  * The text of the LAST user message in the thread — what the user is actually
1968
2573
  * asking for right now, as opposed to the history above it. Used to derive
@@ -2148,4 +2753,92 @@ export function trimThreadBudget(thread, maxChars = DEFAULT_THREAD_BUDGET_CHARS)
2148
2753
  }
2149
2754
  return { thread: out, trimmed };
2150
2755
  }
2756
+ // ─── Within-turn work digest (the memory a trimmed thread keeps) ────────────
2757
+ // `trimThreadBudget` keeps the first 500 chars of each old tool result, but past
2758
+ // that the model loses the VERDICT of what it ran and can re-run finished work.
2759
+ // `working-state` solves this ACROSS turns; this solves it WITHIN one. It is
2760
+ // deliberately deterministic and LLM-free (facts: what changed, what commands
2761
+ // ran and whether they passed, which tools were used) — no summarizer, no
2762
+ // latency, no drift, exactly like the ledger and the budget it complements.
2763
+ /** Marker prefix identifying the loop's within-turn work digest message. */
2764
+ export const WORK_DIGEST_MARKER = '[work digest — your actions so far this turn]';
2765
+ /**
2766
+ * Format the turn's actions so far as a bounded, model-readable digest. Returns
2767
+ * '' when there is nothing worth saying, so a pristine turn adds no noise.
2768
+ */
2769
+ export function buildWorkDigest(input) {
2770
+ const lines = [];
2771
+ const changed = [...new Set(input.mutatedPaths.filter(Boolean))];
2772
+ if (changed.length > 0) {
2773
+ const shown = changed.slice(-12);
2774
+ lines.push(`• Files changed (${changed.length}): ${shown.join(', ')}${changed.length > shown.length ? ', …' : ''}`);
2775
+ }
2776
+ // Commands with their verdict, newest first, deduped by command+verdict — the
2777
+ // single most valuable thing to retain (did the build/test pass?).
2778
+ const cmds = input.executedActions.filter((a) => typeof a.command === 'string' && a.command.trim());
2779
+ if (cmds.length > 0) {
2780
+ const seen = new Set();
2781
+ const shown = [];
2782
+ for (let i = cmds.length - 1; i >= 0 && shown.length < 8; i -= 1) {
2783
+ const a = cmds[i];
2784
+ const key = `${a.ok ? 'ok' : 'fail'}:${a.command}`;
2785
+ if (seen.has(key))
2786
+ continue;
2787
+ seen.add(key);
2788
+ shown.unshift(`${a.ok ? '✅' : '❌'} ${a.command}`);
2789
+ }
2790
+ lines.push('• Commands run:');
2791
+ for (const s of shown)
2792
+ lines.push(` ${s}`);
2793
+ }
2794
+ const tools = [...new Set(input.successfulTools)];
2795
+ if (tools.length > 0)
2796
+ lines.push(`• Tools used: ${tools.join(', ')}`);
2797
+ if (lines.length === 0)
2798
+ return '';
2799
+ return `${WORK_DIGEST_MARKER}\n${lines.join('\n')}`;
2800
+ }
2801
+ /**
2802
+ * Insert or refresh the single work-digest message. Idempotent: a digest already
2803
+ * in the thread is UPDATED in place, never stacked, so repeated compaction in a
2804
+ * long turn keeps one current digest rather than accumulating stale ones. Placed
2805
+ * just after the system prompt + first user message so it sits with the ask and
2806
+ * survives the NEXT compaction (the tail is never trimmed).
2807
+ */
2808
+ export function upsertWorkDigest(thread, digest) {
2809
+ const existing = thread.findIndex((m) => m.content.startsWith(WORK_DIGEST_MARKER));
2810
+ if (existing !== -1) {
2811
+ thread[existing] = { ...thread[existing], content: digest };
2812
+ return;
2813
+ }
2814
+ let at = 0;
2815
+ while (at < thread.length && thread[at].role === 'system')
2816
+ at += 1;
2817
+ const firstUser = thread.findIndex((m) => m.role === 'user');
2818
+ const insertAt = Math.min(thread.length, firstUser === -1 ? at : Math.max(at, firstUser + 1));
2819
+ thread.splice(insertAt, 0, { role: 'user', content: digest });
2820
+ }
2821
+ /**
2822
+ * The bounded SELF-REVIEW correction — the loop's stand-in for a reviewer who
2823
+ * asks "is this actually what was asked for?".
2824
+ *
2825
+ * Fired once, only for a substantial turn that changed files and has already
2826
+ * been verified (the verification gate owns "did you check"). Its job is
2827
+ * orthogonal: catch a result that is verified but does not satisfy the WHOLE
2828
+ * original ask. It demands evidence from THIS turn and forbids padding the
2829
+ * answer with more prose — the failure mode it targets is a confident, verified
2830
+ * answer to a slightly wrong question.
2831
+ */
2832
+ export function selfReviewNudge(ask) {
2833
+ const quoted = (ask || '').trim().replace(/\s+/g, ' ').slice(0, 300);
2834
+ return ('Before you finish, REVIEW your result against the ORIGINAL request' +
2835
+ (quoted ? `: "${quoted}".` : '.') +
2836
+ '\nAnswer these to yourself in one short pass, and fix anything that is not true:' +
2837
+ '\n 1. Does what you produced satisfy EVERY part of that request — not just the part you found easiest?' +
2838
+ '\n 2. Is each claim in your answer backed by a tool result from THIS turn — or is it an assumption you did not check?' +
2839
+ '\n 3. Is anything the request asked for still missing, half-done, or done for the wrong target?' +
2840
+ '\nIf everything is satisfied and evidenced, reply with a brief confirmation and stop.' +
2841
+ ' If something is missing, do it NOW — or state plainly and specifically what is not done and why.' +
2842
+ ' Do NOT restate the work in more words — verify it.');
2843
+ }
2151
2844
  //# sourceMappingURL=tool-loop.js.map