mixdog 0.9.12 → 0.9.13

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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/lib/rules-builder.cjs +15 -11
  5. package/src/mixdog-session-runtime.mjs +10 -0
  6. package/src/rules/agent/00-common.md +5 -17
  7. package/src/rules/agent/00-core.md +21 -0
  8. package/src/rules/lead/lead-brief.md +7 -0
  9. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  10. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  12. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  22. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  23. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  24. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  25. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  26. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  27. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  28. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  29. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  30. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  32. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  33. package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
  34. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  35. package/src/runtime/channels/index.mjs +28 -1
  36. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  37. package/src/runtime/memory/index.mjs +11 -11
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
  39. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
  40. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  41. package/src/standalone/agent-tool.mjs +89 -7
  42. package/src/workflows/default/WORKFLOW.md +2 -0
  43. package/src/workflows/sequential/WORKFLOW.md +2 -0
  44. package/src/workflows/solo/WORKFLOW.md +2 -0
@@ -39,68 +39,6 @@ const PRUNE_TAIL_TURNS = 2;
39
39
  export const MIN_PRESERVE_RECENT_TOKENS = 2_000;
40
40
  export const PRESERVED_FACTS_MAX_CHARS = 600;
41
41
 
42
- // Count raw (unchunked) pending rows still present in a dump_session_roots
43
- // payload. recall-fasttrack must keep cycle1-chunking until this reaches 0 so
44
- // the injected root is the chunked summary, not the raw transcript tail.
45
- export function countRawPendingRows(dumpText) {
46
- const text = String(dumpText || '');
47
- const matches = text.match(/(?:^|\n)# raw_pending\s+\d+\s+id=/gi);
48
- return matches ? matches.length : 0;
49
- }
50
-
51
- // Drain a single session's cycle1 in fixed window×concurrency units, looping
52
- // until no raw rows remain (or a pass stops making progress / the deadline
53
- // elapses). This replaces the previous single-pass cycle1 so large sessions
54
- // get fully chunked before their root is injected into the compacted context.
55
- export async function drainSessionCycle1(runTool, { sessionId, cycleArgs = {}, dumpArgs, maxPasses = 0, deadlineMs = 0 } = {}) {
56
- if (typeof runTool !== 'function') throw new Error('drainSessionCycle1: runTool is required');
57
- if (!sessionId) throw new Error('drainSessionCycle1: sessionId is required');
58
- if (!dumpArgs) throw new Error('drainSessionCycle1: dumpArgs is required');
59
- const startedAt = Date.now();
60
- const hardPasses = Math.max(1, Number(maxPasses) || 50);
61
- const lines = [];
62
- let cycle1Error = null;
63
- let recallText = await runTool('memory', dumpArgs);
64
- let rawRemaining = countRawPendingRows(recallText);
65
- let pass = 0;
66
- while (rawRemaining > 0 && pass < hardPasses) {
67
- if (deadlineMs > 0 && (Date.now() - startedAt) >= deadlineMs) break;
68
- pass += 1;
69
- const passDeadline = deadlineMs > 0
70
- ? Math.max(1, deadlineMs - (Date.now() - startedAt))
71
- : 0;
72
- let passText = '';
73
- try {
74
- passText = await runTool('memory', {
75
- action: 'cycle1',
76
- sessionId,
77
- ...cycleArgs,
78
- ...(passDeadline > 0 ? { _callerDeadlineMs: passDeadline } : {}),
79
- });
80
- } catch (err) {
81
- cycle1Error = err?.message ? String(err.message) : String(err);
82
- lines.push(`cycle1 pass=${pass} error=${cycle1Error}`);
83
- break;
84
- }
85
- if (passText) lines.push(`cycle1 pass=${pass}: ${String(passText).trim()}`);
86
- recallText = await runTool('memory', dumpArgs);
87
- const nextRaw = countRawPendingRows(recallText);
88
- // No forward progress (raw not shrinking) — stop instead of spinning.
89
- if (nextRaw >= rawRemaining) {
90
- rawRemaining = nextRaw;
91
- break;
92
- }
93
- rawRemaining = nextRaw;
94
- }
95
- return {
96
- recallText,
97
- cycle1Text: lines.join('\n'),
98
- passes: pass,
99
- rawRemaining,
100
- error: cycle1Error,
101
- };
102
- }
103
-
104
42
  function preservedFactHints() {
105
43
  return String(process.env.MIXDOG_COMPACT_FACT_HINTS || '')
106
44
  .split(/[\n,;]+/u)
@@ -37,8 +37,6 @@ export { redactToolCallSecretsInMessages } from './compact/text-utils.mjs';
37
37
 
38
38
  export {
39
39
  effectiveBudget,
40
- countRawPendingRows,
41
- drainSessionCycle1,
42
40
  pruneToolOutputs,
43
41
  pruneToolOutputsUnanchored,
44
42
  } from './compact/budget.mjs';
@@ -1,5 +1,5 @@
1
1
  // Completion-first loop guards: escalation ladder (level-2 steering),
2
- // cross-turn identical read-only call dedup, and worker soft-cap wrap-up.
2
+ // cross-turn identical read-only call dedup, and hard-cap refusal stubs.
3
3
  // Pure string/signature helpers extracted from loop.mjs so the loop body only
4
4
  // wires state + messages. No provider/manager coupling.
5
5
 
@@ -41,21 +41,24 @@ export function isEditProgressTool(name, isEager) {
41
41
  // `readOnlyRole` swaps the edit-oriented directive for a report-oriented one:
42
42
  // read-permission sessions (reviewer-style) cannot apply_patch, so telling
43
43
  // them to edit is self-contradictory and pushes premature termination.
44
- export function level2SteerMessage(n, readOnlyRole = false) {
44
+ const LEVEL2_DIMINISHING_RETURNS = ' Further reads add cost, not evidence.';
45
+
46
+ export function level2SteerMessage(n, readOnlyRole = false, level2Fires = 1) {
47
+ const tail = Number(level2Fires) >= 2 ? LEVEL2_DIMINISHING_RETURNS : '';
45
48
  if (readOnlyRole) {
46
- return `<system-reminder>\nYou have received this batching reminder ${n} times. Converge now: report your findings from what you have already read, or state exactly what information is missing for a verdict. A partial report with named gaps is a valid, successful completion — continued exploration is not.\n</system-reminder>`;
49
+ return `<system-reminder>\nEnough evidence gathered report findings now; name any gaps.${tail}\n</system-reminder>`;
47
50
  }
48
- return `<system-reminder>\nYou have received this batching reminder ${n} times without making any edit. Stop exploring now: either apply_patch with what you already know, or return a blocked report stating exactly what is missing. A blocked report is a valid, successful completion — continued exploration is not.\n</system-reminder>`;
51
+ return `<system-reminder>\nStop exploring apply the edit you know, or report blocked with what's missing.${tail}\n</system-reminder>`;
49
52
  }
50
53
 
51
54
  // Step 2 — cross-turn dedup stub. `stuck` appends the escalation tail at the
52
55
  // 5th+ dedup stub in the session.
53
56
  export function crossTurnDedupStub(name, firstIteration, stuck) {
54
- let s = `[cross-turn-dedup] identical read-only \`${name}\` call already executed in iteration ${firstIteration}; its result is unchanged and already in context. Use it, or change path/offset/pattern for new information.`;
55
- if (stuck) s += ` Repeated identical calls indicate you are stuck — apply what you know or return blocked.`;
57
+ let s = `[cross-turn-dedup] \`${name}\` already ran in iteration ${firstIteration}; result unchanged, already in context.`;
58
+ if (stuck) s += ` You appear stuck — use what you have or report blocked.`;
56
59
  return s;
57
60
  }
58
61
 
59
- // Step 3 worker soft-cap wrap-up assistant-visible directive + refusal stub.
60
- export const SOFT_CAP_WRAPUP_MESSAGE = `MAXIMUM EXPLORATION BUDGET REACHED. Tools are now disabled. Respond with text only: summarize work completed so far, list remaining/incomplete items, and state what is blocking or what should be done next. This overrides all other instructions.`;
61
- export const SOFT_CAP_REFUSAL_STUB = `Tools are disabled: exploration budget reached. Provide your final text summary.`;
62
+ // Hard iteration-cap final turn: model may still emit tool calls after tools
63
+ // are stripped from the send; refuse without executing.
64
+ export const ITERATION_CAP_REFUSAL_STUB = `Iteration cap reached tools disabled; reply with your final text only.`;
@@ -13,6 +13,7 @@
13
13
  // command injection), or host input injection. Explicit name list (no imports)
14
14
  // keeps this hot-path gate dependency-free; add new owner/channel tools here.
15
15
  import { isAgentOwner } from '../../agent-owner.mjs';
16
+ import { recursiveWrapperToolNameForPublicAgent } from '../manager/tool-resolution.mjs';
16
17
 
17
18
  const WORKER_DENIED_TOOLS = new Set([
18
19
  // session control-plane — unified into the single `agent` tool
@@ -34,6 +35,17 @@ function _preDispatchDeny(call, toolKind, sessionRef) {
34
35
  if (_agentOwned && _controlPlaneTool) {
35
36
  return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
36
37
  }
38
+ // Anti-recursion break, moved OFF the schema so the read-only tool bundle
39
+ // stays bit-identical across every read role (explore ships in the schema
40
+ // for all of them, incl. the explore agent itself — one cache group).
41
+ // A public agent that IS a recursive wrapper (e.g. explore) must not call
42
+ // its own wrapper tool; reject it here at call time instead.
43
+ if (_agentOwned) {
44
+ const selfWrapper = recursiveWrapperToolNameForPublicAgent(sessionRef?.agent || null);
45
+ if (selfWrapper && name.toLowerCase() === selfWrapper.toLowerCase()) {
46
+ return `Error: tool "${name}" is not available inside agent "${sessionRef.agent}" (would recurse into its own wrapper). Return the answer directly.`;
47
+ }
48
+ }
37
49
  const noToolAgent = sessionRef?.agent === 'cycle1-agent' || sessionRef?.agent === 'cycle2-agent';
38
50
  if (noToolAgent) {
39
51
  return `Error: tool "${name}" is not available in agent "${sessionRef.agent}". Re-emit the answer as pipe-separated text per the agent's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
@@ -1,19 +1,15 @@
1
- // Recall-fasttrack compaction pipeline, extracted from loop.mjs.
1
+ // Recall-fasttrack compaction pipeline (digest injection).
2
2
  // Hydrates the session transcript into the memory pipeline (ingest_session),
3
- // dumps chunked/raw roots, drains cycle1 until no raw rows remain, and folds
4
- // the combined recall text back into a compacted message array capped at
5
- // CONTEXT_SHARE_RATIO of the model context window. No behavior change: this is
6
- // the same body that lived inline in loop.mjs, re-exported via the facade so
7
- // existing importers keep working.
3
+ // then injects a small newest-first digest + recall pointer into the
4
+ // compacted messages. The former full-dump path (dump_session_roots +
5
+ // synchronous cycle1 drain) was removed 2026-07: the drain ran memory-
6
+ // pipeline LLM chunking calls inside the compaction (11.9s of a measured
7
+ // 12.9s compact) and still left raw rows behind; background cycle1 already
8
+ // chunks ingested rows on its own schedule, and recall serves the rest.
8
9
  import { createHash } from 'crypto';
9
10
  import { executeInternalTool } from '../../internal-tools.mjs';
10
- import { loadConfig as loadOrchestratorConfig } from '../../config.mjs';
11
11
  import {
12
12
  recallFastTrackCompactMessages,
13
- CONTEXT_SHARE_RATIO,
14
- RECALL_TOKEN_CAP_FLOOR_TOKENS,
15
- drainSessionCycle1,
16
- countRawPendingRows,
17
13
  } from '../compact.mjs';
18
14
  import {
19
15
  compactDiagnosticError,
@@ -23,23 +19,23 @@ import {
23
19
  import { positiveTokenInt } from './env.mjs';
24
20
  import { TOOL_OUTPUT_MAX_BYTES } from '../../tools/builtin/tool-output-limit.mjs';
25
21
 
26
- // ── Digest mode (compaction.recallDigest=true) ─────────────────────────────
27
- // Instead of folding the FULL chunked session dump into the compacted
28
- // messages (heavy: cycle1 drain + up to CONTEXT_SHARE_RATIO of the context
29
- // window), inject a small newest-first digest plus an instruction telling the
30
- // model to pull details lazily via recall(sessionId/query/period). The memory
31
- // DB already holds the full session (ingest_session below runs in both
32
- // modes), and raw rows are embedded synchronously at ingest, so recall serves
33
- // everything the big injection used to carry.
22
+ // ── Digest injection ────────────────────────────────────────────────────────
23
+ // Inject a small newest-first digest plus an instruction telling the model to
24
+ // pull details lazily via recall(sessionId/query/period). The memory DB holds
25
+ // the full session (ingest_session below), and raw rows are embedded
26
+ // synchronously at ingest, so recall serves everything the old full-dump
27
+ // injection used to carry.
34
28
  // Default digest cap = the SHARED tool-output limit (TOOL_OUTPUT_MAX_BYTES,
35
29
  // 50KB default, env MIXDOG_TOOL_OUTPUT_MAX_BYTES) — the digest injection is
36
30
  // budgeted like any other tool result, not a special context share.
37
31
  // compaction.recallDigestMaxKb still overrides per-session.
38
- const DIGEST_DEFAULT_MAX_KB = Math.max(1, Math.floor(TOOL_OUTPUT_MAX_BYTES / 1024));
32
+ export const DIGEST_DEFAULT_MAX_KB = Math.max(1, Math.floor(TOOL_OUTPUT_MAX_BYTES / 1024));
39
33
 
40
34
  // Byte-capped line-boundary truncation. Digest source is newest-first, so
41
35
  // keeping the HEAD keeps the newest turns.
42
- function truncateToKb(text, maxKb) {
36
+ // Exported for manager/compaction-runner.mjs (manual//clear digest path) so
37
+ // both digest producers share one cap implementation.
38
+ export function truncateToKb(text, maxKb) {
43
39
  const maxBytes = Math.max(1, maxKb) * 1024;
44
40
  const s = String(text || '');
45
41
  if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
@@ -120,156 +116,51 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
120
116
  } finally {
121
117
  diagnostics.ingestMs = Date.now() - t0;
122
118
  }
123
- // ── Digest mode: skip the dump + cycle1 drain entirely. Pull a small
124
- // newest-first session browse (recall path: roots + raw fallback merged
125
- // chronologically), cap it at recallDigestMaxKb, and inject it with a
126
- // recall-usage instruction. The model pulls older/topical detail lazily.
127
- const digestMode = sessionRef?.compaction?.recallDigest === true;
128
- if (digestMode) {
129
- const digestMaxKb = positiveTokenInt(sessionRef?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB;
130
- let digestBody = '';
131
- t0 = Date.now();
132
- try {
133
- const browsed = await executeInternalTool('memory', {
134
- action: 'search',
135
- sessionId,
136
- limit: positiveTokenInt(sessionRef?.compaction?.recallDigestLimit) || 30,
137
- includeMembers: true,
138
- }, callerCtx);
139
- digestBody = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
140
- } catch (err) {
141
- diagnostics.cycle1Error = compactDiagnosticError(err);
142
- try { process.stderr.write(`[loop] recall-digest browse failed (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
143
- }
144
- diagnostics.initialDumpMs = Date.now() - t0;
145
- diagnostics.cycle1Skipped = true;
146
- diagnostics.cycle1SkipReason = 'digest mode';
147
- diagnostics.cycle1Passes = 0;
148
- const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
149
- diagnostics.finalRecallChars = digestText.length;
150
- diagnostics.finalRecallBytes = compactByteLength(digestText);
151
- const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
152
- reserveTokens: compactPolicy.reserveTokens,
153
- force: true,
154
- recallText: digestText,
155
- query,
156
- querySha,
157
- allowEmptyRecall: true,
158
- tailTurns: compactPolicy.tailTurns,
159
- keepTokens: compactPolicy.keepTokens,
160
- preserveRecentTokens: compactPolicy.preserveRecentTokens,
161
- });
162
- diagnostics.totalMs = Date.now() - startedAt;
163
- if (result && typeof result === 'object') {
164
- result.diagnostics = { ...(result.diagnostics || {}), pipeline: { ...diagnostics, digestMode: true } };
165
- }
166
- compactDebugLog('recall-digest pipeline', diagnostics);
167
- return result;
168
- }
169
- const dumpArgs = {
170
- action: 'dump_session_roots',
171
- sessionId,
172
- includeRaw: true,
173
- limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
174
- };
175
- const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
119
+ // ── Digest injection (the only mode) ──────────────────────────────────
120
+ // The old full-dump path (dump_session_roots + synchronous cycle1 drain)
121
+ // is gone: the drain ran memory-pipeline LLM chunking calls INSIDE the
122
+ // compaction (measured 11.9s of a 12.9s compact) and still often left
123
+ // rawRemaining>0. Instead inject a small newest-first digest plus a
124
+ // recall pointer; ingest_session above already put the full transcript
125
+ // in the memory DB, and background cycle1 chunks it on its own schedule.
126
+ const digestMaxKb = positiveTokenInt(sessionRef?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB;
127
+ let digestBody = '';
176
128
  t0 = Date.now();
177
- let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
178
- diagnostics.initialDumpMs = Date.now() - t0;
179
- diagnostics.initialDumpChars = String(recallText || '').length;
180
- diagnostics.initialDumpBytes = compactByteLength(recallText);
181
- diagnostics.initialRawPending = countRawPendingRows(recallText);
182
- let cycle1Text = '';
183
- const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
184
- // Recap off = NO memory-pipeline LLM calls: skip the cycle1 drain entirely
185
- // and let the dump's raw transcript lines (includeRaw:true above) ride into
186
- // the injected summary as-is. Poll-on-use: re-read the flag per compact so
187
- // a runtime toggle applies without restart. Default on if config read fails.
188
- let recapOn = true;
189
- try { recapOn = loadOrchestratorConfig({ secrets: false })?.recap?.enabled !== false; } catch { /* default on */ }
190
- if (hasRawRows && !recapOn) {
191
- diagnostics.cycle1Skipped = true;
192
- diagnostics.cycle1SkipReason = 'recap disabled';
193
- diagnostics.cycle1Passes = 0;
194
- diagnostics.cycle1RawRemaining = countRawPendingRows(recallText);
195
- cycle1Text = 'cycle1: skipped (recap disabled — raw transcript lines kept as-is)';
196
- } else if (hasRawRows) {
197
- t0 = Date.now();
198
- try {
199
- // Drain this session's cycle1 in window×concurrency units until no
200
- // raw rows remain, so the injected root is fully chunked rather than
201
- // carrying the unprocessed transcript tail (single-pass left raw in).
202
- const drained = await drainSessionCycle1(runTool, {
203
- sessionId,
204
- dumpArgs,
205
- deadlineMs: positiveTokenInt(sessionRef?.compaction?.recallCycle1DeadlineMs) || 120_000,
206
- maxPasses: positiveTokenInt(sessionRef?.compaction?.recallCycle1MaxPasses) || 0,
207
- cycleArgs: {
208
- min_batch: 1,
209
- session_cap: 1,
210
- batch_size: positiveTokenInt(sessionRef?.compaction?.recallCycle1BatchSize) || 100,
211
- rows_per_session: positiveTokenInt(sessionRef?.compaction?.recallRowsPerSession) || 100,
212
- window_size: positiveTokenInt(sessionRef?.compaction?.recallWindowSize) || 20,
213
- concurrency: positiveTokenInt(sessionRef?.compaction?.recallConcurrency) || 5,
214
- },
215
- });
216
- recallText = drained.recallText;
217
- cycle1Text = drained.cycle1Text;
218
- diagnostics.cycle1Passes = drained.passes;
219
- diagnostics.cycle1RawRemaining = drained.rawRemaining;
220
- diagnostics.cycle1TextBytes = compactByteLength(cycle1Text);
221
- if (drained.error) {
222
- diagnostics.cycle1Error = drained.error;
223
- try { process.stderr.write(`[loop] recall-fasttrack cycle1 error (sess=${sessionId || 'unknown'}): ${drained.error}\n`); } catch {}
224
- }
225
- if (drained.rawRemaining > 0) {
226
- try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
227
- }
228
- } catch (err) {
229
- diagnostics.cycle1Error = compactDiagnosticError(err);
230
- try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
231
- } finally {
232
- diagnostics.cycle1Ms = Date.now() - t0;
233
- }
234
- } else {
235
- diagnostics.cycle1Skipped = true;
236
- diagnostics.cycle1SkipReason = 'session chunks already hydrated';
237
- diagnostics.cycle1Passes = 0;
238
- diagnostics.cycle1RawRemaining = 0;
239
- cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
129
+ try {
130
+ const browsed = await executeInternalTool('memory', {
131
+ action: 'search',
132
+ sessionId,
133
+ limit: positiveTokenInt(sessionRef?.compaction?.recallDigestLimit) || 30,
134
+ includeMembers: true,
135
+ includeRaw: true,
136
+ }, callerCtx);
137
+ digestBody = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
138
+ } catch (err) {
139
+ diagnostics.cycle1Error = compactDiagnosticError(err);
140
+ try { process.stderr.write(`[loop] recall-digest browse failed (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
240
141
  }
241
- const combinedRecallText = [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n');
242
- diagnostics.finalRecallChars = combinedRecallText.length;
243
- diagnostics.finalRecallBytes = compactByteLength(combinedRecallText);
244
- // Recall injection (chunked-summary + raw-fallback text, combined above)
245
- // never exceeds CONTEXT_SHARE_RATIO (5%) of the model context window
246
- // (floor RECALL_TOKEN_CAP_FLOOR_TOKENS); the rest of the budget belongs to
247
- // live conversation. Same unified 5% as the compact target ratio
248
- // (compact-policy.mjs COMPACT_TARGET_RATIO). Omitted when contextWindow is
249
- // unknown/0 so current no-cap behavior is preserved.
250
- const _recallCapWindow = Number(compactPolicy.contextWindow) || 0;
251
- const recallTokenCap = _recallCapWindow > 0
252
- ? Math.max(RECALL_TOKEN_CAP_FLOOR_TOKENS, Math.floor(_recallCapWindow * CONTEXT_SHARE_RATIO))
253
- : undefined;
142
+ diagnostics.initialDumpMs = Date.now() - t0;
143
+ diagnostics.cycle1Skipped = true;
144
+ diagnostics.cycle1SkipReason = 'digest mode';
145
+ diagnostics.cycle1Passes = 0;
146
+ const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
147
+ diagnostics.finalRecallChars = digestText.length;
148
+ diagnostics.finalRecallBytes = compactByteLength(digestText);
254
149
  const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
255
150
  reserveTokens: compactPolicy.reserveTokens,
256
151
  force: true,
257
- recallText: combinedRecallText,
152
+ recallText: digestText,
258
153
  query,
259
154
  querySha,
260
155
  allowEmptyRecall: true,
261
156
  tailTurns: compactPolicy.tailTurns,
262
157
  keepTokens: compactPolicy.keepTokens,
263
158
  preserveRecentTokens: compactPolicy.preserveRecentTokens,
264
- recallTokenCap,
265
159
  });
266
160
  diagnostics.totalMs = Date.now() - startedAt;
267
161
  if (result && typeof result === 'object') {
268
- result.diagnostics = {
269
- ...(result.diagnostics || {}),
270
- pipeline: diagnostics,
271
- };
162
+ result.diagnostics = { ...(result.diagnostics || {}), pipeline: { ...diagnostics, digestMode: true } };
272
163
  }
273
- compactDebugLog('recall-fasttrack pipeline', diagnostics);
164
+ compactDebugLog('recall-digest pipeline', diagnostics);
274
165
  return result;
275
166
  }
@@ -8,14 +8,10 @@ import { appendAgentTrace } from '../../agent-trace.mjs';
8
8
  import { level2SteerMessage } from './completion-guards.mjs';
9
9
  import { isEagerDispatchable } from './tool-helpers.mjs';
10
10
 
11
- // Consecutive ignored level-2 steers (zero edits) that force the wrap-up early.
12
- export const EARLY_SOFT_CAP_LEVEL2_FIRES = 3;
13
-
14
11
  // Build the completion-first steering-ladder controller. `ctx` supplies live
15
12
  // accessors so every read reflects the loop's current mutable state:
16
13
  // - messages, sessionId, sessionAgent, tools (stable refs/values)
17
14
  // - getIterations() (current iteration)
18
- // - softCapEnabled (constant per loop)
19
15
  // - getEditCount() (mutated by the loop)
20
16
  // - pushSystemReminder(text) → push a meta:'hook' user message
21
17
  // - pushUserMessage(msg) → push a raw user message (level-2 latch text)
@@ -25,14 +21,13 @@ export function createSteeringLadder(ctx) {
25
21
  sessionAgent,
26
22
  tools,
27
23
  getIterations,
28
- softCapEnabled,
29
24
  getEditCount,
30
25
  } = ctx;
31
26
  const pushSystemReminder = ctx.pushSystemReminder;
32
27
  const pushUserMessage = ctx.pushUserMessage;
33
28
  // Permission-based role detection (agent names are user-definable):
34
29
  // read-permission sessions legitimately never edit, so they get the
35
- // report-oriented level-2 text and never arm the early soft-cap.
30
+ // report-oriented level-2 text.
36
31
  const readOnlyRole = ctx.readOnlyRole === true;
37
32
 
38
33
  // Step 1: escalation ladder. _level1FireCount is CUMULATIVE (never reset)
@@ -54,8 +49,6 @@ export function createSteeringLadder(ctx) {
54
49
  // grep on that path; any other tool/path resets it.
55
50
  let _sameFileGrepStreak = 0;
56
51
  let _sameFileGrepPath = null;
57
- // Early soft-cap: N ignored level-2 steers with zero edits forces the
58
- // wrap-up early; any edit disarms it (level-2 requires editCount === 0).
59
52
  let _level2FireCount = 0;
60
53
 
61
54
  // Level-2 steering emitter shared by both ladder paths (single-call
@@ -65,10 +58,7 @@ export function createSteeringLadder(ctx) {
65
58
  const iterations = getIterations();
66
59
  _level2LatchAtIteration = iterations;
67
60
  _level2FireCount += 1;
68
- // When this fire arms the early soft-cap, the wrap-up injected at the
69
- // next loop head supersedes the level-2 text — skip the redundant hint.
70
- const _armsEarlyCap = !readOnlyRole && softCapEnabled && _level2FireCount >= EARLY_SOFT_CAP_LEVEL2_FIRES && getEditCount() === 0;
71
- if (!_armsEarlyCap) pushUserMessage({ role: 'user', content: level2SteerMessage(_level1FireCount, readOnlyRole), meta: 'hook' });
61
+ pushUserMessage({ role: 'user', content: level2SteerMessage(_level1FireCount, readOnlyRole, _level2FireCount), meta: 'hook' });
72
62
  try {
73
63
  appendAgentTrace({
74
64
  sessionId,
@@ -81,21 +71,14 @@ export function createSteeringLadder(ctx) {
81
71
  };
82
72
 
83
73
  return {
84
- // Loop head reads: is the early soft-cap armed?
85
- get level2FireCount() { return _level2FireCount; },
86
- earlySoftCapArmed() {
87
- return !readOnlyRole && _level2FireCount >= EARLY_SOFT_CAP_LEVEL2_FIRES && getEditCount() === 0;
88
- },
89
- // Post-batch steering hint gate. `hintAlreadyFired` seeds the once-per-
90
- // turn latch (soft-cap active suppresses all hints). Returns nothing;
74
+ // Post-batch steering hint gate. `hintAlreadyFired` seeds the once-per-turn
75
+ // latch. Returns nothing;
91
76
  // pushes at most one steering message via the ctx push callbacks.
92
77
  emitPostBatchSteering(calls, hintAlreadyFired) {
93
78
  const iterations = getIterations();
94
79
  const editCount = getEditCount();
95
- // Steering hint gate: at most ONE hint per turn (priority: soft-cap >
96
- // level-2 > same-file grep > level-1), and none once the soft-cap
97
- // wrap-up is active — its "text only" directive must not share a send
98
- // with a "keep exploring" hint.
80
+ // Steering hint gate: at most ONE hint per turn (priority:
81
+ // level-2 > same-file grep > level-1).
99
82
  let _hintFiredThisTurn = hintAlreadyFired;
100
83
  // Missed-parallelism steering: 3+ consecutive turns of a single
101
84
  // read-only tool call suggest the model isn't batching independent
@@ -17,8 +17,6 @@ export const INCOMPLETE_STOP_REASONS = new Set([
17
17
  // branch in agentLoop does (trimmed string content, or any reasoning content).
18
18
  export function classifyTerminationReason(response, {
19
19
  terminatedByCap,
20
- terminatedBySoftCap,
21
- softCapActive,
22
20
  sessionAgent,
23
21
  } = {}) {
24
22
  const _finalHasContent = (typeof response?.content === 'string' && response.content.trim().length > 0)
@@ -31,17 +29,6 @@ export function classifyTerminationReason(response, {
31
29
  // on its own contract.
32
30
  return 'iteration_cap';
33
31
  }
34
- if (terminatedBySoftCap || softCapActive) {
35
- // Worker soft-cap wrap-up path: non-lead session hit the exploration
36
- // budget and was steered to a text-only finish. Distinct from the hard
37
- // cap (iteration_cap) — this is an expected, budget-driven termination,
38
- // not a runaway. Covers BOTH the grace-exhausted path
39
- // (terminatedBySoftCap) AND the compliant path where the model emitted
40
- // the final text with no more tool calls while the soft cap was active
41
- // (loop ends via the no-tool-call break, so terminatedBySoftCap stays
42
- // false but softCapActive is still true).
43
- return 'soft_cap_wrapup';
44
- }
45
32
  if (!_finalHasContent && _finalIncompleteStop) {
46
33
  // Cut short mid-synthesis (token cap / provider pause). Real problem
47
34
  // for hidden agents too.