@nexus-cortex/core 4.69.1 → 4.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/adapters/ChatCompletionsAPIAdapter.d.ts +17 -2
  2. package/dist/adapters/ChatCompletionsAPIAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ChatCompletionsAPIAdapter.js +26 -3
  4. package/dist/adapters/ChatCompletionsAPIAdapter.js.map +1 -1
  5. package/dist/adapters/MessagesAPIAdapter.d.ts.map +1 -1
  6. package/dist/adapters/MessagesAPIAdapter.js +15 -0
  7. package/dist/adapters/MessagesAPIAdapter.js.map +1 -1
  8. package/dist/models/cards/deepseek/deepseek-v4-flash-vision-exp.d.ts +28 -0
  9. package/dist/models/cards/deepseek/deepseek-v4-flash-vision-exp.d.ts.map +1 -0
  10. package/dist/models/cards/deepseek/deepseek-v4-flash-vision-exp.js +47 -0
  11. package/dist/models/cards/deepseek/deepseek-v4-flash-vision-exp.js.map +1 -0
  12. package/dist/models/cards/deepseek/index.d.ts +5 -2
  13. package/dist/models/cards/deepseek/index.d.ts.map +1 -1
  14. package/dist/models/cards/deepseek/index.js +5 -2
  15. package/dist/models/cards/deepseek/index.js.map +1 -1
  16. package/dist/models/configurators/DeepSeekConfigurator.d.ts +5 -0
  17. package/dist/models/configurators/DeepSeekConfigurator.d.ts.map +1 -1
  18. package/dist/models/configurators/DeepSeekConfigurator.js +2 -0
  19. package/dist/models/configurators/DeepSeekConfigurator.js.map +1 -1
  20. package/dist/models/configurators/XAIConfigurator.d.ts +3 -0
  21. package/dist/models/configurators/XAIConfigurator.d.ts.map +1 -1
  22. package/dist/models/configurators/XAIConfigurator.js +1 -0
  23. package/dist/models/configurators/XAIConfigurator.js.map +1 -1
  24. package/dist/models/registry/ModularModelRegistry.d.ts.map +1 -1
  25. package/dist/models/registry/ModularModelRegistry.js +1 -0
  26. package/dist/models/registry/ModularModelRegistry.js.map +1 -1
  27. package/dist/orchestrator/CortexOrchestrator.d.ts +23 -0
  28. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  29. package/dist/orchestrator/CortexOrchestrator.js +628 -22
  30. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  31. package/dist/orchestrator/imageTtl.d.ts +37 -0
  32. package/dist/orchestrator/imageTtl.d.ts.map +1 -0
  33. package/dist/orchestrator/imageTtl.js +72 -0
  34. package/dist/orchestrator/imageTtl.js.map +1 -0
  35. package/dist/orchestrator/inactionGuard.d.ts +42 -0
  36. package/dist/orchestrator/inactionGuard.d.ts.map +1 -0
  37. package/dist/orchestrator/inactionGuard.js +47 -0
  38. package/dist/orchestrator/inactionGuard.js.map +1 -0
  39. package/dist/orchestrator/requirementsVerification.d.ts +43 -0
  40. package/dist/orchestrator/requirementsVerification.d.ts.map +1 -0
  41. package/dist/orchestrator/requirementsVerification.js +87 -0
  42. package/dist/orchestrator/requirementsVerification.js.map +1 -0
  43. package/dist/tools/ToolProfile.d.ts +9 -0
  44. package/dist/tools/ToolProfile.d.ts.map +1 -1
  45. package/dist/tools/ToolProfile.js +26 -3
  46. package/dist/tools/ToolProfile.js.map +1 -1
  47. package/dist/tools/registries/BaseToolRegistry.d.ts.map +1 -1
  48. package/dist/tools/registries/BaseToolRegistry.js +42 -1
  49. package/dist/tools/registries/BaseToolRegistry.js.map +1 -1
  50. package/dist/training/DecisionPriorInjector.d.ts +7 -0
  51. package/dist/training/DecisionPriorInjector.d.ts.map +1 -1
  52. package/dist/training/DecisionPriorInjector.js +22 -0
  53. package/dist/training/DecisionPriorInjector.js.map +1 -1
  54. package/dist/training/DecisionStore.d.ts +39 -0
  55. package/dist/training/DecisionStore.d.ts.map +1 -1
  56. package/dist/training/DecisionStore.js +94 -0
  57. package/dist/training/DecisionStore.js.map +1 -1
  58. package/dist/training/errorFamily.d.ts +15 -0
  59. package/dist/training/errorFamily.d.ts.map +1 -0
  60. package/dist/training/errorFamily.js +40 -0
  61. package/dist/training/errorFamily.js.map +1 -0
  62. package/dist/training/loopLadder.d.ts +35 -0
  63. package/dist/training/loopLadder.d.ts.map +1 -0
  64. package/dist/training/loopLadder.js +58 -0
  65. package/dist/training/loopLadder.js.map +1 -0
  66. package/dist/training/toolOutcome.d.ts +20 -0
  67. package/dist/training/toolOutcome.d.ts.map +1 -0
  68. package/dist/training/toolOutcome.js +100 -0
  69. package/dist/training/toolOutcome.js.map +1 -0
  70. package/package.json +3 -3
@@ -30,11 +30,17 @@ import { CacheMetricsAccumulator } from '../session/CacheMetricsAccumulator.js';
30
30
  import { SubAgentProcessManager, createSubAgentProcessManager } from './SubAgentProcessManager.js';
31
31
  // Phase 1: Tool Architecture Refactor - Unified Tool Registry
32
32
  import { toolFactory } from '../tools/ToolFactory.js';
33
- import { resolveToolProfile, resolveToolAnchor, isNarrowProfile, isToolAllowedByProfile, applyToolProfile } from '../tools/ToolProfile.js';
33
+ import { resolveToolProfile, resolveToolAnchor, resolveFrameProfile, isNarrowProfile, isToolAllowedByProfile, applyToolProfile } from '../tools/ToolProfile.js';
34
34
  import { slashCommandRegistry } from '../commands/SlashCommandRegistry.js';
35
35
  import { prefixMcpToolName, parseMcpToolName } from '../mcp/mcpToolNamespacing.js';
36
- import { DecisionStore, stableInputHash } from '../training/DecisionStore.js';
37
- import { formatPriorReminder } from '../training/DecisionPriorInjector.js';
36
+ import { DecisionStore } from '../training/DecisionStore.js';
37
+ import { formatPriorReminder, formatFamilyReminder } from '../training/DecisionPriorInjector.js';
38
+ import { classifyErrorFamily } from '../training/errorFamily.js';
39
+ import { classifyToolOutcome } from '../training/toolOutcome.js';
40
+ import { LoopLadder, formatLadderSignal } from '../training/loopLadder.js';
41
+ import { shouldNudgeInaction, formatInactionNudge } from './inactionGuard.js';
42
+ import { applyImageTtlForRequest } from './imageTtl.js';
43
+ import { verifyRequirements, resolveEndTurnRequirementsMode } from './requirementsVerification.js';
38
44
  import { ModelRouterMatrix } from '../training/ModelRouterMatrix.js';
39
45
  import { classifyTask } from '../training/TaskClassifier.js';
40
46
  import { closestToolMatches } from './toolNameMatcher.js';
@@ -143,6 +149,10 @@ export class CortexOrchestrator {
143
149
  /** The active model card's anchorProfile (captured at request assembly) —
144
150
  * env CORTEX_TOOL_ANCHOR still overrides inside resolveToolAnchor. */
145
151
  cardAnchorProfile = null;
152
+ /** Card frameProfile ('lifted'|'persist') captured with the anchor — under
153
+ * 'persist' the anchor never lifts (backlog item 5; env
154
+ * CORTEX_TOOL_ANCHOR_PERSIST overrides in resolveFrameProfile). */
155
+ cardFrameProfile = null;
146
156
  /** P6 deferral (CORTEX_PROMPT_MASS=defer): the static corpus is delivered
147
157
  * exactly once, at the anchor-lift boundary. One-shot per orchestrator. */
148
158
  deferredCorpusDelivered = false;
@@ -316,8 +326,10 @@ export class CortexOrchestrator {
316
326
  // deferred setup tools are actually findable.
317
327
  const extras = [...this.getMcpManagementTools(), ...this.getContextManagementTools()];
318
328
  const seen = new Set();
329
+ const catalogVision = this.getModel(this.currentModelId ?? '')?.vision === true;
319
330
  return [...toolFactory.getAllTools(), ...extras]
320
331
  .filter((t) => endTurnGateOn || t.name !== 'EndTurn')
332
+ .filter((t) => catalogVision || t.name !== 'ReadImage')
321
333
  .filter((t) => (seen.has(t.name) ? false : (seen.add(t.name), true)))
322
334
  .map((t) => ({
323
335
  name: namingHandler.convertName(t.name, convention),
@@ -446,9 +458,12 @@ export class CortexOrchestrator {
446
458
  const model = this.getModel(modelId);
447
459
  // Phase 1 & Phase 2: Get tools (used for both injection context and API request)
448
460
  const endTurnGateOn = (process.env.CORTEX_ENDTURN_GATE) === 'true';
449
- const factoryTools = this.applyBaseToolAllowlist(endTurnGateOn
450
- ? toolFactory.getAllTools()
451
- : toolFactory.getAllTools().filter(t => t.name !== 'EndTurn'));
461
+ // Vision gate (item 7): ReadImage is offered ONLY to probe-verified vision
462
+ // cards — a text-only model must never see or reach the image path.
463
+ const visionOn = model?.vision === true;
464
+ const factoryTools = this.applyBaseToolAllowlist(toolFactory.getAllTools()
465
+ .filter(t => endTurnGateOn || t.name !== 'EndTurn')
466
+ .filter(t => visionOn || t.name !== 'ReadImage'));
452
467
  // Tool-profile: MCP/management/context tools bypass ToolFactory, so the
453
468
  // bash-only arm must suppress them here or the surface leaks (lean keeps
454
469
  // them — they are part of the "guided" surface under test).
@@ -699,6 +714,7 @@ export class CortexOrchestrator {
699
714
  // Card-level home door: capture the active card's anchorProfile (env
700
715
  // CORTEX_TOOL_ANCHOR overrides inside the resolver; env 'none' disables).
701
716
  this.cardAnchorProfile = effectiveModel.anchorProfile ?? null;
717
+ this.cardFrameProfile = effectiveModel.frameProfile ?? null;
702
718
  // Thread the CARD's anchor to the executors (per-orchestrator config, by
703
719
  // reference) so framing-aware executor behavior (ShellTool steering
704
720
  // default) is model-card/registry-scoped, not process-global env.
@@ -978,6 +994,9 @@ export class CortexOrchestrator {
978
994
  // Round 18b: track whether we've already retried on empty response so
979
995
  // we don't loop forever. See empty-detection block below.
980
996
  let emptyResponseRetryUsed = false;
997
+ // Inaction guard (backlog item 2 — the ladder's inverse): single-nudge
998
+ // bound for the actless-verbose retry. Default OFF via env.
999
+ let inactionNudgeUsed = false;
981
1000
  // EndTurn gate (Stage 1): the turn must not complete until the model
982
1001
  // calls the EndTurn attestation tool. Steering is ignored by some
983
1002
  // models (grok-4.3); a required tool call is not. Bounded so a model
@@ -1021,6 +1040,13 @@ export class CortexOrchestrator {
1021
1040
  const thisTurnToolOutputs = [];
1022
1041
  // Loop detection: Track recent tool calls to detect repetitive loops
1023
1042
  const recentToolCalls = [];
1043
+ // Unified Outcome Ladder (docs/UNIFIED_OUTCOME_LADDER.md): per-approach
1044
+ // FAILURE escalation — remind(2)→diversify(4)→break(6) over normalized
1045
+ // near-duplicate approaches. Complements isToolProgressStalled (which is
1046
+ // outcome-blind exact-hash cycling) by firing earlier on failing thrash.
1047
+ const loopLadder = new LoopLadder();
1048
+ let ladderBreak = null;
1049
+ let ladderSignal = null;
1024
1050
  // Track all tool uses across iterations for response
1025
1051
  const allExecutedToolUses = [];
1026
1052
  // Track tool call counts for diversity warning
@@ -1198,6 +1224,130 @@ export class CortexOrchestrator {
1198
1224
  continue;
1199
1225
  }
1200
1226
  }
1227
+ // Inaction guard (backlog item 2 — the loop ladder's inverse).
1228
+ // TB2 2×2: persist-frame arms produced never-acted failures (5
1229
+ // pro-persist) — a huge reason-to-the-wall text dump with ZERO tool
1230
+ // calls, invisible to every call-counting guard. One bounded
1231
+ // steering retry, R18b-style; default OFF (CORTEX_INACTION_NUDGE).
1232
+ {
1233
+ const responseChars = Array.isArray(currentAssistantCanonicalMessage.content)
1234
+ ? currentAssistantCanonicalMessage.content
1235
+ .map((b) => b?.type === 'text' ? (b.text || '') : b?.type === 'thinking' ? (b.thinking || '') : '')
1236
+ .join('').length
1237
+ : String(currentAssistantCanonicalMessage.content ?? '').length;
1238
+ if (hasVisibleText &&
1239
+ !structuredOutputState?.result &&
1240
+ shouldNudgeInaction({
1241
+ responseChars,
1242
+ toolUseBlocksThisResponse: 0,
1243
+ executedToolCallsThisTurn: allExecutedToolUses.length,
1244
+ toolsOffered: toolsToUse.length,
1245
+ turnNumber: this.turnNumber,
1246
+ alreadyNudged: inactionNudgeUsed,
1247
+ })) {
1248
+ inactionNudgeUsed = true;
1249
+ console.warn(`[Orchestrator] Inaction guard: actless-verbose response (${responseChars} chars, 0 tool calls). ` +
1250
+ `Injecting act-first nudge (single retry).`);
1251
+ // Observability (backlog item 3): bank the event either way it
1252
+ // resolves — the distiller counts nudge→acted conversions.
1253
+ {
1254
+ const store = this.getDecisionStore();
1255
+ if (store) {
1256
+ void store
1257
+ .recordEvent({
1258
+ sessionId: this.currentSessionId ?? 'unknown',
1259
+ kind: 'inaction_nudge',
1260
+ detail: { responseChars, turnNumber: this.turnNumber },
1261
+ })
1262
+ .catch(() => { });
1263
+ }
1264
+ }
1265
+ const inactionUserMessage = {
1266
+ uuid: uuidv4(),
1267
+ timestamp: new Date().toISOString(),
1268
+ type: 'user',
1269
+ message: {
1270
+ role: 'user',
1271
+ content: [{
1272
+ type: 'text',
1273
+ text: `<system-reminder>${formatInactionNudge()}</system-reminder>`,
1274
+ }],
1275
+ },
1276
+ timeline: {
1277
+ sessionId: this.currentSessionId,
1278
+ conversationId: this.currentConversationId,
1279
+ turnNumber: this.turnNumber + 1,
1280
+ },
1281
+ model: {
1282
+ id: effectiveModel.id,
1283
+ provider: effectiveModel.provider,
1284
+ apiPattern: effectiveModel.api.pattern,
1285
+ },
1286
+ };
1287
+ this.messageHistory.push(inactionUserMessage);
1288
+ await this.historyStore.appendMessage(this.currentSessionId, inactionUserMessage);
1289
+ await this.ensureHistoryFitsModel(effectiveModel);
1290
+ const inactionCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
1291
+ const inactionRequest = this.gatewayTranslation.prepareRequest(inactionCanonicalHistory, toolsToUse, effectiveModel, {
1292
+ temperature: options.parameters?.temperature,
1293
+ maxTokens: options.parameters?.maxTokens,
1294
+ topP: options.parameters?.topP,
1295
+ reasoningEffort: options.parameters?.reasoningEffort,
1296
+ stream: options.streaming,
1297
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
1298
+ conversationId: this.currentConversationId, // R28b
1299
+ });
1300
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
1301
+ inactionRequest.previousResponseId = this.lastResponseId;
1302
+ }
1303
+ inactionRequest.conversationId = this.currentSessionId;
1304
+ let inactionApiResponse;
1305
+ if (this.retryMiddleware) {
1306
+ const r = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(inactionRequest, effectiveModel), 'inaction_nudge_retry');
1307
+ inactionApiResponse = r.result;
1308
+ }
1309
+ else {
1310
+ inactionApiResponse = await this.apiClient.sendRequest(inactionRequest, effectiveModel);
1311
+ }
1312
+ const inactionConverted = this.gatewayTranslation.convertResponse(inactionApiResponse.data, effectiveModel, {
1313
+ sessionId: this.currentSessionId,
1314
+ conversationId: this.currentConversationId,
1315
+ turnNumber: this.turnNumber + 1,
1316
+ });
1317
+ if (effectiveModel.api.pattern === 'responses' && inactionApiResponse.data?.id) {
1318
+ this.lastResponseId = inactionApiResponse.data.id;
1319
+ this.lastResponseIdProvider = effectiveModel.provider; // R20a
1320
+ }
1321
+ if (inactionConverted.messages && inactionConverted.messages.length > 0) {
1322
+ const inactionAssistantCanonical = inactionConverted.messages[0];
1323
+ const inactionAssistantMessage = {
1324
+ uuid: inactionAssistantCanonical.uuid,
1325
+ timestamp: inactionAssistantCanonical.timestamp,
1326
+ type: 'assistant',
1327
+ message: {
1328
+ role: 'assistant',
1329
+ content: inactionAssistantCanonical.content,
1330
+ },
1331
+ timeline: {
1332
+ sessionId: this.currentSessionId,
1333
+ conversationId: this.currentConversationId,
1334
+ turnNumber: this.turnNumber + 1,
1335
+ },
1336
+ model: {
1337
+ id: effectiveModel.id,
1338
+ provider: effectiveModel.provider,
1339
+ apiPattern: effectiveModel.api.pattern,
1340
+ },
1341
+ ...(inactionConverted.usage && { usage: inactionConverted.usage }),
1342
+ };
1343
+ this.messageHistory.push(inactionAssistantMessage);
1344
+ await this.historyStore.appendMessage(this.currentSessionId, inactionAssistantMessage);
1345
+ currentAssistantMessage = inactionAssistantMessage;
1346
+ currentAssistantCanonicalMessage = inactionAssistantCanonical;
1347
+ continue;
1348
+ }
1349
+ }
1350
+ }
1201
1351
  // EndTurn gate (Stage 1): refuse to finalize until the model has
1202
1352
  // called the EndTurn attestation tool. Only arms when the turn
1203
1353
  // actually used a (non-EndTurn) tool — pure-language turns bypass.
@@ -1221,8 +1371,27 @@ export class CortexOrchestrator {
1221
1371
  if (!cv.ok)
1222
1372
  stage3Violations = cv.violations;
1223
1373
  }
1224
- if (endTurnGateEnabled && turnUsedTools && endTurnNudges < END_TURN_MAX_NUDGES &&
1225
- (!endTurnCalled || stage3Violations.length > 0)) {
1374
+ // Fallback observability (backlog item 1.4): when the nudge bound
1375
+ // is exhausted and the gate is STILL unsatisfied, the turn ships
1376
+ // anyway (liveness beats purity) — but that fallback-accept was
1377
+ // previously silent. Bank an event so the distiller can see
1378
+ // un-attested passes.
1379
+ const endTurnGateUnsatisfied = endTurnGateEnabled && turnUsedTools && (!endTurnCalled || stage3Violations.length > 0);
1380
+ if (endTurnGateUnsatisfied && endTurnNudges >= END_TURN_MAX_NUDGES) {
1381
+ const fallbackReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1382
+ console.warn(`[Orchestrator] EndTurn gate FALLBACK-ACCEPT: ${fallbackReason} after ${endTurnNudges} nudges — turn ships un-attested.`);
1383
+ const store = this.getDecisionStore();
1384
+ if (store) {
1385
+ void store
1386
+ .recordEvent({
1387
+ sessionId: this.currentSessionId ?? 'unknown',
1388
+ kind: 'endturn_gate_fallback',
1389
+ detail: { reason: fallbackReason, nudges: endTurnNudges, iteration: toolCallIteration },
1390
+ })
1391
+ .catch(() => { });
1392
+ }
1393
+ }
1394
+ if (endTurnGateUnsatisfied && endTurnNudges < END_TURN_MAX_NUDGES) {
1226
1395
  endTurnNudges++;
1227
1396
  const gateReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1228
1397
  console.warn(`[Orchestrator] EndTurn gate: ${gateReason} ` +
@@ -1498,6 +1667,49 @@ export class CortexOrchestrator {
1498
1667
  try {
1499
1668
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
1500
1669
  clearTimeout(timeoutId);
1670
+ // Unified Outcome Ladder: observe each result's TRUE outcome (exit
1671
+ // codes, not wire is_error) and escalate on repeated failing
1672
+ // approaches. The strongest signal this batch is injected at the
1673
+ // signals block below; a 'break' rung ends the loop R29b-style.
1674
+ ladderSignal = null;
1675
+ {
1676
+ const inputById = new Map(toolUseBlocks.map((b) => [b.id, b.input]));
1677
+ for (const tr of toolResults) {
1678
+ if (tr.tool_name === 'EndTurn')
1679
+ continue;
1680
+ const input = inputById.get(tr.tool_use_id);
1681
+ if (input === undefined)
1682
+ continue;
1683
+ const outcome = classifyToolOutcome(tr.tool_name, input, tr);
1684
+ const ladder = loopLadder.observe(tr.tool_name, outcome);
1685
+ const sig = formatLadderSignal(tr.tool_name, ladder);
1686
+ if (sig)
1687
+ ladderSignal = sig;
1688
+ if (ladder.action === 'break')
1689
+ ladderBreak = sig;
1690
+ // Observability (backlog item 3): escalations are invisible in
1691
+ // the session record (signals inject post-persist) — bank each
1692
+ // rung as a decision-store event for the distiller.
1693
+ if (ladder.action !== 'none') {
1694
+ const store = this.getDecisionStore();
1695
+ if (store) {
1696
+ void store
1697
+ .recordEvent({
1698
+ sessionId: this.currentSessionId ?? 'unknown',
1699
+ kind: 'loop_escalation',
1700
+ toolName: tr.tool_name,
1701
+ detail: {
1702
+ rung: ladder.action,
1703
+ count: ladder.count,
1704
+ approachHash: outcome.approachHash,
1705
+ ...(outcome.family ? { family: outcome.family } : {}),
1706
+ },
1707
+ })
1708
+ .catch(() => { });
1709
+ }
1710
+ }
1711
+ }
1712
+ }
1501
1713
  // File-history snapshots (Claude Code parity — previously built but
1502
1714
  // UNWIRED: FileCheckpointManager existed with zero markModified
1503
1715
  // callers and its snapshot message was discarded). Mark files
@@ -1578,7 +1790,31 @@ export class CortexOrchestrator {
1578
1790
  lastEndTurnCitations = Array.isArray(cits) ? cits : undefined; // Stage 3 baseline
1579
1791
  const verdict = verifyCitationsGrounded(cits, thisTurnToolOutputs.join('\n'));
1580
1792
  if (verdict.grounded) {
1581
- endTurnCalled = true;
1793
+ // Stage 4 (backlog item 1, CORTEX_ENDTURN_REQUIREMENTS): verify
1794
+ // the requirements attestation — the wrong-artifact weapon.
1795
+ // Stage 2 verifies citations; nothing before this forced
1796
+ // re-reading the TASK. Rides the same nudge budget: a Stage-4
1797
+ // reject leaves the gate unsatisfied so the bounded
1798
+ // fallback-accept still guarantees liveness.
1799
+ if (resolveEndTurnRequirementsMode()) {
1800
+ const s4 = verifyRequirements({
1801
+ requirements: etUse?.input?.requirements,
1802
+ verification: etUse?.input?.verification,
1803
+ userTaskText: this.lastRealUserText(),
1804
+ turnUsedMutatingTool,
1805
+ });
1806
+ if (s4.ok) {
1807
+ endTurnCalled = true;
1808
+ }
1809
+ else {
1810
+ tr.is_error = true;
1811
+ tr.content = s4.nudge;
1812
+ console.warn('[Orchestrator] Stage4: EndTurn rejected — requirements attestation unsatisfied.');
1813
+ }
1814
+ }
1815
+ else {
1816
+ endTurnCalled = true;
1817
+ }
1582
1818
  }
1583
1819
  else {
1584
1820
  const bad = verdict.ungrounded
@@ -1592,6 +1828,9 @@ export class CortexOrchestrator {
1592
1828
  console.warn(`[Orchestrator] Stage2: EndTurn rejected — ${verdict.ungrounded.length} ungrounded citation(s).`);
1593
1829
  }
1594
1830
  }
1831
+ // Item 7: scrub ReadImage payloads from metadata BEFORE persist;
1832
+ // the images inject as a user message after the results land.
1833
+ const pendingImages = this.collectPendingImages(toolResults, effectiveModel.vision === true);
1595
1834
  // Create tool_result messages and add to history
1596
1835
  for (const toolResult of toolResults) {
1597
1836
  const toolResultMessageId = uuidv4();
@@ -1726,14 +1965,43 @@ export class CortexOrchestrator {
1726
1965
  const progressStalled = isToolProgressStalled(recentToolCalls);
1727
1966
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
1728
1967
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
1729
- if (budgetSignal || diversityWarning) {
1730
- const signals = [budgetSignal, diversityWarning].filter(Boolean).join('\n');
1968
+ if (budgetSignal || diversityWarning || ladderSignal) {
1969
+ const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
1731
1970
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
1732
1971
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
1733
1972
  const block = lastMsg.message.content[0];
1734
1973
  const existing = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
1735
1974
  block.content = existing + '\n\n' + signals;
1975
+ // Observability (backlog item 3, micro-suite defect #4): this
1976
+ // mutation happens AFTER the tool_result was persisted — the
1977
+ // durable session lacks the steering the model saw. Bank a
1978
+ // steering_injected event so harvest/distill sees it. Session rows
1979
+ // stay append-only (never rewritten).
1980
+ const store = this.getDecisionStore();
1981
+ if (store) {
1982
+ const kinds = [
1983
+ budgetSignal ? 'budget' : null,
1984
+ diversityWarning ? 'diversity' : null,
1985
+ ladderSignal ? 'ladder' : null,
1986
+ ].filter(Boolean);
1987
+ void store
1988
+ .recordEvent({
1989
+ sessionId: this.currentSessionId ?? 'unknown',
1990
+ kind: 'steering_injected',
1991
+ detail: { kinds, iteration: toolCallIteration, chars: signals.length },
1992
+ })
1993
+ .catch(() => { });
1994
+ }
1736
1995
  }
1996
+ ladderSignal = null; // consumed — never re-inject on later iterations
1997
+ }
1998
+ // Ladder break (teach-then-break): the break instruction is already in
1999
+ // the tool result above; exit the loop R29b-style so the post-loop
2000
+ // synthesis net produces an honest final answer — never the blunt
2001
+ // toolCallIteration=MAX turn-kill of the exact-match detector.
2002
+ if (ladderBreak) {
2003
+ console.warn('[Orchestrator] LoopLadder break: repeated failing approach — forcing synthesis.');
2004
+ break;
1737
2005
  }
1738
2006
  // R29b hard cap: a weaker model may ignore the firm budget reminders
1739
2007
  // and keep spinning (benchmark: deepseek 46 successful local calls,
@@ -1774,6 +2042,10 @@ export class CortexOrchestrator {
1774
2042
  console.error(`[Orchestrator Mentorship] Interleaved continuation thinking failed:`, error.message);
1775
2043
  }
1776
2044
  }
2045
+ // Item 7: attach any ReadImage images as a user message so the
2046
+ // continuation request carries them (after the signals block — it
2047
+ // targets the LAST message being a tool_result).
2048
+ await this.injectImageUserMessage(pendingImages, effectiveModel);
1777
2049
  // Send tool results back to model and get continuation response
1778
2050
  // Proactive context management - ensure history fits before continuation
1779
2051
  await this.ensureHistoryFitsModel(effectiveModel);
@@ -1782,7 +2054,11 @@ export class CortexOrchestrator {
1782
2054
  // full profile applies from here. Rebuild from allTools because the
1783
2055
  // armed anchor narrowed toolsToUse at assembly. (When deferred loading
1784
2056
  // is on, the re-filter block below performs the rebuild.)
1785
- if (!this.anchorLifted && resolveToolAnchor(process.env, this.cardAnchorProfile)) {
2057
+ if (!this.anchorLifted &&
2058
+ resolveToolAnchor(process.env, this.cardAnchorProfile) &&
2059
+ // Persist frame (backlog item 5): the anchored surface IS the session
2060
+ // surface — never lift, never deliver the deferred corpus.
2061
+ resolveFrameProfile(process.env, this.cardFrameProfile) !== 'persist') {
1786
2062
  this.anchorLifted = true;
1787
2063
  if (!(this.config.enableDeferredToolLoading && !isPTCEnabled)) {
1788
2064
  toolsToUse = allTools;
@@ -2372,9 +2648,11 @@ export class CortexOrchestrator {
2372
2648
  // === END MENTORSHIP HOOKS ===
2373
2649
  // Get tools (needed for middleware injection decision)
2374
2650
  const endTurnGateOn2 = (process.env.CORTEX_ENDTURN_GATE) === 'true';
2375
- const factoryTools = this.applyBaseToolAllowlist(endTurnGateOn2
2376
- ? toolFactory.getAllTools()
2377
- : toolFactory.getAllTools().filter(t => t.name !== 'EndTurn'));
2651
+ // Vision gate (item 7): streaming mirror of the sendMessage filter.
2652
+ const visionOn2 = model?.vision === true;
2653
+ const factoryTools = this.applyBaseToolAllowlist(toolFactory.getAllTools()
2654
+ .filter(t => endTurnGateOn2 || t.name !== 'EndTurn')
2655
+ .filter(t => visionOn2 || t.name !== 'ReadImage'));
2378
2656
  // Tool-profile: streaming mirror of the non-streaming suppression above.
2379
2657
  const bashOnlyProfile2 = isNarrowProfile();
2380
2658
  const mcpTools = this.mcpAutoInject && !bashOnlyProfile2 ? this.getMcpToolsAsCanonical() : [];
@@ -2564,6 +2842,7 @@ export class CortexOrchestrator {
2564
2842
  // Card-level home door: capture the active card's anchorProfile (env
2565
2843
  // CORTEX_TOOL_ANCHOR overrides inside the resolver; env 'none' disables).
2566
2844
  this.cardAnchorProfile = effectiveModel.anchorProfile ?? null;
2845
+ this.cardFrameProfile = effectiveModel.frameProfile ?? null;
2567
2846
  // Thread the CARD's anchor to the executors (per-orchestrator config, by
2568
2847
  // reference) so framing-aware executor behavior (ShellTool steering
2569
2848
  // default) is model-card/registry-scoped, not process-global env.
@@ -2766,7 +3045,12 @@ export class CortexOrchestrator {
2766
3045
  let toolCallIteration = 0;
2767
3046
  let totalToolErrors = 0;
2768
3047
  let emptyResponseRetryUsed = false; // R32: parity with sendMessage R18b guard
3048
+ let inactionNudgeUsed = false; // Inaction guard (backlog item 2) — streaming parity
2769
3049
  const allToolCalls = [];
3050
+ // Unified Outcome Ladder (streaming parity — see sendMessage).
3051
+ const loopLadder = new LoopLadder();
3052
+ let ladderBreak = null;
3053
+ let ladderSignal = null;
2770
3054
  const allExecutedToolUses = [];
2771
3055
  const toolCallCounts = new Map();
2772
3056
  // DEBUG: Log canonical message content structure for tool extraction (streaming path)
@@ -2933,6 +3217,136 @@ export class CortexOrchestrator {
2933
3217
  console.warn(`[Orchestrator Streaming] R32 retry failed:`, retryErr);
2934
3218
  }
2935
3219
  }
3220
+ // Inaction guard (backlog item 2 — streaming parity with sendMessage).
3221
+ // Actless-verbose first-turn response in a tool-capable request: one
3222
+ // bounded act-first steering retry. Default OFF (CORTEX_INACTION_NUDGE).
3223
+ {
3224
+ const responseChars = Array.isArray(currentAssistantCanonicalMessage.content)
3225
+ ? currentAssistantCanonicalMessage.content
3226
+ .map((b) => b?.type === 'text' ? (b.text || '') : b?.type === 'thinking' ? (b.thinking || '') : '')
3227
+ .join('').length
3228
+ : String(currentAssistantCanonicalMessage.content ?? '').length;
3229
+ if (hasVisibleText &&
3230
+ shouldNudgeInaction({
3231
+ responseChars,
3232
+ toolUseBlocksThisResponse: 0,
3233
+ executedToolCallsThisTurn: allExecutedToolUses.length,
3234
+ toolsOffered: toolsToUse.length,
3235
+ turnNumber: this.turnNumber,
3236
+ alreadyNudged: inactionNudgeUsed,
3237
+ })) {
3238
+ inactionNudgeUsed = true;
3239
+ console.warn(`[Orchestrator Streaming] Inaction guard: actless-verbose response (${responseChars} chars, 0 tool calls). ` +
3240
+ `Injecting act-first nudge (single retry).`);
3241
+ {
3242
+ const store = this.getDecisionStore();
3243
+ if (store) {
3244
+ void store
3245
+ .recordEvent({
3246
+ sessionId: this.currentSessionId ?? 'unknown',
3247
+ kind: 'inaction_nudge',
3248
+ detail: { responseChars, turnNumber: this.turnNumber, streaming: true },
3249
+ })
3250
+ .catch(() => { });
3251
+ }
3252
+ }
3253
+ const inactionUserMessage = {
3254
+ uuid: uuidv4(),
3255
+ timestamp: new Date().toISOString(),
3256
+ type: 'user',
3257
+ message: {
3258
+ role: 'user',
3259
+ content: [{
3260
+ type: 'text',
3261
+ text: `<system-reminder>${formatInactionNudge()}</system-reminder>`,
3262
+ }],
3263
+ },
3264
+ timeline: {
3265
+ sessionId: this.currentSessionId,
3266
+ conversationId: this.currentConversationId,
3267
+ turnNumber: this.turnNumber + 1,
3268
+ },
3269
+ model: {
3270
+ id: effectiveModel.id,
3271
+ provider: effectiveModel.provider,
3272
+ apiPattern: effectiveModel.api.pattern,
3273
+ },
3274
+ };
3275
+ this.messageHistory.push(inactionUserMessage);
3276
+ await this.historyStore.appendMessage(this.currentSessionId, inactionUserMessage);
3277
+ try {
3278
+ await this.ensureHistoryFitsModel(effectiveModel);
3279
+ const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3280
+ const retryRequest = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
3281
+ temperature: options.parameters?.temperature,
3282
+ maxTokens: options.parameters?.maxTokens,
3283
+ topP: options.parameters?.topP,
3284
+ reasoningEffort: options.parameters?.reasoningEffort,
3285
+ stream: false,
3286
+ staticSystemPrompt: this.currentStaticSystemPrompt,
3287
+ conversationId: this.currentConversationId,
3288
+ });
3289
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
3290
+ retryRequest.previousResponseId = this.lastResponseId;
3291
+ }
3292
+ retryRequest.conversationId = this.currentSessionId;
3293
+ let retryApiResponse;
3294
+ if (this.retryMiddleware) {
3295
+ const retryResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(retryRequest, effectiveModel), 'inaction_nudge_retry');
3296
+ retryApiResponse = retryResult.result;
3297
+ }
3298
+ else {
3299
+ retryApiResponse = await this.apiClient.sendRequest(retryRequest, effectiveModel);
3300
+ }
3301
+ const retryConverted = this.gatewayTranslation.convertResponse(retryApiResponse.data, effectiveModel, {
3302
+ sessionId: this.currentSessionId,
3303
+ conversationId: this.currentConversationId,
3304
+ turnNumber: this.turnNumber + 1,
3305
+ });
3306
+ if (effectiveModel.api.pattern === 'responses' && retryApiResponse.data?.id) {
3307
+ this.lastResponseId = retryApiResponse.data.id;
3308
+ this.lastResponseIdProvider = effectiveModel.provider;
3309
+ }
3310
+ if (retryConverted.messages && retryConverted.messages.length > 0) {
3311
+ const retryAssistantCanonical = retryConverted.messages[0];
3312
+ const retryAssistantMessage = {
3313
+ uuid: retryAssistantCanonical.uuid,
3314
+ timestamp: retryAssistantCanonical.timestamp,
3315
+ type: 'assistant',
3316
+ message: {
3317
+ role: 'assistant',
3318
+ content: retryAssistantCanonical.content,
3319
+ },
3320
+ timeline: {
3321
+ sessionId: this.currentSessionId,
3322
+ conversationId: this.currentConversationId,
3323
+ turnNumber: this.turnNumber + 1,
3324
+ },
3325
+ model: {
3326
+ id: effectiveModel.id,
3327
+ provider: effectiveModel.provider,
3328
+ apiPattern: effectiveModel.api.pattern,
3329
+ },
3330
+ ...(retryConverted.usage && { usage: retryConverted.usage }),
3331
+ };
3332
+ this.messageHistory.push(retryAssistantMessage);
3333
+ await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
3334
+ currentAssistantCanonicalMessage = retryAssistantCanonical;
3335
+ const retryText = retryAssistantCanonical.content
3336
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
3337
+ .map((b) => b.text)
3338
+ .join('');
3339
+ if (retryText.trim().length > 0) {
3340
+ yield { type: 'text_delta', delta: retryText };
3341
+ }
3342
+ continue; // re-enter loop — retry may have produced tool_use
3343
+ }
3344
+ }
3345
+ catch (retryErr) {
3346
+ console.warn(`[Orchestrator Streaming] Inaction-nudge retry failed:`, retryErr);
3347
+ }
3348
+ }
3349
+ }
2936
3350
  hasToolUse = false;
2937
3351
  break;
2938
3352
  }
@@ -2972,6 +3386,45 @@ export class CortexOrchestrator {
2972
3386
  // Execute tools (reuse existing method)
2973
3387
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
2974
3388
  clearTimeout(timeoutId);
3389
+ // Unified Outcome Ladder (streaming parity with sendMessage).
3390
+ ladderSignal = null;
3391
+ {
3392
+ const inputById = new Map(toolUseBlocks.map((b) => [b.id, b.input]));
3393
+ for (const tr of toolResults) {
3394
+ if (tr.tool_name === 'EndTurn')
3395
+ continue;
3396
+ const input = inputById.get(tr.tool_use_id);
3397
+ if (input === undefined)
3398
+ continue;
3399
+ const outcome = classifyToolOutcome(tr.tool_name, input, tr);
3400
+ const ladder = loopLadder.observe(tr.tool_name, outcome);
3401
+ const sig = formatLadderSignal(tr.tool_name, ladder);
3402
+ if (sig)
3403
+ ladderSignal = sig;
3404
+ if (ladder.action === 'break')
3405
+ ladderBreak = sig;
3406
+ // Observability (backlog item 3): streaming parity — bank each
3407
+ // escalation rung as a decision-store event.
3408
+ if (ladder.action !== 'none') {
3409
+ const store = this.getDecisionStore();
3410
+ if (store) {
3411
+ void store
3412
+ .recordEvent({
3413
+ sessionId: this.currentSessionId ?? 'unknown',
3414
+ kind: 'loop_escalation',
3415
+ toolName: tr.tool_name,
3416
+ detail: {
3417
+ rung: ladder.action,
3418
+ count: ladder.count,
3419
+ approachHash: outcome.approachHash,
3420
+ ...(outcome.family ? { family: outcome.family } : {}),
3421
+ },
3422
+ })
3423
+ .catch(() => { });
3424
+ }
3425
+ }
3426
+ }
3427
+ }
2975
3428
  // R21 (2026-05-15): same fix as non-streaming path — count consecutive
2976
3429
  // ITERATIONS WITH ZERO SUCCESSFUL TOOLS, not cumulative individual
2977
3430
  // failures. Reset whenever at least one tool succeeded.
@@ -2992,6 +3445,8 @@ export class CortexOrchestrator {
2992
3445
  else if (successCount > 0) {
2993
3446
  totalToolErrors = 0; // Reset — model is making progress
2994
3447
  }
3448
+ // Item 7: scrub ReadImage payloads before persist (streaming parity).
3449
+ const pendingImages = this.collectPendingImages(toolResults, effectiveModel.vision === true);
2995
3450
  // Create tool_result messages and save to history
2996
3451
  for (const toolResult of toolResults) {
2997
3452
  // Emit tool result as streaming chunk for CLI display
@@ -3123,15 +3578,43 @@ export class CortexOrchestrator {
3123
3578
  const progressStalled = isToolProgressStalled(allToolCalls);
3124
3579
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
3125
3580
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
3126
- if (budgetSignal || diversityWarning) {
3127
- const signals = [budgetSignal, diversityWarning].filter(Boolean).join('\n');
3581
+ if (budgetSignal || diversityWarning || ladderSignal) {
3582
+ const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
3128
3583
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
3129
3584
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
3130
3585
  const block = lastMsg.message.content[0];
3131
3586
  const existing = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
3132
3587
  block.content = existing + '\n\n' + signals;
3588
+ // Observability (backlog item 3): post-persist mutation — bank a
3589
+ // steering_injected event (streaming parity).
3590
+ const store = this.getDecisionStore();
3591
+ if (store) {
3592
+ const kinds = [
3593
+ budgetSignal ? 'budget' : null,
3594
+ diversityWarning ? 'diversity' : null,
3595
+ ladderSignal ? 'ladder' : null,
3596
+ ].filter(Boolean);
3597
+ void store
3598
+ .recordEvent({
3599
+ sessionId: this.currentSessionId ?? 'unknown',
3600
+ kind: 'steering_injected',
3601
+ detail: { kinds, iteration: toolCallIteration, chars: signals.length, streaming: true },
3602
+ })
3603
+ .catch(() => { });
3604
+ }
3133
3605
  }
3606
+ ladderSignal = null; // consumed
3134
3607
  }
3608
+ // Ladder break (streaming parity): instruction already injected above;
3609
+ // exit R29b-style so the streaming synthesis net closes the turn.
3610
+ if (ladderBreak) {
3611
+ console.warn('[Orchestrator Streaming] LoopLadder break: repeated failing approach — forcing synthesis.');
3612
+ break;
3613
+ }
3614
+ // Item 7: attach ReadImage images as a user message (streaming parity;
3615
+ // after the signals block — it targets the LAST message being a
3616
+ // tool_result).
3617
+ await this.injectImageUserMessage(pendingImages, effectiveModel);
3135
3618
  // R29b hard cap (streaming parity): force-stop at 2x soft budget;
3136
3619
  // the post-loop R29a streaming net then synthesizes a deliverable.
3137
3620
  // Progress-gated hard cap (parity with sendMessage): force-stop only when
@@ -3170,7 +3653,11 @@ export class CortexOrchestrator {
3170
3653
  // Proactive context management - ensure history fits before streaming continuation
3171
3654
  await this.ensureHistoryFitsModel(effectiveModel);
3172
3655
  // Anchor lift — streaming mirror of the sendMessage continuation path.
3173
- if (!this.anchorLifted && resolveToolAnchor(process.env, this.cardAnchorProfile)) {
3656
+ if (!this.anchorLifted &&
3657
+ resolveToolAnchor(process.env, this.cardAnchorProfile) &&
3658
+ // Persist frame (backlog item 5): the anchored surface IS the session
3659
+ // surface — never lift, never deliver the deferred corpus.
3660
+ resolveFrameProfile(process.env, this.cardFrameProfile) !== 'persist') {
3174
3661
  this.anchorLifted = true;
3175
3662
  if (!(this.config.enableDeferredToolLoading && !isPTCEnabled)) {
3176
3663
  toolsToUse = allTools;
@@ -5886,6 +6373,89 @@ export class CortexOrchestrator {
5886
6373
  }
5887
6374
  return this.routerMatrix;
5888
6375
  }
6376
+ /** Item 7 (image-path bridge): consume ReadImage payloads from this batch.
6377
+ * SCRUBS the base64 payload out of tool_result metadata BEFORE the result
6378
+ * is persisted (no double-stored image; the image rides the injected user
6379
+ * message instead). When the active model is not vision-capable (a
6380
+ * hallucinated/PTC call that slipped the surface gate), the result is
6381
+ * rewritten into an actionable error — a text-only model must never have
6382
+ * an image dangled at it. */
6383
+ collectPendingImages(toolResults, modelVision) {
6384
+ const out = [];
6385
+ for (const tr of toolResults) {
6386
+ const payload = tr.metadata?.imagePayload;
6387
+ if (!payload)
6388
+ continue;
6389
+ delete tr.metadata.imagePayload;
6390
+ if (tr.is_error)
6391
+ continue;
6392
+ if (!modelVision) {
6393
+ tr.is_error = true;
6394
+ tr.content =
6395
+ 'ReadImage requires a vision-capable model — the active model cannot see images. ' +
6396
+ 'Extract what you need through bash instead (e.g. tesseract OCR, ffprobe, PIL).';
6397
+ continue;
6398
+ }
6399
+ out.push({ mediaType: payload.mediaType, data: payload.base64 });
6400
+ }
6401
+ return out;
6402
+ }
6403
+ /** Item 7: attach collected images as a synthetic USER message (R18b shape).
6404
+ * Providers reject image parts on tool messages — DeepSeek accepts them on
6405
+ * user messages only — so the image hops from the tool_result to the next
6406
+ * user turn. Persisted BEFORE the continuation request is assembled, so the
6407
+ * session record shows exactly what the model saw (item-3 lesson). */
6408
+ async injectImageUserMessage(images, effectiveModel) {
6409
+ if (images.length === 0)
6410
+ return;
6411
+ const content = [{
6412
+ type: 'text',
6413
+ text: `<system-reminder>Visual input from ReadImage (${images.length} image${images.length > 1 ? 's' : ''}) attached below — read the image content directly.</system-reminder>`,
6414
+ }];
6415
+ for (const img of images) {
6416
+ content.push({ type: 'image', image: { mediaType: img.mediaType, data: img.data } });
6417
+ }
6418
+ const imageUserMessage = {
6419
+ uuid: uuidv4(),
6420
+ timestamp: new Date().toISOString(),
6421
+ type: 'user',
6422
+ message: { role: 'user', content },
6423
+ timeline: {
6424
+ sessionId: this.currentSessionId,
6425
+ conversationId: this.currentConversationId,
6426
+ turnNumber: this.turnNumber + 1,
6427
+ },
6428
+ model: {
6429
+ id: effectiveModel.id,
6430
+ provider: effectiveModel.provider,
6431
+ apiPattern: effectiveModel.api.pattern,
6432
+ },
6433
+ };
6434
+ this.messageHistory.push(imageUserMessage);
6435
+ await this.historyStore.appendMessage(this.currentSessionId, imageUserMessage);
6436
+ }
6437
+ /** The turn's real user prompt text — the most recent user message that is
6438
+ * neither a tool_result carrier nor an injected <system-reminder> steering
6439
+ * message. Used by EndTurn Stage 4 to judge task shape. Empty string when
6440
+ * none found (Stage 4 then never fires its task-shape branch). */
6441
+ lastRealUserText() {
6442
+ for (let i = this.messageHistory.length - 1; i >= 0; i--) {
6443
+ const m = this.messageHistory[i];
6444
+ const isUser = m?.type === 'user' || m?.message?.role === 'user';
6445
+ if (!isUser)
6446
+ continue;
6447
+ const content = m?.message?.content;
6448
+ if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result'))
6449
+ continue;
6450
+ const text = Array.isArray(content)
6451
+ ? content.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n')
6452
+ : typeof content === 'string' ? content : '';
6453
+ if (!text.trim() || text.trimStart().startsWith('<system-reminder>'))
6454
+ continue;
6455
+ return text;
6456
+ }
6457
+ return '';
6458
+ }
5889
6459
  getDecisionStore() {
5890
6460
  if (this.isRecordingEnabled() === false && this.isLookupEnabled() === false) {
5891
6461
  return undefined;
@@ -5915,7 +6485,14 @@ export class CortexOrchestrator {
5915
6485
  const store = this.getDecisionStore();
5916
6486
  if (!store)
5917
6487
  return result;
5918
- const inputHash = stableInputHash(toolUse.input);
6488
+ // Unified outcome layer (docs/UNIFIED_OUTCOME_LADDER.md): ONE failure
6489
+ // semantics for recording + reminders. Critically, a bash command that
6490
+ // ran and exited nonzero is a FAILURE here even though its tool result
6491
+ // is not is_error on the wire (ShellTool returns success results for
6492
+ // exit!=0) — previously those loops were recorded as successes, so the
6493
+ // prior/family reminders never fired on the dominant thrash mode.
6494
+ const outcome = classifyToolOutcome(toolUse.name, toolUse.input, result);
6495
+ const inputHash = outcome.exactHash;
5919
6496
  let augmented = result;
5920
6497
  // 1. Lookup priors (read side) — only emits reminder when failures exist.
5921
6498
  // Fetch the 3 most recent matching decisions so the formatter can
@@ -5930,6 +6507,21 @@ export class CortexOrchestrator {
5930
6507
  if (reminder && result.content) {
5931
6508
  augmented = { ...result, content: reminder + result.content };
5932
6509
  }
6510
+ else if (outcome.status !== 'ok' && result.content) {
6511
+ // Family lens (AHE borrow): no exact-input prior fired, but the
6512
+ // CURRENT failure may be the latest of a repeated-approach family
6513
+ // across different inputs — hint once the family has >=2 prior
6514
+ // failures spanning >=2 distinct inputs. Gated on the UNIFIED
6515
+ // outcome (not is_error) so failing-command loops are covered.
6516
+ const family = outcome.family ?? classifyErrorFamily(String(result.content).slice(0, 200));
6517
+ if (family) {
6518
+ const ff = await store.familyFailures(toolUse.name, family);
6519
+ const famReminder = formatFamilyReminder(toolUse.name, family, ff.count, ff.distinctInputs, ff.recent);
6520
+ if (famReminder) {
6521
+ augmented = { ...result, content: famReminder + result.content };
6522
+ }
6523
+ }
6524
+ }
5933
6525
  }
5934
6526
  catch (err) {
5935
6527
  if (this.config.debug) {
@@ -5945,8 +6537,10 @@ export class CortexOrchestrator {
5945
6537
  sessionId: this.currentSessionId ?? 'unknown',
5946
6538
  toolName: toolUse.name,
5947
6539
  input: toolUse.input,
5948
- success: !result.is_error,
5949
- ...(result.is_error
6540
+ // Unified outcome: exit!=0 commands are FAILURES for the store even
6541
+ // though their wire result is not is_error (see outcome above).
6542
+ success: outcome.status === 'ok',
6543
+ ...(outcome.status !== 'ok'
5950
6544
  ? { errorSnippet: String(result.content).slice(0, 200) }
5951
6545
  : {}),
5952
6546
  });
@@ -6503,10 +7097,15 @@ export class CortexOrchestrator {
6503
7097
  });
6504
7098
  // Validate and repair any orphaned tool_use blocks (crash recovery)
6505
7099
  const repaired = this.validateAndRepairMessages(converted);
7100
+ // Image TTL eviction (item 7 addendum): stale images forfeit the whole
7101
+ // request's cache reads on vision-exp — stub them out of the REQUEST copy
7102
+ // after CORTEX_IMAGE_TTL_TURNS. Always-on (not utilization-gated); pure;
7103
+ // swaps content arrays only (cache contract).
7104
+ const imageAged = applyImageTtlForRequest(repaired);
6506
7105
  // Age-tiered tool-result pruning on the outgoing request copy (all
6507
7106
  // request-build call sites flow through here). No-op below the
6508
7107
  // utilization gate; never touches the cache (pruner clones).
6509
- return this.pruneAgedForRequest(repaired);
7108
+ return this.pruneAgedForRequest(imageAged);
6510
7109
  }
6511
7110
  /**
6512
7111
  * Apply age-tiered tool-result pruning to an outgoing canonical request
@@ -6664,6 +7263,13 @@ export class CortexOrchestrator {
6664
7263
  }
6665
7264
  return thinkingBlock;
6666
7265
  }
7266
+ else if (block.type === 'image') {
7267
+ // Item 7 (image-path bridge): pass image blocks through
7268
+ // untouched — the unknown-type fallback below stringifies,
7269
+ // which delivered the base64 as TEXT to the model (live-probe
7270
+ // finding 2026-08-25: "attached base64 blob").
7271
+ return { type: 'image', image: block.image };
7272
+ }
6667
7273
  else if (block.type === 'redacted_thinking') {
6668
7274
  // XAI grok-4/4.1 encrypted reasoning — preserve opaquely
6669
7275
  return {