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
@@ -4,22 +4,67 @@ import { buildContractSection } from "../pil/agent-operating-contract.js";
4
4
  import { buildNativeCapabilitiesSection } from "../pil/native-capabilities-workbook.js";
5
5
  import { getProviderCapabilities } from "../providers/capabilities.js";
6
6
  import { loadCustomInstructions } from "../utils/instructions.js";
7
- import { loadValidSubAgents, } from "../utils/settings.js";
7
+ import { logger } from "../utils/logger.js";
8
+ import { loadUserSettings, loadValidSubAgents, } from "../utils/settings.js";
8
9
  import { resolveShell } from "../utils/shell.js";
9
10
  import { discoverSkills, formatSkillsForPrompt } from "../utils/skills.js";
10
- // F3 — hard cap on tool rounds per user turn. Default reduced 400 100
11
- // after session bca83bcbaad1 logged 178 tool calls in a single turn while
12
- // monotonically growing billed input. Env override allowed range 10..400.
11
+ // F3a — hard cap on tool rounds per user turn. Reduced 10040
12
+ // after session 526a83cf22df logged 2.44M input tokens over 46 LLM calls
13
+ // with 3 turns (seq 33/81/1) consuming 82% of tokens.
14
+ // Env override allowed range 10..400.
15
+ // Env override allowed range 10..400 (or up to 2000 in agent-first).
13
16
  function readMaxToolRoundsFromEnv() {
14
- const raw = process.env.MUONROI_MAX_TOOL_ROUNDS;
17
+ const settings = loadUserSettings();
18
+ const agentFirst = settings.agentFirst !== false &&
19
+ process.env.MUONROI_AGENT_FIRST !== "0" &&
20
+ process.env.MUONROI_AGENT_FIRST !== "false";
21
+ const raw = process.env.MUONROI_MAX_TOOL_ROUNDS || settings.maxToolRounds;
15
22
  if (!raw)
16
- return 100;
23
+ return agentFirst ? 200 : 40;
17
24
  const n = Number(raw);
18
25
  if (!Number.isFinite(n))
19
- return 100;
20
- return Math.max(10, Math.min(400, Math.floor(n)));
26
+ return agentFirst ? 200 : 40;
27
+ const maxLimit = agentFirst ? 2000 : 400;
28
+ return Math.max(10, Math.min(maxLimit, Math.floor(n)));
21
29
  }
22
30
  export const MAX_TOOL_ROUNDS = readMaxToolRoundsFromEnv();
31
+ // F3b — HARD cap: absolute non-bumpable ceiling per user turn.
32
+ // Fires AFTER the soft cap has been bumped by the user.
33
+ // Env override allowed range 20..400 (or up to 3000 in agent-first).
34
+ function readHardMaxToolRoundsFromEnv() {
35
+ const settings = loadUserSettings();
36
+ const agentFirst = settings.agentFirst !== false &&
37
+ process.env.MUONROI_AGENT_FIRST !== "0" &&
38
+ process.env.MUONROI_AGENT_FIRST !== "false";
39
+ const raw = process.env.MUONROI_HARD_MAX_TOOL_ROUNDS || settings.hardMaxToolRounds;
40
+ if (!raw)
41
+ return agentFirst ? 300 : 60;
42
+ const n = Number(raw);
43
+ if (!Number.isFinite(n))
44
+ return agentFirst ? 300 : 60;
45
+ const maxLimit = agentFirst ? 3000 : 400;
46
+ return Math.max(20, Math.min(maxLimit, Math.floor(n)));
47
+ }
48
+ export const HARD_MAX_TOOL_ROUNDS = readHardMaxToolRoundsFromEnv();
49
+ // F3c — per-turn LLM call cap: how many streamText() invocations are
50
+ // allowed per user turn.
51
+ // Default 12 (or 100 in agent-first).
52
+ // Env override MUONROI_MAX_LLM_CALLS_PER_TURN, range 3..100 (or up to 500 in agent-first).
53
+ function readMaxLlmCallsPerTurn() {
54
+ const settings = loadUserSettings();
55
+ const agentFirst = settings.agentFirst !== false &&
56
+ process.env.MUONROI_AGENT_FIRST !== "0" &&
57
+ process.env.MUONROI_AGENT_FIRST !== "false";
58
+ const raw = process.env.MUONROI_MAX_LLM_CALLS_PER_TURN || settings.maxLlmCallsPerTurn;
59
+ if (!raw)
60
+ return agentFirst ? 100 : 12;
61
+ const n = Number(raw);
62
+ if (!Number.isFinite(n))
63
+ return agentFirst ? 100 : 12;
64
+ const maxLimit = agentFirst ? 500 : 100;
65
+ return Math.max(3, Math.min(maxLimit, Math.floor(n)));
66
+ }
67
+ export const MAX_LLM_CALLS_PER_TURN = readMaxLlmCallsPerTurn();
23
68
  export const VISION_MODEL = "grok-4-1-fast-reasoning";
24
69
  export const COMPUTER_MODEL = "grok-4.20-0309-reasoning";
25
70
  /**
@@ -62,9 +107,7 @@ export function detectProjectStack(cwd) {
62
107
  // Best-effort enrichment: a missing/unreadable cwd simply omits the stack
63
108
  // line (the ENVIRONMENT cwd line already surfaces "<unknown>"). Debug-gated
64
109
  // so prompt assembly never corrupts the TUI at startup.
65
- if (process.env.MUONROI_DEBUG === "1") {
66
- console.error(`[orchestrator/prompts] detectProjectStack failed for ${cwd}: ${err?.message}`);
67
- }
110
+ logger.error("orchestrator", `detectProjectStack failed for ${cwd}`, { error: err });
68
111
  return "";
69
112
  }
70
113
  const has = (name) => entries.includes(name);
@@ -406,15 +449,18 @@ export function buildMcpCapabilityBlock(toolNames) {
406
449
  }
407
450
  const _staticPrefixCache = new Map();
408
451
  const STATIC_PREFIX_CACHE_TTL_MS = 300_000; // 5 min — ample; inputs are session-stable
409
- function staticPrefixCacheKey(cwd, mode, providerId, isChitchat, subagentsHash, subAgent = false) {
410
- return `${cwd}|${mode}|${providerId}|${isChitchat}|${subagentsHash}|${subAgent}`;
452
+ function staticPrefixCacheKey(cwd, mode, providerId, isChitchat, subagentsHash, subAgent = false, toolTurn = false) {
453
+ return `${cwd}|${mode}|${providerId}|${isChitchat}|${subagentsHash}|${subAgent}|${toolTurn}`;
411
454
  }
412
- function computeStaticPrefix(cwd, mode, subagents, providerId, chitchat, subAgent = false) {
455
+ function computeStaticPrefix(cwd, mode, subagents, providerId, chitchat, subAgent = false, toolTurn = false) {
413
456
  const custom = loadCustomInstructions(cwd);
414
457
  const customSection = subAgent || !custom
415
458
  ? ""
416
459
  : `\n\nCUSTOM INSTRUCTIONS:\n${custom}\n\nFollow the above alongside standard instructions.\n`;
417
- const skillsText = chitchat || subAgent ? "" : formatSkillsForPrompt(discoverSkills(cwd));
460
+ // Tool-turn: skip agent-skills catalog (~2K tokens) and native-capabilities block (~2K tokens).
461
+ // The agent was already shown these in the first call of this turn and does not need
462
+ // to re-read them on every tool round-trip.
463
+ const skillsText = chitchat || subAgent || toolTurn ? "" : formatSkillsForPrompt(discoverSkills(cwd));
418
464
  const skillsSection = skillsText ? `\n\n${skillsText}\n` : "";
419
465
  const subagentsSection = chitchat ? "" : formatCustomSubagentsPromptSection(subagents ?? loadValidSubAgents());
420
466
  let modePrompt = MODE_PROMPTS[mode];
@@ -430,19 +476,20 @@ function computeStaticPrefix(cwd, mode, subagents, providerId, chitchat, subAgen
430
476
  modePrompt = modePrompt.replace(/\n- (wallet_|paid_|fetch_payment|schedule_|generate_|computer_|search_x).*/g, "");
431
477
  }
432
478
  const contractSection = buildContractSection({ chitchat });
433
- const nativeCapabilitiesSection = buildNativeCapabilitiesSection({ mode, chitchat });
479
+ const nativeCapabilitiesSection = toolTurn ? "" : buildNativeCapabilitiesSection({ mode, chitchat });
434
480
  const prefix = `${contractSection}${nativeCapabilitiesSection}${modePrompt}${customSection}${skillsSection}${subagentsSection}`;
435
481
  return { prefix };
436
482
  }
437
483
  export function buildSystemPromptParts(cwd, mode, sandboxMode, planContext, subagents, sandboxSettings, providerId, resumeDigest, options) {
438
484
  const chitchat = options?.chitchat === true;
439
485
  const subAgent = options?.subAgent ?? false;
486
+ const toolTurn = options?.toolTurn === true;
440
487
  const pid = providerId ?? "default";
441
488
  // Subagents rarely change mid-session, but when they do we need a cache miss.
442
489
  // JSON-stable stringify is fast for typical configs (< 10 entries, no circular refs).
443
490
  const subagentsHash = subagents ? JSON.stringify(subagents) : "none";
444
491
  // Try cache for the static prefix
445
- const key = staticPrefixCacheKey(cwd, mode, pid, chitchat, subagentsHash, subAgent);
492
+ const key = staticPrefixCacheKey(cwd, mode, pid, chitchat, subagentsHash, subAgent, toolTurn);
446
493
  const now = Date.now();
447
494
  const cached = _staticPrefixCache.get(key);
448
495
  let staticPrefix;
@@ -451,7 +498,7 @@ export function buildSystemPromptParts(cwd, mode, sandboxMode, planContext, suba
451
498
  }
452
499
  else {
453
500
  // Cache miss — compute and store
454
- const result = computeStaticPrefix(cwd, mode, subagents, pid, chitchat, subAgent);
501
+ const result = computeStaticPrefix(cwd, mode, subagents, pid, chitchat, subAgent, toolTurn);
455
502
  staticPrefix = result.prefix;
456
503
  _staticPrefixCache.set(key, {
457
504
  prefix: staticPrefix,
@@ -64,6 +64,8 @@ export interface SubAgentCapState {
64
64
  cumulative: number;
65
65
  /** Configured ceiling. */
66
66
  max: number;
67
+ /** Hard cap ceiling (e.g. max * 2). */
68
+ hardMax?: number;
67
69
  /** True once `cumulative >= max` (sub-agent should wrap up). */
68
70
  exhausted: boolean;
69
71
  /** Number of duplicate-output detections (telemetry / tests). */
@@ -46,7 +46,9 @@ function shortHash(text) {
46
46
  return createHash("sha1").update(text).digest("hex").slice(0, 12);
47
47
  }
48
48
  export function compressForCap(state, raw) {
49
- if (state.exhausted) {
49
+ const hardCeiling = state.hardMax ?? state.max;
50
+ if (state.exhausted || state.cumulative >= hardCeiling) {
51
+ state.exhausted = true;
50
52
  return `[${state.label} tool budget exhausted (${state.cumulative}/${state.max} chars). Further tool calls will return this stub. Summarize findings now and return.]`;
51
53
  }
52
54
  state.callIndex += 1;
@@ -68,7 +70,11 @@ export function compressForCap(state, raw) {
68
70
  }
69
71
  const ratio = state.cumulative / state.max;
70
72
  let out;
71
- if (ratio >= state.highTierRatio) {
73
+ if (state.cumulative >= state.max) {
74
+ const trimmed = trimHead(raw, state.highTierChars, state.label);
75
+ out = `${trimmed}\n\n[Warning: ${state.label} tool budget exceeded (${state.cumulative}/${state.max} chars). Please finalize your work and summarize findings now.]`;
76
+ }
77
+ else if (ratio >= state.highTierRatio) {
72
78
  out = trimHead(raw, state.highTierChars, state.label);
73
79
  }
74
80
  else if (ratio >= state.midTierRatio) {
@@ -78,7 +84,7 @@ export function compressForCap(state, raw) {
78
84
  out = raw;
79
85
  }
80
86
  state.cumulative += out.length;
81
- if (state.cumulative >= state.max)
87
+ if (state.cumulative >= hardCeiling)
82
88
  state.exhausted = true;
83
89
  return out;
84
90
  }
@@ -141,9 +147,11 @@ function wrapInternal(tools, state) {
141
147
  * cumulative state.
142
148
  */
143
149
  export function wrapToolSetWithCap(tools, opts = {}) {
150
+ const max = Math.max(20_000, opts.maxCumulativeChars ?? DEFAULT_MAX_CUMULATIVE_CHARS);
144
151
  const state = {
145
152
  cumulative: 0,
146
- max: Math.max(20_000, opts.maxCumulativeChars ?? DEFAULT_MAX_CUMULATIVE_CHARS),
153
+ max,
154
+ hardMax: max * 2,
147
155
  exhausted: false,
148
156
  dedupHits: 0,
149
157
  seenHashes: new Map(),
@@ -154,3 +154,10 @@ export declare function compactSubAgentMessages(messages: ReadonlyArray<ModelMes
154
154
  * Creates a copy of the messages array and the last message to avoid mutating in-place.
155
155
  */
156
156
  export declare function applyAnthropicPromptCaching(messages: readonly ModelMessage[], modelId: string): ModelMessage[];
157
+ /**
158
+ * Safely slice message history to keep at most `maxMessages` messages.
159
+ * Preserves the system message(s) at the front and ensures that the slice
160
+ * starts with a "user" message and does not split assistant tool calls and
161
+ * corresponding tool results.
162
+ */
163
+ export declare function sliceMessageHistory(messages: ReadonlyArray<ModelMessage>, maxMessages?: number): ModelMessage[];
@@ -326,22 +326,25 @@ function rewriteOlderToolMessage(msg, previewChars, label, keepToolIds, persistA
326
326
  export function compactSubAgentMessages(messages, opts = {}) {
327
327
  const resolved = resolveOpts(opts);
328
328
  const { outputPreviewChars, label, envelopeChars } = resolved;
329
- // F2 threshold check uses TRUE prompt size (messages + system + tools).
330
- // The envelope (system prompt + JSON-schema for every tool) is re-sent on
331
- // every step and was previously invisible to the compactor, so a session
332
- // with 20-50K of fixed overhead would never trip the messages-only check.
329
+ // Step 4: Hard-limit message history sent to the model to prevent token bloating
330
+ // When input (messages + envelope) exceeds 50K characters and messages array is > 30,
331
+ // we slice the history to keep at most 30 messages (preserving system and user start).
332
+ let processedMessages = messages;
333
333
  const messagesTotal = cumulativeMessageChars(messages);
334
334
  const total = messagesTotal + envelopeChars;
335
- // G1 + G2 derive effective threshold and keepLastTurns from context
336
- // window utilization. Falls back to static char threshold + keepLast
337
- // when no contextWindowTokens supplied (preserves old behaviour).
338
- const { effectiveThresholdChars, effectiveKeepLastTurns } = computeDynamicParams(total, resolved);
335
+ if (total > 50_000 && messages.length > 30) {
336
+ processedMessages = sliceMessageHistory(messages, 30);
337
+ }
338
+ // Calculate effective thresholds and keep last turns using the processed messages
339
+ const processedMessagesTotal = cumulativeMessageChars(processedMessages);
340
+ const processedTotal = processedMessagesTotal + envelopeChars;
341
+ const { effectiveThresholdChars, effectiveKeepLastTurns } = computeDynamicParams(processedTotal, resolved);
339
342
  // No-op: return the input BY REFERENCE (contract above) so `compacted === input`.
340
- if (total < effectiveThresholdChars)
341
- return messages;
342
- const keepFrom = findKeepFromIndex(messages, effectiveKeepLastTurns);
343
+ if (processedTotal < effectiveThresholdChars)
344
+ return processedMessages;
345
+ const keepFrom = findKeepFromIndex(processedMessages, effectiveKeepLastTurns);
343
346
  if (keepFrom <= 0)
344
- return messages;
347
+ return processedMessages;
345
348
  // Walk older messages; rewrite fresh tool results into stubs, super-shrink
346
349
  // already-stubbed results (F1), and strip args off older assistant
347
350
  // tool-call shells (F1). The 1:1 assistant↔tool pairing required by the AI
@@ -349,8 +352,8 @@ export function compactSubAgentMessages(messages, opts = {}) {
349
352
  // structure or count.
350
353
  let firstUserSeen = false;
351
354
  const out = [];
352
- for (let i = 0; i < messages.length; i++) {
353
- const msg = messages[i];
355
+ for (let i = 0; i < processedMessages.length; i++) {
356
+ const msg = processedMessages[i];
354
357
  if (i >= keepFrom) {
355
358
  out.push(msg);
356
359
  continue;
@@ -497,4 +500,52 @@ export function applyAnthropicPromptCaching(messages, modelId) {
497
500
  newMessages[lastIndex] = lastMsg;
498
501
  return newMessages;
499
502
  }
503
+ /**
504
+ * Safely slice message history to keep at most `maxMessages` messages.
505
+ * Preserves the system message(s) at the front and ensures that the slice
506
+ * starts with a "user" message and does not split assistant tool calls and
507
+ * corresponding tool results.
508
+ */
509
+ export function sliceMessageHistory(messages, maxMessages = 30) {
510
+ if (messages.length <= maxMessages)
511
+ return messages;
512
+ // Find all user message indices (excluding system messages)
513
+ const userIndices = [];
514
+ for (let idx = 0; idx < messages.length; idx++) {
515
+ if (messages[idx]?.role === "user") {
516
+ userIndices.push(idx);
517
+ }
518
+ }
519
+ if (userIndices.length === 0) {
520
+ return messages;
521
+ }
522
+ // Group messages into turns.
523
+ // Each turn is a range [start, end] inclusive.
524
+ const turns = [];
525
+ for (let idx = 0; idx < userIndices.length; idx++) {
526
+ const start = userIndices[idx];
527
+ const end = idx + 1 < userIndices.length ? userIndices[idx + 1] - 1 : messages.length - 1;
528
+ turns.push({ start, end });
529
+ }
530
+ // Accumulate turns from the end, up to maxMessages
531
+ let keptMessagesCount = 0;
532
+ let keepFromIndex = -1;
533
+ for (let idx = turns.length - 1; idx >= 0; idx--) {
534
+ const turn = turns[idx];
535
+ const turnLength = turn.end - turn.start + 1;
536
+ if (keptMessagesCount === 0 || keptMessagesCount + turnLength <= maxMessages) {
537
+ keptMessagesCount += turnLength;
538
+ keepFromIndex = turn.start;
539
+ }
540
+ else {
541
+ break;
542
+ }
543
+ }
544
+ if (keepFromIndex === -1) {
545
+ return messages;
546
+ }
547
+ const kept = messages.slice(keepFromIndex);
548
+ const systemMessages = messages.filter((m) => m.role === "system");
549
+ return [...systemMessages, ...kept];
550
+ }
500
551
  //# sourceMappingURL=subagent-compactor.js.map
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
- import { compactSubAgentMessages, cumulativeMessageChars } from "./subagent-compactor.js";
2
+ import { compactSubAgentMessages, cumulativeMessageChars, sliceMessageHistory } from "./subagent-compactor.js";
3
3
  function bigText(label, kb) {
4
4
  const block = `${label}:${"x".repeat(kb * 1000)}`;
5
5
  return block;
@@ -400,7 +400,14 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
400
400
  },
401
401
  {
402
402
  role: "tool",
403
- content: [{ type: "tool-result", toolCallId: "call_low", toolName: "read_file", output: { type: "text", value: lowValueOutput } }],
403
+ content: [
404
+ {
405
+ type: "tool-result",
406
+ toolCallId: "call_low",
407
+ toolName: "read_file",
408
+ output: { type: "text", value: lowValueOutput },
409
+ },
410
+ ],
404
411
  },
405
412
  {
406
413
  role: "assistant",
@@ -408,7 +415,14 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
408
415
  },
409
416
  {
410
417
  role: "tool",
411
- content: [{ type: "tool-result", toolCallId: "call_err", toolName: "read_file", output: { type: "text", value: highValueOutputWithError } }],
418
+ content: [
419
+ {
420
+ type: "tool-result",
421
+ toolCallId: "call_err",
422
+ toolName: "read_file",
423
+ output: { type: "text", value: highValueOutputWithError },
424
+ },
425
+ ],
412
426
  },
413
427
  {
414
428
  role: "assistant",
@@ -416,7 +430,14 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
416
430
  },
417
431
  {
418
432
  role: "tool",
419
- content: [{ type: "tool-result", toolCallId: "call_src", toolName: "read_file", output: { type: "text", value: highValueSourceOutput } }],
433
+ content: [
434
+ {
435
+ type: "tool-result",
436
+ toolCallId: "call_src",
437
+ toolName: "read_file",
438
+ output: { type: "text", value: highValueSourceOutput },
439
+ },
440
+ ],
420
441
  },
421
442
  // Add a couple of low-value turns to push past threshold and keepLast turns (keepLast=1)
422
443
  ...toolTurn(9, 10),
@@ -425,13 +446,13 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
425
446
  msgs[msgs.length - 1].content[0].toolName = "other_tool";
426
447
  const out = compactSubAgentMessages(msgs, { thresholdChars: 10_000, keepLastTurns: 1 });
427
448
  // low-value large read_file MUST be elided
428
- const lowValMsg = out.find(m => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_low");
449
+ const lowValMsg = out.find((m) => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_low");
429
450
  expect((lowValMsg?.content)[0].output.value).toMatch(/elided by sub-agent compactor/);
430
451
  // high-value large error read_file MUST NOT be elided
431
- const errValMsg = out.find(m => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_err");
452
+ const errValMsg = out.find((m) => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_err");
432
453
  expect((errValMsg?.content)[0].output.value).toBe(highValueOutputWithError);
433
454
  // high-value small source read_file MUST NOT be elided
434
- const srcValMsg = out.find(m => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_src");
455
+ const srcValMsg = out.find((m) => m.role === "tool" && Array.isArray(m.content) && m.content[0]?.toolCallId === "call_src");
435
456
  expect((srcValMsg?.content)[0].output.value).toBe(highValueSourceOutput);
436
457
  });
437
458
  it("T1.1 — strips reasoning parts from old assistant turns when stripOldReasoning is enabled", () => {
@@ -453,7 +474,12 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
453
474
  msgs.push({
454
475
  role: "tool",
455
476
  content: [
456
- { type: "tool-result", toolCallId: `call_${i}`, toolName: "bash", output: { type: "text", value: bigText(`R${i}`, 2) } },
477
+ {
478
+ type: "tool-result",
479
+ toolCallId: `call_${i}`,
480
+ toolName: "bash",
481
+ output: { type: "text", value: bigText(`R${i}`, 2) },
482
+ },
457
483
  ],
458
484
  });
459
485
  }
@@ -497,7 +523,12 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
497
523
  msgs.push({
498
524
  role: "tool",
499
525
  content: [
500
- { type: "tool-result", toolCallId: `call_${i}`, toolName: "bash", output: { type: "text", value: bigText(`R${i}`, 2) } },
526
+ {
527
+ type: "tool-result",
528
+ toolCallId: `call_${i}`,
529
+ toolName: "bash",
530
+ output: { type: "text", value: bigText(`R${i}`, 2) },
531
+ },
501
532
  ],
502
533
  });
503
534
  }
@@ -516,5 +547,90 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
516
547
  expect(hasReasoning).toBe(true);
517
548
  }
518
549
  });
550
+ describe("sliceMessageHistory & Step 4 truncation", () => {
551
+ it("preserves system prompt and user start while slicing excess messages", () => {
552
+ const msgs = [
553
+ { role: "system", content: "system prompt" },
554
+ { role: "user", content: "first prompt" },
555
+ { role: "assistant", content: "first answer" },
556
+ { role: "user", content: "second prompt" },
557
+ {
558
+ role: "assistant",
559
+ content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash", input: "ls" }],
560
+ },
561
+ {
562
+ role: "tool",
563
+ content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash", output: "file1" }],
564
+ },
565
+ { role: "assistant", content: "second answer" },
566
+ { role: "user", content: "third prompt" },
567
+ {
568
+ role: "assistant",
569
+ content: [{ type: "tool-call", toolCallId: "call-2", toolName: "bash", input: "pwd" }],
570
+ },
571
+ {
572
+ role: "tool",
573
+ content: [{ type: "tool-result", toolCallId: "call-2", toolName: "bash", output: "/dir" }],
574
+ },
575
+ { role: "assistant", content: "third answer" },
576
+ ];
577
+ // Slicing to max 5 messages should start cleanly at a user message
578
+ const sliced = sliceMessageHistory(msgs, 5);
579
+ expect(sliced[0].role).toBe("system");
580
+ expect(sliced[1].role).toBe("user");
581
+ // Slicing walked backward to find a user message start that satisfies max 5.
582
+ // Last messages:
583
+ // assistant: third answer (idx 10) -> tool result 2 (idx 9) -> assistant call 2 (idx 8) -> user third prompt (idx 7).
584
+ // That is 4 messages. Since kept must start with user, it stops at idx 7.
585
+ // So kept messages are from idx 7 onwards: [user third prompt, assistant call 2, tool result 2, assistant third answer]
586
+ expect(sliced).toHaveLength(5); // 1 system + 4 kept messages
587
+ expect(sliced[1].content).toBe("third prompt");
588
+ });
589
+ it("does not orphaned tool-calls and tool-results", () => {
590
+ const msgs = [
591
+ { role: "system", content: "system prompt" },
592
+ { role: "user", content: "prompt" },
593
+ {
594
+ role: "assistant",
595
+ content: [{ type: "tool-call", toolCallId: "call-99", toolName: "bash", input: "whoami" }],
596
+ },
597
+ {
598
+ role: "tool",
599
+ content: [{ type: "tool-result", toolCallId: "call-99", toolName: "bash", output: "root" }],
600
+ },
601
+ { role: "assistant", content: "hello" },
602
+ ];
603
+ // Even if maxMessages is 2, it shouldn't split assistant call-99 and tool result-99.
604
+ // Walking backward:
605
+ // - assistant: hello (count 1)
606
+ // - tool: result-99 (count 2, pendingToolCalls = {call-99})
607
+ // - assistant: call-99 (count 3, pendingToolCalls = {})
608
+ // - user: prompt (count 4, clean user start)
609
+ // So it must keep 4 messages + 1 system = 5 messages.
610
+ const sliced = sliceMessageHistory(msgs, 2);
611
+ expect(sliced).toHaveLength(5);
612
+ expect(sliced[1].role).toBe("user");
613
+ });
614
+ it("triggers slicing in compactSubAgentMessages when total exceeds 50K and messages count is > 30", () => {
615
+ const msgs = [
616
+ { role: "system", content: "system" },
617
+ { role: "user", content: "start" },
618
+ ];
619
+ // Push 40 messages to exceed 30 messages limit
620
+ for (let i = 0; i < 20; i++) {
621
+ msgs.push({ role: "assistant", content: `helper ${i}` });
622
+ msgs.push({ role: "user", content: `next question ${i}` });
623
+ }
624
+ // Generate a massive envelope size or message content to exceed 50K total chars
625
+ const out = compactSubAgentMessages(msgs, {
626
+ envelopeChars: 60_000, // force exceeds 50K
627
+ thresholdChars: 200_000, // high threshold to prevent compaction from running on top
628
+ });
629
+ // The message history should have been sliced (will be much fewer than 42 messages)
630
+ expect(out.length).toBeLessThan(msgs.length);
631
+ expect(out[0].role).toBe("system");
632
+ expect(out[1].role).toBe("user");
633
+ });
634
+ });
519
635
  });
520
636
  //# sourceMappingURL=subagent-compactor.spec.js.map
@@ -21,6 +21,7 @@ import type { TurnRunnerDepsBase } from "./turn-runner-deps.js";
21
21
  * subsequent turns). Method callbacks delegate to Agent private methods.
22
22
  */
23
23
  export interface MessageProcessorDeps extends TurnRunnerDepsBase {
24
+ readonly isSubSession?: boolean;
24
25
  /** Live messageSeqs array (mutated by push; parallel to messages). */
25
26
  readonly messageSeqs: Array<number | null>;
26
27
  /** Session bookkeeping. */
@@ -75,6 +76,7 @@ export interface MessageProcessorDeps extends TurnRunnerDepsBase {
75
76
  setMessages(messages: ModelMessage[]): void;
76
77
  requireProvider(): LegacyProvider;
77
78
  emitSubagentStatus(status: SubagentStatus | null): void;
79
+ consultParentSession?: (question: string) => Promise<string>;
78
80
  fireHook(input: unknown, signal?: AbortSignal): Promise<{
79
81
  blocked: boolean;
80
82
  blockingErrors: Array<{