@nexus-cortex/core 4.69.1 → 4.70.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 (48) hide show
  1. package/dist/models/configurators/DeepSeekConfigurator.d.ts +3 -0
  2. package/dist/models/configurators/DeepSeekConfigurator.d.ts.map +1 -1
  3. package/dist/models/configurators/DeepSeekConfigurator.js +1 -0
  4. package/dist/models/configurators/DeepSeekConfigurator.js.map +1 -1
  5. package/dist/models/configurators/XAIConfigurator.d.ts +3 -0
  6. package/dist/models/configurators/XAIConfigurator.d.ts.map +1 -1
  7. package/dist/models/configurators/XAIConfigurator.js +1 -0
  8. package/dist/models/configurators/XAIConfigurator.js.map +1 -1
  9. package/dist/orchestrator/CortexOrchestrator.d.ts +9 -0
  10. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  11. package/dist/orchestrator/CortexOrchestrator.js +527 -15
  12. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  13. package/dist/orchestrator/inactionGuard.d.ts +42 -0
  14. package/dist/orchestrator/inactionGuard.d.ts.map +1 -0
  15. package/dist/orchestrator/inactionGuard.js +47 -0
  16. package/dist/orchestrator/inactionGuard.js.map +1 -0
  17. package/dist/orchestrator/requirementsVerification.d.ts +43 -0
  18. package/dist/orchestrator/requirementsVerification.d.ts.map +1 -0
  19. package/dist/orchestrator/requirementsVerification.js +87 -0
  20. package/dist/orchestrator/requirementsVerification.js.map +1 -0
  21. package/dist/tools/ToolProfile.d.ts +9 -0
  22. package/dist/tools/ToolProfile.d.ts.map +1 -1
  23. package/dist/tools/ToolProfile.js +19 -0
  24. package/dist/tools/ToolProfile.js.map +1 -1
  25. package/dist/tools/registries/BaseToolRegistry.d.ts.map +1 -1
  26. package/dist/tools/registries/BaseToolRegistry.js +14 -0
  27. package/dist/tools/registries/BaseToolRegistry.js.map +1 -1
  28. package/dist/training/DecisionPriorInjector.d.ts +7 -0
  29. package/dist/training/DecisionPriorInjector.d.ts.map +1 -1
  30. package/dist/training/DecisionPriorInjector.js +22 -0
  31. package/dist/training/DecisionPriorInjector.js.map +1 -1
  32. package/dist/training/DecisionStore.d.ts +39 -0
  33. package/dist/training/DecisionStore.d.ts.map +1 -1
  34. package/dist/training/DecisionStore.js +94 -0
  35. package/dist/training/DecisionStore.js.map +1 -1
  36. package/dist/training/errorFamily.d.ts +15 -0
  37. package/dist/training/errorFamily.d.ts.map +1 -0
  38. package/dist/training/errorFamily.js +40 -0
  39. package/dist/training/errorFamily.js.map +1 -0
  40. package/dist/training/loopLadder.d.ts +35 -0
  41. package/dist/training/loopLadder.d.ts.map +1 -0
  42. package/dist/training/loopLadder.js +58 -0
  43. package/dist/training/loopLadder.js.map +1 -0
  44. package/dist/training/toolOutcome.d.ts +20 -0
  45. package/dist/training/toolOutcome.d.ts.map +1 -0
  46. package/dist/training/toolOutcome.js +100 -0
  47. package/dist/training/toolOutcome.js.map +1 -0
  48. package/package.json +3 -3
@@ -30,11 +30,16 @@ 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 { verifyRequirements, resolveEndTurnRequirementsMode } from './requirementsVerification.js';
38
43
  import { ModelRouterMatrix } from '../training/ModelRouterMatrix.js';
39
44
  import { classifyTask } from '../training/TaskClassifier.js';
40
45
  import { closestToolMatches } from './toolNameMatcher.js';
@@ -143,6 +148,10 @@ export class CortexOrchestrator {
143
148
  /** The active model card's anchorProfile (captured at request assembly) —
144
149
  * env CORTEX_TOOL_ANCHOR still overrides inside resolveToolAnchor. */
145
150
  cardAnchorProfile = null;
151
+ /** Card frameProfile ('lifted'|'persist') captured with the anchor — under
152
+ * 'persist' the anchor never lifts (backlog item 5; env
153
+ * CORTEX_TOOL_ANCHOR_PERSIST overrides in resolveFrameProfile). */
154
+ cardFrameProfile = null;
146
155
  /** P6 deferral (CORTEX_PROMPT_MASS=defer): the static corpus is delivered
147
156
  * exactly once, at the anchor-lift boundary. One-shot per orchestrator. */
148
157
  deferredCorpusDelivered = false;
@@ -699,6 +708,7 @@ export class CortexOrchestrator {
699
708
  // Card-level home door: capture the active card's anchorProfile (env
700
709
  // CORTEX_TOOL_ANCHOR overrides inside the resolver; env 'none' disables).
701
710
  this.cardAnchorProfile = effectiveModel.anchorProfile ?? null;
711
+ this.cardFrameProfile = effectiveModel.frameProfile ?? null;
702
712
  // Thread the CARD's anchor to the executors (per-orchestrator config, by
703
713
  // reference) so framing-aware executor behavior (ShellTool steering
704
714
  // default) is model-card/registry-scoped, not process-global env.
@@ -978,6 +988,9 @@ export class CortexOrchestrator {
978
988
  // Round 18b: track whether we've already retried on empty response so
979
989
  // we don't loop forever. See empty-detection block below.
980
990
  let emptyResponseRetryUsed = false;
991
+ // Inaction guard (backlog item 2 — the ladder's inverse): single-nudge
992
+ // bound for the actless-verbose retry. Default OFF via env.
993
+ let inactionNudgeUsed = false;
981
994
  // EndTurn gate (Stage 1): the turn must not complete until the model
982
995
  // calls the EndTurn attestation tool. Steering is ignored by some
983
996
  // models (grok-4.3); a required tool call is not. Bounded so a model
@@ -1021,6 +1034,13 @@ export class CortexOrchestrator {
1021
1034
  const thisTurnToolOutputs = [];
1022
1035
  // Loop detection: Track recent tool calls to detect repetitive loops
1023
1036
  const recentToolCalls = [];
1037
+ // Unified Outcome Ladder (docs/UNIFIED_OUTCOME_LADDER.md): per-approach
1038
+ // FAILURE escalation — remind(2)→diversify(4)→break(6) over normalized
1039
+ // near-duplicate approaches. Complements isToolProgressStalled (which is
1040
+ // outcome-blind exact-hash cycling) by firing earlier on failing thrash.
1041
+ const loopLadder = new LoopLadder();
1042
+ let ladderBreak = null;
1043
+ let ladderSignal = null;
1024
1044
  // Track all tool uses across iterations for response
1025
1045
  const allExecutedToolUses = [];
1026
1046
  // Track tool call counts for diversity warning
@@ -1198,6 +1218,130 @@ export class CortexOrchestrator {
1198
1218
  continue;
1199
1219
  }
1200
1220
  }
1221
+ // Inaction guard (backlog item 2 — the loop ladder's inverse).
1222
+ // TB2 2×2: persist-frame arms produced never-acted failures (5
1223
+ // pro-persist) — a huge reason-to-the-wall text dump with ZERO tool
1224
+ // calls, invisible to every call-counting guard. One bounded
1225
+ // steering retry, R18b-style; default OFF (CORTEX_INACTION_NUDGE).
1226
+ {
1227
+ const responseChars = Array.isArray(currentAssistantCanonicalMessage.content)
1228
+ ? currentAssistantCanonicalMessage.content
1229
+ .map((b) => b?.type === 'text' ? (b.text || '') : b?.type === 'thinking' ? (b.thinking || '') : '')
1230
+ .join('').length
1231
+ : String(currentAssistantCanonicalMessage.content ?? '').length;
1232
+ if (hasVisibleText &&
1233
+ !structuredOutputState?.result &&
1234
+ shouldNudgeInaction({
1235
+ responseChars,
1236
+ toolUseBlocksThisResponse: 0,
1237
+ executedToolCallsThisTurn: allExecutedToolUses.length,
1238
+ toolsOffered: toolsToUse.length,
1239
+ turnNumber: this.turnNumber,
1240
+ alreadyNudged: inactionNudgeUsed,
1241
+ })) {
1242
+ inactionNudgeUsed = true;
1243
+ console.warn(`[Orchestrator] Inaction guard: actless-verbose response (${responseChars} chars, 0 tool calls). ` +
1244
+ `Injecting act-first nudge (single retry).`);
1245
+ // Observability (backlog item 3): bank the event either way it
1246
+ // resolves — the distiller counts nudge→acted conversions.
1247
+ {
1248
+ const store = this.getDecisionStore();
1249
+ if (store) {
1250
+ void store
1251
+ .recordEvent({
1252
+ sessionId: this.currentSessionId ?? 'unknown',
1253
+ kind: 'inaction_nudge',
1254
+ detail: { responseChars, turnNumber: this.turnNumber },
1255
+ })
1256
+ .catch(() => { });
1257
+ }
1258
+ }
1259
+ const inactionUserMessage = {
1260
+ uuid: uuidv4(),
1261
+ timestamp: new Date().toISOString(),
1262
+ type: 'user',
1263
+ message: {
1264
+ role: 'user',
1265
+ content: [{
1266
+ type: 'text',
1267
+ text: `<system-reminder>${formatInactionNudge()}</system-reminder>`,
1268
+ }],
1269
+ },
1270
+ timeline: {
1271
+ sessionId: this.currentSessionId,
1272
+ conversationId: this.currentConversationId,
1273
+ turnNumber: this.turnNumber + 1,
1274
+ },
1275
+ model: {
1276
+ id: effectiveModel.id,
1277
+ provider: effectiveModel.provider,
1278
+ apiPattern: effectiveModel.api.pattern,
1279
+ },
1280
+ };
1281
+ this.messageHistory.push(inactionUserMessage);
1282
+ await this.historyStore.appendMessage(this.currentSessionId, inactionUserMessage);
1283
+ await this.ensureHistoryFitsModel(effectiveModel);
1284
+ const inactionCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
1285
+ const inactionRequest = this.gatewayTranslation.prepareRequest(inactionCanonicalHistory, toolsToUse, effectiveModel, {
1286
+ temperature: options.parameters?.temperature,
1287
+ maxTokens: options.parameters?.maxTokens,
1288
+ topP: options.parameters?.topP,
1289
+ reasoningEffort: options.parameters?.reasoningEffort,
1290
+ stream: options.streaming,
1291
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
1292
+ conversationId: this.currentConversationId, // R28b
1293
+ });
1294
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
1295
+ inactionRequest.previousResponseId = this.lastResponseId;
1296
+ }
1297
+ inactionRequest.conversationId = this.currentSessionId;
1298
+ let inactionApiResponse;
1299
+ if (this.retryMiddleware) {
1300
+ const r = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(inactionRequest, effectiveModel), 'inaction_nudge_retry');
1301
+ inactionApiResponse = r.result;
1302
+ }
1303
+ else {
1304
+ inactionApiResponse = await this.apiClient.sendRequest(inactionRequest, effectiveModel);
1305
+ }
1306
+ const inactionConverted = this.gatewayTranslation.convertResponse(inactionApiResponse.data, effectiveModel, {
1307
+ sessionId: this.currentSessionId,
1308
+ conversationId: this.currentConversationId,
1309
+ turnNumber: this.turnNumber + 1,
1310
+ });
1311
+ if (effectiveModel.api.pattern === 'responses' && inactionApiResponse.data?.id) {
1312
+ this.lastResponseId = inactionApiResponse.data.id;
1313
+ this.lastResponseIdProvider = effectiveModel.provider; // R20a
1314
+ }
1315
+ if (inactionConverted.messages && inactionConverted.messages.length > 0) {
1316
+ const inactionAssistantCanonical = inactionConverted.messages[0];
1317
+ const inactionAssistantMessage = {
1318
+ uuid: inactionAssistantCanonical.uuid,
1319
+ timestamp: inactionAssistantCanonical.timestamp,
1320
+ type: 'assistant',
1321
+ message: {
1322
+ role: 'assistant',
1323
+ content: inactionAssistantCanonical.content,
1324
+ },
1325
+ timeline: {
1326
+ sessionId: this.currentSessionId,
1327
+ conversationId: this.currentConversationId,
1328
+ turnNumber: this.turnNumber + 1,
1329
+ },
1330
+ model: {
1331
+ id: effectiveModel.id,
1332
+ provider: effectiveModel.provider,
1333
+ apiPattern: effectiveModel.api.pattern,
1334
+ },
1335
+ ...(inactionConverted.usage && { usage: inactionConverted.usage }),
1336
+ };
1337
+ this.messageHistory.push(inactionAssistantMessage);
1338
+ await this.historyStore.appendMessage(this.currentSessionId, inactionAssistantMessage);
1339
+ currentAssistantMessage = inactionAssistantMessage;
1340
+ currentAssistantCanonicalMessage = inactionAssistantCanonical;
1341
+ continue;
1342
+ }
1343
+ }
1344
+ }
1201
1345
  // EndTurn gate (Stage 1): refuse to finalize until the model has
1202
1346
  // called the EndTurn attestation tool. Only arms when the turn
1203
1347
  // actually used a (non-EndTurn) tool — pure-language turns bypass.
@@ -1221,8 +1365,27 @@ export class CortexOrchestrator {
1221
1365
  if (!cv.ok)
1222
1366
  stage3Violations = cv.violations;
1223
1367
  }
1224
- if (endTurnGateEnabled && turnUsedTools && endTurnNudges < END_TURN_MAX_NUDGES &&
1225
- (!endTurnCalled || stage3Violations.length > 0)) {
1368
+ // Fallback observability (backlog item 1.4): when the nudge bound
1369
+ // is exhausted and the gate is STILL unsatisfied, the turn ships
1370
+ // anyway (liveness beats purity) — but that fallback-accept was
1371
+ // previously silent. Bank an event so the distiller can see
1372
+ // un-attested passes.
1373
+ const endTurnGateUnsatisfied = endTurnGateEnabled && turnUsedTools && (!endTurnCalled || stage3Violations.length > 0);
1374
+ if (endTurnGateUnsatisfied && endTurnNudges >= END_TURN_MAX_NUDGES) {
1375
+ const fallbackReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1376
+ console.warn(`[Orchestrator] EndTurn gate FALLBACK-ACCEPT: ${fallbackReason} after ${endTurnNudges} nudges — turn ships un-attested.`);
1377
+ const store = this.getDecisionStore();
1378
+ if (store) {
1379
+ void store
1380
+ .recordEvent({
1381
+ sessionId: this.currentSessionId ?? 'unknown',
1382
+ kind: 'endturn_gate_fallback',
1383
+ detail: { reason: fallbackReason, nudges: endTurnNudges, iteration: toolCallIteration },
1384
+ })
1385
+ .catch(() => { });
1386
+ }
1387
+ }
1388
+ if (endTurnGateUnsatisfied && endTurnNudges < END_TURN_MAX_NUDGES) {
1226
1389
  endTurnNudges++;
1227
1390
  const gateReason = !endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
1228
1391
  console.warn(`[Orchestrator] EndTurn gate: ${gateReason} ` +
@@ -1498,6 +1661,49 @@ export class CortexOrchestrator {
1498
1661
  try {
1499
1662
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
1500
1663
  clearTimeout(timeoutId);
1664
+ // Unified Outcome Ladder: observe each result's TRUE outcome (exit
1665
+ // codes, not wire is_error) and escalate on repeated failing
1666
+ // approaches. The strongest signal this batch is injected at the
1667
+ // signals block below; a 'break' rung ends the loop R29b-style.
1668
+ ladderSignal = null;
1669
+ {
1670
+ const inputById = new Map(toolUseBlocks.map((b) => [b.id, b.input]));
1671
+ for (const tr of toolResults) {
1672
+ if (tr.tool_name === 'EndTurn')
1673
+ continue;
1674
+ const input = inputById.get(tr.tool_use_id);
1675
+ if (input === undefined)
1676
+ continue;
1677
+ const outcome = classifyToolOutcome(tr.tool_name, input, tr);
1678
+ const ladder = loopLadder.observe(tr.tool_name, outcome);
1679
+ const sig = formatLadderSignal(tr.tool_name, ladder);
1680
+ if (sig)
1681
+ ladderSignal = sig;
1682
+ if (ladder.action === 'break')
1683
+ ladderBreak = sig;
1684
+ // Observability (backlog item 3): escalations are invisible in
1685
+ // the session record (signals inject post-persist) — bank each
1686
+ // rung as a decision-store event for the distiller.
1687
+ if (ladder.action !== 'none') {
1688
+ const store = this.getDecisionStore();
1689
+ if (store) {
1690
+ void store
1691
+ .recordEvent({
1692
+ sessionId: this.currentSessionId ?? 'unknown',
1693
+ kind: 'loop_escalation',
1694
+ toolName: tr.tool_name,
1695
+ detail: {
1696
+ rung: ladder.action,
1697
+ count: ladder.count,
1698
+ approachHash: outcome.approachHash,
1699
+ ...(outcome.family ? { family: outcome.family } : {}),
1700
+ },
1701
+ })
1702
+ .catch(() => { });
1703
+ }
1704
+ }
1705
+ }
1706
+ }
1501
1707
  // File-history snapshots (Claude Code parity — previously built but
1502
1708
  // UNWIRED: FileCheckpointManager existed with zero markModified
1503
1709
  // callers and its snapshot message was discarded). Mark files
@@ -1578,7 +1784,31 @@ export class CortexOrchestrator {
1578
1784
  lastEndTurnCitations = Array.isArray(cits) ? cits : undefined; // Stage 3 baseline
1579
1785
  const verdict = verifyCitationsGrounded(cits, thisTurnToolOutputs.join('\n'));
1580
1786
  if (verdict.grounded) {
1581
- endTurnCalled = true;
1787
+ // Stage 4 (backlog item 1, CORTEX_ENDTURN_REQUIREMENTS): verify
1788
+ // the requirements attestation — the wrong-artifact weapon.
1789
+ // Stage 2 verifies citations; nothing before this forced
1790
+ // re-reading the TASK. Rides the same nudge budget: a Stage-4
1791
+ // reject leaves the gate unsatisfied so the bounded
1792
+ // fallback-accept still guarantees liveness.
1793
+ if (resolveEndTurnRequirementsMode()) {
1794
+ const s4 = verifyRequirements({
1795
+ requirements: etUse?.input?.requirements,
1796
+ verification: etUse?.input?.verification,
1797
+ userTaskText: this.lastRealUserText(),
1798
+ turnUsedMutatingTool,
1799
+ });
1800
+ if (s4.ok) {
1801
+ endTurnCalled = true;
1802
+ }
1803
+ else {
1804
+ tr.is_error = true;
1805
+ tr.content = s4.nudge;
1806
+ console.warn('[Orchestrator] Stage4: EndTurn rejected — requirements attestation unsatisfied.');
1807
+ }
1808
+ }
1809
+ else {
1810
+ endTurnCalled = true;
1811
+ }
1582
1812
  }
1583
1813
  else {
1584
1814
  const bad = verdict.ungrounded
@@ -1726,14 +1956,43 @@ export class CortexOrchestrator {
1726
1956
  const progressStalled = isToolProgressStalled(recentToolCalls);
1727
1957
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
1728
1958
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
1729
- if (budgetSignal || diversityWarning) {
1730
- const signals = [budgetSignal, diversityWarning].filter(Boolean).join('\n');
1959
+ if (budgetSignal || diversityWarning || ladderSignal) {
1960
+ const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
1731
1961
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
1732
1962
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
1733
1963
  const block = lastMsg.message.content[0];
1734
1964
  const existing = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
1735
1965
  block.content = existing + '\n\n' + signals;
1966
+ // Observability (backlog item 3, micro-suite defect #4): this
1967
+ // mutation happens AFTER the tool_result was persisted — the
1968
+ // durable session lacks the steering the model saw. Bank a
1969
+ // steering_injected event so harvest/distill sees it. Session rows
1970
+ // stay append-only (never rewritten).
1971
+ const store = this.getDecisionStore();
1972
+ if (store) {
1973
+ const kinds = [
1974
+ budgetSignal ? 'budget' : null,
1975
+ diversityWarning ? 'diversity' : null,
1976
+ ladderSignal ? 'ladder' : null,
1977
+ ].filter(Boolean);
1978
+ void store
1979
+ .recordEvent({
1980
+ sessionId: this.currentSessionId ?? 'unknown',
1981
+ kind: 'steering_injected',
1982
+ detail: { kinds, iteration: toolCallIteration, chars: signals.length },
1983
+ })
1984
+ .catch(() => { });
1985
+ }
1736
1986
  }
1987
+ ladderSignal = null; // consumed — never re-inject on later iterations
1988
+ }
1989
+ // Ladder break (teach-then-break): the break instruction is already in
1990
+ // the tool result above; exit the loop R29b-style so the post-loop
1991
+ // synthesis net produces an honest final answer — never the blunt
1992
+ // toolCallIteration=MAX turn-kill of the exact-match detector.
1993
+ if (ladderBreak) {
1994
+ console.warn('[Orchestrator] LoopLadder break: repeated failing approach — forcing synthesis.');
1995
+ break;
1737
1996
  }
1738
1997
  // R29b hard cap: a weaker model may ignore the firm budget reminders
1739
1998
  // and keep spinning (benchmark: deepseek 46 successful local calls,
@@ -1782,7 +2041,11 @@ export class CortexOrchestrator {
1782
2041
  // full profile applies from here. Rebuild from allTools because the
1783
2042
  // armed anchor narrowed toolsToUse at assembly. (When deferred loading
1784
2043
  // is on, the re-filter block below performs the rebuild.)
1785
- if (!this.anchorLifted && resolveToolAnchor(process.env, this.cardAnchorProfile)) {
2044
+ if (!this.anchorLifted &&
2045
+ resolveToolAnchor(process.env, this.cardAnchorProfile) &&
2046
+ // Persist frame (backlog item 5): the anchored surface IS the session
2047
+ // surface — never lift, never deliver the deferred corpus.
2048
+ resolveFrameProfile(process.env, this.cardFrameProfile) !== 'persist') {
1786
2049
  this.anchorLifted = true;
1787
2050
  if (!(this.config.enableDeferredToolLoading && !isPTCEnabled)) {
1788
2051
  toolsToUse = allTools;
@@ -2564,6 +2827,7 @@ export class CortexOrchestrator {
2564
2827
  // Card-level home door: capture the active card's anchorProfile (env
2565
2828
  // CORTEX_TOOL_ANCHOR overrides inside the resolver; env 'none' disables).
2566
2829
  this.cardAnchorProfile = effectiveModel.anchorProfile ?? null;
2830
+ this.cardFrameProfile = effectiveModel.frameProfile ?? null;
2567
2831
  // Thread the CARD's anchor to the executors (per-orchestrator config, by
2568
2832
  // reference) so framing-aware executor behavior (ShellTool steering
2569
2833
  // default) is model-card/registry-scoped, not process-global env.
@@ -2766,7 +3030,12 @@ export class CortexOrchestrator {
2766
3030
  let toolCallIteration = 0;
2767
3031
  let totalToolErrors = 0;
2768
3032
  let emptyResponseRetryUsed = false; // R32: parity with sendMessage R18b guard
3033
+ let inactionNudgeUsed = false; // Inaction guard (backlog item 2) — streaming parity
2769
3034
  const allToolCalls = [];
3035
+ // Unified Outcome Ladder (streaming parity — see sendMessage).
3036
+ const loopLadder = new LoopLadder();
3037
+ let ladderBreak = null;
3038
+ let ladderSignal = null;
2770
3039
  const allExecutedToolUses = [];
2771
3040
  const toolCallCounts = new Map();
2772
3041
  // DEBUG: Log canonical message content structure for tool extraction (streaming path)
@@ -2933,6 +3202,136 @@ export class CortexOrchestrator {
2933
3202
  console.warn(`[Orchestrator Streaming] R32 retry failed:`, retryErr);
2934
3203
  }
2935
3204
  }
3205
+ // Inaction guard (backlog item 2 — streaming parity with sendMessage).
3206
+ // Actless-verbose first-turn response in a tool-capable request: one
3207
+ // bounded act-first steering retry. Default OFF (CORTEX_INACTION_NUDGE).
3208
+ {
3209
+ const responseChars = Array.isArray(currentAssistantCanonicalMessage.content)
3210
+ ? currentAssistantCanonicalMessage.content
3211
+ .map((b) => b?.type === 'text' ? (b.text || '') : b?.type === 'thinking' ? (b.thinking || '') : '')
3212
+ .join('').length
3213
+ : String(currentAssistantCanonicalMessage.content ?? '').length;
3214
+ if (hasVisibleText &&
3215
+ shouldNudgeInaction({
3216
+ responseChars,
3217
+ toolUseBlocksThisResponse: 0,
3218
+ executedToolCallsThisTurn: allExecutedToolUses.length,
3219
+ toolsOffered: toolsToUse.length,
3220
+ turnNumber: this.turnNumber,
3221
+ alreadyNudged: inactionNudgeUsed,
3222
+ })) {
3223
+ inactionNudgeUsed = true;
3224
+ console.warn(`[Orchestrator Streaming] Inaction guard: actless-verbose response (${responseChars} chars, 0 tool calls). ` +
3225
+ `Injecting act-first nudge (single retry).`);
3226
+ {
3227
+ const store = this.getDecisionStore();
3228
+ if (store) {
3229
+ void store
3230
+ .recordEvent({
3231
+ sessionId: this.currentSessionId ?? 'unknown',
3232
+ kind: 'inaction_nudge',
3233
+ detail: { responseChars, turnNumber: this.turnNumber, streaming: true },
3234
+ })
3235
+ .catch(() => { });
3236
+ }
3237
+ }
3238
+ const inactionUserMessage = {
3239
+ uuid: uuidv4(),
3240
+ timestamp: new Date().toISOString(),
3241
+ type: 'user',
3242
+ message: {
3243
+ role: 'user',
3244
+ content: [{
3245
+ type: 'text',
3246
+ text: `<system-reminder>${formatInactionNudge()}</system-reminder>`,
3247
+ }],
3248
+ },
3249
+ timeline: {
3250
+ sessionId: this.currentSessionId,
3251
+ conversationId: this.currentConversationId,
3252
+ turnNumber: this.turnNumber + 1,
3253
+ },
3254
+ model: {
3255
+ id: effectiveModel.id,
3256
+ provider: effectiveModel.provider,
3257
+ apiPattern: effectiveModel.api.pattern,
3258
+ },
3259
+ };
3260
+ this.messageHistory.push(inactionUserMessage);
3261
+ await this.historyStore.appendMessage(this.currentSessionId, inactionUserMessage);
3262
+ try {
3263
+ await this.ensureHistoryFitsModel(effectiveModel);
3264
+ const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3265
+ const retryRequest = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
3266
+ temperature: options.parameters?.temperature,
3267
+ maxTokens: options.parameters?.maxTokens,
3268
+ topP: options.parameters?.topP,
3269
+ reasoningEffort: options.parameters?.reasoningEffort,
3270
+ stream: false,
3271
+ staticSystemPrompt: this.currentStaticSystemPrompt,
3272
+ conversationId: this.currentConversationId,
3273
+ });
3274
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
3275
+ retryRequest.previousResponseId = this.lastResponseId;
3276
+ }
3277
+ retryRequest.conversationId = this.currentSessionId;
3278
+ let retryApiResponse;
3279
+ if (this.retryMiddleware) {
3280
+ const retryResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(retryRequest, effectiveModel), 'inaction_nudge_retry');
3281
+ retryApiResponse = retryResult.result;
3282
+ }
3283
+ else {
3284
+ retryApiResponse = await this.apiClient.sendRequest(retryRequest, effectiveModel);
3285
+ }
3286
+ const retryConverted = this.gatewayTranslation.convertResponse(retryApiResponse.data, effectiveModel, {
3287
+ sessionId: this.currentSessionId,
3288
+ conversationId: this.currentConversationId,
3289
+ turnNumber: this.turnNumber + 1,
3290
+ });
3291
+ if (effectiveModel.api.pattern === 'responses' && retryApiResponse.data?.id) {
3292
+ this.lastResponseId = retryApiResponse.data.id;
3293
+ this.lastResponseIdProvider = effectiveModel.provider;
3294
+ }
3295
+ if (retryConverted.messages && retryConverted.messages.length > 0) {
3296
+ const retryAssistantCanonical = retryConverted.messages[0];
3297
+ const retryAssistantMessage = {
3298
+ uuid: retryAssistantCanonical.uuid,
3299
+ timestamp: retryAssistantCanonical.timestamp,
3300
+ type: 'assistant',
3301
+ message: {
3302
+ role: 'assistant',
3303
+ content: retryAssistantCanonical.content,
3304
+ },
3305
+ timeline: {
3306
+ sessionId: this.currentSessionId,
3307
+ conversationId: this.currentConversationId,
3308
+ turnNumber: this.turnNumber + 1,
3309
+ },
3310
+ model: {
3311
+ id: effectiveModel.id,
3312
+ provider: effectiveModel.provider,
3313
+ apiPattern: effectiveModel.api.pattern,
3314
+ },
3315
+ ...(retryConverted.usage && { usage: retryConverted.usage }),
3316
+ };
3317
+ this.messageHistory.push(retryAssistantMessage);
3318
+ await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
3319
+ currentAssistantCanonicalMessage = retryAssistantCanonical;
3320
+ const retryText = retryAssistantCanonical.content
3321
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
3322
+ .map((b) => b.text)
3323
+ .join('');
3324
+ if (retryText.trim().length > 0) {
3325
+ yield { type: 'text_delta', delta: retryText };
3326
+ }
3327
+ continue; // re-enter loop — retry may have produced tool_use
3328
+ }
3329
+ }
3330
+ catch (retryErr) {
3331
+ console.warn(`[Orchestrator Streaming] Inaction-nudge retry failed:`, retryErr);
3332
+ }
3333
+ }
3334
+ }
2936
3335
  hasToolUse = false;
2937
3336
  break;
2938
3337
  }
@@ -2972,6 +3371,45 @@ export class CortexOrchestrator {
2972
3371
  // Execute tools (reuse existing method)
2973
3372
  const toolResults = await this.handleToolCalls(toolUseBlocks, abortController.signal, structuredOutputState);
2974
3373
  clearTimeout(timeoutId);
3374
+ // Unified Outcome Ladder (streaming parity with sendMessage).
3375
+ ladderSignal = null;
3376
+ {
3377
+ const inputById = new Map(toolUseBlocks.map((b) => [b.id, b.input]));
3378
+ for (const tr of toolResults) {
3379
+ if (tr.tool_name === 'EndTurn')
3380
+ continue;
3381
+ const input = inputById.get(tr.tool_use_id);
3382
+ if (input === undefined)
3383
+ continue;
3384
+ const outcome = classifyToolOutcome(tr.tool_name, input, tr);
3385
+ const ladder = loopLadder.observe(tr.tool_name, outcome);
3386
+ const sig = formatLadderSignal(tr.tool_name, ladder);
3387
+ if (sig)
3388
+ ladderSignal = sig;
3389
+ if (ladder.action === 'break')
3390
+ ladderBreak = sig;
3391
+ // Observability (backlog item 3): streaming parity — bank each
3392
+ // escalation rung as a decision-store event.
3393
+ if (ladder.action !== 'none') {
3394
+ const store = this.getDecisionStore();
3395
+ if (store) {
3396
+ void store
3397
+ .recordEvent({
3398
+ sessionId: this.currentSessionId ?? 'unknown',
3399
+ kind: 'loop_escalation',
3400
+ toolName: tr.tool_name,
3401
+ detail: {
3402
+ rung: ladder.action,
3403
+ count: ladder.count,
3404
+ approachHash: outcome.approachHash,
3405
+ ...(outcome.family ? { family: outcome.family } : {}),
3406
+ },
3407
+ })
3408
+ .catch(() => { });
3409
+ }
3410
+ }
3411
+ }
3412
+ }
2975
3413
  // R21 (2026-05-15): same fix as non-streaming path — count consecutive
2976
3414
  // ITERATIONS WITH ZERO SUCCESSFUL TOOLS, not cumulative individual
2977
3415
  // failures. Reset whenever at least one tool succeeded.
@@ -3123,14 +3561,38 @@ export class CortexOrchestrator {
3123
3561
  const progressStalled = isToolProgressStalled(allToolCalls);
3124
3562
  const budgetSignal = computeToolBudgetSignal(effectiveToolBudgetCount, TOOL_BUDGET_SOFT, progressStalled);
3125
3563
  const diversityWarning = this.getDiversityWarning(toolCallCounts);
3126
- if (budgetSignal || diversityWarning) {
3127
- const signals = [budgetSignal, diversityWarning].filter(Boolean).join('\n');
3564
+ if (budgetSignal || diversityWarning || ladderSignal) {
3565
+ const signals = [budgetSignal, diversityWarning, ladderSignal].filter(Boolean).join('\n');
3128
3566
  const lastMsg = this.messageHistory[this.messageHistory.length - 1];
3129
3567
  if (lastMsg?.message?.content?.[0]?.type === 'tool_result') {
3130
3568
  const block = lastMsg.message.content[0];
3131
3569
  const existing = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
3132
3570
  block.content = existing + '\n\n' + signals;
3571
+ // Observability (backlog item 3): post-persist mutation — bank a
3572
+ // steering_injected event (streaming parity).
3573
+ const store = this.getDecisionStore();
3574
+ if (store) {
3575
+ const kinds = [
3576
+ budgetSignal ? 'budget' : null,
3577
+ diversityWarning ? 'diversity' : null,
3578
+ ladderSignal ? 'ladder' : null,
3579
+ ].filter(Boolean);
3580
+ void store
3581
+ .recordEvent({
3582
+ sessionId: this.currentSessionId ?? 'unknown',
3583
+ kind: 'steering_injected',
3584
+ detail: { kinds, iteration: toolCallIteration, chars: signals.length, streaming: true },
3585
+ })
3586
+ .catch(() => { });
3587
+ }
3133
3588
  }
3589
+ ladderSignal = null; // consumed
3590
+ }
3591
+ // Ladder break (streaming parity): instruction already injected above;
3592
+ // exit R29b-style so the streaming synthesis net closes the turn.
3593
+ if (ladderBreak) {
3594
+ console.warn('[Orchestrator Streaming] LoopLadder break: repeated failing approach — forcing synthesis.');
3595
+ break;
3134
3596
  }
3135
3597
  // R29b hard cap (streaming parity): force-stop at 2x soft budget;
3136
3598
  // the post-loop R29a streaming net then synthesizes a deliverable.
@@ -3170,7 +3632,11 @@ export class CortexOrchestrator {
3170
3632
  // Proactive context management - ensure history fits before streaming continuation
3171
3633
  await this.ensureHistoryFitsModel(effectiveModel);
3172
3634
  // Anchor lift — streaming mirror of the sendMessage continuation path.
3173
- if (!this.anchorLifted && resolveToolAnchor(process.env, this.cardAnchorProfile)) {
3635
+ if (!this.anchorLifted &&
3636
+ resolveToolAnchor(process.env, this.cardAnchorProfile) &&
3637
+ // Persist frame (backlog item 5): the anchored surface IS the session
3638
+ // surface — never lift, never deliver the deferred corpus.
3639
+ resolveFrameProfile(process.env, this.cardFrameProfile) !== 'persist') {
3174
3640
  this.anchorLifted = true;
3175
3641
  if (!(this.config.enableDeferredToolLoading && !isPTCEnabled)) {
3176
3642
  toolsToUse = allTools;
@@ -5886,6 +6352,28 @@ export class CortexOrchestrator {
5886
6352
  }
5887
6353
  return this.routerMatrix;
5888
6354
  }
6355
+ /** The turn's real user prompt text — the most recent user message that is
6356
+ * neither a tool_result carrier nor an injected <system-reminder> steering
6357
+ * message. Used by EndTurn Stage 4 to judge task shape. Empty string when
6358
+ * none found (Stage 4 then never fires its task-shape branch). */
6359
+ lastRealUserText() {
6360
+ for (let i = this.messageHistory.length - 1; i >= 0; i--) {
6361
+ const m = this.messageHistory[i];
6362
+ const isUser = m?.type === 'user' || m?.message?.role === 'user';
6363
+ if (!isUser)
6364
+ continue;
6365
+ const content = m?.message?.content;
6366
+ if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result'))
6367
+ continue;
6368
+ const text = Array.isArray(content)
6369
+ ? content.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n')
6370
+ : typeof content === 'string' ? content : '';
6371
+ if (!text.trim() || text.trimStart().startsWith('<system-reminder>'))
6372
+ continue;
6373
+ return text;
6374
+ }
6375
+ return '';
6376
+ }
5889
6377
  getDecisionStore() {
5890
6378
  if (this.isRecordingEnabled() === false && this.isLookupEnabled() === false) {
5891
6379
  return undefined;
@@ -5915,7 +6403,14 @@ export class CortexOrchestrator {
5915
6403
  const store = this.getDecisionStore();
5916
6404
  if (!store)
5917
6405
  return result;
5918
- const inputHash = stableInputHash(toolUse.input);
6406
+ // Unified outcome layer (docs/UNIFIED_OUTCOME_LADDER.md): ONE failure
6407
+ // semantics for recording + reminders. Critically, a bash command that
6408
+ // ran and exited nonzero is a FAILURE here even though its tool result
6409
+ // is not is_error on the wire (ShellTool returns success results for
6410
+ // exit!=0) — previously those loops were recorded as successes, so the
6411
+ // prior/family reminders never fired on the dominant thrash mode.
6412
+ const outcome = classifyToolOutcome(toolUse.name, toolUse.input, result);
6413
+ const inputHash = outcome.exactHash;
5919
6414
  let augmented = result;
5920
6415
  // 1. Lookup priors (read side) — only emits reminder when failures exist.
5921
6416
  // Fetch the 3 most recent matching decisions so the formatter can
@@ -5930,6 +6425,21 @@ export class CortexOrchestrator {
5930
6425
  if (reminder && result.content) {
5931
6426
  augmented = { ...result, content: reminder + result.content };
5932
6427
  }
6428
+ else if (outcome.status !== 'ok' && result.content) {
6429
+ // Family lens (AHE borrow): no exact-input prior fired, but the
6430
+ // CURRENT failure may be the latest of a repeated-approach family
6431
+ // across different inputs — hint once the family has >=2 prior
6432
+ // failures spanning >=2 distinct inputs. Gated on the UNIFIED
6433
+ // outcome (not is_error) so failing-command loops are covered.
6434
+ const family = outcome.family ?? classifyErrorFamily(String(result.content).slice(0, 200));
6435
+ if (family) {
6436
+ const ff = await store.familyFailures(toolUse.name, family);
6437
+ const famReminder = formatFamilyReminder(toolUse.name, family, ff.count, ff.distinctInputs, ff.recent);
6438
+ if (famReminder) {
6439
+ augmented = { ...result, content: famReminder + result.content };
6440
+ }
6441
+ }
6442
+ }
5933
6443
  }
5934
6444
  catch (err) {
5935
6445
  if (this.config.debug) {
@@ -5945,8 +6455,10 @@ export class CortexOrchestrator {
5945
6455
  sessionId: this.currentSessionId ?? 'unknown',
5946
6456
  toolName: toolUse.name,
5947
6457
  input: toolUse.input,
5948
- success: !result.is_error,
5949
- ...(result.is_error
6458
+ // Unified outcome: exit!=0 commands are FAILURES for the store even
6459
+ // though their wire result is not is_error (see outcome above).
6460
+ success: outcome.status === 'ok',
6461
+ ...(outcome.status !== 'ok'
5950
6462
  ? { errorSnippet: String(result.content).slice(0, 200) }
5951
6463
  : {}),
5952
6464
  });