mixdog 0.9.14 → 0.9.16
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.
- package/package.json +1 -1
- package/scripts/bench/cache-probe-tasks.json +1 -1
- package/scripts/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench.mjs +5 -4
- package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
- package/scripts/output-style-smoke.mjs +3 -3
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/termio-input-smoke.mjs +199 -0
- package/scripts/tool-efficiency-diag.mjs +88 -0
- package/scripts/tool-smoke.mjs +40 -24
- package/scripts/tui-frame-harness-shim.mjs +32 -0
- package/scripts/tui-frame-harness.mjs +306 -0
- package/src/agents/heavy-worker/AGENT.md +6 -7
- package/src/agents/worker/AGENT.md +6 -7
- package/src/lib/keychain-cjs.cjs +28 -11
- package/src/lib/rules-builder.cjs +6 -10
- package/src/mixdog-session-runtime.mjs +90 -20
- package/src/output-styles/simple.md +22 -24
- package/src/rules/agent/00-core.md +12 -11
- package/src/rules/agent/30-explorer.md +22 -21
- package/src/rules/lead/01-general.md +8 -8
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-brief.md +11 -12
- package/src/rules/shared/01-tool.md +25 -14
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
- package/src/runtime/channels/backends/telegram.mjs +29 -9
- package/src/runtime/channels/lib/event-queue.mjs +6 -2
- package/src/runtime/channels/lib/memory-client.mjs +66 -1
- package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
- package/src/runtime/memory/index.mjs +95 -8
- package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
- package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
- package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
- package/src/runtime/memory/lib/memory-embed.mjs +48 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
- package/src/runtime/memory/lib/memory.mjs +88 -1
- package/src/runtime/memory/lib/session-ingest.mjs +34 -3
- package/src/runtime/memory/lib/trace-store.mjs +52 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
- package/src/runtime/shared/child-guardian.mjs +4 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/singleton-owner.mjs +26 -0
- package/src/runtime/shared/tool-execution-contract.mjs +25 -0
- package/src/runtime/shared/transcript-writer.mjs +3 -1
- package/src/session-runtime/config-helpers.mjs +7 -2
- package/src/session-runtime/cwd-plugins.mjs +6 -1
- package/src/session-runtime/effort.mjs +6 -1
- package/src/session-runtime/plugin-mcp.mjs +116 -12
- package/src/session-runtime/settings-api.mjs +7 -1
- package/src/standalone/channel-worker.mjs +25 -3
- package/src/standalone/explore-tool.mjs +5 -5
- package/src/standalone/hook-bus/config.mjs +38 -2
- package/src/standalone/hook-bus/handlers.mjs +103 -11
- package/src/standalone/hook-bus.mjs +20 -6
- package/src/standalone/memory-runtime-proxy.mjs +40 -5
- package/src/tui/App.jsx +214 -30
- package/src/tui/app/core-memory-picker.mjs +6 -6
- package/src/tui/app/model-options.mjs +10 -4
- package/src/tui/app/settings-picker.mjs +5 -30
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/text-layout.mjs +57 -0
- package/src/tui/app/transcript-window.mjs +53 -2
- package/src/tui/app/use-mouse-input.mjs +60 -47
- package/src/tui/app/use-transcript-window.mjs +38 -10
- package/src/tui/components/PromptInput.jsx +18 -1
- package/src/tui/components/TextEntryPanel.jsx +97 -33
- package/src/tui/dist/index.mjs +567 -229
- package/src/tui/engine/tool-card-results.mjs +22 -5
- package/src/tui/engine.mjs +126 -7
- package/src/tui/index.jsx +4 -1
- package/src/workflows/default/WORKFLOW.md +27 -31
- package/vendor/ink/build/components/App.js +62 -17
- package/vendor/ink/build/ink.js +78 -3
- package/vendor/ink/build/input-parser.d.ts +9 -4
- package/vendor/ink/build/input-parser.js +45 -176
- package/vendor/ink/build/log-update.js +47 -2
- package/vendor/ink/build/termio-keypress.js +240 -0
- package/vendor/ink/build/termio-tokenize.js +253 -0
|
@@ -669,6 +669,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
669
669
|
} else if (event.response.status === 'incomplete') {
|
|
670
670
|
const reason = incompleteReasonFromResponsesEvent(event);
|
|
671
671
|
if (isMaxOutputIncompleteReason(reason)) {
|
|
672
|
+
// Max-output cutoff with a tool call still in flight means
|
|
673
|
+
// the function-call arguments were truncated — do NOT mark
|
|
674
|
+
// this a clean completion, or partial args surface as a
|
|
675
|
+
// successful tool call. Treat as unsafe/partial instead.
|
|
676
|
+
if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
|
|
677
|
+
const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
|
|
678
|
+
err.streamStalled = true;
|
|
679
|
+
err.pendingToolUse = true;
|
|
680
|
+
err.partialContent = state.content || '';
|
|
681
|
+
throw err;
|
|
682
|
+
}
|
|
672
683
|
state.completed = true;
|
|
673
684
|
state.stopReason = 'length';
|
|
674
685
|
state.completedResponse = event.response || state.completedResponse;
|
|
@@ -686,6 +697,13 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
686
697
|
case 'response.incomplete': {
|
|
687
698
|
const reason = incompleteReasonFromResponsesEvent(event);
|
|
688
699
|
if (isMaxOutputIncompleteReason(reason)) {
|
|
700
|
+
if (state.toolInFlight || (state.pendingCalls && state.pendingCalls.size > 0)) {
|
|
701
|
+
const err = truncatedCompatStreamError(label, `incomplete (${reason}) with tool call in flight`);
|
|
702
|
+
err.streamStalled = true;
|
|
703
|
+
err.pendingToolUse = true;
|
|
704
|
+
err.partialContent = state.content || '';
|
|
705
|
+
throw err;
|
|
706
|
+
}
|
|
689
707
|
state.completed = true;
|
|
690
708
|
state.stopReason = 'length';
|
|
691
709
|
state.completedResponse = event.response || state.completedResponse;
|
|
@@ -113,7 +113,7 @@ async function _resolveCodexClientVersion() {
|
|
|
113
113
|
return CODEX_CLIENT_VERSION_FLOOR;
|
|
114
114
|
}
|
|
115
115
|
const CODEX_MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
|
|
116
|
-
const CODEX_MODEL_CACHE_SCHEMA_VERSION =
|
|
116
|
+
const CODEX_MODEL_CACHE_SCHEMA_VERSION = 3;
|
|
117
117
|
const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
118
118
|
|
|
119
119
|
const _codexModelCache = makeModelCache({
|
|
@@ -587,6 +587,10 @@ export async function _streamResponse({
|
|
|
587
587
|
// Partial-final recovery: attach streamed partial state so
|
|
588
588
|
// a wedged FINAL no-tool summary can be accepted as partial-final
|
|
589
589
|
// success by the loop. pendingToolUse gates out mid-flight tools.
|
|
590
|
+
// Fold the held leak-guard tail into `content` FIRST so the
|
|
591
|
+
// partial snapshot below keeps legitimate trailing text; finish()
|
|
592
|
+
// then skips flushLeak (terminalError set) without losing it.
|
|
593
|
+
flushLeak();
|
|
590
594
|
try {
|
|
591
595
|
terminalError.partialContent = content;
|
|
592
596
|
terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
|
|
@@ -631,9 +635,14 @@ export async function _streamResponse({
|
|
|
631
635
|
};
|
|
632
636
|
const finish = () => {
|
|
633
637
|
logReasoningDeltaSuppression();
|
|
638
|
+
// On a terminal error we must NOT flush buffered text/tool calls:
|
|
639
|
+
// finish() rejects below, so flushing would emit partial output the
|
|
640
|
+
// caller then never consumes as a clean result (double-render/
|
|
641
|
+
// double-dispatch risk). The partial-final path reads partialContent
|
|
642
|
+
// off the error instead. Only flush the held-back tail on success.
|
|
643
|
+
if (!terminalError) flushLeak();
|
|
634
644
|
// Flush any partial-sentinel tail held back mid-stream so
|
|
635
645
|
// legitimate trailing text is never lost (streamed-text path).
|
|
636
|
-
flushLeak();
|
|
637
646
|
cleanup();
|
|
638
647
|
if (terminalError) { reject(terminalError); return; }
|
|
639
648
|
resolve({
|
|
@@ -694,6 +703,12 @@ export async function _streamResponse({
|
|
|
694
703
|
// first-meaningful watchdog (keepalive/metadata frames do
|
|
695
704
|
// NOT reach this case, so they never clear it).
|
|
696
705
|
clearFirstMeaningfulWatchdog();
|
|
706
|
+
// Arm the semantic idle/stall timer from response.created
|
|
707
|
+
// too: it otherwise only arms on the first meaningful delta
|
|
708
|
+
// (bumpSemanticIdle), so a created+keepalive-only stream that
|
|
709
|
+
// never emits a delta would stall unbounded until the outer
|
|
710
|
+
// watchdog. Arming here bounds that gap.
|
|
711
|
+
resetSemanticIdle();
|
|
697
712
|
break;
|
|
698
713
|
case 'response.output_text.delta':
|
|
699
714
|
try {
|
|
@@ -748,6 +763,10 @@ export async function _streamResponse({
|
|
|
748
763
|
// response.output_item.done.
|
|
749
764
|
_toolInFlight = true;
|
|
750
765
|
}
|
|
766
|
+
// Item lifecycle is genuine progress: reset the semantic-idle
|
|
767
|
+
// timer so long server-side tool latency after item-added
|
|
768
|
+
// (before any arg delta) is not mistaken for a silent stall.
|
|
769
|
+
resetSemanticIdle();
|
|
751
770
|
break;
|
|
752
771
|
case 'response.function_call_arguments.delta':
|
|
753
772
|
_toolInFlight = true;
|
|
@@ -826,6 +845,9 @@ export async function _streamResponse({
|
|
|
826
845
|
if (event.item?.type === 'custom_tool_call') {
|
|
827
846
|
pushCustomToolCall(event.item);
|
|
828
847
|
}
|
|
848
|
+
// Item-done is genuine lifecycle progress — reset semantic
|
|
849
|
+
// idle so latency before the next item/args does not stall.
|
|
850
|
+
resetSemanticIdle();
|
|
829
851
|
break;
|
|
830
852
|
case 'response.completed': {
|
|
831
853
|
const completedServiceTier = event.response?.service_tier || event.response?.serviceTier || '';
|
|
@@ -1048,7 +1070,13 @@ export async function _streamResponse({
|
|
|
1048
1070
|
try { onStreamDelta?.(); } catch {}
|
|
1049
1071
|
bumpSemanticIdle();
|
|
1050
1072
|
}
|
|
1051
|
-
//
|
|
1073
|
+
// response.in_progress is a server lifecycle heartbeat during
|
|
1074
|
+
// long tool/generation latency — reset semantic idle so it is
|
|
1075
|
+
// not counted as silence (resetIdle already ran at the top).
|
|
1076
|
+
else if (event.type === 'response.in_progress') {
|
|
1077
|
+
resetSemanticIdle();
|
|
1078
|
+
}
|
|
1079
|
+
// Other trace-only events fall through.
|
|
1052
1080
|
break;
|
|
1053
1081
|
}
|
|
1054
1082
|
};
|
|
@@ -134,6 +134,25 @@ function _scopedDependencyRoots(toolName, args, cwd) {
|
|
|
134
134
|
}
|
|
135
135
|
if (rawPaths.length > 0) {
|
|
136
136
|
for (const p of rawPaths) add(p);
|
|
137
|
+
// `glob` results are gated on the pattern's static (non-magic) prefix,
|
|
138
|
+
// not just cwd/path root — a pattern like "src/**/*.mjs" must register
|
|
139
|
+
// "src", not just cwd, or edits under src/ that are not directly under
|
|
140
|
+
// the given path root will not invalidate the cached glob result.
|
|
141
|
+
if (toolName === 'glob' && canonicalArgs && typeof canonicalArgs.pattern !== 'undefined') {
|
|
142
|
+
const patterns = [];
|
|
143
|
+
_collectPathValues(canonicalArgs.pattern, patterns);
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
if (typeof pattern !== 'string' || !_hasGlobMagic(pattern)) continue;
|
|
146
|
+
const patternRoot = _extractGlobRoot(pattern);
|
|
147
|
+
if (_pathIsAbs(patternRoot)) {
|
|
148
|
+
add(patternRoot);
|
|
149
|
+
} else if (rawPaths.length > 0) {
|
|
150
|
+
for (const p of rawPaths) add(join(p, patternRoot));
|
|
151
|
+
} else {
|
|
152
|
+
add(patternRoot);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
137
156
|
} else if (cwd && typeof cwd === 'string') {
|
|
138
157
|
add(cwd);
|
|
139
158
|
}
|
|
@@ -22,11 +22,11 @@ export {
|
|
|
22
22
|
export const SUMMARY_PREFIX = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.';
|
|
23
23
|
export const SUMMARY_OUTPUT_TOKENS = 4_096;
|
|
24
24
|
// Unified context-share rule: every derived "how much of the model context
|
|
25
|
-
// may this budget take" ratio uses ONE number —
|
|
25
|
+
// may this budget take" ratio uses ONE number — 10%. Consumers:
|
|
26
26
|
// - compact target budget (loop/compact-policy.mjs COMPACT_TARGET_RATIO)
|
|
27
27
|
// - recall-fasttrack injection cap (loop.mjs recallTokenCap)
|
|
28
28
|
// Keep them in lockstep; do not fork per-consumer ratios without a decision.
|
|
29
|
-
export const CONTEXT_SHARE_RATIO = 0.
|
|
29
|
+
export const CONTEXT_SHARE_RATIO = 0.10;
|
|
30
30
|
// Floor for the recall-injection cap so small-context models still get a
|
|
31
31
|
// usable recall slice (cap = max(floor, contextWindow * CONTEXT_SHARE_RATIO)).
|
|
32
32
|
export const RECALL_TOKEN_CAP_FLOOR_TOKENS = 2_048;
|
|
@@ -672,7 +672,7 @@ function _recallFastTrackCompactMessages(messages, budgetTokens, opts = {}) {
|
|
|
672
672
|
};
|
|
673
673
|
// Recall injection room is capped separately from the mandatory
|
|
674
674
|
// (system + preserved tail) budget: recall text never gets more than
|
|
675
|
-
// opts.recallTokenCap (
|
|
675
|
+
// opts.recallTokenCap (10% of context window, floor 2048 tokens, set by
|
|
676
676
|
// the caller); the remaining budget stays reserved for live
|
|
677
677
|
// conversation. Only applied when the cap is a positive finite number,
|
|
678
678
|
// preserving current uncapped behavior otherwise.
|
|
@@ -253,21 +253,43 @@ export function fitCompactionPrompt(input, targetTokens) {
|
|
|
253
253
|
const buildReduced = (k) => {
|
|
254
254
|
const kept = k > 0 ? head.slice(head.length - k) : [];
|
|
255
255
|
const omitted = head.length - kept.length;
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
256
|
+
const finalize = (stubHead) => {
|
|
257
|
+
let inp = { ...baseNoFacts, head: stubHead };
|
|
258
|
+
// Also shrink/drop a prior <previous-summary> (same as the normal
|
|
259
|
+
// fitAt path) — a large prior summary can keep the prompt over
|
|
260
|
+
// budget even at K=0. fitPreviousSummaryForCompactionPrompt is a
|
|
261
|
+
// no-op when there is no previousSummary, so this is safe for the
|
|
262
|
+
// summary-less case.
|
|
263
|
+
if (estimateCompactionPromptTokens(inp, 0) > targetTokens) {
|
|
264
|
+
const fitted = fitPreviousSummaryForCompactionPrompt(inp, 0, targetTokens);
|
|
265
|
+
if (!fitted) return null;
|
|
266
|
+
inp = fitted;
|
|
267
|
+
if (estimateCompactionPromptTokens(inp, 0) > targetTokens) return null;
|
|
268
|
+
}
|
|
269
|
+
return buildCompactionPrompt(inp, 0);
|
|
270
|
+
};
|
|
271
|
+
if (omitted <= 0) return finalize(kept);
|
|
272
|
+
// The omitted head messages never reappear in the session afterward
|
|
273
|
+
// (the caller replaces the whole head with the produced summary), so
|
|
274
|
+
// a bare "[N older messages omitted]" stub used to discard their
|
|
275
|
+
// content with zero trace. Prefer a compact per-message digest line
|
|
276
|
+
// for each omitted message so at least a sliver of detail survives
|
|
277
|
+
// into the summary input; only fall back to the count-only stub if
|
|
278
|
+
// even the digest cannot fit the emergency budget, preserving the
|
|
279
|
+
// original guarantee that this reduction always finds a fit.
|
|
280
|
+
const digestLines = head.slice(0, omitted).map((m, i) => {
|
|
281
|
+
const role = m?.role || 'unknown';
|
|
282
|
+
const text = truncateMiddle(extractText(m).trim(), 30);
|
|
283
|
+
return text ? `${i + 1}. ${role}: ${text}` : `${i + 1}. ${role}`;
|
|
284
|
+
});
|
|
285
|
+
const digestStub = {
|
|
286
|
+
role: 'user',
|
|
287
|
+
content: [`[${omitted} older messages compacted to a digest below]`, ...digestLines].join('\n'),
|
|
288
|
+
};
|
|
289
|
+
const withDigest = finalize([digestStub, ...kept]);
|
|
290
|
+
if (withDigest) return withDigest;
|
|
291
|
+
const countStub = { role: 'user', content: `[${omitted} older messages omitted]` };
|
|
292
|
+
return finalize([countStub, ...kept]);
|
|
271
293
|
};
|
|
272
294
|
let lo = 0;
|
|
273
295
|
let hi = head.length;
|
|
@@ -641,3 +641,58 @@ export function sanitizeAnthropicContentPairs(messages) {
|
|
|
641
641
|
}
|
|
642
642
|
return out;
|
|
643
643
|
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Fold a plain user text turn into the trailing tool_result block of the
|
|
647
|
+
* previous user message (Claude Code parity: utils/messages.ts
|
|
648
|
+
* mergeUserContentBlocks / smooshIntoToolResult). Any sibling text after a
|
|
649
|
+
* tool_result renders as `</function_results>\n\nHuman:<...>` on the
|
|
650
|
+
* Anthropic wire; repeated mid-conversation this teaches the model to emit
|
|
651
|
+
* 3-token empty end_turn completions (upstream A/B sai-20260310-161901:
|
|
652
|
+
* 92% → 0% after smooshing). Observed in mixdog as the empty-turn nudge
|
|
653
|
+
* livelock: each contract nudge was pushed as its own user turn right after
|
|
654
|
+
* a tool_result turn, reinforcing the empty-completion pattern.
|
|
655
|
+
*
|
|
656
|
+
* Returns true when the text was folded (caller must NOT push the message);
|
|
657
|
+
* false when the message must keep its own turn (no tool_result tail,
|
|
658
|
+
* tool_reference result, or non-text content such as images).
|
|
659
|
+
*/
|
|
660
|
+
export function foldUserTextIntoToolResultTail(result, content) {
|
|
661
|
+
const last = result[result.length - 1];
|
|
662
|
+
if (last?.role !== 'user' || !Array.isArray(last.content) || last.content.length === 0) return false;
|
|
663
|
+
const tail = last.content[last.content.length - 1];
|
|
664
|
+
if (tail?.type !== 'tool_result') return false;
|
|
665
|
+
// tool_reference results must keep their exact shape — leave as sibling.
|
|
666
|
+
if (Array.isArray(tail.content) && tail.content.some((b) => b?.type === 'tool_reference')) return false;
|
|
667
|
+
// Only fold pure text (string or all-text blocks). Images/documents keep
|
|
668
|
+
// their own user turn.
|
|
669
|
+
let texts;
|
|
670
|
+
if (typeof content === 'string') {
|
|
671
|
+
texts = content.trim() ? [content.trim()] : [];
|
|
672
|
+
} else if (Array.isArray(content) && content.every((b) => b?.type === 'text' && typeof b.text === 'string')) {
|
|
673
|
+
texts = content.map((b) => b.text.trim()).filter(Boolean);
|
|
674
|
+
} else {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
if (texts.length === 0) return true; // empty text turn — drop it entirely
|
|
678
|
+
const joined = texts.join('\n\n');
|
|
679
|
+
if (typeof tail.content === 'string') {
|
|
680
|
+
last.content[last.content.length - 1] = {
|
|
681
|
+
...tail,
|
|
682
|
+
content: tail.content.trim() ? `${tail.content}\n\n${joined}` : joined,
|
|
683
|
+
};
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
if (Array.isArray(tail.content)) {
|
|
687
|
+
const blocks = tail.content.slice();
|
|
688
|
+
const prev = blocks[blocks.length - 1];
|
|
689
|
+
if (prev?.type === 'text' && typeof prev.text === 'string') {
|
|
690
|
+
blocks[blocks.length - 1] = { ...prev, text: `${prev.text}\n\n${joined}` };
|
|
691
|
+
} else {
|
|
692
|
+
blocks.push({ type: 'text', text: joined });
|
|
693
|
+
}
|
|
694
|
+
last.content[last.content.length - 1] = { ...tail, content: blocks };
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap, bounded top-level-only scan for the `closed`/`generation`
|
|
3
|
+
* lifecycle fields in a serialized session JSON document — a substitute
|
|
4
|
+
* for JSON.parse on the session-store save-guard hot path.
|
|
5
|
+
*
|
|
6
|
+
* Why not JSON.parse: `generation` is a top-level field on every persisted
|
|
7
|
+
* session (added by _ensureLifecycleFields on first save), so a raw
|
|
8
|
+
* substring pre-check (`raw.includes('"generation"')`) is true for nearly
|
|
9
|
+
* every guarded write — paying full-parse-and-allocate cost (building the
|
|
10
|
+
* entire messages array, unescaping every string) on the hot path even
|
|
11
|
+
* though only two scalar fields are ever consulted.
|
|
12
|
+
*
|
|
13
|
+
* Why not a regex-only check: a stored message body can itself contain the
|
|
14
|
+
* literal text `{"closed":true}` (a tool result, a pasted JSON blob, an
|
|
15
|
+
* assistant-authored snippet) inside `messages[i].content`. A substring or
|
|
16
|
+
* naively-anchored regex cannot tell that occurrence apart from the real
|
|
17
|
+
* top-level lifecycle field, so it is spoofable. This scanner walks the raw
|
|
18
|
+
* text as a real (bracket-depth + string-escape aware) tokenizer, but only
|
|
19
|
+
* *interprets* key/value pairs at depth 1 (directly inside the root
|
|
20
|
+
* object); every nested object/array/string value — including the entire
|
|
21
|
+
* `messages` array — is skipped by depth counting alone, never allocated
|
|
22
|
+
* or parsed. That keeps it both spoof-proof (nested `"closed"` can never be
|
|
23
|
+
* mistaken for the top-level one) and cheap (no JS object/array/string
|
|
24
|
+
* allocation for content we don't care about).
|
|
25
|
+
*
|
|
26
|
+
* Returns `{ closed, generation }` (either key absent if the field wasn't
|
|
27
|
+
* present at depth 1) or `null` if `raw` is not a well-formed top-level
|
|
28
|
+
* JSON object (caller should treat that the same as a parse failure).
|
|
29
|
+
*
|
|
30
|
+
* Scope contract: this is not a general JSON validator. `raw` is always our
|
|
31
|
+
* own JSON.stringify output (or truncated/concatenated fragments thereof
|
|
32
|
+
* from partial writes) — never hand-authored or third-party JSON. It is
|
|
33
|
+
* only responsible for detecting the failure modes a writer like ours can
|
|
34
|
+
* actually produce: mid-write truncation, nested-field spoofing, mismatched
|
|
35
|
+
* brackets, and trailing garbage from a botched/partial write. It does NOT
|
|
36
|
+
* validate separator-level well-formedness (missing/trailing commas,
|
|
37
|
+
* missing colons beyond the one check above, etc.) since JSON.stringify
|
|
38
|
+
* cannot emit those — a well-formed prefix is assumed between structural
|
|
39
|
+
* checks. Anything the scanner doesn't positively confirm returns `null`,
|
|
40
|
+
* and the caller falls back to `JSON.parse` for the definitive answer.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
function isWs(ch) {
|
|
44
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// True iff raw[i..] is nothing but whitespace to EOF. Enforces the "single
|
|
48
|
+
// complete root JSON object" contract: trailing garbage after the root `}`
|
|
49
|
+
// (e.g. a concatenated second document, or corruption) must not be silently
|
|
50
|
+
// accepted as if `raw` were exactly one well-formed object.
|
|
51
|
+
function isTrailingWhitespaceOnly(raw, i) {
|
|
52
|
+
const len = raw.length;
|
|
53
|
+
while (i < len) {
|
|
54
|
+
if (!isWs(raw[i])) return false;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Advances past a JSON string literal starting at raw[i] === '"'.
|
|
61
|
+
// Escape handling only needs to skip exactly one char after each `\` —
|
|
62
|
+
// even for `\u0041`-style escapes, the trailing hex digits can never be
|
|
63
|
+
// `"` or `\`, so a naive 2-char skip never misses the real closing quote.
|
|
64
|
+
function skipString(raw, i) {
|
|
65
|
+
const len = raw.length;
|
|
66
|
+
i++; // opening quote
|
|
67
|
+
while (i < len) {
|
|
68
|
+
const ch = raw[i];
|
|
69
|
+
if (ch === '\\') { i += 2; continue; }
|
|
70
|
+
if (ch === '"') return i + 1;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
return i;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Advances past one JSON value (string/object/array/number/true/false/null)
|
|
77
|
+
// starting at raw[i]. Objects/arrays are skipped via universal bracket-depth
|
|
78
|
+
// counting (any `{`/`[` opens, any `}`/`]` closes) with string-awareness so
|
|
79
|
+
// braces inside string content never perturb the count — never descends
|
|
80
|
+
// into the structure to interpret its keys.
|
|
81
|
+
function skipValue(raw, i) {
|
|
82
|
+
const len = raw.length;
|
|
83
|
+
const c = raw[i];
|
|
84
|
+
if (c === '"') return skipString(raw, i);
|
|
85
|
+
if (c === '{' || c === '[') {
|
|
86
|
+
let depth = 1;
|
|
87
|
+
i++;
|
|
88
|
+
while (i < len && depth > 0) {
|
|
89
|
+
const ch = raw[i];
|
|
90
|
+
if (ch === '"') { i = skipString(raw, i); continue; }
|
|
91
|
+
if (ch === '{' || ch === '[') depth++;
|
|
92
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
return i;
|
|
96
|
+
}
|
|
97
|
+
// number / true / false / null — run to the next structural delimiter.
|
|
98
|
+
while (i < len && raw[i] !== ',' && raw[i] !== '}' && raw[i] !== ']' && !isWs(raw[i])) i++;
|
|
99
|
+
return i;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function scanTopLevelLifecycle(raw) {
|
|
103
|
+
const len = raw.length;
|
|
104
|
+
let i = 0;
|
|
105
|
+
while (i < len && isWs(raw[i])) i++;
|
|
106
|
+
if (raw[i] !== '{') return null;
|
|
107
|
+
i++;
|
|
108
|
+
const result = {};
|
|
109
|
+
let found = 0;
|
|
110
|
+
while (i < len) {
|
|
111
|
+
while (i < len && isWs(raw[i])) i++;
|
|
112
|
+
if (i >= len) return null;
|
|
113
|
+
if (raw[i] === '}') {
|
|
114
|
+
i++;
|
|
115
|
+
return isTrailingWhitespaceOnly(raw, i) ? result : null;
|
|
116
|
+
}
|
|
117
|
+
if (raw[i] === ',') { i++; continue; }
|
|
118
|
+
if (raw[i] !== '"') return null;
|
|
119
|
+
const keyStart = i;
|
|
120
|
+
i = skipString(raw, i);
|
|
121
|
+
let key;
|
|
122
|
+
try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return null; }
|
|
123
|
+
while (i < len && isWs(raw[i])) i++;
|
|
124
|
+
if (raw[i] !== ':') return null;
|
|
125
|
+
i++;
|
|
126
|
+
while (i < len && isWs(raw[i])) i++;
|
|
127
|
+
if (i >= len) return null;
|
|
128
|
+
if (key === 'closed' || key === 'generation') {
|
|
129
|
+
const valStart = i;
|
|
130
|
+
i = skipValue(raw, i);
|
|
131
|
+
try { result[key] = JSON.parse(raw.slice(valStart, i)); } catch { return null; }
|
|
132
|
+
found++;
|
|
133
|
+
// Both lifecycle fields resolved. We no longer need to interpret
|
|
134
|
+
// further keys, but we MUST still confirm the root object is not
|
|
135
|
+
// truncated (e.g. a session file cut off mid-write as
|
|
136
|
+
// `{"closed":true,"generation":1,"messages":[`) before trusting
|
|
137
|
+
// `result` — otherwise a truncated-but-field-bearing prefix would
|
|
138
|
+
// be treated as well-formed. Finish with a cheap depth-only walk
|
|
139
|
+
// (no key/value JSON.parse) to the matching close of the root
|
|
140
|
+
// object; only return `result` once that close is actually
|
|
141
|
+
// reached before EOF.
|
|
142
|
+
if (found === 2) {
|
|
143
|
+
// Track exact bracket types (not just depth) so a mismatched
|
|
144
|
+
// pair anywhere — including the root itself, e.g. malformed
|
|
145
|
+
// `{"closed":true,"generation":1]` closing the root object
|
|
146
|
+
// with `]` instead of `}` — is rejected rather than silently
|
|
147
|
+
// accepted because *a* bracket happened to bring the count to
|
|
148
|
+
// zero. `stack[0]` is always '}' (the root object we opened
|
|
149
|
+
// at function entry); the walk must consume exactly that to
|
|
150
|
+
// finish, and any closer that doesn't match the innermost
|
|
151
|
+
// opener is a structural error.
|
|
152
|
+
const stack = ['}'];
|
|
153
|
+
while (i < len) {
|
|
154
|
+
const ch = raw[i];
|
|
155
|
+
if (ch === '"') { i = skipString(raw, i); continue; }
|
|
156
|
+
if (ch === '{') { stack.push('}'); i++; continue; }
|
|
157
|
+
if (ch === '[') { stack.push(']'); i++; continue; }
|
|
158
|
+
if (ch === '}' || ch === ']') {
|
|
159
|
+
if (stack.pop() !== ch) return null; // mismatched bracket pair
|
|
160
|
+
i++;
|
|
161
|
+
if (stack.length === 0) {
|
|
162
|
+
// Root object closed with '}' — only trust it if
|
|
163
|
+
// nothing but whitespace follows to EOF.
|
|
164
|
+
return isTrailingWhitespaceOnly(raw, i) ? result : null;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
return null; // ran off the end (or hit EOF) before the root object closed: truncated
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
i = skipValue(raw, i);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null; // ran off the end without a closing '}': malformed/truncated
|
|
177
|
+
}
|
|
@@ -10,29 +10,29 @@ import {
|
|
|
10
10
|
import {
|
|
11
11
|
compactTypeIsRecallFastTrack,
|
|
12
12
|
compactTypeIsSemantic,
|
|
13
|
-
normalizeCompactType,
|
|
14
13
|
DEFAULT_COMPACT_TYPE,
|
|
15
14
|
DEFAULT_COMPACTION_KEEP_TOKENS,
|
|
16
15
|
CONTEXT_SHARE_RATIO,
|
|
16
|
+
COMPACT_TYPE_RECALL_FASTTRACK,
|
|
17
17
|
} from '../compact.mjs';
|
|
18
18
|
import { positiveTokenInt, envFlag, envTokenInt } from './env.mjs';
|
|
19
19
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
20
20
|
|
|
21
21
|
const COMPACT_SAFETY_PERCENT = 1.00;
|
|
22
22
|
// Unified context-share rule (compact/constants.mjs CONTEXT_SHARE_RATIO): the
|
|
23
|
-
// post-compaction target is
|
|
23
|
+
// post-compaction target is 10% of the boundary/context window — the same 10%
|
|
24
24
|
// the recall-fasttrack injection cap uses (loop.mjs recallTokenCap). One
|
|
25
25
|
// number governs every "share of model context" budget.
|
|
26
26
|
const COMPACT_TARGET_RATIO = CONTEXT_SHARE_RATIO;
|
|
27
27
|
const COMPACT_TARGET_MIN_TOKENS = 4_000;
|
|
28
|
-
const COMPACT_TARGET_MAX_TOKENS = 16_000;
|
|
29
28
|
|
|
30
29
|
function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
30
|
+
// Types are hard-locked (agent -> semantic, main/user -> recall-fasttrack).
|
|
31
|
+
// Semantic must always be available as a compact path or agent sessions
|
|
32
|
+
// would have none (loop.mjs throws when no type is available). Env/cfg
|
|
33
|
+
// off-switches no longer apply.
|
|
34
|
+
void sessionRef;
|
|
35
|
+
void cfg;
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -43,12 +43,9 @@ function resolveCompactTypeSetting(sessionRef, cfg = {}) {
|
|
|
43
43
|
// fasttrack compact would inject unrelated main-session memories and drop
|
|
44
44
|
// the agent's own working context. Env/config overrides do not apply.
|
|
45
45
|
if (isAgentOwner(sessionRef)) return DEFAULT_COMPACT_TYPE;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
?? cfg.compactType
|
|
50
|
-
?? cfg.compact_type;
|
|
51
|
-
return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
|
|
46
|
+
// Non-agent (main/user) sessions are ALWAYS recall-fasttrack. Hard-locked:
|
|
47
|
+
// config/env overrides no longer change the type.
|
|
48
|
+
return COMPACT_TYPE_RECALL_FASTTRACK;
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
function resolveCompactTargetRatio(cfg = {}) {
|
|
@@ -74,12 +71,8 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
|
|
|
74
71
|
|| envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
|
|
75
72
|
|| envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
|
|
76
73
|
|| COMPACT_TARGET_MIN_TOKENS);
|
|
77
|
-
const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
|
|
78
|
-
|| envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
|
|
79
|
-
|| envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
|
|
80
|
-
|| COMPACT_TARGET_MAX_TOKENS);
|
|
81
74
|
const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
|
|
82
|
-
return Math.max(1, Math.min(boundary,
|
|
75
|
+
return Math.max(1, Math.min(boundary, Math.max(minTarget, byRatio)));
|
|
83
76
|
}
|
|
84
77
|
function resolveCompactKeepTokens(cfg = {}) {
|
|
85
78
|
return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
|
|
@@ -10,6 +10,8 @@ import { createHash } from 'crypto';
|
|
|
10
10
|
import { executeInternalTool } from '../../internal-tools.mjs';
|
|
11
11
|
import {
|
|
12
12
|
recallFastTrackCompactMessages,
|
|
13
|
+
CONTEXT_SHARE_RATIO,
|
|
14
|
+
RECALL_TOKEN_CAP_FLOOR_TOKENS,
|
|
13
15
|
} from '../compact.mjs';
|
|
14
16
|
import {
|
|
15
17
|
compactDiagnosticError,
|
|
@@ -146,6 +148,13 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
|
|
|
146
148
|
const digestText = buildRecallDigestText(sessionId, digestBody, digestMaxKb);
|
|
147
149
|
diagnostics.finalRecallChars = digestText.length;
|
|
148
150
|
diagnostics.finalRecallBytes = compactByteLength(digestText);
|
|
151
|
+
const contextWindow = positiveTokenInt(compactPolicy?.contextWindow)
|
|
152
|
+
|| positiveTokenInt(compactPolicy?.boundaryTokens)
|
|
153
|
+
|| positiveTokenInt(sessionRef?.contextWindow)
|
|
154
|
+
|| positiveTokenInt(sessionRef?.compactBoundaryTokens);
|
|
155
|
+
const recallTokenCap = contextWindow
|
|
156
|
+
? Math.max(RECALL_TOKEN_CAP_FLOOR_TOKENS, Math.floor(contextWindow * CONTEXT_SHARE_RATIO))
|
|
157
|
+
: null;
|
|
149
158
|
const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
|
|
150
159
|
reserveTokens: compactPolicy.reserveTokens,
|
|
151
160
|
force: true,
|
|
@@ -156,6 +165,7 @@ export async function runRecallFastTrackCompact({ sessionRef, messages, compactB
|
|
|
156
165
|
tailTurns: compactPolicy.tailTurns,
|
|
157
166
|
keepTokens: compactPolicy.keepTokens,
|
|
158
167
|
preserveRecentTokens: compactPolicy.preserveRecentTokens,
|
|
168
|
+
recallTokenCap,
|
|
159
169
|
});
|
|
160
170
|
diagnostics.totalMs = Date.now() - startedAt;
|
|
161
171
|
if (result && typeof result === 'object') {
|
|
@@ -243,6 +243,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
243
243
|
let _capFinalTurnUsed = false;
|
|
244
244
|
// True while the granted hard-cap final turn is active (no tool defs).
|
|
245
245
|
let _capFinalToolsDisabled = false;
|
|
246
|
+
// Consecutive empty-turn contract nudges. A model that answers the same
|
|
247
|
+
// nudge with another empty turn is in a deterministic livelock (same
|
|
248
|
+
// context in → same empty completion out); re-sending an identical nudge
|
|
249
|
+
// 199× just burns the iteration budget (observed: sess_10400…9dfdc436,
|
|
250
|
+
// 199 identical nudges to the 200-iteration cap). Cap the streak and end
|
|
251
|
+
// the loop as an explicit empty termination instead.
|
|
252
|
+
let _emptyNudgeStreak = 0;
|
|
253
|
+
const EMPTY_NUDGE_MAX = 3;
|
|
246
254
|
// Completion-first steering ladder controller. Owns the (cumulative) level-1
|
|
247
255
|
// fire count, the all-read-only / serial-single / same-file-grep streaks,
|
|
248
256
|
// and the level-2 latch. Threaded via live getters so it reads the loop's
|
|
@@ -1158,17 +1166,30 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1158
1166
|
continue;
|
|
1159
1167
|
}
|
|
1160
1168
|
if (!hasContent && !isHidden) {
|
|
1169
|
+
_emptyNudgeStreak += 1;
|
|
1170
|
+
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
1171
|
+
// Livelock: identical nudges keep producing identical empty
|
|
1172
|
+
// completions. Stop re-prompting; classifyTerminationReason
|
|
1173
|
+
// tags this final empty response as 'empty' so the caller
|
|
1174
|
+
// surfaces an explicit error instead of a silent finish.
|
|
1175
|
+
process.stderr.write(`[loop] empty-turn nudge cap ${EMPTY_NUDGE_MAX} reached (sess=${sessionId || 'unknown'}); ending loop as empty termination.\n`);
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1161
1178
|
let nudgeMsg;
|
|
1162
1179
|
if (isIncompleteStop) {
|
|
1163
1180
|
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
|
|
1164
1181
|
} else {
|
|
1165
|
-
|
|
1182
|
+
// Vary the nudge per attempt — a byte-identical repeat
|
|
1183
|
+
// reinforces the empty-completion pattern it is trying to
|
|
1184
|
+
// break (the request context stays effectively constant).
|
|
1185
|
+
nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
|
|
1166
1186
|
}
|
|
1167
1187
|
messages.push({ role: 'user', content: nudgeMsg });
|
|
1168
1188
|
continue;
|
|
1169
1189
|
}
|
|
1170
1190
|
break;
|
|
1171
1191
|
}
|
|
1192
|
+
_emptyNudgeStreak = 0;
|
|
1172
1193
|
const calls = response.toolCalls;
|
|
1173
1194
|
toolCallsTotal += calls.length;
|
|
1174
1195
|
// Surface any mid-turn assistant text (preamble that precedes a tool
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
effectiveBudget as compactEffectiveBudget,
|
|
13
13
|
compactTypeIsRecallFastTrack,
|
|
14
14
|
compactTypeIsSemantic,
|
|
15
|
+
CONTEXT_SHARE_RATIO,
|
|
16
|
+
RECALL_TOKEN_CAP_FLOOR_TOKENS,
|
|
15
17
|
} from '../compact.mjs';
|
|
16
18
|
import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactBufferRatio } from '../context-utils.mjs';
|
|
17
19
|
import { executeInternalTool } from '../../internal-tools.mjs';
|
|
@@ -195,6 +197,11 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
195
197
|
if (compactTypeIsRecallFastTrack(compactType)) {
|
|
196
198
|
try {
|
|
197
199
|
const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
|
|
200
|
+
const contextWindow = positiveContextWindow(session.contextWindow) || boundary;
|
|
201
|
+
const recallTokenCap = Math.max(
|
|
202
|
+
RECALL_TOKEN_CAP_FLOOR_TOKENS,
|
|
203
|
+
Math.floor(contextWindow * CONTEXT_SHARE_RATIO),
|
|
204
|
+
);
|
|
198
205
|
recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
|
|
199
206
|
reserveTokens,
|
|
200
207
|
force: true,
|
|
@@ -209,6 +216,7 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
209
216
|
tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
|
|
210
217
|
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
|
|
211
218
|
preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
|
|
219
|
+
recallTokenCap,
|
|
212
220
|
});
|
|
213
221
|
if (Array.isArray(recallFastTrackResult?.messages)) {
|
|
214
222
|
compacted = recallFastTrackResult.messages;
|