@nexus-cortex/core 4.94.0 → 4.95.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.
@@ -50,6 +50,8 @@ import { resolveThrashState, resolveThrashConfig } from '../training/thrashDetec
50
50
  import { classifyErrorFamily } from '../training/errorFamily.js';
51
51
  import { classifyToolOutcome } from '../training/toolOutcome.js';
52
52
  import { LoopLadder, formatLadderSignal } from '../training/loopLadder.js';
53
+ import { decideLoopBlock, isLoopBlockTrigger } from './loopToolBlock.js';
54
+ import { resolveLoopExitConfig, parseLoopExitVerdict } from '../training/loopExitPlanner.js';
53
55
  import { shouldNudgeInaction, formatInactionNudge } from './inactionGuard.js';
54
56
  import { applyImageTtlForRequest } from './imageTtl.js';
55
57
  import { detectSurrenderText, resolveSurrenderNudgeMode, SURRENDER_REMINDER } from './turnEndGuards.js';
@@ -171,6 +173,15 @@ export class CortexOrchestrator {
171
173
  * exactly once, at the anchor-lift boundary. One-shot per orchestrator. */
172
174
  deferredCorpusDelivered = false;
173
175
  liftNudgeDelivered = false; // A′ proposal-1: lift-boundary SearchTools/AskForAdvice nudge, one-shot
176
+ // CORTEX_LOOP_TOOL_BLOCK (hard loop intervention): when the ladder flags a
177
+ // non-converging same-approach loop, disable the looping tool's EXECUTOR for
178
+ // ONE turn (tools list unchanged → cache-safe); a call to it returns an
179
+ // append-only redirect error. `loopBlockTool` is armed for the NEXT turn;
180
+ // `loopBlockCounts` tracks blocks per tool (escalate to a mentor consult after
181
+ // 2); `loopBlockEscalate` requests the direct-invoke consult.
182
+ loopBlockTool = null;
183
+ loopBlockCounts = new Map();
184
+ loopBlockEscalate = false;
174
185
  liftPlanDelivered = false; // LIFT_MENTOR_PLANNER: bounded mentor-planner at the lift, one-shot
175
186
  cachedEnvReport; // ENV_RECON_COMMAND output, gathered once, shared by lift-planner + endturn-resolver
176
187
  endTurnResolverRejects = 0; // endTurnResolver: GAP vetoes so far this task (bounded by maxRejects → fallback-accept)
@@ -477,6 +488,48 @@ export class CortexOrchestrator {
477
488
  }
478
489
  return '';
479
490
  }
491
+ /**
492
+ * The last `n` tool calls of `toolName` (each input paired with its result output), oldest→newest.
493
+ * Fed to the loop-exit mentor so it sees the ACTUAL looping calls + their failing outputs — the
494
+ * concrete thing to redirect — not just the model's reasoning prose. The tool being blocked right
495
+ * now is not yet in history, so this returns the PRIOR (looping) calls of that tool.
496
+ */
497
+ recentToolCalls(toolName, n) {
498
+ const resultById = new Map();
499
+ for (const m of this.messageHistory) {
500
+ const c = m?.message?.content;
501
+ if (!Array.isArray(c))
502
+ continue;
503
+ for (const b of c) {
504
+ if (b?.type !== 'tool_result')
505
+ continue;
506
+ const rc = typeof b.content === 'string'
507
+ ? b.content
508
+ : Array.isArray(b.content)
509
+ ? b.content.filter((x) => x?.type === 'text').map((x) => x.text || '').join('\n')
510
+ : '';
511
+ resultById.set(b.tool_use_id, rc);
512
+ }
513
+ }
514
+ const calls = [];
515
+ for (let i = this.messageHistory.length - 1; i >= 0 && calls.length < n; i--) {
516
+ const m = this.messageHistory[i];
517
+ if (m?.message?.role !== 'assistant')
518
+ continue;
519
+ const c = m.message.content;
520
+ if (!Array.isArray(c))
521
+ continue;
522
+ for (let j = c.length - 1; j >= 0 && calls.length < n; j--) {
523
+ const b = c[j];
524
+ if (b?.type !== 'tool_use' || b.name !== toolName)
525
+ continue;
526
+ const input = JSON.stringify(b.input ?? {}).slice(0, 500);
527
+ const result = (resultById.get(b.id) ?? '(no result captured)').slice(0, 500);
528
+ calls.push(`call: ${input}\n → result: ${result}`);
529
+ }
530
+ }
531
+ return calls.reverse().join('\n');
532
+ }
480
533
  /**
481
534
  * endTurnResolver (operator design 2026-09-04) — the mentor-as-EndTurn-judge, the finish-side twin of
482
535
  * deliverLiftPlanAtLift. Runs AFTER evaluateEndTurnGates: only when the mechanical gate ACCEPTED the
@@ -2217,6 +2270,7 @@ export class CortexOrchestrator {
2217
2270
  continue;
2218
2271
  const outcome = classifyToolOutcome(tr.tool_name, input, tr);
2219
2272
  const ladder = loopLadder.observe(tr.tool_name, outcome);
2273
+ this.armLoopBlock(tr.tool_name, ladder.action);
2220
2274
  const sig = formatLadderSignal(tr.tool_name, ladder);
2221
2275
  if (sig)
2222
2276
  ladderSignal = sig;
@@ -3995,6 +4049,7 @@ export class CortexOrchestrator {
3995
4049
  continue;
3996
4050
  const outcome = classifyToolOutcome(tr.tool_name, input, tr);
3997
4051
  const ladder = loopLadder.observe(tr.tool_name, outcome);
4052
+ this.armLoopBlock(tr.tool_name, ladder.action);
3998
4053
  const sig = formatLadderSignal(tr.tool_name, ladder);
3999
4054
  if (sig)
4000
4055
  ladderSignal = sig;
@@ -5939,6 +5994,13 @@ export class CortexOrchestrator {
5939
5994
  if (this.config.debug) {
5940
5995
  console.log(`[Orchestrator Phase 2.5] Executing tool: ${toolUse.name}`);
5941
5996
  }
5997
+ // CORTEX_LOOP_TOOL_BLOCK: if this tool's executor is loop-disabled this
5998
+ // turn, return the append-only redirect error instead of running it.
5999
+ const loopBlocked = await this.maybeBlockLoopingTool(toolUse);
6000
+ if (loopBlocked) {
6001
+ results.push(await this.processToolTraining(toolUse, loopBlocked));
6002
+ continue;
6003
+ }
5942
6004
  // Context management tools (CORTEX.md generation + two-tier memory)
5943
6005
  const contextManagementToolNames = [
5944
6006
  'InitCortexContext',
@@ -6365,6 +6427,10 @@ export class CortexOrchestrator {
6365
6427
  if (this.config.debug) {
6366
6428
  console.log(`[Orchestrator] Executing single tool: ${toolUse.name}`);
6367
6429
  }
6430
+ // CORTEX_LOOP_TOOL_BLOCK: executor-gate for the parallel/single path.
6431
+ const loopBlocked = await this.maybeBlockLoopingTool(toolUse);
6432
+ if (loopBlocked)
6433
+ return loopBlocked;
6368
6434
  // Context management tools
6369
6435
  const contextManagementToolNames = ['InitCortexContext'];
6370
6436
  const isContextManagementTool = contextManagementToolNames.includes(toolUse.name);
@@ -7453,6 +7519,101 @@ export class CortexOrchestrator {
7453
7519
  }
7454
7520
  return { success: true, llmContent: hint, metadata: { source: 'mentor-consult', rung, helperModel: this.config.reactiveMentorship?.helperModelId } };
7455
7521
  }
7522
+ /** CORTEX_LOOP_TOOL_BLOCK — arm the hard intervention when the ladder flags a
7523
+ * non-converging same-approach loop (diversify/break). If this tool has
7524
+ * already been blocked twice, request a mentor escalation instead of a 3rd
7525
+ * block. Called from the turn loop where the ladder result is computed. */
7526
+ armLoopBlock(toolName, ladderAction) {
7527
+ if ((process.env.CORTEX_LOOP_TOOL_BLOCK ?? '').trim().toLowerCase() !== 'true')
7528
+ return;
7529
+ if (!isLoopBlockTrigger(ladderAction))
7530
+ return;
7531
+ if (this.loopBlockEscalate)
7532
+ return; // already pending
7533
+ const prior = this.loopBlockCounts.get(toolName) ?? 0;
7534
+ const decision = decideLoopBlock(toolName, prior);
7535
+ if (decision.action === 'escalate') {
7536
+ // Arm the tool AND the escalation flag: the next call to this tool
7537
+ // triggers a mentor consult (delivered as the redirect) instead of a 3rd
7538
+ // generic block.
7539
+ this.loopBlockTool = toolName;
7540
+ this.loopBlockEscalate = true;
7541
+ if (this.config.debug)
7542
+ console.log(`[LoopBlock] ${toolName} looped past ${prior} blocks → next call escalates to mentor consult`);
7543
+ }
7544
+ else if (decision.action === 'block') {
7545
+ this.loopBlockTool = toolName;
7546
+ this.loopBlockCounts.set(toolName, prior + 1);
7547
+ if (this.config.debug)
7548
+ console.log(`[LoopBlock] arming block #${prior + 1} on ${toolName} for next turn`);
7549
+ }
7550
+ }
7551
+ /** Executor-gate: if a tool is loop-blocked THIS turn, return an append-only
7552
+ * redirect error tool_result INSTEAD of executing it (the tools list is
7553
+ * unchanged → prompt cache is not busted). One-shot: the block clears on use
7554
+ * so the tool is available again next turn. Returns null when not blocked. */
7555
+ async maybeBlockLoopingTool(toolUse) {
7556
+ if (!this.loopBlockTool || this.loopBlockTool !== toolUse.name)
7557
+ return null;
7558
+ this.loopBlockTool = null; // one turn only
7559
+ const store = this.getDecisionStore();
7560
+ // Escalation: 2 blocks did not break the loop → deliver a mentor plan
7561
+ // directly as the redirect (orchestrator-direct-invoke, NOT forced
7562
+ // tool_choice which is unreliable on DeepSeek once history holds a tool_call).
7563
+ if (this.loopBlockEscalate) {
7564
+ this.loopBlockEscalate = false;
7565
+ // Escalation = the mentor-as-loop-exit-planner (bounded pro-max, sibling of the EndTurn
7566
+ // resolver). ORCHESTRATOR-DIRECT-INVOKE — the orchestrator calls evaluateLoopExit itself and
7567
+ // delivers the verdict as this redirect; it is NOT a tool the low-compliance model must reach
7568
+ // for (forced tool_choice is dead on DeepSeek mid-history — the MENTOR_AUTO pattern).
7569
+ let plan = '';
7570
+ let verdict = 'UNKNOWN';
7571
+ try {
7572
+ if (this.helperMiddleware?.evaluateLoopExit) {
7573
+ const cfg = resolveLoopExitConfig();
7574
+ const text = await withTimeout(this.helperMiddleware.evaluateLoopExit({
7575
+ task: this.lastRealUserText(),
7576
+ envReport: this.gatherEnvReport(),
7577
+ loopingTool: toolUse.name,
7578
+ recentAttempts: this.recentToolCalls(toolUse.name, 4) || this.lastAssistantText().slice(0, 2000),
7579
+ outputBudgetTokens: cfg.outputBudgetTokens,
7580
+ effort: cfg.effort,
7581
+ helperModelId: this.config.reactiveMentorship?.helperModelId,
7582
+ }), cfg.timeoutMs);
7583
+ plan = String(text ?? '');
7584
+ verdict = parseLoopExitVerdict(plan);
7585
+ }
7586
+ }
7587
+ catch { /* fail-open */ }
7588
+ const content = plan
7589
+ ? `Tool "${toolUse.name}" is DISABLED: repeated loop interventions have not broken the pattern. A senior engineer reviewed your situation and gave this exit plan:\n\n${plan}\n\nFollow this instead of retrying "${toolUse.name}".`
7590
+ : `Tool "${toolUse.name}" is DISABLED: repeated loop interventions have not broken this pattern. Stop retrying "${toolUse.name}", re-read the task requirements, and take a fundamentally different approach.`;
7591
+ if (store)
7592
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'loop_tool_block', toolName: toolUse.name, detail: { escalated: true, consulted: !!plan, verdict } }).catch(() => { });
7593
+ if (this.config.debug)
7594
+ console.log(`[LoopBlock] ESCALATED ${toolUse.name} → exit-planner (${plan ? verdict + ' plan delivered' : 'fail-open'})`);
7595
+ return { tool_use_id: toolUse.id, tool_name: toolUse.name, content, is_error: true, metadata: { loopToolBlock: true, escalated: true, verdict } };
7596
+ }
7597
+ const prior = (this.loopBlockCounts.get(toolUse.name) ?? 1) - 1;
7598
+ const decision = decideLoopBlock(toolUse.name, prior);
7599
+ if (store) {
7600
+ void store.recordEvent({
7601
+ sessionId: this.currentSessionId ?? 'unknown',
7602
+ kind: 'loop_tool_block',
7603
+ toolName: toolUse.name,
7604
+ detail: { blockNumber: prior + 1, redirectTools: decision.redirectTools },
7605
+ }).catch(() => { });
7606
+ }
7607
+ if (this.config.debug)
7608
+ console.log(`[LoopBlock] redirected ${toolUse.name} → ${decision.redirectTools.join(',')}`);
7609
+ return {
7610
+ tool_use_id: toolUse.id,
7611
+ tool_name: toolUse.name,
7612
+ content: decision.message,
7613
+ is_error: true,
7614
+ metadata: { loopToolBlock: true, redirectTools: decision.redirectTools },
7615
+ };
7616
+ }
7456
7617
  async processToolTraining(toolUse, result) {
7457
7618
  this.applySliceNudge(toolUse, result); // universal post-execution hook: every path funnels through processToolTraining
7458
7619
  const store = this.getDecisionStore();