muonroi-cli 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/src/agent-harness/mock-model.js +112 -4
  4. package/dist/src/cli/cost-forensics.js +17 -14
  5. package/dist/src/council/prompts.js +20 -20
  6. package/dist/src/flow/compaction/index.d.ts +1 -0
  7. package/dist/src/flow/compaction/index.js +4 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/index.js +4 -2
  11. package/dist/src/lsp/manager.js +11 -3
  12. package/dist/src/lsp/manager.test.js +39 -0
  13. package/dist/src/mcp/auto-setup.js +0 -8
  14. package/dist/src/mcp/oauth-callback.js +2 -2
  15. package/dist/src/mcp/parse-headers.test.js +14 -14
  16. package/dist/src/mcp/self-verify-jobs.d.ts +1 -0
  17. package/dist/src/mcp/self-verify-runner.js +1 -1
  18. package/dist/src/mcp/setup-guide-text.js +74 -74
  19. package/dist/src/mcp/smoke.test.js +43 -43
  20. package/dist/src/ops/doctor.js +7 -7
  21. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +1 -0
  22. package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
  23. package/dist/src/orchestrator/__tests__/prompts.test.d.ts +1 -0
  24. package/dist/src/orchestrator/__tests__/prompts.test.js +88 -0
  25. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.d.ts +1 -0
  26. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.js +507 -0
  27. package/dist/src/orchestrator/auto-commit.js +9 -7
  28. package/dist/src/orchestrator/message-processor.d.ts +2 -0
  29. package/dist/src/orchestrator/message-processor.js +7 -0
  30. package/dist/src/orchestrator/orchestrator.d.ts +3 -0
  31. package/dist/src/orchestrator/orchestrator.js +362 -28
  32. package/dist/src/orchestrator/pending-calls.js +2 -1
  33. package/dist/src/orchestrator/preprocessor.js +3 -2
  34. package/dist/src/orchestrator/prompts.d.ts +8 -0
  35. package/dist/src/orchestrator/prompts.js +65 -18
  36. package/dist/src/orchestrator/sub-agent-cap.d.ts +2 -0
  37. package/dist/src/orchestrator/sub-agent-cap.js +12 -4
  38. package/dist/src/orchestrator/subagent-compactor.d.ts +7 -0
  39. package/dist/src/orchestrator/subagent-compactor.js +65 -14
  40. package/dist/src/orchestrator/subagent-compactor.spec.js +125 -9
  41. package/dist/src/orchestrator/tool-engine.d.ts +2 -0
  42. package/dist/src/orchestrator/tool-engine.js +80 -21
  43. package/dist/src/orchestrator/turn-runner-deps.d.ts +2 -0
  44. package/dist/src/pil/__tests__/llm-classify.test.js +79 -1
  45. package/dist/src/pil/layer1-intent.js +37 -37
  46. package/dist/src/pil/layer2_5-ponytail.js +8 -8
  47. package/dist/src/pil/llm-classify.d.ts +11 -0
  48. package/dist/src/pil/llm-classify.js +193 -0
  49. package/dist/src/product-loop/done-gate.js +3 -3
  50. package/dist/src/product-loop/loop-driver.js +18 -18
  51. package/dist/src/product-loop/progress-snapshot.js +4 -4
  52. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  53. package/dist/src/reporter/index.js +1 -1
  54. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  55. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  56. package/dist/src/scaffold/continuation-prompt.js +60 -60
  57. package/dist/src/scaffold/init-new.js +453 -453
  58. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  59. package/dist/src/self-qa/agentic-loop.js +11 -2
  60. package/dist/src/storage/__tests__/hierarchical-session.test.d.ts +1 -0
  61. package/dist/src/storage/__tests__/hierarchical-session.test.js +161 -0
  62. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  63. package/dist/src/storage/__tests__/sweep-stale-pending.test.js +2 -2
  64. package/dist/src/storage/index.d.ts +1 -1
  65. package/dist/src/storage/index.js +1 -1
  66. package/dist/src/storage/interaction-log.js +5 -5
  67. package/dist/src/storage/migrations.js +135 -123
  68. package/dist/src/storage/session-experience-store.js +4 -4
  69. package/dist/src/storage/sessions.js +43 -43
  70. package/dist/src/storage/transcript-response-entry.test.js +18 -5
  71. package/dist/src/storage/transcript.d.ts +2 -0
  72. package/dist/src/storage/transcript.js +223 -97
  73. package/dist/src/storage/usage.js +34 -17
  74. package/dist/src/storage/workspaces.js +12 -12
  75. package/dist/src/tools/native-tools.js +10 -1
  76. package/dist/src/tools/registry.d.ts +1 -0
  77. package/dist/src/tools/registry.js +30 -2
  78. package/dist/src/types/index.d.ts +1 -1
  79. package/dist/src/ui/app.js +0 -0
  80. package/dist/src/ui/slash/council-inspect.js +4 -4
  81. package/dist/src/ui/slash/expand.js +14 -1
  82. package/dist/src/ui/use-app-logic.js +0 -0
  83. package/dist/src/utils/__tests__/compaction-caps.test.js +9 -9
  84. package/dist/src/utils/__tests__/llm-deadline.test.js +16 -4
  85. package/dist/src/utils/__tests__/logger.test.d.ts +1 -0
  86. package/dist/src/utils/__tests__/logger.test.js +115 -0
  87. package/dist/src/utils/__tests__/settings-agent-first.test.d.ts +1 -0
  88. package/dist/src/utils/__tests__/settings-agent-first.test.js +48 -0
  89. package/dist/src/utils/clipboard-image.js +23 -23
  90. package/dist/src/utils/install-manager.d.ts +1 -0
  91. package/dist/src/utils/install-manager.js +76 -7
  92. package/dist/src/utils/install-manager.test.js +43 -1
  93. package/dist/src/utils/logger.d.ts +28 -0
  94. package/dist/src/utils/logger.js +171 -0
  95. package/dist/src/utils/settings.d.ts +9 -1
  96. package/dist/src/utils/settings.js +59 -22
  97. package/dist/src/utils/side-question.js +2 -2
  98. package/dist/src/utils/skills.js +3 -3
  99. package/dist/src/utils/update-checker.test.js +5 -1
  100. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  101. package/package.json +1 -1
@@ -76,6 +76,7 @@ import { visionToolsNeeded } from "../tools/vision-gate.js";
76
76
  import { recordTurnTrace } from "../ui/slash/debug.js";
77
77
  import { statusBarStore } from "../ui/status-bar/store.js";
78
78
  import { appendDecisionLog } from "../usage/decision-log.js";
79
+ import { logger } from "../utils/logger.js";
79
80
  import { openUrl } from "../utils/open-url.js";
80
81
  import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
81
82
  import { getAutoCouncilConfidence, getAutoCouncilMinRoles, getProviderStallRetries, getProviderStallTimeoutMs, getRoleModels, getSteerInjectionEnabled, getTopLevelCompactKeepLast, getTopLevelCompactThresholdChars, getTopLevelToolBudgetChars, isAutoCouncilEnabled, loadMcpServers, } from "../utils/settings.js";
@@ -86,7 +87,7 @@ import { humanizeApiError, isAuthenticationError, isContextLimitError, summarize
86
87
  import { buildGroundingFootnote, findUnverifiedClaims } from "./grounding-check.js";
87
88
  import { buildInterruptedTurnNote } from "./interrupted-turn.js";
88
89
  import { stableCallId } from "./pending-calls.js";
89
- import { buildMcpCapabilityBlock } from "./prompts.js";
90
+ import { buildMcpCapabilityBlock, MAX_LLM_CALLS_PER_TURN, } from "./prompts.js";
90
91
  import { extractProviderOptionsShape } from "./provider-options-shape.js";
91
92
  import { wrapToolSetWithReadBudget } from "./read-path-budget.js";
92
93
  import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning.js";
@@ -194,7 +195,7 @@ export function rewriteSafetyApprovedToolResults(messages) {
194
195
  }
195
196
  import { stripDsmlMarkup } from "./message-processor.js";
196
197
  export async function* executeToolEngine(args) {
197
- let { deps, stepRouterPhase, phase2Runtime, runtime, modelInfo, _debugSteps, _ceilingHit, userMessage, pilCtx, turnModelId, turnProvider, _stepCeiling, userModelMessage, userEnrichedMessage, signal, observer, taskHash, provider, system, routerStore, attemptedOverflowRecovery, patternLoopForceHalt, userWriteAheadSeq, streamRetryCount, MAX_STREAM_RETRIES, subagents, systemParts, playwrightGuidance, _hasResponseTools, _pilResponseTools, patternLoopInjectCount, agentLoopDecisionCount, MAX_AGENT_LOOP_DECISIONS, _naturalCeiling, _ceilingTaskType, _ceilingSize, textToolReSteerCount, MAX_TEXT_TOOL_RESTEER, turnStartMs, _debugOn, _debugTurnId, _pilEnrichmentDeltaSnapshot, isChitchat, } = args;
198
+ let { deps, stepRouterPhase, phase2Runtime, runtime, modelInfo, _debugSteps, _ceilingHit, userMessage, pilCtx, turnModelId, turnProvider, _stepCeiling, userModelMessage, userEnrichedMessage, signal, observer, taskHash, provider, system, routerStore, attemptedOverflowRecovery, patternLoopForceHalt, userWriteAheadSeq, streamRetryCount, MAX_STREAM_RETRIES, subagents, systemParts, toolTurnSystem, playwrightGuidance, _hasResponseTools, _pilResponseTools, patternLoopInjectCount, agentLoopDecisionCount, MAX_AGENT_LOOP_DECISIONS, _naturalCeiling, _ceilingTaskType, _ceilingSize, textToolReSteerCount, MAX_TEXT_TOOL_RESTEER, turnStartMs, _debugOn, _debugTurnId, _pilEnrichmentDeltaSnapshot, isChitchat, } = args;
198
199
  // Put all extracted code here:
199
200
  let stallTriggered = false;
200
201
  // Time-to-first-byte stall RE-PROMPT: some providers (observed:
@@ -219,6 +220,11 @@ export async function* executeToolEngine(args) {
219
220
  // Bounded by the same maxStallRetries cap so a persistently-dead provider
220
221
  // still falls through to the partial-answer rescue.
221
222
  let midLoopStallRetryCount = 0;
223
+ // F3c — per-turn LLM call cap: counts every streamText() invocation
224
+ // (tool round-trip, stall re-prompt, stream retry) and hard-aborts
225
+ // the turn when exceeded. Prevents the session 526a83cf22df pattern
226
+ // where 3 user messages burnt 82% of 2.44M tokens in 36 LLM calls.
227
+ let llmCallsThisTurn = 0;
222
228
  // Live-queue steering: messages the user typed mid-turn are drained at a
223
229
  // prepareStep boundary and accumulated here, then re-appended (deduped) to
224
230
  // the messages returned for each subsequent step. Loop-persistent so they
@@ -395,7 +401,7 @@ export async function* executeToolEngine(args) {
395
401
  });
396
402
  }
397
403
  catch (emitErr) {
398
- console.error(`[message-processor] stall-reprompt telemetry failed: ${emitErr?.message}`);
404
+ logger.error("orchestrator", "stall-reprompt telemetry failed", { error: emitErr });
399
405
  }
400
406
  try {
401
407
  if (deps.session) {
@@ -411,7 +417,7 @@ export async function* executeToolEngine(args) {
411
417
  }
412
418
  }
413
419
  catch (logErr) {
414
- console.error(`[message-processor] stall-reprompt log failed: ${logErr?.message}`);
420
+ logger.error("orchestrator", "stall-reprompt log failed", { error: logErr });
415
421
  }
416
422
  return backoffMs;
417
423
  };
@@ -434,11 +440,19 @@ export async function* executeToolEngine(args) {
434
440
  let _pendingStructuredResponse = null;
435
441
  let _pendingStructuredResponseLen = -1;
436
442
  try {
443
+ const { getDatabase } = await import("../storage/db.js");
444
+ const db = getDatabase();
445
+ const row = db.prepare("SELECT parent_session_id FROM sessions WHERE id = ?").get(deps.session.id);
446
+ const isSubSession = !!row?.parent_session_id;
447
+ let contextWindow = modelInfo?.contextWindow || 0;
448
+ if (isSubSession && contextWindow > 0) {
449
+ contextWindow = Math.min(45000, contextWindow);
450
+ }
437
451
  const settings = attemptedOverflowRecovery
438
- ? relaxCompactionSettings(deps.getCompactionSettings(modelInfo?.contextWindow))
439
- : deps.getCompactionSettings(modelInfo?.contextWindow);
440
- if (modelInfo?.contextWindow) {
441
- await deps.compactForContext(provider, system, modelInfo.contextWindow, signal, settings, attemptedOverflowRecovery);
452
+ ? relaxCompactionSettings(deps.getCompactionSettings(contextWindow))
453
+ : deps.getCompactionSettings(contextWindow);
454
+ if (contextWindow) {
455
+ await deps.compactForContext(provider, system, contextWindow, signal, settings, attemptedOverflowRecovery);
442
456
  }
443
457
  // Vision-tool gate: for vision-proxy (text-only) models the registry
444
458
  // adds 3 image tools (~500-700 tok) on every turn. Drop them when the
@@ -457,6 +471,7 @@ export async function* executeToolEngine(args) {
457
471
  listDelegations: () => deps.listDelegations(),
458
472
  modelId: turnModelId,
459
473
  includeVisionTools,
474
+ consultParentSession: deps.consultParentSession,
460
475
  });
461
476
  // Top-level cumulative cap state. We accumulate the raw tool set
462
477
  // (base + MCP + PIL response tools) across the assembly below,
@@ -532,7 +547,7 @@ export async function* executeToolEngine(args) {
532
547
  });
533
548
  }
534
549
  catch (err) {
535
- console.error("[MCP] buildMcpToolSet failed, proceeding with builtins only", err);
550
+ logger.error("mcp", "buildMcpToolSet failed, proceeding with builtins only", { error: err });
536
551
  }
537
552
  if (mcpBundle) {
538
553
  closeMcp = mcpBundle.close;
@@ -596,7 +611,7 @@ export async function* executeToolEngine(args) {
596
611
  // Apply the top-level cumulative cap once over the fully-assembled
597
612
  // raw tool set. State is per-turn; each turn gets a fresh budget.
598
613
  const topLevelCap = wrapToolSetWithCap(rawToolSet, {
599
- maxCumulativeChars: getTopLevelToolBudgetChars(),
614
+ maxCumulativeChars: getTopLevelToolBudgetChars(deps.maxToolRounds),
600
615
  midTierRatio: 0.5,
601
616
  highTierRatio: 0.8,
602
617
  label: "top-level",
@@ -669,9 +684,12 @@ export async function* executeToolEngine(args) {
669
684
  // cheap-model cost) layered UNDER the tool-use playbook so the
670
685
  // CRITICAL tool rules stay at the very front. Both fixed per turn, so
671
686
  // they stay inside the cached prefix.
687
+ // F3c — tool-turn: use reduced system prompt (skip
688
+ // native-capabilities + skills already shown in first call).
689
+ const activeSystem = llmCallsThisTurn > 0 && toolTurnSystem ? toolTurnSystem : system;
672
690
  const systemWithWorkbook = shouldInjectCheapModelWorkbook(runtime.modelInfo)
673
- ? injectCheapModelWorkbook(system, pilCtx.taskType)
674
- : system;
691
+ ? injectCheapModelWorkbook(activeSystem, pilCtx.taskType)
692
+ : activeSystem;
675
693
  const systemWithPlaybook = shouldInjectCheapModelPlaybook(runtime.modelInfo)
676
694
  ? injectCheapModelPlaybook(systemWithWorkbook)
677
695
  : systemWithWorkbook;
@@ -761,6 +779,8 @@ export async function* executeToolEngine(args) {
761
779
  // stepCountIs(maxToolRounds) fires and the user sees the TUI freeze
762
780
  // (session 7dcf8fd7d6a4 hit exactly 100 rounds → looked like a crash).
763
781
  let _lastFinishReason = null;
782
+ // F3b — track hard cap hit for visible toast after stream ends.
783
+ let _hardCapHit = false;
764
784
  // Phase B4: compact older tool_result parts before each top-level
765
785
  // step once cumulative message chars exceed the configured threshold.
766
786
  // The compactor preserves system + first user verbatim and keeps the
@@ -864,7 +884,7 @@ export async function* executeToolEngine(args) {
864
884
  }
865
885
  }
866
886
  catch (err) {
867
- console.error(`[Agent] loop auto-decision failed: ${err?.message ?? err}`);
887
+ logger.error("orchestrator", "loop auto-decision failed", { error: err });
868
888
  }
869
889
  }
870
890
  return deps.askToolLoopContinue ? await deps.askToolLoopContinue(info) : "stop";
@@ -910,6 +930,14 @@ export async function* executeToolEngine(args) {
910
930
  // _ceilingHit and _ceilingHitAtStep are kept for telemetry: a
911
931
  // crossing event is logged for forensics, but no action is taken.
912
932
  const dynamicStopWhen = (async (state) => {
933
+ // F3b — HARD cap: absolute non-bumpable ceiling per user turn.
934
+ // Fires AFTER the soft cap (maxToolRounds) has been bumped by the
935
+ // user. Prevents runaway sessions (session 526a83cf22df: 16 LLM
936
+ // calls for a single user message, 2.44M total input tokens).
937
+ if (state.steps.length > deps.hardMaxToolRounds) {
938
+ _hardCapHit = true;
939
+ return true;
940
+ }
913
941
  // Terminal response tool: a `respond_*` call IS the model's final
914
942
  // structured answer (its `execute` is identity — the payload lives
915
943
  // in the tool-call args). `shouldHaltOnResponseTool` decides if the
@@ -1007,6 +1035,17 @@ export async function* executeToolEngine(args) {
1007
1035
  const stall = createStallWatchdog(getProviderStallTimeoutMs(), () => {
1008
1036
  stallTriggered = true;
1009
1037
  });
1038
+ // F3c — hard-cap LLM calls per turn before this streamText()
1039
+ if (++llmCallsThisTurn > MAX_LLM_CALLS_PER_TURN) {
1040
+ stall.dispose();
1041
+ yield {
1042
+ type: "error",
1043
+ content: `Turn aborted: reached the limit of ${MAX_LLM_CALLS_PER_TURN} LLM calls for this message. Try a narrower request or break your task into smaller steps.`,
1044
+ isAuthError: false,
1045
+ };
1046
+ yield { type: "done" };
1047
+ return;
1048
+ }
1010
1049
  const result = streamText({
1011
1050
  model: runtime.model,
1012
1051
  system: systemForModel,
@@ -1022,12 +1061,18 @@ export async function* executeToolEngine(args) {
1022
1061
  // See src/orchestrator/tool-args-repair.ts for the transforms.
1023
1062
  experimental_repairToolCall: repairToolCallHook,
1024
1063
  prepareStep: ({ stepNumber: sn, messages: stepMessages }) => {
1025
- // A new step's provider request is about to go out — reset the
1026
- // per-step chunk counter. Fires after the previous step's chunks
1027
- // were counted (prepareStep runs post tool-execution), so if THIS
1028
- // step's request wedges before any byte, chunksThisStep stays 0 and
1029
- // the mid-loop dead-socket continuation can safely resume from here.
1030
1064
  chunksThisStep = 0;
1065
+ if (deps.isSubSession) {
1066
+ logger.info("orchestrator", "Sub-session executing tool round", {
1067
+ stepNumber: sn,
1068
+ maxToolRounds: deps.maxToolRounds,
1069
+ });
1070
+ deps.emitSubagentStatus({
1071
+ agent: "sub-session",
1072
+ description: `Running sub-session task...`,
1073
+ detail: `[Sub-Session] Executing tool round ${sn + 1} of ${deps.maxToolRounds}...`,
1074
+ });
1075
+ }
1031
1076
  // --- Live-queue steering injection ---------------------------
1032
1077
  // Drain the UI steer queue ONCE per prepareStep call (sn >= 1),
1033
1078
  // accumulate into pendingSteers, and graft pendingSteers onto the
@@ -1058,7 +1103,7 @@ export async function* executeToolEngine(args) {
1058
1103
  });
1059
1104
  }
1060
1105
  catch (emitErr) {
1061
- console.error(`[message-processor] steer-inject telemetry failed: ${emitErr?.message}`);
1106
+ logger.error("orchestrator", "steer-inject telemetry failed", { error: emitErr });
1062
1107
  }
1063
1108
  }
1064
1109
  const baseRes = (() => {
@@ -1338,7 +1383,7 @@ export async function* executeToolEngine(args) {
1338
1383
  });
1339
1384
  }
1340
1385
  catch (err) {
1341
- console.error("[Agent:onFinish] failed to emit llm-done", err);
1386
+ logger.error("orchestrator", "failed to emit llm-done", { error: err });
1342
1387
  }
1343
1388
  deps.setCurrentCallId("");
1344
1389
  // Rec #1 persisted forensics: onFinish fires once per top-level turn,
@@ -1349,7 +1394,7 @@ export async function* executeToolEngine(args) {
1349
1394
  persistSessionExperience(deps.session?.id ?? null, getSessionExperienceCounts());
1350
1395
  }
1351
1396
  catch (err) {
1352
- console.error("[Agent:onFinish] persistSessionExperience failed", err);
1397
+ logger.error("orchestrator", "persistSessionExperience failed", { error: err });
1353
1398
  }
1354
1399
  },
1355
1400
  });
@@ -2749,6 +2794,15 @@ export async function* executeToolEngine(args) {
2749
2794
  catch {
2750
2795
  /* fail-open */
2751
2796
  }
2797
+ // F3b — surface hard-cap stop (absolute ceiling, cannot be bumped).
2798
+ if (_hardCapHit) {
2799
+ yield {
2800
+ type: "content",
2801
+ content: `\n\n[Hard limit reached: Agent hit the absolute step ceiling of ${deps.hardMaxToolRounds} steps. ` +
2802
+ `This turn cannot continue — too many LLM round-trips. ` +
2803
+ `Start a new turn or break your task into smaller steps.]\n`,
2804
+ };
2805
+ }
2752
2806
  // Surface the round-cap stop so the user knows why the agent halted
2753
2807
  // (session 7dcf8fd7d6a4 hit stepCountIs(100) silently, looked like a
2754
2808
  // crash). AI SDK reports finishReason='tool-calls' when the step cap
@@ -3075,5 +3129,10 @@ export async function* executeToolEngine(args) {
3075
3129
  catch (err) {
3076
3130
  throw err;
3077
3131
  }
3132
+ finally {
3133
+ if (deps.isSubSession) {
3134
+ deps.emitSubagentStatus(null);
3135
+ }
3136
+ }
3078
3137
  }
3079
3138
  //# sourceMappingURL=tool-engine.js.map
@@ -23,6 +23,8 @@ export interface TurnRunnerDepsBase {
23
23
  readonly bash: BashTool;
24
24
  readonly mode: AgentMode;
25
25
  readonly maxToolRounds: number;
26
+ /** Absolute non-bumpable ceiling per turn — fires after the user bumps past maxToolRounds. */
27
+ readonly hardMaxToolRounds: number;
26
28
  readonly schedules: import("../tools/schedule.js").ScheduleManager;
27
29
  readonly sendTelegramFile: ((filePath: string) => Promise<ToolResult>) | null;
28
30
  getCompactedThisTurn(): boolean;
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
2
2
  import { installMockModel, textOnlyStream } from "../../agent-harness/mock-model.js";
3
3
  import { loadCatalog } from "../../models/registry.js";
4
- import { createLlmClassifier } from "../llm-classify.js";
4
+ import { classifySubSessionAction, createLlmClassifier } from "../llm-classify.js";
5
5
  describe("createLlmClassifier (PIL Layer 1 Pass 4)", () => {
6
6
  beforeAll(async () => {
7
7
  await loadCatalog();
@@ -194,4 +194,82 @@ describe("createLlmClassifier (PIL Layer 1 Pass 4)", () => {
194
194
  expect((await bareClassify("fix it"))?.deliverableKind).toBeNull();
195
195
  });
196
196
  });
197
+ describe("classifySubSessionAction", () => {
198
+ let cleanup = null;
199
+ afterEach(() => {
200
+ cleanup?.();
201
+ cleanup = null;
202
+ });
203
+ it("correctly parses SPAWN_SUB_SESSION from model response", async () => {
204
+ const handle = installMockModel({
205
+ fixture: { stream: textOnlyStream("SPAWN_SUB_SESSION,0.98,Requires writing a test suite") },
206
+ });
207
+ cleanup = handle.uninstall;
208
+ const factory = (() => handle.model);
209
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "fix all compile errors");
210
+ expect(result).not.toBeNull();
211
+ expect(result?.action).toBe("SPAWN_SUB_SESSION");
212
+ expect(result?.confidence).toBe(0.98);
213
+ expect(result?.reason).toBe("Requires writing a test suite");
214
+ });
215
+ it("correctly parses DIRECT_ANSWER from model response", async () => {
216
+ const handle = installMockModel({ fixture: { stream: textOnlyStream("DIRECT_ANSWER,0.95,Simple query") } });
217
+ cleanup = handle.uninstall;
218
+ const factory = (() => handle.model);
219
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "explain database index");
220
+ expect(result?.action).toBe("DIRECT_ANSWER");
221
+ expect(result?.confidence).toBe(0.95);
222
+ });
223
+ it("correctly routes greetings, thanks, or simple math via heuristic without calling LLM", async () => {
224
+ const factory = (() => {
225
+ throw new Error("Should not be called because of heuristic short-circuit");
226
+ });
227
+ const resultMath = await classifySubSessionAction(factory, "deepseek-v4-flash", "2+2");
228
+ expect(resultMath?.action).toBe("DIRECT_ANSWER");
229
+ expect(resultMath?.confidence).toBe(0.99);
230
+ expect(resultMath?.reason).toContain("heuristic (simple math)");
231
+ const resultGreeting = await classifySubSessionAction(factory, "deepseek-v4-flash", "hello");
232
+ expect(resultGreeting?.action).toBe("DIRECT_ANSWER");
233
+ expect(resultGreeting?.confidence).toBe(0.99);
234
+ expect(resultGreeting?.reason).toContain("heuristic (greeting)");
235
+ const resultThanks = await classifySubSessionAction(factory, "deepseek-v4-flash", "thank you");
236
+ expect(resultThanks?.action).toBe("DIRECT_ANSWER");
237
+ expect(resultThanks?.confidence).toBe(0.99);
238
+ expect(resultThanks?.reason).toContain("heuristic (thanks)");
239
+ });
240
+ it("does not route greetings/thanks via heuristic if they contain other text (fuzzy bypass prevention)", async () => {
241
+ const handle = installMockModel({ fixture: { stream: textOnlyStream("SPAWN_SUB_SESSION,0.95,Delete file task") } });
242
+ cleanup = handle.uninstall;
243
+ const factory = (() => handle.model);
244
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "hello, delete file X");
245
+ expect(result?.action).toBe("SPAWN_SUB_SESSION");
246
+ });
247
+ it("bypasses heuristic when MUONROI_DISABLE_HEURISTIC_ROUTING is set to 1", async () => {
248
+ process.env.MUONROI_DISABLE_HEURISTIC_ROUTING = "1";
249
+ const handle = installMockModel({ fixture: { stream: textOnlyStream("SPAWN_SUB_SESSION,0.95,Forced class") } });
250
+ cleanup = handle.uninstall;
251
+ try {
252
+ const factory = (() => handle.model);
253
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "hello");
254
+ expect(result?.action).toBe("SPAWN_SUB_SESSION");
255
+ }
256
+ finally {
257
+ delete process.env.MUONROI_DISABLE_HEURISTIC_ROUTING;
258
+ }
259
+ });
260
+ it("correctly parses ROTATE_SESSION from model response", async () => {
261
+ const handle = installMockModel({ fixture: { stream: textOnlyStream("ROTATE_SESSION,0.90,New topic") } });
262
+ cleanup = handle.uninstall;
263
+ const factory = (() => handle.model);
264
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "switch topic");
265
+ expect(result?.action).toBe("ROTATE_SESSION");
266
+ });
267
+ it("returns null when the reply cannot be parsed", async () => {
268
+ const handle = installMockModel({ fixture: { stream: textOnlyStream("bad response") } });
269
+ cleanup = handle.uninstall;
270
+ const factory = (() => handle.model);
271
+ const result = await classifySubSessionAction(factory, "deepseek-v4-flash", "test");
272
+ expect(result).toBeNull();
273
+ });
274
+ });
197
275
  //# sourceMappingURL=llm-classify.test.js.map
@@ -1243,40 +1243,40 @@ export async function layer1Intent(ctx, opts = {}) {
1243
1243
  // touch existing files.
1244
1244
  // 0.7 confidence threshold for Pass 2 keyword override remains
1245
1245
  // unchanged (HIGH_CONF_THRESHOLD_PASS2 above).
1246
- const brainRaw = await classifyViaBrain(`You are a multilingual prompt classifier. The user's prompt may be in English, Vietnamese, or a mix of both.
1247
- Classify the prompt's INTENT (not its language). Reply with TWO lowercase words separated by a comma: <category>,<style>
1248
-
1249
- Category — pick ONE (listed in neutral order, no precedence):
1250
- analyze — explain / inspect / review existing code (giải thích, phân tích, review)
1251
- debug — fix a bug or investigate failure (sửa lỗi, fix bug, lỗi, traceback)
1252
- generate — create new code/file or add new behavior (tạo, sinh code, viết function mới, thêm)
1253
- refactor — restructure existing code (tái cấu trúc, refactor)
1254
- plan — design / roadmap / architecture (kế hoạch, thiết kế, kiến trúc)
1255
- documentation — write docs/comments (viết docs, comment, jsdoc)
1256
- general — chitchat OR unclear / ambiguous coding intent
1257
-
1258
- Rules (Phase 4 4P-2 disambiguation):
1259
- - Only return refactor when the user EXPLICITLY uses one of: rename, restructure, reorganize, extract, inline, move, migrate, reshape — applied to EXISTING code WITHOUT adding new behavior.
1260
- - Feature additions ('add flag', 'thêm', 'create endpoint', 'thêm option'), changing a DEFAULT value, adding tests, or improving coverage are 'generate' — NOT refactor.
1261
- - 'improve', 'change', 'update', 'modify', 'đổi', 'cải thiện' alone do NOT imply refactor — pick the specific category by what the change actually does.
1262
- - When the request is ambiguous, prefer 'general' over guessing refactor.
1263
-
1264
- Negative examples (NOT refactor):
1265
- - "đổi default --max-tool-rounds 8 sang 12" → generate
1266
- - "improve test coverage" → generate
1267
- - "tại sao X trả empty" → analyze
1268
- - "fix CI failing" → debug
1269
-
1270
- Style — pick ONE:
1271
- concise (ngắn gọn) | balanced (cân bằng) | detailed (chi tiết)
1272
-
1273
- Examples:
1274
- "Refactor this function" → refactor,balanced
1275
- "tại sao test fail" → debug,balanced
1276
- "thiết kế hệ thống auth" → plan,detailed
1277
- "thêm flag --foo" → generate,concise
1278
- "hi" → general,concise
1279
-
1246
+ const brainRaw = await classifyViaBrain(`You are a multilingual prompt classifier. The user's prompt may be in English, Vietnamese, or a mix of both.
1247
+ Classify the prompt's INTENT (not its language). Reply with TWO lowercase words separated by a comma: <category>,<style>
1248
+
1249
+ Category — pick ONE (listed in neutral order, no precedence):
1250
+ analyze — explain / inspect / review existing code (giải thích, phân tích, review)
1251
+ debug — fix a bug or investigate failure (sửa lỗi, fix bug, lỗi, traceback)
1252
+ generate — create new code/file or add new behavior (tạo, sinh code, viết function mới, thêm)
1253
+ refactor — restructure existing code (tái cấu trúc, refactor)
1254
+ plan — design / roadmap / architecture (kế hoạch, thiết kế, kiến trúc)
1255
+ documentation — write docs/comments (viết docs, comment, jsdoc)
1256
+ general — chitchat OR unclear / ambiguous coding intent
1257
+
1258
+ Rules (Phase 4 4P-2 disambiguation):
1259
+ - Only return refactor when the user EXPLICITLY uses one of: rename, restructure, reorganize, extract, inline, move, migrate, reshape — applied to EXISTING code WITHOUT adding new behavior.
1260
+ - Feature additions ('add flag', 'thêm', 'create endpoint', 'thêm option'), changing a DEFAULT value, adding tests, or improving coverage are 'generate' — NOT refactor.
1261
+ - 'improve', 'change', 'update', 'modify', 'đổi', 'cải thiện' alone do NOT imply refactor — pick the specific category by what the change actually does.
1262
+ - When the request is ambiguous, prefer 'general' over guessing refactor.
1263
+
1264
+ Negative examples (NOT refactor):
1265
+ - "đổi default --max-tool-rounds 8 sang 12" → generate
1266
+ - "improve test coverage" → generate
1267
+ - "tại sao X trả empty" → analyze
1268
+ - "fix CI failing" → debug
1269
+
1270
+ Style — pick ONE:
1271
+ concise (ngắn gọn) | balanced (cân bằng) | detailed (chi tiết)
1272
+
1273
+ Examples:
1274
+ "Refactor this function" → refactor,balanced
1275
+ "tại sao test fail" → debug,balanced
1276
+ "thiết kế hệ thống auth" → plan,detailed
1277
+ "thêm flag --foo" → generate,concise
1278
+ "hi" → general,concise
1279
+
1280
1280
  Prompt: "${ctx.raw.slice(0, 500)}"`, 1500);
1281
1281
  if (brainRaw) {
1282
1282
  pass3LegacyTaskSucceeded = true;
@@ -1327,9 +1327,9 @@ Prompt: "${ctx.raw.slice(0, 500)}"`, 1500);
1327
1327
  if (pass3LegacyTaskAttempted) {
1328
1328
  legacyBrainAttempted = true;
1329
1329
  pass3LegacyStyleAttempted = true;
1330
- const brainRawStyle = await classifyViaBrain(`Detect the user's preferred output style. The prompt may be EN or VN.
1331
- Reply with ONE word: concise (ngắn gọn) | balanced (bình thường) | detailed (chi tiết).
1332
-
1330
+ const brainRawStyle = await classifyViaBrain(`Detect the user's preferred output style. The prompt may be EN or VN.
1331
+ Reply with ONE word: concise (ngắn gọn) | balanced (bình thường) | detailed (chi tiết).
1332
+
1333
1333
  Prompt: "${ctx.raw.slice(0, 300)}"`, 800);
1334
1334
  if (brainRawStyle) {
1335
1335
  pass3LegacyStyleSucceeded = true;
@@ -1,13 +1,13 @@
1
1
  import { truncateToBudget } from "./budget.js";
2
2
  import { isPonytailModeEnabled } from "./config.js";
3
- const PONYTAIL_INSTRUCTION = `[LAZY SENIOR / PONYTAIL MODE ACTIVE]
4
- "The best code is the code you never wrote." You MUST adhere to the following Decision Ladder:
5
- 1. Standard Library first.
6
- 2. Native platform features next.
7
- 3. 1-line solution next.
8
- 4. YAGNI: Do not write it if not strictly necessary.
9
- 5. Smallest correct change.
10
-
3
+ const PONYTAIL_INSTRUCTION = `[LAZY SENIOR / PONYTAIL MODE ACTIVE]
4
+ "The best code is the code you never wrote." You MUST adhere to the following Decision Ladder:
5
+ 1. Standard Library first.
6
+ 2. Native platform features next.
7
+ 3. 1-line solution next.
8
+ 4. YAGNI: Do not write it if not strictly necessary.
9
+ 5. Smallest correct change.
10
+
11
11
  DOCUMENTATION RULE: If you bypass a complex/scalable pattern in favor of a simple one to adhere to this rule, you MUST add a comment in the code starting exactly with "// Intentional simplification: [Reason]".`;
12
12
  export async function layer2_5Ponytail(ctx) {
13
13
  // If task is not coding/action-related (e.g., chitchat), we might not need it, but pipeline.ts already skips layers if taskType is null.
@@ -76,3 +76,14 @@ export type LlmClassifyFn = (prompt: string, signal?: AbortSignal) => Promise<Ll
76
76
  * fail-open (keep prior taskType, do not block the turn).
77
77
  */
78
78
  export declare function createLlmClassifier(factory: ProviderFactory, modelId: string): LlmClassifyFn;
79
+ export type SubSessionAction = "DIRECT_ANSWER" | "ROTATE_SESSION" | "SPAWN_SUB_SESSION";
80
+ export interface SubSessionRouteResult {
81
+ action: SubSessionAction;
82
+ confidence: number;
83
+ reason: string;
84
+ }
85
+ export declare function classifySubSessionActionHeuristic(prompt: string): SubSessionRouteResult | null;
86
+ export declare function classifySubSessionAction(factory: ProviderFactory, modelId: string, prompt: string, contextInfo?: {
87
+ currentChars: number;
88
+ threshold: number;
89
+ }, signal?: AbortSignal): Promise<SubSessionRouteResult | null>;