@nexus-cortex/core 4.90.2 → 4.91.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.env.example +19 -5
  2. package/dist/config/RuntimeConfigRegistry.d.ts.map +1 -1
  3. package/dist/config/RuntimeConfigRegistry.js +2 -0
  4. package/dist/config/RuntimeConfigRegistry.js.map +1 -1
  5. package/dist/config/SettingsLoader.d.ts.map +1 -1
  6. package/dist/config/SettingsLoader.js +2 -0
  7. package/dist/config/SettingsLoader.js.map +1 -1
  8. package/dist/config/SettingsSchema.d.ts +4 -0
  9. package/dist/config/SettingsSchema.d.ts.map +1 -1
  10. package/dist/config/SettingsSchema.js +18 -0
  11. package/dist/config/SettingsSchema.js.map +1 -1
  12. package/dist/config/effectiveConfig.d.ts.map +1 -1
  13. package/dist/config/effectiveConfig.js +3 -0
  14. package/dist/config/effectiveConfig.js.map +1 -1
  15. package/dist/middleware/HelperModelMiddleware.d.ts.map +1 -1
  16. package/dist/middleware/HelperModelMiddleware.js +2 -1
  17. package/dist/middleware/HelperModelMiddleware.js.map +1 -1
  18. package/dist/orchestrator/CortexOrchestrator.d.ts +11 -0
  19. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  20. package/dist/orchestrator/CortexOrchestrator.js +138 -222
  21. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  22. package/dist/orchestrator/citationVerification.d.ts.map +1 -1
  23. package/dist/orchestrator/citationVerification.js +19 -1
  24. package/dist/orchestrator/citationVerification.js.map +1 -1
  25. package/dist/orchestrator/endTurnGates.d.ts +63 -0
  26. package/dist/orchestrator/endTurnGates.d.ts.map +1 -0
  27. package/dist/orchestrator/endTurnGates.js +203 -0
  28. package/dist/orchestrator/endTurnGates.js.map +1 -0
  29. package/dist/orchestrator/requirementsVerification.d.ts.map +1 -1
  30. package/dist/orchestrator/requirementsVerification.js +2 -1
  31. package/dist/orchestrator/requirementsVerification.js.map +1 -1
  32. package/dist/tools/ToolProfile.d.ts +6 -0
  33. package/dist/tools/ToolProfile.d.ts.map +1 -1
  34. package/dist/tools/ToolProfile.js +14 -0
  35. package/dist/tools/ToolProfile.js.map +1 -1
  36. package/dist/tools/registries/BaseToolRegistry.js +1 -1
  37. package/dist/tools/registries/BaseToolRegistry.js.map +1 -1
  38. package/package.json +3 -3
@@ -12,7 +12,6 @@ import { ToolNamingHandler } from '../adapters/ToolNamingHandler.js';
12
12
  // Phase 2.4: Server-Side Tools Support
13
13
  import { shouldUseServerSideTools, isServerSideToolsEnabled, modelSupportsServerSideTools } from '../adapters/ServerSideToolDetection.js';
14
14
  import { shouldAutoInjectMcp } from './mcpAutoInjectPolicy.js';
15
- import { verifyCitationsGrounded } from './citationVerification.js';
16
15
  import { verifyCoordinates, deterministicCoordinateScore } from './coordinateVerification.js';
17
16
  import { buildRouterSample, appendJsonlRotating } from './cortexTrainingRecord.js';
18
17
  import { join as pathJoin } from 'path';
@@ -30,7 +29,8 @@ import { CacheMetricsAccumulator } from '../session/CacheMetricsAccumulator.js';
30
29
  import { SubAgentProcessManager, createSubAgentProcessManager } from './SubAgentProcessManager.js';
31
30
  // Phase 1: Tool Architecture Refactor - Unified Tool Registry
32
31
  import { toolFactory } from '../tools/ToolFactory.js';
33
- import { resolveToolProfile, resolveToolAnchor, resolveFrameProfile, resolveLiftNudge, resolveHeadlessDropAskUser, resolveDeferredLoading, isNarrowProfile, isToolAllowedByProfile, applyToolProfile, webToolBlocked, resolveVisionHelperModel } from '../tools/ToolProfile.js';
32
+ import { resolveToolProfile, resolveToolAnchor, resolveFrameProfile, resolveLiftNudge, resolveHeadlessDropAskUser, resolveDeferredLoading, isNarrowProfile, isToolAllowedByProfile, applyToolProfile, webToolBlocked, resolveVisionHelperModel, resolveVisionHandoffMax, resolveSliceNudge } from '../tools/ToolProfile.js';
33
+ import { TurnEvidence, evaluateEndTurnGates, buildMissingEndTurnReminder } from './endTurnGates.js';
34
34
  import { readStagedDoctrine, applyCuratedDoctrine, runOrientForStaging, withTimeout } from './doctrineCuration.js';
35
35
  import { ExactRepeatTracker } from '../training/loopLadder.js';
36
36
  import { slashCommandRegistry } from '../commands/SlashCommandRegistry.js';
@@ -44,8 +44,6 @@ import { classifyToolOutcome } from '../training/toolOutcome.js';
44
44
  import { LoopLadder, formatLadderSignal } from '../training/loopLadder.js';
45
45
  import { shouldNudgeInaction, formatInactionNudge } from './inactionGuard.js';
46
46
  import { applyImageTtlForRequest } from './imageTtl.js';
47
- import { verifyRequirements, resolveEndTurnRequirementsMode, resolveEndTurnRequirementsStrict } from './requirementsVerification.js';
48
- import { verifyIntegrity, resolveEndTurnIntegrityMode } from './integrityVerification.js';
49
47
  import { detectSurrenderText, resolveSurrenderNudgeMode, SURRENDER_REMINDER } from './turnEndGuards.js';
50
48
  import { ModelRouterMatrix } from '../training/ModelRouterMatrix.js';
51
49
  import { classifyTask } from '../training/TaskClassifier.js';
@@ -1191,7 +1189,8 @@ export class CortexOrchestrator {
1191
1189
  // calls the EndTurn attestation tool. Steering is ignored by some
1192
1190
  // models (grok-4.3); a required tool call is not. Bounded so a model
1193
1191
  // that refuses cannot hang the turn (fallback: accept after N nudges).
1194
- let endTurnCalled = false;
1192
+ const ev = new TurnEvidence(); // per-turn evidence for the EndTurn gates (shared module, 4.91.0)
1193
+ this.visionHandoffsThisTurn = 0;
1195
1194
  let endTurnNudges = 0;
1196
1195
  let surrenderNudgeUsed = false; // item 13b: one execute-your-plan nudge per turn
1197
1196
  const END_TURN_MAX_NUDGES = 2;
@@ -1207,14 +1206,12 @@ export class CortexOrchestrator {
1207
1206
  // answer's line-number claims can be verified against what was actually
1208
1207
  // read this turn (verifyCoordinates). Stage-2-grounded citations are the
1209
1208
  // trusted baseline a coordinate must map to.
1210
- let lastEndTurnCitations;
1211
1209
  // EFFORT TAIL one-shot (def-efdbb67fd8): the first EndTurn of this turn has
1212
1210
  // been bounced for a high-effort re-attestation cycle.
1213
- let effortTailBounced = false;
1214
1211
  let endTurnLastToolName; // for the cortex training record
1215
1212
  // R60: the ROUTING decision = the FIRST real tool called in response to the
1216
1213
  // user prompt, WITH ITS ACTUAL INPUT ARGS. Previously the router sample's
1217
- // selected_args_json was filled with turn-quality metadata (endTurnCalled/
1214
+ // selected_args_json was filled with turn-quality metadata (ev.endTurnCalled/
1218
1215
  // nudges/violations/draftPreview) — fine-tunes on that corpus learned to
1219
1216
  // emit garbage args (proven by the v2p3 r9b pilot adapter).
1220
1217
  let routerFirstToolName;
@@ -1222,25 +1219,18 @@ export class CortexOrchestrator {
1222
1219
  // Scope: the gate ONLY arms when the turn actually used a (non-EndTurn)
1223
1220
  // tool. A pure-language turn has no tool-derived artifact to
1224
1221
  // misattribute, so forcing EndTurn there is pure overhead → bypass.
1225
- let turnUsedTools = false;
1226
1222
  // Drives the adaptive reminder (verification emphasis vs citation
1227
1223
  // emphasis) keyed to what the turn actually did.
1228
- let turnUsedMutatingTool = false; // Edit/Write/Bash/NotebookEdit
1229
- let turnUsedReadishTool = false; // Read/Grep/Glob
1230
1224
  // Stage 2: concatenated text of every (non-EndTurn) tool result the
1231
1225
  // model saw THIS turn — the haystack EndTurn citations are verified
1232
1226
  // against. A cited verbatim_source not found here is a regurgitated
1233
1227
  // prior, not an observation: the EndTurn call is rejected.
1234
- const thisTurnToolOutputs = [];
1235
1228
  // Loop detection: Track recent tool calls to detect repetitive loops
1236
1229
  const recentToolCalls = [];
1237
1230
  const exactRepeatTracker = new ExactRepeatTracker();
1238
1231
  // EndTurn Stage 5 (integrity): turn-level evidence accumulators — web
1239
1232
  // activity and mutating-tool inputs can land many batches before the
1240
1233
  // EndTurn call, so per-batch state is not enough.
1241
- const integrityWebQueries = [];
1242
- const integrityWebContent = [];
1243
- const integrityWriteInputs = [];
1244
1234
  // Unified Outcome Ladder (docs/UNIFIED_OUTCOME_LADDER.md): per-approach
1245
1235
  // FAILURE escalation — remind(2)→diversify(4)→break(6) over normalized
1246
1236
  // near-duplicate approaches. Complements isToolProgressStalled (which is
@@ -1252,6 +1242,7 @@ export class CortexOrchestrator {
1252
1242
  const allExecutedToolUses = [];
1253
1243
  // Track tool call counts for diversity warning
1254
1244
  const toolCallCounts = new Map();
1245
+ let lastExecutedBatchUuid = null; // 4.90.3 re-execution guard (def-7f510b5635)
1255
1246
  while (toolCallIteration < MAX_TOOL_ITERATIONS) {
1256
1247
  try {
1257
1248
  // DEBUG: Log canonical message content structure for tool extraction
@@ -1561,14 +1552,14 @@ export class CortexOrchestrator {
1561
1552
  // Q1 proved the fabrication is intrinsic regurgitation — only a
1562
1553
  // deterministic reject closes it.
1563
1554
  let stage3Violations = [];
1564
- if (endTurnGateEnabled && turnUsedTools && endTurnCalled) {
1555
+ if (endTurnGateEnabled && ev.usedTools && ev.endTurnCalled) {
1565
1556
  const draft = Array.isArray(currentAssistantCanonicalMessage.content)
1566
1557
  ? currentAssistantCanonicalMessage.content
1567
1558
  .filter((b) => b?.type === 'text')
1568
1559
  .map((b) => b.text || '')
1569
1560
  .join('\n')
1570
1561
  : String(currentAssistantCanonicalMessage.content ?? '');
1571
- const cv = verifyCoordinates(draft, thisTurnToolOutputs.join('\n'), lastEndTurnCitations);
1562
+ const cv = verifyCoordinates(draft, ev.outputs.join('\n'), ev.lastCitations);
1572
1563
  if (!cv.ok)
1573
1564
  stage3Violations = cv.violations;
1574
1565
  }
@@ -1577,7 +1568,7 @@ export class CortexOrchestrator {
1577
1568
  // anyway (liveness beats purity) — but that fallback-accept was
1578
1569
  // previously silent. Bank an event so the distiller can see
1579
1570
  // un-attested passes.
1580
- const endTurnGateUnsatisfied = endTurnGateEnabled && turnUsedTools && (!endTurnCalled || stage3Violations.length > 0);
1571
+ const endTurnGateUnsatisfied = endTurnGateEnabled && ev.usedTools && (!ev.endTurnCalled || stage3Violations.length > 0);
1581
1572
  // Item 13b (surrender guard): honest capitulation-with-a-plan — the
1582
1573
  // final text enumerates remaining steps instead of executing them.
1583
1574
  // One nudge per turn, own flag, shares the continuation plumbing.
@@ -1587,10 +1578,10 @@ export class CortexOrchestrator {
1587
1578
  .map((b) => b.text || '')
1588
1579
  .join('\n')
1589
1580
  : String(currentAssistantCanonicalMessage.content ?? '');
1590
- const surrenderUnsatisfied = resolveSurrenderNudgeMode() && turnUsedTools && !surrenderNudgeUsed &&
1581
+ const surrenderUnsatisfied = resolveSurrenderNudgeMode() && ev.usedTools && !surrenderNudgeUsed &&
1591
1582
  detectSurrenderText(surrenderDraftText);
1592
1583
  if (endTurnGateUnsatisfied && endTurnNudges >= END_TURN_MAX_NUDGES) {
1593
- const fallbackReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1584
+ const fallbackReason = !ev.endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1594
1585
  console.warn(`[Orchestrator] EndTurn gate FALLBACK-ACCEPT: ${fallbackReason} after ${endTurnNudges} nudges — turn ships un-attested.`);
1595
1586
  const store = this.getDecisionStore();
1596
1587
  if (store) {
@@ -1619,7 +1610,7 @@ export class CortexOrchestrator {
1619
1610
  else {
1620
1611
  endTurnNudges++;
1621
1612
  }
1622
- const gateReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1613
+ const gateReason = !ev.endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1623
1614
  console.warn(`[Orchestrator] EndTurn gate: ${gateReason} ` +
1624
1615
  `(nudge ${endTurnNudges}/${END_TURN_MAX_NUDGES}, iteration=${toolCallIteration}` +
1625
1616
  `${stage3Violations.length ? `, ungrounded lines: ${stage3Violations.map((v) => v.line).join(',')}` : ''}).`);
@@ -1640,24 +1631,10 @@ export class CortexOrchestrator {
1640
1631
  }
1641
1632
  // Adaptive reminder: name what THIS turn actually did (specific
1642
1633
  // imperatives bind harder than generic boilerplate).
1643
- const citEmphasis = turnUsedReadishTool
1644
- ? ' You read code/files this turn: for EACH reference in your draft give the EXACT verbatim source you copied it from; if you cannot, DELETE that reference and quote the code instead of asserting a coordinate.'
1645
- : '';
1646
- const verEmphasis = turnUsedMutatingTool
1647
- ? ' You ran edit/write/bash this turn: in `verification` list every build/test/lint command you ACTUALLY ran with the real result line you saw — do not claim a check you did not run.'
1648
- : '';
1649
1634
  const endTurnReminderText = surrenderUnsatisfied
1650
1635
  ? SURRENDER_REMINDER
1651
- : !endTurnCalled
1652
- ? ('<system-reminder>You used tools this turn but have not called EndTurn. ' +
1653
- 'You MUST call EndTurn before any final answer. It is generative, not a checkbox: ' +
1654
- 'reconstruct `citations` (array of {reference, verbatim_source}), `verification` ' +
1655
- '(array of {command, observed_result}), `summary`, `open_items`, and a skeptical ' +
1656
- '`self_review` (what you did NOT check, what is assumed/possibly wrong, what one ' +
1657
- 'more tool call would verify).' +
1658
- citEmphasis +
1659
- verEmphasis +
1660
- ' Call EndTurn now — do not produce a final answer until you have.</system-reminder>')
1636
+ : !ev.endTurnCalled
1637
+ ? buildMissingEndTurnReminder(ev)
1661
1638
  : ('<system-reminder>EndTurn REJECTED. Your drafted answer asserts line number(s) ' +
1662
1639
  stage3Violations.map((v) => v.line).join(', ') +
1663
1640
  ' that are NOT backed by any citation whose verbatim_source actually sits at that ' +
@@ -1758,7 +1735,7 @@ export class CortexOrchestrator {
1758
1735
  // IS the deterministic verdict — the slot the nexus schema reserves
1759
1736
  // ("alignment scorer / CHALLENGE-graded review") but leaves empty.
1760
1737
  // Fail-safe: training telemetry must never break a turn.
1761
- if (turnUsedTools) {
1738
+ if (ev.usedTools) {
1762
1739
  try {
1763
1740
  const draftText = Array.isArray(currentAssistantCanonicalMessage.content)
1764
1741
  ? currentAssistantCanonicalMessage.content
@@ -1771,8 +1748,8 @@ export class CortexOrchestrator {
1771
1748
  // line-number claims that correspond to a real line in this
1772
1749
  // turn's cat -n reads (not a flat 0). With the ReadFileTool
1773
1750
  // root-cause fix the model transcribes real numbers → ~1.0.
1774
- const outcomeScore = !endTurnCalled
1775
- ? deterministicCoordinateScore(draftText, thisTurnToolOutputs.join('\n'))
1751
+ const outcomeScore = !ev.endTurnCalled
1752
+ ? deterministicCoordinateScore(draftText, ev.outputs.join('\n'))
1776
1753
  : stage3Violations.length > 0
1777
1754
  ? 0.3
1778
1755
  : endTurnNudges > 0
@@ -1836,6 +1813,7 @@ export class CortexOrchestrator {
1836
1813
  }
1837
1814
  // Track these tool uses for the response
1838
1815
  allExecutedToolUses.push(...toolUseBlocks);
1816
+ ev.noteToolUses(toolUseBlocks); // per-turn evidence (shared with the streaming loop — endTurnGates.ts)
1839
1817
  // Track per-tool call counts for diversity warning
1840
1818
  for (const tu of toolUseBlocks) {
1841
1819
  toolCallCounts.set(tu.name, (toolCallCounts.get(tu.name) || 0) + 1);
@@ -1843,20 +1821,23 @@ export class CortexOrchestrator {
1843
1821
  // verify against this turn's tool output (set post-execution
1844
1822
  // below). A non-EndTurn tool arms the gate + records category.
1845
1823
  if (tu.name !== 'EndTurn') {
1846
- turnUsedTools = true;
1847
1824
  endTurnLastToolName = tu.name; // last real tool — for cortex record
1848
1825
  if (routerFirstToolName === undefined) {
1849
1826
  routerFirstToolName = tu.name; // the routing decision (R60)
1850
1827
  routerFirstToolArgs = tu.input ?? {};
1851
1828
  }
1852
- if (['Edit', 'Write', 'Bash', 'NotebookEdit'].includes(tu.name)) {
1853
- turnUsedMutatingTool = true;
1854
- }
1855
- if (['Read', 'Grep', 'Glob'].includes(tu.name)) {
1856
- turnUsedReadishTool = true;
1857
- }
1858
1829
  }
1859
1830
  }
1831
+ // 4.90.3 (def-7f510b5635): a batch that ALREADY EXECUTED must never run again. When an exception
1832
+ // escapes after execution (gate/lens/banking path), the catch below emits error tool_results but
1833
+ // the loop re-enters with the SAME assistant message — re-running its tools (real side effects)
1834
+ // until the error cap (v4 flash/dna-insert: 1000 iterations). Leave the loop instead; the
1835
+ // post-loop synthesis (R29a) gives the model one call to answer over the error results.
1836
+ if (lastExecutedBatchUuid !== null && currentAssistantCanonicalMessage.uuid === lastExecutedBatchUuid) {
1837
+ console.error(`[Orchestrator Phase 2.5] Tool batch ${lastExecutedBatchUuid} already executed — an exception escaped after execution; NOT re-running the batch (leaving the tool loop).`);
1838
+ break;
1839
+ }
1840
+ lastExecutedBatchUuid = currentAssistantCanonicalMessage.uuid ?? null;
1860
1841
  toolCallIteration++;
1861
1842
  if (this.config.debug) {
1862
1843
  console.log(`[Orchestrator Phase 2.5] Iteration ${toolCallIteration}: Model requested ${toolUseBlocks.length} tool call(s)`);
@@ -1867,30 +1848,6 @@ export class CortexOrchestrator {
1867
1848
  const inputHash = JSON.stringify(toolUse.input);
1868
1849
  const toolSignature = { name: toolUse.name, inputHash };
1869
1850
  recentToolCalls.push(toolSignature);
1870
- // Stage-5 evidence accumulation (cheap, always-on; verdicts only
1871
- // computed at the gate when CORTEX_ENDTURN_INTEGRITY is armed).
1872
- if (toolUse.name === 'WebSearch') {
1873
- const q = toolUse.input?.query;
1874
- if (typeof q === 'string' && q)
1875
- integrityWebQueries.push(q);
1876
- }
1877
- else if (toolUse.name === 'Browse') {
1878
- // Browse renders full pages — the richest retrieval channel; its
1879
- // task text rides the query checks and triggers attestation.
1880
- const t = toolUse.input?.task;
1881
- if (typeof t === 'string' && t)
1882
- integrityWebQueries.push(t);
1883
- }
1884
- else if (toolUse.name === 'Write') {
1885
- const c = toolUse.input?.content;
1886
- if (typeof c === 'string' && c)
1887
- integrityWriteInputs.push(c);
1888
- }
1889
- else if (toolUse.name === 'Edit') {
1890
- const ns = toolUse.input?.new_string;
1891
- if (typeof ns === 'string' && ns)
1892
- integrityWriteInputs.push(ns);
1893
- }
1894
1851
  // CONSECUTIVE byte-identical repeats only (2026-08-26 fix — the old
1895
1852
  // whole-turn occurrence count killed legitimate scattered repeats,
1896
1853
  // e.g. identical `npm test` after each fix; see ExactRepeatTracker).
@@ -1925,6 +1882,7 @@ export class CortexOrchestrator {
1925
1882
  const processedToolUseIds = new Set();
1926
1883
  try {
1927
1884
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
1885
+ this.testPostExecThrow(); // test-only fault injection for the re-execution guard (inert in prod)
1928
1886
  clearTimeout(timeoutId);
1929
1887
  // Unified Outcome Ladder: observe each result's TRUE outcome (exit
1930
1888
  // codes, not wire is_error) and escalate on repeated failing
@@ -2034,155 +1992,9 @@ export class CortexOrchestrator {
2034
1992
  // ungrounded verbatim_source is a regurgitated prior, not an
2035
1993
  // observation → reject the EndTurn (mutate its result into an
2036
1994
  // error the model must act on) and leave the gate unsatisfied.
2037
- for (const tr of toolResults) {
2038
- if (tr.tool_name === 'EndTurn' || tr.is_error)
2039
- continue;
2040
- const txt = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content);
2041
- if (txt)
2042
- thisTurnToolOutputs.push(txt);
2043
- if ((tr.tool_name === 'WebSearch' || tr.tool_name === 'WebFetch' || tr.tool_name === 'Browse') && txt) {
2044
- integrityWebContent.push(txt);
2045
- }
2046
- }
2047
- // 4.90.2: the whole EndTurn gate block is exception-safe. A throw here (4.90.0 strict
2048
- // null.filter) escaped to the tool-loop catch, which RE-RUNS the already-executed batch
2049
- // without a model call (v4 flash/dna-insert: 1000 iterations, 4 identical results) —
2050
- // a gate bug must never re-execute tools; it becomes an is_error EndTurn result instead.
2051
- try {
2052
- for (const tr of toolResults) {
2053
- if (tr.tool_name !== 'EndTurn')
2054
- continue;
2055
- const etUse = toolUseBlocks.find((t) => t.name === 'EndTurn');
2056
- // EFFORT TAIL (def-efdbb67fd8, finish-discipline half): the FIRST
2057
- // EndTurn is the model's own "I'm done" declaration — generated at
2058
- // body effort. Bounce it ONCE ("verify at depth") and arm the effort
2059
- // pulse so the re-attestation cycle runs elevated: structure forces
2060
- // the check (the gate), effort funds it (the pulse). One-shot per
2061
- // turn; skipped when a thrash pulse is already active (chain already
2062
- // elevated). Rides the same is_error rejection channel as Stages
2063
- // 2/4/5; liveness bounded by the gate's existing fallback-accept.
2064
- // Requires CORTEX_ENDTURN_GATE (EndTurn must be offered). Ship dark.
2065
- if (process.env.CORTEX_EFFORT_TAIL === 'true' &&
2066
- !effortTailBounced &&
2067
- this.effortPulseRemaining <= 0) {
2068
- effortTailBounced = true;
2069
- const tailTurns = Math.max(1, parseInt(process.env.CORTEX_EFFORT_TAIL_TURNS || '2', 10) || 2);
2070
- this.effortPulseRemaining = tailTurns;
2071
- // Mechanism-engagement evidence (bench adjudication rule): bank the
2072
- // tail-fired fact — the fd cell (2026-09-02) had to read engagement
2073
- // from session text because the tail armed the counter silently.
2074
- const tailStore = this.getDecisionStore();
2075
- if (tailStore) {
2076
- void tailStore.recordEvent({
2077
- sessionId: this.currentSessionId ?? 'unknown',
2078
- kind: 'effort_pulse',
2079
- toolName: 'EndTurn',
2080
- detail: { trigger: 'tail', cumFailures: 0, turns: tailTurns, level: process.env.CORTEX_EFFORT_PULSE_LEVEL || 'high' },
2081
- }).catch(() => { });
2082
- }
2083
- tr.is_error = true;
2084
- tr.content =
2085
- 'Before finishing: verify at depth. Re-read the original task statement; ' +
2086
- 're-open and check each requirement against the ACTUAL files/outputs you produced this turn ' +
2087
- '(do not rely on memory of them); fix anything that does not match; then call EndTurn again ' +
2088
- 'with the verified attestation.';
2089
- if (this.config.debug) {
2090
- console.log(`[EffortTail] first EndTurn bounced — re-attestation at elevated effort (${this.effortPulseRemaining} continuation(s))`);
2091
- }
2092
- continue;
2093
- }
2094
- const cits = etUse?.input?.citations;
2095
- lastEndTurnCitations = Array.isArray(cits) ? cits : undefined; // Stage 3 baseline
2096
- const verdict = verifyCitationsGrounded(cits, thisTurnToolOutputs.join('\n'));
2097
- if (verdict.grounded) {
2098
- // Stage 4 (backlog item 1, CORTEX_ENDTURN_REQUIREMENTS): verify
2099
- // the requirements attestation — the wrong-artifact weapon.
2100
- // Stage 2 verifies citations; nothing before this forced
2101
- // re-reading the TASK. Rides the same nudge budget: a Stage-4
2102
- // reject leaves the gate unsatisfied so the bounded
2103
- // fallback-accept still guarantees liveness.
2104
- if (resolveEndTurnRequirementsMode()) {
2105
- const s4 = verifyRequirements({
2106
- requirements: etUse?.input?.requirements,
2107
- verification: etUse?.input?.verification,
2108
- userTaskText: this.lastRealUserText(),
2109
- turnUsedMutatingTool,
2110
- strict: resolveEndTurnRequirementsStrict(process.env),
2111
- toolOutputs: thisTurnToolOutputs.join('\n'),
2112
- });
2113
- if (s4.ok) {
2114
- endTurnCalled = true;
2115
- }
2116
- else {
2117
- tr.is_error = true;
2118
- tr.content = s4.nudge;
2119
- console.warn('[Orchestrator] Stage4: EndTurn rejected — requirements attestation unsatisfied.');
2120
- }
2121
- }
2122
- else {
2123
- endTurnCalled = true;
2124
- }
2125
- // Stage 5 (item 12 layer 4, CORTEX_ENDTURN_INTEGRITY): mechanical
2126
- // task-integrity checks — web-content transplant into artifacts +
2127
- // solution-seeking queries. Flags ALWAYS bank as integrity_flag
2128
- // events (the distiller lens reads them); the nudge rides the
2129
- // same bounded budget, fallback-accept preserves liveness.
2130
- if (endTurnCalled && resolveEndTurnIntegrityMode()) {
2131
- const s5 = verifyIntegrity({
2132
- webQueries: integrityWebQueries,
2133
- webContent: integrityWebContent,
2134
- writeInputs: integrityWriteInputs,
2135
- userTaskText: this.lastRealUserText(),
2136
- sourcesAttestation: etUse?.input?.sources,
2137
- });
2138
- if (s5.flags.length > 0) {
2139
- const store = this.getDecisionStore();
2140
- if (store) {
2141
- for (const f of s5.flags) {
2142
- void store.recordEvent({
2143
- sessionId: this.currentSessionId ?? 'unknown',
2144
- kind: 'integrity_flag',
2145
- detail: { check: f.check, detail: f.detail },
2146
- }).catch(() => { });
2147
- }
2148
- }
2149
- }
2150
- if (!s5.ok) {
2151
- endTurnCalled = false;
2152
- tr.is_error = true;
2153
- tr.content = s5.nudge;
2154
- console.warn(`[Orchestrator] Stage5: EndTurn rejected — ${s5.flags.length} integrity flag(s).`);
2155
- }
2156
- }
2157
- }
2158
- else {
2159
- const bad = verdict.ungrounded
2160
- .map(u => ` - "${u.reference}" — not found in this turn's tool output: ${String(u.verbatim_source).slice(0, 120)}`)
2161
- .join('\n');
2162
- tr.is_error = true;
2163
- tr.content =
2164
- `EndTurn REJECTED — these citations are not grounded in anything you read this turn:\n${bad}\n\n` +
2165
- `A quote or coordinate you did not transcribe from this turn's tool output is a fabrication (a regurgitated guess), exactly like a non-matching edit old_string. ` +
2166
- `Either RE-READ the exact region and copy the real text, or DELETE that reference from your answer (quote only code you can ground), then call EndTurn again. ` +
2167
- `Grounding is per-TURN: if the line you want to cite is not in THIS turn's tool output (you read it earlier, or it is a file you wrote), RE-RUN the command that displays it now (cat/grep/sed the file, run the test) and copy the line from THAT output — do not resubmit the same citations.`;
2168
- console.warn(`[Orchestrator] Stage2: EndTurn rejected — ${verdict.ungrounded.length} ungrounded citation(s).`);
2169
- }
2170
- }
2171
- }
2172
- catch (gateErr) {
2173
- const msg = String(gateErr?.message ?? gateErr).slice(0, 200);
2174
- console.error(`[Orchestrator] EndTurn gate evaluation threw (converted to an error result, batch NOT re-run): ${msg}`);
2175
- for (const tr of toolResults) {
2176
- if (tr.tool_name !== 'EndTurn')
2177
- continue;
2178
- tr.is_error = true;
2179
- tr.content =
2180
- `EndTurn gate evaluation failed internally (${msg}). This is a harness fault, not your attestation. ` +
2181
- 'Call EndTurn again with well-formed arrays: citations [{reference, verbatim_source}], verification [{command, observed_result}], requirements [{requirement, satisfied_by, verified_how}].';
2182
- }
2183
- }
2184
- // Item 7: scrub ReadImage payloads from metadata BEFORE persist;
2185
- // the images inject as a user message after the results land.
1995
+ ev.noteToolResults(toolResults);
1996
+ // Stages 2/4/5 + effort tail — ONE implementation shared with the streaming loop (endTurnGates.ts, 4.91.0).
1997
+ evaluateEndTurnGates(ev, toolResults, toolUseBlocks, this.gateDeps());
2186
1998
  const pendingImages = this.collectPendingImages(toolResults, effectiveModel.vision === true);
2187
1999
  await this.applyVisionHandoffs();
2188
2000
  // Create tool_result messages and add to history
@@ -2695,7 +2507,7 @@ export class CortexOrchestrator {
2695
2507
  // Item 13a: the R29a path structurally bypasses the EndTurn gate (it
2696
2508
  // runs post-loop with tools suppressed). When the gate was armed and
2697
2509
  // owed an attestation, make the un-attested pass VISIBLE.
2698
- if (endTurnGateEnabled && turnUsedTools && !endTurnCalled) {
2510
+ if (endTurnGateEnabled && ev.usedTools && !ev.endTurnCalled) {
2699
2511
  const store = this.getDecisionStore();
2700
2512
  if (store) {
2701
2513
  void store.recordEvent({
@@ -3474,7 +3286,25 @@ export class CortexOrchestrator {
3474
3286
  // (no reminders, no force-synthesis cap). Runaway protection then rests on
3475
3287
  // loop detection, the consecutive-error breaker, and MAX_TOOL_ITERATIONS.
3476
3288
  const TOOL_BUDGET_HARD = TOOL_BUDGET_SOFT > 0 ? TOOL_BUDGET_SOFT * 2 : Infinity; // force-synthesis cap
3289
+ let lastExecutedBatchUuid = null; // 4.90.3 re-execution guard (def-7f510b5635)
3290
+ const ev = new TurnEvidence(); // 4.91.0: per-turn evidence for the EndTurn gates (streaming)
3291
+ this.visionHandoffsThisTurn = 0;
3292
+ let streamEndTurnNudges = 0;
3293
+ let pendingGateRequest = false; // Stage-1: a reminder was appended; run one continuation with NO tool batch
3294
+ const streamEndTurnGateEnabled = process.env.CORTEX_ENDTURN_GATE === 'true';
3477
3295
  while (hasToolUse && toolCallIteration < MAX_TOOL_ITERATIONS) {
3296
+ const gateReRequest = pendingGateRequest;
3297
+ pendingGateRequest = false;
3298
+ // 4.90.3 (def-7f510b5635): a batch that ALREADY EXECUTED must never run again. When an exception
3299
+ // escapes after execution (gate/lens/banking path), the catch below emits error tool_results but
3300
+ // the loop re-enters with the SAME assistant message — re-running its tools (real side effects)
3301
+ // until the error cap (v4 flash/dna-insert: 1000 iterations). Leave the loop instead; the
3302
+ // post-loop synthesis (R29a) gives the model one call to answer over the error results.
3303
+ if (!gateReRequest && lastExecutedBatchUuid !== null && currentAssistantCanonicalMessage.uuid === lastExecutedBatchUuid) {
3304
+ console.error(`[Orchestrator Streaming] Tool batch ${lastExecutedBatchUuid} already executed — an exception escaped after execution; NOT re-running the batch (leaving the tool loop).`);
3305
+ break;
3306
+ }
3307
+ lastExecutedBatchUuid = currentAssistantCanonicalMessage.uuid ?? null;
3478
3308
  toolCallIteration++;
3479
3309
  // Extract tool use blocks from current message
3480
3310
  const toolUseBlocks = currentAssistantCanonicalMessage.content
@@ -3484,7 +3314,8 @@ export class CortexOrchestrator {
3484
3314
  name: block.toolUse.name,
3485
3315
  input: block.toolUse.input
3486
3316
  }));
3487
- if (toolUseBlocks.length === 0) {
3317
+ ev.noteToolUses(toolUseBlocks);
3318
+ if (toolUseBlocks.length === 0 && !gateReRequest) {
3488
3319
  // R32 (streaming R18b parity): detect empty/thinking-only responses.
3489
3320
  // sendMessage retries ONCE inside the loop with tools preserved.
3490
3321
  const hasVisibleText = hasVisibleAssistantText(currentAssistantCanonicalMessage.content);
@@ -3776,7 +3607,11 @@ export class CortexOrchestrator {
3776
3607
  try {
3777
3608
  // Execute tools (reuse existing method)
3778
3609
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
3610
+ this.testPostExecThrow(); // test-only fault injection for the re-execution guard (inert in prod)
3779
3611
  clearTimeout(timeoutId);
3612
+ // 4.91.0: EndTurn gates for STREAMING (were sendMessage-only) — same evidence + evaluation.
3613
+ ev.noteToolResults(toolResults);
3614
+ evaluateEndTurnGates(ev, toolResults, toolUseBlocks, this.gateDeps());
3780
3615
  // Unified Outcome Ladder (streaming parity with sendMessage).
3781
3616
  ladderSignal = null;
3782
3617
  {
@@ -4236,6 +4071,31 @@ export class CortexOrchestrator {
4236
4071
  input: block.toolUse.input
4237
4072
  }));
4238
4073
  hasToolUse = continuationToolUses.length > 0;
4074
+ // 4.91.0 Stage-1 for STREAMING: tools were used but no valid EndTurn yet → append the reminder and
4075
+ // run ONE more continuation with no tool batch (pendingGateRequest bypasses the empty-batch break
4076
+ // and the re-execution guard). Two nudges, then fallback-accept (parity with sendMessage).
4077
+ if (!hasToolUse && streamEndTurnGateEnabled && ev.usedTools && !ev.endTurnCalled) {
4078
+ if (streamEndTurnNudges < 2) {
4079
+ streamEndTurnNudges++;
4080
+ const reminderMsg = {
4081
+ uuid: uuidv4(),
4082
+ timestamp: new Date().toISOString(),
4083
+ type: 'user',
4084
+ message: { role: 'user', content: [{ type: 'text', text: buildMissingEndTurnReminder(ev) }] },
4085
+ timeline: { sessionId: this.currentSessionId, conversationId: this.currentConversationId, turnNumber: this.turnNumber + 1 },
4086
+ model: { id: effectiveModel.id, provider: effectiveModel.provider, apiPattern: effectiveModel.api.pattern },
4087
+ };
4088
+ this.messageHistory.push(reminderMsg);
4089
+ await this.historyStore.appendMessage(this.currentSessionId, reminderMsg);
4090
+ console.warn(`[Orchestrator Streaming] EndTurn gate: missing-EndTurn (nudge ${streamEndTurnNudges}/2, iteration=${toolCallIteration}) — re-requesting.`);
4091
+ pendingGateRequest = true;
4092
+ hasToolUse = true;
4093
+ }
4094
+ else {
4095
+ console.warn('[Orchestrator Streaming] EndTurn gate FALLBACK-ACCEPT: missing-EndTurn after 2 nudges — turn ships un-attested.');
4096
+ this.gateDeps().recordEvent('endturn_gate_fallback', { reason: 'missing-EndTurn', nudges: streamEndTurnNudges, iteration: toolCallIteration, streaming: true });
4097
+ }
4098
+ }
4239
4099
  // Stop if too many errors
4240
4100
  if (totalToolErrors >= MAX_CONSECUTIVE_ERRORS) {
4241
4101
  console.warn(`[Orchestrator Streaming] Stopping loop due to accumulated errors`);
@@ -6836,16 +6696,71 @@ export class CortexOrchestrator {
6836
6696
  pendingVisionHandoffs = [];
6837
6697
  /** Resolve queued ReadImage hand-offs through the helper middleware: the vision helper's TEXT
6838
6698
  * replaces the tool_result content. Failures degrade to the pre-hand-off guidance (bash OCR). */
6699
+ /** Per-turn ReadImage→vision-helper hand-off count (VISION_HANDOFF_MAX); reset at the start of each turn. */
6700
+ visionHandoffsThisTurn = 0;
6701
+ /** Per-session bash slice-read counts per file (CORTEX_SLICE_NUDGE). */
6702
+ sliceReads = new Map();
6703
+ /** Dependencies the shared EndTurn gates need from the orchestrator (endTurnGates.ts). */
6704
+ gateDeps() {
6705
+ return {
6706
+ userTaskText: this.lastRealUserText(),
6707
+ env: process.env,
6708
+ debug: this.config.debug,
6709
+ recordEvent: (kind, detail, toolName) => {
6710
+ const store = this.getDecisionStore();
6711
+ if (!store)
6712
+ return;
6713
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind, toolName, detail }).catch(() => { });
6714
+ },
6715
+ effortTail: { remaining: () => this.effortPulseRemaining, arm: (turns) => { this.effortPulseRemaining = turns; } },
6716
+ };
6717
+ }
6718
+ /** Test-only fault injection: CORTEX_TEST_POST_EXEC_THROW=once throws exactly once right after a tool
6719
+ * batch executed, to prove the loop never re-runs an executed batch (def-7f510b5635). Inert otherwise. */
6720
+ testPostExecThrow() {
6721
+ if (process.env.CORTEX_TEST_POST_EXEC_THROW === 'once') {
6722
+ process.env.CORTEX_TEST_POST_EXEC_THROW = 'fired';
6723
+ throw new Error('CORTEX_TEST_POST_EXEC_THROW: synthetic post-execution fault');
6724
+ }
6725
+ }
6726
+ /** CORTEX_SLICE_NUDGE: after the 3rd bash slice-read of the same file, append a one-line Read reminder. */
6727
+ applySliceNudge(toolUse, result) {
6728
+ if (toolUse.name !== 'Bash' || result.is_error || !resolveSliceNudge(process.env))
6729
+ return;
6730
+ const cmd = String(toolUse.input?.command ?? '');
6731
+ const m = cmd.match(/(?:sed\s+-n\s+['"]?\d+\s*,\s*\d+\s*p['"]?|head\s+-n?\s*\d+|tail\s+-n?\s*\d+)\s+([^\s;|&>]+)/);
6732
+ if (!m)
6733
+ return;
6734
+ const file = m[1];
6735
+ const n = (this.sliceReads.get(file) ?? 0) + 1;
6736
+ this.sliceReads.set(file, n);
6737
+ if (n !== 3)
6738
+ return;
6739
+ if (typeof result.content === 'string') {
6740
+ result.content += `\n\n<system-reminder>You have now read ${file} in ${n} bash slices. Read it ONCE with the Read tool (offset/limit for long files) instead of repeated sed/head/tail slices — slicing loses context and burns turns.</system-reminder>`;
6741
+ }
6742
+ this.gateDeps().recordEvent('steering_injected', { kind: 'slice_read', file, count: n }, 'Bash');
6743
+ }
6839
6744
  async applyVisionHandoffs() {
6840
6745
  const queue = this.pendingVisionHandoffs;
6841
6746
  this.pendingVisionHandoffs = [];
6747
+ const max = resolveVisionHandoffMax(process.env);
6842
6748
  for (const h of queue) {
6843
6749
  const modelId = resolveVisionHelperModel(process.env) || 'deepseek-v4-flash-vision-exp';
6750
+ if (max > 0 && this.visionHandoffsThisTurn >= max) {
6751
+ h.tr.is_error = false;
6752
+ h.tr.content =
6753
+ `[ReadImage → vision budget] This turn has already used ${this.visionHandoffsThisTurn} vision reads (VISION_HANDOFF_MAX=${max}); this read was NOT sent. ` +
6754
+ 'Consolidate: tile the regions you still need into ONE labeled montage and ask one focused question, or switch to a non-visual approach (parse the source data directly).';
6755
+ this.gateDeps().recordEvent('vision_handoff_capped', { max, used: this.visionHandoffsThisTurn, filePath: h.filePath }, 'ReadImage');
6756
+ continue;
6757
+ }
6844
6758
  try {
6845
6759
  const t0 = Date.now();
6846
6760
  const text = await this.helperMiddleware.describeImage({
6847
6761
  mediaType: h.payload.mediaType, base64: h.payload.base64, prompt: h.prompt, filePath: h.filePath, helperModelId: modelId,
6848
6762
  });
6763
+ this.visionHandoffsThisTurn++;
6849
6764
  h.tr.is_error = false;
6850
6765
  h.tr.content =
6851
6766
  `[ReadImage → vision helper ${modelId}${h.filePath ? ` on ${h.filePath}` : ''}]\n` +
@@ -7130,6 +7045,7 @@ export class CortexOrchestrator {
7130
7045
  return { success: true, llmContent: hint, metadata: { source: 'mentor-consult', rung, helperModel: this.config.reactiveMentorship?.helperModelId } };
7131
7046
  }
7132
7047
  async processToolTraining(toolUse, result) {
7048
+ this.applySliceNudge(toolUse, result); // universal post-execution hook: every path funnels through processToolTraining
7133
7049
  const store = this.getDecisionStore();
7134
7050
  if (!store)
7135
7051
  return result;