mixdog 0.9.39 → 0.9.41
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 +2 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
- package/scripts/compact-pressure-test.mjs +128 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/internal-comms-smoke.mjs +66 -25
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +222 -0
- package/scripts/provider-toolcall-test.mjs +32 -0
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
- package/src/workflows/bench/WORKFLOW.md +51 -27
- package/src/workflows/default/WORKFLOW.md +29 -24
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// runRecallFastTrackCompact stays in the loop (it drives the recall pipeline
|
|
4
4
|
// against live session state).
|
|
5
5
|
import {
|
|
6
|
+
estimateMessagesTokens,
|
|
6
7
|
estimateRequestReserveTokens,
|
|
7
8
|
resolveSessionCompactPolicy,
|
|
8
9
|
} from '../context-utils.mjs';
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from '../compact.mjs';
|
|
19
20
|
import { positiveTokenInt, envFlag, envTokenInt } from './env.mjs';
|
|
20
21
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
22
|
+
import { providerInputExcludesCache } from '../../providers/registry.mjs';
|
|
21
23
|
|
|
22
24
|
// Unified context-share rule (compact/constants.mjs CONTEXT_SHARE_RATIO): the
|
|
23
25
|
// post-compaction target is 10% of the boundary/context window — the same 10%
|
|
@@ -91,8 +93,8 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
|
|
|
91
93
|
if (!boundaryTokens) return null;
|
|
92
94
|
const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
|
|
93
95
|
// Shared session-compaction policy (context-utils): agent semantic keeps the
|
|
94
|
-
// default early-trigger buffer (90%); main/user
|
|
95
|
-
//
|
|
96
|
+
// default early-trigger buffer (90%); main/user keep 5% headroom (95%);
|
|
97
|
+
// a truly-explicit sub-boundary limit wins. explicitAutoCompactTokenLimit
|
|
96
98
|
// is the sanitized (null when legacy full-window) value so telemetry never
|
|
97
99
|
// re-persists a boundary-collapsing limit.
|
|
98
100
|
const policy = resolveSessionCompactPolicy(sessionRef, compactBoundaryTokens);
|
|
@@ -132,15 +134,103 @@ export function resolveWorkerCompactPolicy(sessionRef, tools) {
|
|
|
132
134
|
configuredReserveTokens: configuredReserve,
|
|
133
135
|
};
|
|
134
136
|
}
|
|
135
|
-
/** Transcript + request reserve
|
|
137
|
+
/** Transcript + request reserve fallback used until an aligned provider baseline exists. */
|
|
136
138
|
function compactPressureTokens(messageTokensEst, policy) {
|
|
137
139
|
if (messageTokensEst === null) return 0;
|
|
138
140
|
return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
function providerPressureTokens(sessionRef, usage) {
|
|
144
|
+
if (!usage || typeof usage !== 'object') return 0;
|
|
145
|
+
const input = Math.max(0, Number(usage.inputTokens) || 0);
|
|
146
|
+
const cachedRead = Math.max(0, Number(usage.cachedTokens) || 0);
|
|
147
|
+
const cacheWrite = Math.max(0, Number(usage.cacheWriteTokens) || 0);
|
|
148
|
+
const explicitPrompt = Math.max(0, Number(usage.promptTokens) || 0);
|
|
149
|
+
const normalizedPrompt = providerInputExcludesCache(sessionRef?.provider)
|
|
150
|
+
? input + cachedRead + cacheWrite
|
|
151
|
+
: input;
|
|
152
|
+
const prompt = Math.max(explicitPrompt, normalizedPrompt);
|
|
153
|
+
const output = Math.max(0, Number(usage.outputTokens) || 0);
|
|
154
|
+
return Math.max(0, Math.round(prompt + output));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Align an authoritative provider usage snapshot to the message prefix it
|
|
159
|
+
* covers. Later pressure checks add estimates only for messages after this
|
|
160
|
+
* baseline, matching Claude Code's actual-usage-plus-growth accounting.
|
|
161
|
+
*/
|
|
162
|
+
export function recordProviderContextBaseline(sessionRef, messages, usage, { boundary = 'complete' } = {}) {
|
|
163
|
+
if (!sessionRef || !Array.isArray(messages)) return false;
|
|
164
|
+
const tokens = providerPressureTokens(sessionRef, usage);
|
|
165
|
+
if (!tokens) return false;
|
|
166
|
+
sessionRef.contextPressureBaselineTokens = tokens;
|
|
167
|
+
sessionRef.contextPressureBaselineOutputTokens = Math.max(0, Math.round(Number(usage?.outputTokens) || 0));
|
|
168
|
+
sessionRef.contextPressureBaselineMessageCount = messages.length;
|
|
169
|
+
// provider_send usage arrives before the response's assistant message is
|
|
170
|
+
// appended. Mark that request boundary so pressure resolution skips the
|
|
171
|
+
// first subsequent assistant representation: its output (including opaque
|
|
172
|
+
// reasoningItems/tool calls) is already authoritative provider usage.
|
|
173
|
+
sessionRef.contextPressureBaselineBoundary = boundary === 'request' ? 'request' : 'complete';
|
|
174
|
+
sessionRef.contextPressureBaselineUpdatedAt = Date.now();
|
|
175
|
+
sessionRef.lastContextTokensStaleAfterCompact = false;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A changed transcript cannot reuse usage measured against its old prefix. */
|
|
180
|
+
export function invalidateProviderContextBaseline(sessionRef) {
|
|
181
|
+
if (!sessionRef) return;
|
|
182
|
+
sessionRef.contextPressureBaselineTokens = null;
|
|
183
|
+
sessionRef.contextPressureBaselineOutputTokens = null;
|
|
184
|
+
sessionRef.contextPressureBaselineMessageCount = null;
|
|
185
|
+
sessionRef.contextPressureBaselineBoundary = null;
|
|
186
|
+
sessionRef.contextPressureBaselineUpdatedAt = null;
|
|
187
|
+
sessionRef.lastContextTokensStaleAfterCompact = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function providerBaselinePressureTokens(messages, sessionRef) {
|
|
191
|
+
if (!Array.isArray(messages) || !sessionRef
|
|
192
|
+
|| sessionRef.lastContextTokensStaleAfterCompact === true) return null;
|
|
193
|
+
let tokens = positiveTokenInt(sessionRef.contextPressureBaselineTokens);
|
|
194
|
+
const outputTokens = Math.max(0, Number(sessionRef.contextPressureBaselineOutputTokens) || 0);
|
|
195
|
+
let count = Number(sessionRef.contextPressureBaselineMessageCount);
|
|
196
|
+
const baselineAt = Number(sessionRef.contextPressureBaselineUpdatedAt || 0);
|
|
197
|
+
const compactAt = Number(sessionRef.compaction?.lastChangedAt || sessionRef.compaction?.lastCompactAt || 0);
|
|
198
|
+
if (!tokens || !Number.isInteger(count) || count < 0 || count > messages.length
|
|
199
|
+
|| (compactAt > 0 && baselineAt > 0 && baselineAt < compactAt)) return null;
|
|
200
|
+
if (sessionRef.contextPressureBaselineBoundary === 'request') {
|
|
201
|
+
const assistantOffset = messages.slice(count).findIndex(message => message?.role === 'assistant');
|
|
202
|
+
if (assistantOffset >= 0) {
|
|
203
|
+
// The represented assistant is covered by actual output usage.
|
|
204
|
+
count += assistantOffset + 1;
|
|
205
|
+
} else {
|
|
206
|
+
// Empty/thinking-only continuations append no assistant replay.
|
|
207
|
+
// Their output was billed but is absent from the next request, so
|
|
208
|
+
// remove it and estimate every genuinely later message (the nudge).
|
|
209
|
+
tokens = Math.max(0, tokens - outputTokens);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const growth = count < messages.length
|
|
214
|
+
? estimateMessagesTokens(messages.slice(count))
|
|
215
|
+
: 0;
|
|
216
|
+
return Math.max(0, tokens + growth);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef } = {}) {
|
|
223
|
+
return providerBaselinePressureTokens(messages, sessionRef)
|
|
224
|
+
?? compactPressureTokens(messageTokensEst, policy);
|
|
225
|
+
}
|
|
226
|
+
|
|
141
227
|
/** Telemetry pressure when a reactive overflow retry forces the next compact. */
|
|
142
|
-
export function compactionTelemetryPressureTokens(messageTokensEst, policy, {
|
|
143
|
-
|
|
228
|
+
export function compactionTelemetryPressureTokens(messageTokensEst, policy, {
|
|
229
|
+
reactivePending = false,
|
|
230
|
+
messages,
|
|
231
|
+
sessionRef,
|
|
232
|
+
} = {}) {
|
|
233
|
+
const base = resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef });
|
|
144
234
|
if (!reactivePending) return base;
|
|
145
235
|
const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
|
|
146
236
|
return floor ? Math.max(base, floor) : base;
|
|
@@ -152,11 +242,19 @@ export function compactTargetBudget(policy) {
|
|
|
152
242
|
const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
|
|
153
243
|
return Math.max(1, Math.min(boundary, targetEffective + reserve));
|
|
154
244
|
}
|
|
155
|
-
export function shouldCompactForSession(messageTokensEst, policy, {
|
|
245
|
+
export function shouldCompactForSession(messageTokensEst, policy, {
|
|
246
|
+
forceReactive = false,
|
|
247
|
+
messages,
|
|
248
|
+
sessionRef,
|
|
249
|
+
pressureTokens,
|
|
250
|
+
} = {}) {
|
|
156
251
|
if (!policy?.auto || !policy.boundaryTokens) return false;
|
|
157
252
|
if (forceReactive) return true;
|
|
158
253
|
if (messageTokensEst === null) return true;
|
|
159
|
-
|
|
254
|
+
const pressure = Number.isFinite(Number(pressureTokens))
|
|
255
|
+
? Number(pressureTokens)
|
|
256
|
+
: resolveCompactionPressureTokens(messageTokensEst, policy, { messages, sessionRef });
|
|
257
|
+
return pressure >= (policy.triggerTokens || policy.boundaryTokens);
|
|
160
258
|
}
|
|
161
259
|
export function countPrunedToolOutputs(before, after) {
|
|
162
260
|
if (!Array.isArray(before) || !Array.isArray(after)) return 0;
|
|
@@ -228,7 +326,7 @@ export function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
|
|
|
228
326
|
const changedAt = Date.now();
|
|
229
327
|
sessionRef.compaction.lastChangedAt = changedAt;
|
|
230
328
|
sessionRef.compaction.lastCompactAt = changedAt;
|
|
231
|
-
sessionRef
|
|
329
|
+
invalidateProviderContextBaseline(sessionRef);
|
|
232
330
|
}
|
|
233
331
|
sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
|
|
234
332
|
sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
|
|
@@ -146,6 +146,14 @@ export function createSteeringLadder(ctx) {
|
|
|
146
146
|
// read-permission sessions legitimately never edit, so they get the
|
|
147
147
|
// report-oriented level-2 text.
|
|
148
148
|
const readOnlyRole = ctx.readOnlyRole === true;
|
|
149
|
+
// Edit-push steering is EXPLORER-ONLY: leads legitimately read broadly
|
|
150
|
+
// before delegating, and reviewer/debugger-style roles read continuously
|
|
151
|
+
// by design. Pushing "start editing / apply the edit" at those roles
|
|
152
|
+
// suppresses delegation (observed on TB2.1: forced-delegation workflow
|
|
153
|
+
// leads went solo right after these nudges). Non-explorer roles keep the
|
|
154
|
+
// batching guidance but never receive an edit directive, and level-2
|
|
155
|
+
// edit-push is skipped for them entirely.
|
|
156
|
+
const editPushEligible = sessionAgent === 'explorer';
|
|
149
157
|
|
|
150
158
|
// Step 1: escalation ladder. _level1FireCount is CUMULATIVE (never reset)
|
|
151
159
|
// so repeated batching reminders accumulate across the whole session.
|
|
@@ -194,6 +202,7 @@ export function createSteeringLadder(ctx) {
|
|
|
194
202
|
// level-1 streak and the independent all-read-only streak). Sets the latch
|
|
195
203
|
// so it fires at most once per 5 turns regardless of which path triggered.
|
|
196
204
|
const _emitLevel2Steer = () => {
|
|
205
|
+
if (!editPushEligible && !readOnlyRole) return;
|
|
197
206
|
const iterations = getIterations();
|
|
198
207
|
_level2LatchAtIteration = iterations;
|
|
199
208
|
_level2FireCount += 1;
|
|
@@ -252,7 +261,9 @@ export function createSteeringLadder(ctx) {
|
|
|
252
261
|
if (_canEscalate) {
|
|
253
262
|
_emitLevel2Steer();
|
|
254
263
|
} else {
|
|
255
|
-
pushSystemReminder(
|
|
264
|
+
pushSystemReminder(editPushEligible
|
|
265
|
+
? 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.'
|
|
266
|
+
: 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn.');
|
|
256
267
|
}
|
|
257
268
|
_hintFiredThisTurn = true;
|
|
258
269
|
}
|
|
@@ -3,14 +3,27 @@
|
|
|
3
3
|
// the classification ladder is verbatim from the tail of agentLoop.
|
|
4
4
|
import { HIDDEN_AGENT_NAMES } from './hidden-agents.mjs';
|
|
5
5
|
|
|
6
|
-
// Stop reasons that signal the turn was cut short mid-synthesis
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Stop reasons that signal the turn was cut short mid-synthesis. This broad set
|
|
7
|
+
// retains the pre-existing EMPTY-turn nudge semantics for provider pauses and
|
|
8
|
+
// unknown Gemini stops; only OUTPUT_LIMIT_STOP_REASONS below is eligible for
|
|
9
|
+
// non-empty continuation recovery.
|
|
10
10
|
export const INCOMPLETE_STOP_REASONS = new Set([
|
|
11
11
|
'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
|
+
// True provider output ceilings. pause_turn is a provider-controlled pause and
|
|
15
|
+
// Gemini OTHER is intentionally opaque; neither is safe to treat as a token-cap
|
|
16
|
+
// continuation. Compare case-insensitively so provider spelling variants do not
|
|
17
|
+
// create a second policy.
|
|
18
|
+
export const OUTPUT_LIMIT_STOP_REASONS = new Set([
|
|
19
|
+
'length', 'max_tokens', 'max_output_tokens',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function isOutputLimitStopReason(reason) {
|
|
23
|
+
return typeof reason === 'string'
|
|
24
|
+
&& OUTPUT_LIMIT_STOP_REASONS.has(reason.trim().toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
15
28
|
// finish to an explicit Lead-facing error instead of a silent empty
|
|
16
29
|
// "completed". Determine "has content" exactly the way the no-tool-call
|
|
@@ -23,15 +36,17 @@ export function classifyTerminationReason(response, {
|
|
|
23
36
|
|| (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
|
|
24
37
|
const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
|
|
25
38
|
const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
|
|
39
|
+
const _finalOutputLimitStop = isOutputLimitStopReason(_finalStopReason);
|
|
26
40
|
const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
27
41
|
if (terminatedByCap) {
|
|
28
42
|
// Real problem regardless of hidden/public: the loop never terminated
|
|
29
43
|
// on its own contract.
|
|
30
44
|
return 'iteration_cap';
|
|
31
45
|
}
|
|
32
|
-
if (!_finalHasContent && _finalIncompleteStop) {
|
|
33
|
-
//
|
|
34
|
-
//
|
|
46
|
+
if (_finalOutputLimitStop || (!_finalHasContent && _finalIncompleteStop)) {
|
|
47
|
+
// Exhausted token-cap recovery is abnormal even with preserved partial
|
|
48
|
+
// text. pause_turn/OTHER retain their prior non-empty completion
|
|
49
|
+
// semantics, while their empty forms remain abnormal.
|
|
35
50
|
return 'truncated';
|
|
36
51
|
}
|
|
37
52
|
if (!_finalHasContent && !_finalIsHidden) {
|
|
@@ -44,6 +44,7 @@ import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
|
|
|
44
44
|
import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
|
|
45
45
|
import { _getAgentLoop } from './runtime-loaders.mjs';
|
|
46
46
|
import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
|
|
47
|
+
import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
@@ -327,6 +328,21 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
327
328
|
onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
|
|
328
329
|
onUsageDelta: (d) => {
|
|
329
330
|
persistIterationMetrics(d).catch(() => {});
|
|
331
|
+
// provider_send usage arrives before agentLoop appends
|
|
332
|
+
// the assistant response. Preserve the full actual
|
|
333
|
+
// input/cache/output count and mark this request
|
|
334
|
+
// boundary; compact pressure will skip that first
|
|
335
|
+
// assistant representation and estimate only later
|
|
336
|
+
// tool results/steering.
|
|
337
|
+
if (d?.source === 'provider_send') {
|
|
338
|
+
recordProviderContextBaseline(session, outgoing, {
|
|
339
|
+
inputTokens: d.deltaInput,
|
|
340
|
+
outputTokens: d.deltaOutput,
|
|
341
|
+
promptTokens: d.deltaPrompt,
|
|
342
|
+
cachedTokens: d.deltaCachedRead,
|
|
343
|
+
cacheWriteTokens: d.deltaCacheWrite,
|
|
344
|
+
}, { boundary: 'request' });
|
|
345
|
+
}
|
|
330
346
|
try { askOpts?.onUsageDelta?.(d); } catch {}
|
|
331
347
|
},
|
|
332
348
|
onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
|
|
@@ -401,12 +417,20 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
401
417
|
// contextStatus() reverts to the authoritative committed transcript.
|
|
402
418
|
session.liveTurnMessages = null;
|
|
403
419
|
if (result.content || result.reasoningContent) {
|
|
420
|
+
// Max-output recovery returns the complete concatenated text to
|
|
421
|
+
// callers/TUI, while outgoing already contains prior partial
|
|
422
|
+
// assistant turns and their continuation prompts. Persist only
|
|
423
|
+
// the terminal segment here so model history contains every byte
|
|
424
|
+
// exactly once.
|
|
425
|
+
const persistedAssistantContent = typeof result.historyContent === 'string'
|
|
426
|
+
? result.historyContent
|
|
427
|
+
: (result.content || '');
|
|
404
428
|
session.messages.push({
|
|
405
429
|
role: 'assistant',
|
|
406
430
|
// Keep content as-is in memory (model-visible). Image bytes,
|
|
407
431
|
// if any, are swapped for a placeholder only at disk write
|
|
408
432
|
// time inside the session store (store.mjs _sessionForDisk).
|
|
409
|
-
content:
|
|
433
|
+
content: persistedAssistantContent,
|
|
410
434
|
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
411
435
|
? { reasoningContent: result.reasoningContent }
|
|
412
436
|
: {}),
|
|
@@ -458,6 +482,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
458
482
|
applyAskTerminalUsageTotals(session, result, {
|
|
459
483
|
skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
|
|
460
484
|
});
|
|
485
|
+
recordProviderContextBaseline(session, session.messages, result.lastTurnUsage || result.usage);
|
|
461
486
|
// Agent Runtime cache stats — record hit/miss after every successful
|
|
462
487
|
// ask so the registry reflects all agent traffic, not just
|
|
463
488
|
// maintenance cycles. Guarded against any agent-runtime error so
|
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
compactTypeForSession,
|
|
27
27
|
} from './context-meta.mjs';
|
|
28
28
|
import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
|
|
29
|
+
import { pruneOffloadSession } from '../tool-result-offload.mjs';
|
|
30
|
+
import { _getPendingMessagesForSession } from './pending-messages.mjs';
|
|
31
|
+
import { isSessionCompactionBlocked } from './runtime-liveness.mjs';
|
|
32
|
+
import { invalidateProviderContextBaseline } from '../loop/compact-policy.mjs';
|
|
29
33
|
|
|
30
34
|
// 'compacting' is a transient in-flight stage written just before semantic /
|
|
31
35
|
// recall-fasttrack compaction runs. If the process crashes or only partially
|
|
@@ -489,6 +493,18 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
489
493
|
const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
|
|
490
494
|
const now = Date.now();
|
|
491
495
|
session.messages = compacted;
|
|
496
|
+
// Best-effort GC only: the 10-minute mtime gate plus this idle-only guard
|
|
497
|
+
// lets an in-flight turn's sidecars survive until a later compaction/close.
|
|
498
|
+
const pruneSessionId = opts.sessionId || session.id;
|
|
499
|
+
if (!isSessionCompactionBlocked(pruneSessionId)) {
|
|
500
|
+
try {
|
|
501
|
+
await pruneOffloadSession(pruneSessionId, () => [
|
|
502
|
+
session.messages,
|
|
503
|
+
session.liveTurnMessages,
|
|
504
|
+
_getPendingMessagesForSession(pruneSessionId),
|
|
505
|
+
]);
|
|
506
|
+
} catch { /* best-effort */ }
|
|
507
|
+
}
|
|
492
508
|
session.providerState = undefined;
|
|
493
509
|
session.compaction = {
|
|
494
510
|
...(session.compaction || {}),
|
|
@@ -524,7 +540,7 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
524
540
|
} : null,
|
|
525
541
|
compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
|
|
526
542
|
};
|
|
527
|
-
if (changed
|
|
543
|
+
if (changed) invalidateProviderContextBaseline(session);
|
|
528
544
|
return {
|
|
529
545
|
changed,
|
|
530
546
|
reason: unchangedReason,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Steering / pending-message queue with sync buffered + atomic-file persistence.
|
|
2
2
|
// Extracted verbatim from manager.mjs (behavior-preserving).
|
|
3
3
|
import { join } from 'path';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
4
5
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
5
6
|
import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
|
|
6
7
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
@@ -458,6 +459,31 @@ export function drainPendingMessages(sessionId) {
|
|
|
458
459
|
return out;
|
|
459
460
|
}
|
|
460
461
|
|
|
462
|
+
// Snapshot queued entries without draining them. Compaction uses this to keep
|
|
463
|
+
// sidecars referenced by a message that is waiting for the next turn, whether
|
|
464
|
+
// it is still in memory, buffered for persistence, or already on disk.
|
|
465
|
+
export function _getPendingMessagesForSession(sessionId) {
|
|
466
|
+
if (!isValidPendingSessionId(sessionId)) return [];
|
|
467
|
+
const queued = [
|
|
468
|
+
...(_sessionPendingMessages.get(sessionId) || []),
|
|
469
|
+
...(_pendingPersistBuffers.get(sessionId) || []),
|
|
470
|
+
];
|
|
471
|
+
let raw;
|
|
472
|
+
try {
|
|
473
|
+
raw = readFileSync(pendingMessagesPath(), 'utf8');
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err?.code === 'ENOENT') return queued;
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const persisted = normalizePendingStore(JSON.parse(raw)).sessions[sessionId];
|
|
480
|
+
if (Array.isArray(persisted)) queued.push(...persisted);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
return queued;
|
|
485
|
+
}
|
|
486
|
+
|
|
461
487
|
// Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
|
|
462
488
|
// entry so both Maps do not accumulate one entry per closed session.
|
|
463
489
|
export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// avoid a circular import back into manager.mjs.
|
|
12
12
|
//
|
|
13
13
|
// Entry shape: {
|
|
14
|
-
// stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
|
|
14
|
+
// stage, lastStreamDeltaAt, lastTransportAt, lastToolCall, lastError, updatedAt,
|
|
15
15
|
// controller?: AbortController, // set while an ask is in flight
|
|
16
16
|
// generation?: number, // snapshot taken at ask start
|
|
17
17
|
// closed?: boolean, // flipped by closeSession()
|
|
@@ -46,7 +46,7 @@ export function configureRuntimeLiveness(deps = {}) {
|
|
|
46
46
|
export function _touchRuntime(id) {
|
|
47
47
|
let entry = _runtimeState.get(id);
|
|
48
48
|
if (!entry) {
|
|
49
|
-
entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
|
|
49
|
+
entry = { stage: 'idle', lastStreamDeltaAt: null, lastTransportAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
|
|
50
50
|
_runtimeState.set(id, entry);
|
|
51
51
|
}
|
|
52
52
|
return entry;
|
|
@@ -106,6 +106,8 @@ export function markSessionAskStart(id) {
|
|
|
106
106
|
if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
|
|
107
107
|
entry.stage = 'connecting';
|
|
108
108
|
entry.lastStreamDeltaAt = null;
|
|
109
|
+
entry.lastTransportAt = null;
|
|
110
|
+
entry.transportTrackingEnabled = false;
|
|
109
111
|
entry.lastToolCall = null;
|
|
110
112
|
entry.toolStartedAt = null;
|
|
111
113
|
entry.toolSelfDeadlineMs = null;
|
|
@@ -134,6 +136,27 @@ export function markSessionAskStart(id) {
|
|
|
134
136
|
// markSessionStreamDelta keeps refreshing once chunks arrive.
|
|
135
137
|
publishHeartbeat(id, now);
|
|
136
138
|
}
|
|
139
|
+
export function enableSessionTransportTracking(id) {
|
|
140
|
+
if (!id) return;
|
|
141
|
+
const entry = _runtimeState.get(id);
|
|
142
|
+
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
143
|
+
entry.transportTrackingEnabled = true;
|
|
144
|
+
}
|
|
145
|
+
export function disableSessionTransportTracking(id) {
|
|
146
|
+
if (!id) return;
|
|
147
|
+
const entry = _runtimeState.get(id);
|
|
148
|
+
if (!entry) return;
|
|
149
|
+
entry.transportTrackingEnabled = false;
|
|
150
|
+
}
|
|
151
|
+
export function markSessionTransportActivity(id) {
|
|
152
|
+
if (!id) return;
|
|
153
|
+
const entry = _runtimeState.get(id);
|
|
154
|
+
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
entry.lastTransportAt = now;
|
|
157
|
+
entry.updatedAt = now;
|
|
158
|
+
publishHeartbeat(id, now);
|
|
159
|
+
}
|
|
137
160
|
export async function markSessionStreamDelta(id) {
|
|
138
161
|
if (!id) return;
|
|
139
162
|
// Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
|
|
@@ -285,16 +308,20 @@ export function getSessionProgressSnapshot(sessionId) {
|
|
|
285
308
|
entry.toolStartedAt || 0,
|
|
286
309
|
);
|
|
287
310
|
const stage = entry.stage || 'idle';
|
|
311
|
+
const waitingStage = stage === 'connecting'
|
|
312
|
+
|| stage === 'requesting'
|
|
313
|
+
|| (stage === 'streaming' && entry.transportTrackingEnabled === true);
|
|
288
314
|
const waitingForFirstActivity = Boolean(
|
|
289
315
|
modelRequestStartedAt
|
|
290
|
-
&&
|
|
291
|
-
&& firstActivityAt <= modelRequestStartedAt
|
|
316
|
+
&& waitingStage
|
|
317
|
+
&& (!firstActivityAt || firstActivityAt <= modelRequestStartedAt)
|
|
292
318
|
);
|
|
293
319
|
return {
|
|
294
320
|
stage,
|
|
295
321
|
askStartedAt,
|
|
296
322
|
modelRequestStartedAt,
|
|
297
323
|
firstActivityAt,
|
|
324
|
+
lastTransportAt: entry.lastTransportAt || 0,
|
|
298
325
|
lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
|
|
299
326
|
toolStartedAt: entry.toolStartedAt || 0,
|
|
300
327
|
currentTool: entry.lastToolCall || null,
|
|
@@ -163,6 +163,8 @@ export function bumpUsageMetricsTurnId(session) {
|
|
|
163
163
|
if (!session || typeof session !== 'object') return 0;
|
|
164
164
|
const next = (Number(session.usageMetricsTurnId) || 0) + 1;
|
|
165
165
|
session.usageMetricsTurnId = next;
|
|
166
|
+
const seen = _metricSeenIter.get(session.id);
|
|
167
|
+
if (seen) seen.clear();
|
|
166
168
|
return next;
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -61,8 +61,17 @@ export async function runPreSendCompactPass(state) {
|
|
|
61
61
|
};
|
|
62
62
|
const messageTokensEst = estimateMessagesTokensSafe(messages);
|
|
63
63
|
const reactivePending = reactiveOverflowRetryPending === true;
|
|
64
|
-
const
|
|
65
|
-
|
|
64
|
+
const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, {
|
|
65
|
+
reactivePending,
|
|
66
|
+
messages,
|
|
67
|
+
sessionRef,
|
|
68
|
+
});
|
|
69
|
+
const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, {
|
|
70
|
+
forceReactive: reactivePending,
|
|
71
|
+
messages,
|
|
72
|
+
sessionRef,
|
|
73
|
+
pressureTokens,
|
|
74
|
+
});
|
|
66
75
|
// A pending reactive-overflow retry makes THIS compact pass the
|
|
67
76
|
// recovery from a provider overflow refusal, not the proactive
|
|
68
77
|
// pressure trigger. Tag the emitted events so telemetry can tell
|
|
@@ -9,6 +9,24 @@ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
|
|
|
9
9
|
import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
|
|
10
10
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
11
11
|
import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
|
|
12
|
+
import { isOutputLimitStopReason } from './loop/termination.mjs';
|
|
13
|
+
|
|
14
|
+
function normalizedIncompleteUsage(raw) {
|
|
15
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
16
|
+
const inputTokens = Number(raw.promptTokenCount ?? raw.prompt_token_count ?? raw.input_tokens ?? raw.prompt_tokens ?? 0) || 0;
|
|
17
|
+
const candidateTokens = Number(raw.candidatesTokenCount ?? raw.candidates_token_count ?? 0) || 0;
|
|
18
|
+
const thoughtTokens = Number(raw.thoughtsTokenCount ?? raw.thoughts_token_count ?? 0) || 0;
|
|
19
|
+
const outputFallback = Number(raw.output_tokens ?? raw.completion_tokens ?? 0) || 0;
|
|
20
|
+
const cachedTokens = Number(raw.cachedContentTokenCount ?? raw.cached_content_token_count ?? raw.cached_tokens ?? 0) || 0;
|
|
21
|
+
return {
|
|
22
|
+
inputTokens,
|
|
23
|
+
outputTokens: candidateTokens + thoughtTokens || outputFallback,
|
|
24
|
+
cachedTokens,
|
|
25
|
+
cacheWriteTokens: 0,
|
|
26
|
+
promptTokens: inputTokens,
|
|
27
|
+
raw,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
12
30
|
|
|
13
31
|
export async function sendWithRecovery(ctx) {
|
|
14
32
|
const {
|
|
@@ -19,6 +37,33 @@ export async function sendWithRecovery(ctx) {
|
|
|
19
37
|
try {
|
|
20
38
|
response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
|
|
21
39
|
} catch (sendErr) {
|
|
40
|
+
// Gemini REST/SDK reports MAX_TOKENS by throwing a typed
|
|
41
|
+
// ProviderIncompleteError after preserving the streamed candidate.
|
|
42
|
+
// Normalize only that exact, safe no-tool output-limit shape into a
|
|
43
|
+
// regular truncated response; all moderation/OTHER/tool-bearing and
|
|
44
|
+
// unrelated errors continue through their existing error paths.
|
|
45
|
+
if (
|
|
46
|
+
sendErr?.providerIncomplete === true
|
|
47
|
+
&& sendErr.code === 'PROVIDER_INCOMPLETE'
|
|
48
|
+
&& isOutputLimitStopReason(sendErr.finishReason)
|
|
49
|
+
&& typeof sendErr.partialContent === 'string'
|
|
50
|
+
&& sendErr.partialContent.trim().length > 0
|
|
51
|
+
&& sendErr.pendingToolUse !== true
|
|
52
|
+
&& sendErr.emittedToolCall !== true
|
|
53
|
+
&& !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
|
|
54
|
+
) {
|
|
55
|
+
response = {
|
|
56
|
+
content: sendErr.partialContent,
|
|
57
|
+
model: sendErr.model || model,
|
|
58
|
+
toolCalls: undefined,
|
|
59
|
+
usage: normalizedIncompleteUsage(sendErr.rawUsage),
|
|
60
|
+
stopReason: sendErr.finishReason,
|
|
61
|
+
truncated: true,
|
|
62
|
+
providerState: opts.providerState,
|
|
63
|
+
providerIncompleteRecovery: true,
|
|
64
|
+
};
|
|
65
|
+
return { action: 'proceed', response };
|
|
66
|
+
} else
|
|
22
67
|
// Partial-final recovery (owner-notify fix): the recurring "worker
|
|
23
68
|
// finished but the task hung / no result delivered" case is a FINAL,
|
|
24
69
|
// no-tool summary stream that wedges (ping-only) AFTER all real tool
|
|
@@ -289,7 +289,10 @@ function _getOrSpawnWorker() {
|
|
|
289
289
|
// (the originating call plus any that coalesced onto it before it was
|
|
290
290
|
// posted). A supersede never lands here as a rejection — only a real
|
|
291
291
|
// worker failure does.
|
|
292
|
-
if (ok) {
|
|
292
|
+
if (ok) {
|
|
293
|
+
clearSessionSaveError(id);
|
|
294
|
+
for (const w of waiters) w.resolve();
|
|
295
|
+
}
|
|
293
296
|
else {
|
|
294
297
|
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
295
298
|
for (const w of waiters) w.reject(e);
|
|
@@ -441,6 +444,7 @@ function _doSaveSync(payload) {
|
|
|
441
444
|
}
|
|
442
445
|
_renameWithRetrySync(tmp, target);
|
|
443
446
|
_upsertSessionSummary(session);
|
|
447
|
+
clearSessionSaveError(id);
|
|
444
448
|
} catch (err) {
|
|
445
449
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
446
450
|
throw err;
|
|
@@ -580,6 +584,7 @@ async function _doSave(payload) {
|
|
|
580
584
|
}
|
|
581
585
|
_renameWithRetrySync(tmp, target);
|
|
582
586
|
_upsertSessionSummary(session);
|
|
587
|
+
clearSessionSaveError(id);
|
|
583
588
|
} catch (err) {
|
|
584
589
|
try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
|
|
585
590
|
_savePending.delete(id);
|
|
@@ -643,6 +648,7 @@ export function markSessionClosed(id, reason = 'manual') {
|
|
|
643
648
|
return null;
|
|
644
649
|
}
|
|
645
650
|
_savePending.delete(id);
|
|
651
|
+
clearSessionSaveError(id);
|
|
646
652
|
_clearLiveSession(id);
|
|
647
653
|
_deleteHeartbeat(id);
|
|
648
654
|
_upsertSessionSummary(tombstone);
|
|
@@ -702,6 +708,7 @@ export function bumpSessionGeneration(id, reason = 'detach') {
|
|
|
702
708
|
return null;
|
|
703
709
|
}
|
|
704
710
|
_savePending.delete(id);
|
|
711
|
+
clearSessionSaveError(id);
|
|
705
712
|
_clearLiveSession(id);
|
|
706
713
|
_deleteHeartbeat(id);
|
|
707
714
|
_upsertSessionSummary(detached);
|
|
@@ -748,6 +755,7 @@ export function deleteSession(id, options = {}) {
|
|
|
748
755
|
catch { /* fall through to .hb cleanup */ }
|
|
749
756
|
}
|
|
750
757
|
_deleteHeartbeat(id);
|
|
758
|
+
if (removed || !existsSync(path)) clearSessionSaveError(id);
|
|
751
759
|
// deferSummaryUpdate: bulk callers (tombstone sweep) remove thousands of
|
|
752
760
|
// rows — a per-id _removeSessionSummary would parse+rewrite the multi-MB
|
|
753
761
|
// summary index once PER DELETION. They batch the index update themselves.
|