mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// manager/ask-session.mjs
|
|
2
|
+
// The core ask pipeline extracted verbatim from manager.mjs: the per-session
|
|
3
|
+
// mutex-guarded turn loop (askSession) plus the abort-aware call wrapper
|
|
4
|
+
// (_api_call_with_interrupt). Behavior-preserving move — every runtime
|
|
5
|
+
// helper it used in manager.mjs is now imported from its split module.
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { getProvider } from '../../providers/registry.mjs';
|
|
8
|
+
import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
|
|
9
|
+
import { loadSession, saveSessionAsync } from '../store.mjs';
|
|
10
|
+
import { createAbortController } from '../../../../shared/abort-controller.mjs';
|
|
11
|
+
import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
|
|
12
|
+
import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
13
|
+
import { recordStandaloneStatusTelemetry } from './status-telemetry.mjs';
|
|
14
|
+
import { normalizeStaleCompactingStage } from './compaction-runner.mjs';
|
|
15
|
+
import { resolveSessionContextMeta, positiveContextWindow } from './context-meta.mjs';
|
|
16
|
+
import {
|
|
17
|
+
promptContentText,
|
|
18
|
+
hasModelVisiblePromptContent,
|
|
19
|
+
promptContentBytes,
|
|
20
|
+
prefixUserTurnContent,
|
|
21
|
+
prefixSessionStartContent,
|
|
22
|
+
buildCurrentTimeBlock,
|
|
23
|
+
buildSessionStartBlock,
|
|
24
|
+
hasUserConversationMessage,
|
|
25
|
+
isInternalRuntimeNotificationText,
|
|
26
|
+
} from './prompt-utils.mjs';
|
|
27
|
+
import { _mergePendingMessageEntries, drainPendingMessages } from './pending-messages.mjs';
|
|
28
|
+
import { persistIterationMetrics, applyAskTerminalUsageTotals } from './usage-metrics.mjs';
|
|
29
|
+
import {
|
|
30
|
+
updateSessionStage,
|
|
31
|
+
markSessionAskStart,
|
|
32
|
+
markSessionStreamDelta,
|
|
33
|
+
markSessionDone,
|
|
34
|
+
markSessionEmptyFinal,
|
|
35
|
+
markSessionError,
|
|
36
|
+
markSessionCancelled,
|
|
37
|
+
_touchRuntime,
|
|
38
|
+
_unlinkParentAbortListener,
|
|
39
|
+
_getRuntimeEntry,
|
|
40
|
+
} from './runtime-liveness.mjs';
|
|
41
|
+
import { SessionClosedError } from './session-errors.mjs';
|
|
42
|
+
import { acquireSessionLock } from './session-lock.mjs';
|
|
43
|
+
import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
|
|
44
|
+
import { sanitizeSessionMessagesForModel, persistCompactedOutgoingAfterAskFailure } from './message-sanitize.mjs';
|
|
45
|
+
import { _getAgentLoop } from './runtime-loaders.mjs';
|
|
46
|
+
import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
|
|
47
|
+
import { nonNegativeIntEnv } from './env-utils.mjs';
|
|
48
|
+
|
|
49
|
+
// Cap how long the terminal unwind blocks on the post-result session save.
|
|
50
|
+
// The result is already produced (and relayed for agent surfaces) before this
|
|
51
|
+
// save, so a stalled disk write must not hold askSession() open — otherwise the
|
|
52
|
+
// owning background task is stranded in `running` and its completion
|
|
53
|
+
// notification never fires. A slow write finishes in the background.
|
|
54
|
+
const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
58
|
+
* the wrapper settles with a SessionClosedError even if the underlying promise
|
|
59
|
+
* hasn't returned yet. The original promise is kept alive with a detached
|
|
60
|
+
* `.catch()` to prevent unhandled-rejection warnings once it eventually
|
|
61
|
+
* settles. Callers still must check generation/closed after await returns
|
|
62
|
+
* to handle providers that ignore the AbortSignal entirely.
|
|
63
|
+
*/
|
|
64
|
+
export async function _api_call_with_interrupt(sessionId, fn) {
|
|
65
|
+
const entry = _touchRuntime(sessionId);
|
|
66
|
+
if (!entry.controller) entry.controller = createAbortController();
|
|
67
|
+
const signal = entry.controller.signal;
|
|
68
|
+
const closedFromAbort = (phase) => {
|
|
69
|
+
const reason = signal.reason;
|
|
70
|
+
if (reason instanceof SessionClosedError) return reason;
|
|
71
|
+
const detail = reason instanceof Error
|
|
72
|
+
? reason.message
|
|
73
|
+
: (reason !== undefined && reason !== null && reason !== '' ? String(reason) : '');
|
|
74
|
+
return new SessionClosedError(sessionId, detail ? `${phase}: ${detail}` : phase);
|
|
75
|
+
};
|
|
76
|
+
if (signal.aborted) throw closedFromAbort('aborted before call');
|
|
77
|
+
const underlying = fn(signal);
|
|
78
|
+
underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
|
|
79
|
+
let onAbort = null;
|
|
80
|
+
const aborted = new Promise((_, reject) => {
|
|
81
|
+
onAbort = () => reject(closedFromAbort('aborted during call'));
|
|
82
|
+
if (signal.aborted) onAbort();
|
|
83
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
return await Promise.race([underlying, aborted]);
|
|
87
|
+
} finally {
|
|
88
|
+
// If the underlying promise settled first, the abort listener is
|
|
89
|
+
// still attached. Remove it to avoid accumulating listeners across
|
|
90
|
+
// many asks on the same session.
|
|
91
|
+
if (onAbort && !signal.aborted) {
|
|
92
|
+
try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
|
|
98
|
+
const _askStartedAt = Date.now();
|
|
99
|
+
const _promptSrc = 'prompt';
|
|
100
|
+
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
101
|
+
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
102
|
+
const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
|
|
103
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
104
|
+
process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
|
|
105
|
+
}
|
|
106
|
+
const unlock = await acquireSessionLock(sessionId);
|
|
107
|
+
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
108
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
109
|
+
process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
110
|
+
}
|
|
111
|
+
// The mutex is held for the WHOLE askSession call, including any follow-up
|
|
112
|
+
// turns drained from the pending-message queue below — the single outer
|
|
113
|
+
// try/finally releases it exactly once. _result holds the last turn's
|
|
114
|
+
// return value (the queued tail turns supersede the original prompt's
|
|
115
|
+
// result, mirroring how a live chat returns the latest turn).
|
|
116
|
+
let _result;
|
|
117
|
+
// Local FIFO of follow-up prompts drained from the pending-message queue
|
|
118
|
+
// after each turn — keeps queued `agent type=send` messages in order.
|
|
119
|
+
const _pendingTail = [];
|
|
120
|
+
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
121
|
+
// can compare against the last turn's generation.
|
|
122
|
+
let askGeneration = 0;
|
|
123
|
+
try {
|
|
124
|
+
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
125
|
+
// any `agent type=send` messages that were queued while this turn was in
|
|
126
|
+
// flight and run them — in order — as the next user turn(s). Because the
|
|
127
|
+
// queued send always lands AFTER the in-flight prompt here, ordering is
|
|
128
|
+
// preserved and the spawn/connecting startup race disappears.
|
|
129
|
+
for (;;) {
|
|
130
|
+
let _pwstTurnDrained = null;
|
|
131
|
+
// After the first turn, the next prompt comes from the drained queue.
|
|
132
|
+
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
133
|
+
// caller's original message.)
|
|
134
|
+
if (_pendingTail.length > 0) {
|
|
135
|
+
prompt = _pendingTail.shift();
|
|
136
|
+
// Queued follow-ups are plain user turns — no caller context /
|
|
137
|
+
// prefetch is re-applied (those belonged to the original ask).
|
|
138
|
+
context = null;
|
|
139
|
+
explicitPrefetch = null;
|
|
140
|
+
} else if (!hasModelVisiblePromptContent(prompt)) {
|
|
141
|
+
// Idle resume: TUI kicks an empty ask() after execution completions
|
|
142
|
+
// mirror model-visible bodies into session pending. Drain that queue
|
|
143
|
+
// here so we never synthesize an empty user turn for the model.
|
|
144
|
+
const _preDrained = drainPendingMessages(sessionId);
|
|
145
|
+
if (_preDrained.length > 0) {
|
|
146
|
+
const _mergedPre = _mergePendingMessageEntries(_preDrained);
|
|
147
|
+
if (_mergedPre?.content) {
|
|
148
|
+
prompt = _mergedPre.content;
|
|
149
|
+
context = null;
|
|
150
|
+
explicitPrefetch = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!hasModelVisiblePromptContent(prompt)) {
|
|
155
|
+
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
156
|
+
return _result;
|
|
157
|
+
}
|
|
158
|
+
// ── Synchronous pre-await setup (must happen before any await so
|
|
159
|
+
// closeSession() can't interleave between load and registration) ──
|
|
160
|
+
const preSession = loadSession(sessionId);
|
|
161
|
+
if (!preSession) {
|
|
162
|
+
throw new Error(`Session "${sessionId}" not found`);
|
|
163
|
+
}
|
|
164
|
+
if (preSession.closed === true) {
|
|
165
|
+
throw new SessionClosedError(sessionId, 'session already closed');
|
|
166
|
+
}
|
|
167
|
+
// A prior crash/partial-save during compaction may have pinned
|
|
168
|
+
// compaction.lastStage='compacting'. This ask is starting fresh, so
|
|
169
|
+
// recover the stale transient stage before the loop's pre-send compact
|
|
170
|
+
// path runs (it will overwrite lastStage with real telemetry).
|
|
171
|
+
normalizeStaleCompactingStage(preSession);
|
|
172
|
+
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
173
|
+
const runtime = _touchRuntime(sessionId);
|
|
174
|
+
// Fresh controller per ask — the previous ask's controller may have aborted.
|
|
175
|
+
runtime.controller = createAbortController();
|
|
176
|
+
runtime.generation = askGeneration;
|
|
177
|
+
runtime.closed = false;
|
|
178
|
+
runtime.session = preSession;
|
|
179
|
+
markSessionAskStart(sessionId);
|
|
180
|
+
// Preprocessing is inside try so provider-not-available / trim failures
|
|
181
|
+
// fall into the catch and mark the session as errored rather than
|
|
182
|
+
// leaving stage='connecting' forever.
|
|
183
|
+
let activeSession = preSession;
|
|
184
|
+
let cancelledUserTurnContent = '';
|
|
185
|
+
let _turnOutgoing = null;
|
|
186
|
+
try {
|
|
187
|
+
const session = activeSession;
|
|
188
|
+
const provider = getProvider(session.provider);
|
|
189
|
+
// Register the live session object into runtime so closeSession()
|
|
190
|
+
// can read allBashSessionIds that loop.mjs appends mid-turn.
|
|
191
|
+
runtime.session = session;
|
|
192
|
+
if (!provider)
|
|
193
|
+
throw new Error(`Provider "${session.provider}" not available`);
|
|
194
|
+
const contextMeta = resolveSessionContextMeta(provider, session.model, session);
|
|
195
|
+
session.contextWindow = contextMeta.contextWindow;
|
|
196
|
+
session.rawContextWindow = contextMeta.rawContextWindow;
|
|
197
|
+
session.effectiveContextWindowPercent = contextMeta.effectiveContextWindowPercent;
|
|
198
|
+
session.autoCompactTokenLimit = contextMeta.autoCompactTokenLimit;
|
|
199
|
+
session.compactBoundaryTokens = contextMeta.compactBoundaryTokens;
|
|
200
|
+
session.compaction = {
|
|
201
|
+
...(session.compaction || {}),
|
|
202
|
+
auto: session.compaction?.auto !== false,
|
|
203
|
+
semantic: session.compaction?.semantic ?? 'auto',
|
|
204
|
+
type: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
205
|
+
compactType: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
|
|
206
|
+
boundaryTokens: contextMeta.compactBoundaryTokens,
|
|
207
|
+
bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
|
|
208
|
+
keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
|
|
209
|
+
contextWindow: contextMeta.contextWindow,
|
|
210
|
+
rawContextWindow: contextMeta.rawContextWindow,
|
|
211
|
+
effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
|
|
212
|
+
autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
|
|
213
|
+
};
|
|
214
|
+
// Cap caller-supplied / prefetched context so an oversized
|
|
215
|
+
// payload can't blow the session token budget before the
|
|
216
|
+
// first model call. 32 KB ~ 8k tokens at the 4 B/tok
|
|
217
|
+
// working average; longer is silently truncated with a
|
|
218
|
+
// visible marker so the model still sees the prefix and
|
|
219
|
+
// a hint about the cut.
|
|
220
|
+
const _CTX_CHAR_CAP = 32 * 1024;
|
|
221
|
+
const _capCtx = (text) => {
|
|
222
|
+
if (typeof text !== 'string') return '';
|
|
223
|
+
if (text.length <= _CTX_CHAR_CAP) return text;
|
|
224
|
+
return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
|
|
225
|
+
};
|
|
226
|
+
// Inline context + prefetch INTO the prompt as a single user turn,
|
|
227
|
+
// marked with explicit section headers. The previous design pushed
|
|
228
|
+
// context as separate user messages with pre-injected assistant
|
|
229
|
+
// "Noted." acks; that conversational pattern taught some models a
|
|
230
|
+
// low-effort rhythm and they responded with "Noted." / empty tags
|
|
231
|
+
// even to the real task. Single-turn structure with a labelled
|
|
232
|
+
// `# Task` block forces the model to treat the brief as the work
|
|
233
|
+
// unit, not as another piece of context to ack.
|
|
234
|
+
const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
|
|
235
|
+
let _contextBlock = '';
|
|
236
|
+
if (context) {
|
|
237
|
+
_contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
|
|
238
|
+
}
|
|
239
|
+
if (explicitPrefetchResult) {
|
|
240
|
+
_contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
|
|
241
|
+
}
|
|
242
|
+
const historyMessages = sanitizeSessionMessagesForModel(session.messages);
|
|
243
|
+
const beforeCount = historyMessages.length + 1;
|
|
244
|
+
const promptTextForMetrics = promptContentText(prompt);
|
|
245
|
+
// Soft warning only; real size management (compaction primary,
|
|
246
|
+
// byte-budget trim as safety net) lives in agentLoop. Selecting a
|
|
247
|
+
// 25% pre-trim here would starve compaction's 50% threshold.
|
|
248
|
+
const softBudget = Math.floor(session.contextWindow * 0.25);
|
|
249
|
+
const promptTokenEstimate = promptTextForMetrics.length * 0.5; // conservative for CJK
|
|
250
|
+
if (promptTokenEstimate > softBudget * 0.7) {
|
|
251
|
+
process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
|
|
252
|
+
}
|
|
253
|
+
const effectiveCwd = cwdOverride || session.cwd;
|
|
254
|
+
const shouldInjectSessionStart = session.sessionStartMetaInjected !== true
|
|
255
|
+
&& !hasUserConversationMessage(historyMessages);
|
|
256
|
+
const _sessionStartBlock = shouldInjectSessionStart
|
|
257
|
+
? buildSessionStartBlock(session, effectiveCwd)
|
|
258
|
+
: '';
|
|
259
|
+
const _currentTimeBlock = buildCurrentTimeBlock(prompt);
|
|
260
|
+
const _turnReminderBlock = _currentTimeBlock
|
|
261
|
+
? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
|
|
262
|
+
: '';
|
|
263
|
+
const _turnPrefixBlock = [_sessionStartBlock, _turnReminderBlock].filter(Boolean).join('\n\n');
|
|
264
|
+
const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
|
|
265
|
+
const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnPrefixBlock);
|
|
266
|
+
if (shouldInjectSessionStart && _sessionStartBlock) {
|
|
267
|
+
session.sessionStartMetaInjected = true;
|
|
268
|
+
}
|
|
269
|
+
cancelledUserTurnContent = _userTurnContent;
|
|
270
|
+
const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
|
|
271
|
+
_turnOutgoing = outgoing;
|
|
272
|
+
// Expose the in-flight working transcript so contextStatus() can
|
|
273
|
+
// estimate the LIVE context footprint mid-turn. agentLoop mutates
|
|
274
|
+
// `outgoing` in place (user turn + tool calls/results + compaction),
|
|
275
|
+
// so the statusline context gauge climbs as the turn accumulates
|
|
276
|
+
// tool output instead of freezing at the pre-turn snapshot. Cleared
|
|
277
|
+
// on turn commit (below) and in the ask finally.
|
|
278
|
+
session.liveTurnMessages = outgoing;
|
|
279
|
+
// Per-turn injected-context trace row (complements kind:"usage").
|
|
280
|
+
// Cheap byte-length accounting — no hashing, no payload bodies.
|
|
281
|
+
// Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
|
|
282
|
+
// appendAgentTrace is a no-op when that env is set.
|
|
283
|
+
try {
|
|
284
|
+
const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
|
|
285
|
+
const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
|
|
286
|
+
const _promptBytes = promptContentBytes(prompt);
|
|
287
|
+
const _userTurnBytes = promptContentBytes(_userTurnContent);
|
|
288
|
+
const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
|
|
289
|
+
const _totalBytes = _userTurnBytes + _messagesBytes;
|
|
290
|
+
appendAgentTrace({
|
|
291
|
+
kind: 'context',
|
|
292
|
+
sessionId,
|
|
293
|
+
model: session.model,
|
|
294
|
+
provider: session.provider,
|
|
295
|
+
totalBytes: _totalBytes,
|
|
296
|
+
breakdown: {
|
|
297
|
+
contextBytes: _ctxBytes,
|
|
298
|
+
prefetchBytes: _prefetchBytes,
|
|
299
|
+
promptBytes: _promptBytes,
|
|
300
|
+
userTurnBytes: _userTurnBytes,
|
|
301
|
+
messagesBytes: _messagesBytes,
|
|
302
|
+
messagesCount: historyMessages.length,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
} catch { /* trace must never break the ask path */ }
|
|
306
|
+
const agentLoop = await _getAgentLoop();
|
|
307
|
+
const priorToolApprovalHook = session.toolApprovalHook;
|
|
308
|
+
if (typeof askOpts?.onToolApproval === 'function') {
|
|
309
|
+
session.toolApprovalHook = askOpts.onToolApproval;
|
|
310
|
+
}
|
|
311
|
+
let result;
|
|
312
|
+
try {
|
|
313
|
+
result = await _api_call_with_interrupt(sessionId, (signal) =>
|
|
314
|
+
agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
|
|
315
|
+
effort: session.effort || null,
|
|
316
|
+
fast: session.fast === true,
|
|
317
|
+
sessionId,
|
|
318
|
+
onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
|
|
319
|
+
onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
|
|
320
|
+
onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
|
|
321
|
+
onUsageDelta: (d) => {
|
|
322
|
+
persistIterationMetrics(d).catch(() => {});
|
|
323
|
+
try { askOpts?.onUsageDelta?.(d); } catch {}
|
|
324
|
+
},
|
|
325
|
+
onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
|
|
326
|
+
onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
|
|
327
|
+
onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
|
|
328
|
+
// Mid-turn steering drain. agentLoop calls this at every
|
|
329
|
+
// tool-batch boundary (before the next provider.send) and
|
|
330
|
+
// injects any returned strings as user turns — so input
|
|
331
|
+
// (user typing, `agent type=send`) that arrives WHILE a
|
|
332
|
+
// long multi-tool turn is in flight is picked up on the
|
|
333
|
+
// model's very next iteration instead of waiting for the
|
|
334
|
+
// whole task to finish. The post-turn _pendingTail drain
|
|
335
|
+
// below still handles "followUp" input that lands after the
|
|
336
|
+
// agent would otherwise stop. Same queue, two drain points.
|
|
337
|
+
drainSteering: (sid) => {
|
|
338
|
+
const out = [];
|
|
339
|
+
if (typeof askOpts?.drainSteering === 'function') {
|
|
340
|
+
try {
|
|
341
|
+
const drained = askOpts.drainSteering(sid || sessionId);
|
|
342
|
+
if (Array.isArray(drained)) out.push(...drained);
|
|
343
|
+
} catch { /* best-effort steering drain */ }
|
|
344
|
+
}
|
|
345
|
+
try { out.push(...drainPendingMessages(sid || sessionId)); }
|
|
346
|
+
catch { /* best-effort pending drain */ }
|
|
347
|
+
return out;
|
|
348
|
+
},
|
|
349
|
+
onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
|
|
350
|
+
notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
|
|
351
|
+
promptCacheKey: session.promptCacheKey || sessionId,
|
|
352
|
+
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
353
|
+
// Distinct from sessionId — providers that pool sockets
|
|
354
|
+
// per-session (openai-oauth WS) use sessionId as the
|
|
355
|
+
// pool bucket and providerCacheKey as the server-side
|
|
356
|
+
// prompt-cache shard so parallel callers don't collide
|
|
357
|
+
// on a mid-turn socket while still sharing prefix cache.
|
|
358
|
+
providerCacheKey: session.promptCacheKey || null,
|
|
359
|
+
signal,
|
|
360
|
+
providerState: session.providerState ?? undefined,
|
|
361
|
+
session,
|
|
362
|
+
// Agent Runtime cache settings — merged last so session overrides
|
|
363
|
+
// don't get overridden by defaults. When session has no profile,
|
|
364
|
+
// providerCacheOpts is null and this spread is a no-op.
|
|
365
|
+
...(session.providerCacheOpts || {}),
|
|
366
|
+
onStageChange: (stage) => {
|
|
367
|
+
updateSessionStage(sessionId, stage);
|
|
368
|
+
try { askOpts?.onStageChange?.(stage); } catch {}
|
|
369
|
+
},
|
|
370
|
+
onStreamDelta: () => {
|
|
371
|
+
markSessionStreamDelta(sessionId).catch(() => {});
|
|
372
|
+
try { askOpts?.onStreamDelta?.(); } catch {}
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
} finally {
|
|
377
|
+
if (priorToolApprovalHook === undefined) {
|
|
378
|
+
delete session.toolApprovalHook;
|
|
379
|
+
} else {
|
|
380
|
+
session.toolApprovalHook = priorToolApprovalHook;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Post-loop validation: if closeSession() landed while we were awaiting,
|
|
384
|
+
// drop the save so the tombstone on disk isn't overwritten.
|
|
385
|
+
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
386
|
+
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
|
|
387
|
+
const reason = currentRuntime?.closedReason;
|
|
388
|
+
throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
|
|
389
|
+
}
|
|
390
|
+
// Update and save. outgoing is mutated in place by agentLoop
|
|
391
|
+
// (compaction + safety trim), so its length reflects post-loop state.
|
|
392
|
+
const messagesDropped = Math.max(0, beforeCount - outgoing.length);
|
|
393
|
+
session.messages = sanitizeSessionMessagesForModel(outgoing);
|
|
394
|
+
// Turn committed into session.messages; drop the live-turn alias so
|
|
395
|
+
// contextStatus() reverts to the authoritative committed transcript.
|
|
396
|
+
session.liveTurnMessages = null;
|
|
397
|
+
if (result.content || result.reasoningContent) {
|
|
398
|
+
session.messages.push({
|
|
399
|
+
role: 'assistant',
|
|
400
|
+
// Keep content as-is in memory (model-visible). Image bytes,
|
|
401
|
+
// if any, are swapped for a placeholder only at disk write
|
|
402
|
+
// time inside the session store (store.mjs _sessionForDisk).
|
|
403
|
+
content: result.content || '',
|
|
404
|
+
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
405
|
+
? { reasoningContent: result.reasoningContent }
|
|
406
|
+
: {}),
|
|
407
|
+
});
|
|
408
|
+
} else {
|
|
409
|
+
// Empty terminal turn: still persist a forensic record so
|
|
410
|
+
// post-mortem inspection can distinguish "work landed but
|
|
411
|
+
// synthesis missing" from "session never ran". Stop reason,
|
|
412
|
+
// usage, iterations, and tool-call totals survive even when
|
|
413
|
+
// the assistant produced no content/reasoning.
|
|
414
|
+
const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
|
|
415
|
+
const _emptyUsage = result?.usage ? {
|
|
416
|
+
inputTokens: result.usage.inputTokens || 0,
|
|
417
|
+
outputTokens: result.usage.outputTokens || 0,
|
|
418
|
+
cachedTokens: result.usage.cachedTokens || 0,
|
|
419
|
+
cacheWriteTokens: result.usage.cacheWriteTokens || 0,
|
|
420
|
+
} : null;
|
|
421
|
+
// Provider content-block classification — distinguishes a
|
|
422
|
+
// thinking-only stall (model emitted reasoning blocks but no
|
|
423
|
+
// text/tool_use) from a true silent empty turn. Anthropic
|
|
424
|
+
// providers (anthropic.mjs, anthropic-oauth.mjs) set these
|
|
425
|
+
// fields on the result; other providers may omit them.
|
|
426
|
+
const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
|
|
427
|
+
? result.hasThinkingContent
|
|
428
|
+
: null;
|
|
429
|
+
const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
|
|
430
|
+
? result.contentBlockTypes.slice()
|
|
431
|
+
: null;
|
|
432
|
+
session.messages.push({
|
|
433
|
+
role: 'assistant',
|
|
434
|
+
content: '',
|
|
435
|
+
emptyFinal: true,
|
|
436
|
+
stopReason: _emptyStop,
|
|
437
|
+
iterations: result?.iterations ?? null,
|
|
438
|
+
toolCallsTotal: result?.toolCallsTotal ?? null,
|
|
439
|
+
usage: _emptyUsage,
|
|
440
|
+
...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
|
|
441
|
+
...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
|
|
442
|
+
ts: Date.now(),
|
|
443
|
+
});
|
|
444
|
+
try {
|
|
445
|
+
const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
|
|
446
|
+
const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
|
|
447
|
+
process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
|
|
448
|
+
} catch {}
|
|
449
|
+
}
|
|
450
|
+
session.updatedAt = Date.now();
|
|
451
|
+
session.lastUsedAt = Date.now();
|
|
452
|
+
applyAskTerminalUsageTotals(session, result, {
|
|
453
|
+
skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
|
|
454
|
+
});
|
|
455
|
+
// Agent Runtime cache stats — record hit/miss after every successful
|
|
456
|
+
// ask so the registry reflects all agent traffic, not just
|
|
457
|
+
// maintenance cycles. Guarded against any agent-runtime error so
|
|
458
|
+
// metric recording never breaks the ask itself.
|
|
459
|
+
let prefixHashForLog = null;
|
|
460
|
+
const _agentRuntimeApi = getAgentRuntimeSync();
|
|
461
|
+
if (session.profileId && result.usage && _agentRuntimeApi) {
|
|
462
|
+
try {
|
|
463
|
+
const profile = _agentRuntimeApi.getProfile(session.profileId);
|
|
464
|
+
if (profile) {
|
|
465
|
+
// Collect every leading system-role message (BP1, BP2, ...)
|
|
466
|
+
// until the first non-system message so the registry hash
|
|
467
|
+
// captures the full ordered provider prefix, not just BP1.
|
|
468
|
+
const systemMsgs = [];
|
|
469
|
+
for (const m of session.messages) {
|
|
470
|
+
if (m?.role !== 'system') break;
|
|
471
|
+
systemMsgs.push(typeof m.content === 'string' ? m.content : '');
|
|
472
|
+
}
|
|
473
|
+
_agentRuntimeApi.recordCall(profile, session.provider, {
|
|
474
|
+
systemPrompt: systemMsgs,
|
|
475
|
+
tools: session.tools || [],
|
|
476
|
+
usage: result.usage,
|
|
477
|
+
});
|
|
478
|
+
const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
|
|
479
|
+
prefixHashForLog = entry?.prefixHash || null;
|
|
480
|
+
}
|
|
481
|
+
} catch {}
|
|
482
|
+
}
|
|
483
|
+
// Append to the agent trace store with rich usage fields.
|
|
484
|
+
if (result.usage) {
|
|
485
|
+
const inputTokens = result.usage.inputTokens || 0;
|
|
486
|
+
const outputTokens = result.usage.outputTokens || 0;
|
|
487
|
+
const cacheReadTokens = result.usage.cachedTokens || 0;
|
|
488
|
+
const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
|
|
489
|
+
// Unified total-prompt field. Anthropic = input+cache_read+cache_write
|
|
490
|
+
// (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
|
|
491
|
+
// cached portion (inclusive), so the fallback must not double-count.
|
|
492
|
+
const { isInclusiveProvider, computeCostUsd } = await import('../../../../shared/llm/cost.mjs');
|
|
493
|
+
const inclusive = isInclusiveProvider(session.provider);
|
|
494
|
+
const promptTokens = typeof result.usage.promptTokens === 'number'
|
|
495
|
+
? result.usage.promptTokens
|
|
496
|
+
: (inclusive
|
|
497
|
+
? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
|
|
498
|
+
: inputTokens + cacheReadTokens + cacheWriteTokens);
|
|
499
|
+
let costUsd = result.usage.costUsd || 0;
|
|
500
|
+
if (!costUsd) {
|
|
501
|
+
try {
|
|
502
|
+
costUsd = computeCostUsd({
|
|
503
|
+
model: session.model,
|
|
504
|
+
provider: session.provider,
|
|
505
|
+
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
506
|
+
});
|
|
507
|
+
} catch { /* best-effort */ }
|
|
508
|
+
}
|
|
509
|
+
logLlmCall({
|
|
510
|
+
ts: new Date().toISOString(),
|
|
511
|
+
sourceType: session.sourceType || 'lead',
|
|
512
|
+
sourceName: session.sourceName || session.agent || null,
|
|
513
|
+
preset: session.presetName || null,
|
|
514
|
+
model: session.model,
|
|
515
|
+
provider: session.provider,
|
|
516
|
+
duration: Date.now() - _askStartedAt,
|
|
517
|
+
profileId: session.profileId || null,
|
|
518
|
+
sessionId: session.id,
|
|
519
|
+
inputTokens,
|
|
520
|
+
outputTokens,
|
|
521
|
+
cacheReadTokens,
|
|
522
|
+
cacheWriteTokens,
|
|
523
|
+
promptTokens,
|
|
524
|
+
prefixHash: prefixHashForLog,
|
|
525
|
+
costUsd,
|
|
526
|
+
});
|
|
527
|
+
recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
|
|
528
|
+
}
|
|
529
|
+
// Persist opaque providerState for future stateful providers.
|
|
530
|
+
// No provider currently emits it (openai-oauth is stateless per
|
|
531
|
+
// contract), so this branch is dormant — kept so a future
|
|
532
|
+
// Responses-API provider with stable continuation can plug in
|
|
533
|
+
// without reworking the session shape.
|
|
534
|
+
if (result.providerState !== undefined) {
|
|
535
|
+
session.providerState = result.providerState;
|
|
536
|
+
}
|
|
537
|
+
const terminalResultPreview = {
|
|
538
|
+
...result,
|
|
539
|
+
trimmed: messagesDropped > 0,
|
|
540
|
+
messagesDropped,
|
|
541
|
+
};
|
|
542
|
+
_pwstTurnDrained = drainPendingMessages(sessionId);
|
|
543
|
+
if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
|
|
544
|
+
try {
|
|
545
|
+
askOpts.onTerminalResult(terminalResultPreview, {
|
|
546
|
+
sessionId,
|
|
547
|
+
beforeSave: true,
|
|
548
|
+
durationMs: Date.now() - _askStartedAt,
|
|
549
|
+
});
|
|
550
|
+
} catch { /* best-effort early completion relay */ }
|
|
551
|
+
}
|
|
552
|
+
// Auto-compact runs at the start of the next
|
|
553
|
+
// query/provider send (agentLoop pre-send), not after the previous
|
|
554
|
+
// answer. This lets queued follow-up prompts resume immediately;
|
|
555
|
+
// if they need compaction, their own spinner shows compacting first.
|
|
556
|
+
// Bounded, best-effort terminal save. The result is already produced
|
|
557
|
+
// and (for agent surfaces) relayed via onTerminalResult above. If the
|
|
558
|
+
// disk write stalls, blocking the terminal unwind here would strand the
|
|
559
|
+
// owning background task in `running` and suppress its completion
|
|
560
|
+
// notification. Cap the wait; let a slow write finish in the
|
|
561
|
+
// background instead of holding askSession() open indefinitely.
|
|
562
|
+
{
|
|
563
|
+
const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
564
|
+
let saveTimer = null;
|
|
565
|
+
const saveTimeout = new Promise((resolveTimeout) => {
|
|
566
|
+
saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
|
|
567
|
+
saveTimer.unref?.();
|
|
568
|
+
});
|
|
569
|
+
try {
|
|
570
|
+
const outcome = await Promise.race([
|
|
571
|
+
savePromise.then(() => '__save_ok__', (err) => { throw err; }),
|
|
572
|
+
saveTimeout,
|
|
573
|
+
]);
|
|
574
|
+
if (outcome === '__save_timeout__') {
|
|
575
|
+
try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
|
|
576
|
+
// Don't drop the write — let it settle in the background.
|
|
577
|
+
savePromise.catch((err) => {
|
|
578
|
+
try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
activeSession = session;
|
|
586
|
+
runtime.session = session;
|
|
587
|
+
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
588
|
+
// (which inspects entry.emptyFinal first) classifies the
|
|
589
|
+
// terminal state correctly even if it ticks during unwind.
|
|
590
|
+
const isEmptyFinal = !result.content && !result.reasoningContent;
|
|
591
|
+
if (isEmptyFinal) {
|
|
592
|
+
markSessionEmptyFinal(sessionId);
|
|
593
|
+
}
|
|
594
|
+
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
595
|
+
_result = terminalResultPreview;
|
|
596
|
+
} catch (err) {
|
|
597
|
+
// Cancellation/error paths bypass the commit point above; drop the
|
|
598
|
+
// live-turn alias so contextStatus() stops estimating from the
|
|
599
|
+
// stale in-flight array once the turn unwinds.
|
|
600
|
+
if (activeSession) activeSession.liveTurnMessages = null;
|
|
601
|
+
if (err instanceof SessionClosedError) {
|
|
602
|
+
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
603
|
+
if (!currentRuntime?.closed) {
|
|
604
|
+
if (activeSession) {
|
|
605
|
+
const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
|
|
606
|
+
const cleanedMessages = sanitizeSessionMessagesForModel(originalMessages);
|
|
607
|
+
const nextMessages = cleanedMessages.slice();
|
|
608
|
+
// In-memory cancelled turn keeps its original content
|
|
609
|
+
// (images intact for the next model send); the store
|
|
610
|
+
// layer placeholders image bytes on disk serialization.
|
|
611
|
+
const cancelledStoredContent = cancelledUserTurnContent;
|
|
612
|
+
const shouldPreserveUserTurn = cancelledStoredContent && !isInternalRuntimeNotificationText(cancelledStoredContent);
|
|
613
|
+
const lastMessage = nextMessages[nextMessages.length - 1];
|
|
614
|
+
if (shouldPreserveUserTurn && !(lastMessage?.role === 'user' && promptContentText(lastMessage.content) === promptContentText(cancelledStoredContent))) {
|
|
615
|
+
nextMessages.push({ role: 'user', content: cancelledStoredContent });
|
|
616
|
+
}
|
|
617
|
+
const messagesChanged = nextMessages.length !== originalMessages.length
|
|
618
|
+
|| nextMessages.some((message, index) => message !== originalMessages[index]);
|
|
619
|
+
if (messagesChanged) {
|
|
620
|
+
activeSession.messages = nextMessages;
|
|
621
|
+
activeSession.updatedAt = Date.now();
|
|
622
|
+
activeSession.lastUsedAt = Date.now();
|
|
623
|
+
try {
|
|
624
|
+
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
625
|
+
} catch { /* cancellation cleanup is best-effort */ }
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
markSessionCancelled(sessionId);
|
|
629
|
+
}
|
|
630
|
+
// Cancellation is not an error; propagate silently so callers
|
|
631
|
+
// can render it as "cancelled" rather than a red failure.
|
|
632
|
+
throw err;
|
|
633
|
+
}
|
|
634
|
+
await persistCompactedOutgoingAfterAskFailure({
|
|
635
|
+
sessionId,
|
|
636
|
+
activeSession,
|
|
637
|
+
askGeneration,
|
|
638
|
+
turnOutgoing: _turnOutgoing,
|
|
639
|
+
error: err,
|
|
640
|
+
});
|
|
641
|
+
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
642
|
+
throw err;
|
|
643
|
+
}
|
|
644
|
+
// ── Turn complete. Drain the pending-message queue: any `agent type=send` that arrived while this
|
|
645
|
+
// turn was in flight runs next, in order, as a follow-up user turn.
|
|
646
|
+
// The mutex is still held, so a send racing this drain either landed
|
|
647
|
+
// before (picked up here) or enqueues for the next loop. When the
|
|
648
|
+
// queue is empty we return the latest turn's result. ──
|
|
649
|
+
const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
|
|
650
|
+
? _pwstTurnDrained
|
|
651
|
+
: drainPendingMessages(sessionId);
|
|
652
|
+
if (_drained.length > 0) {
|
|
653
|
+
// Same merge rule as the mid-turn steering drain (loop.mjs) and
|
|
654
|
+
// the TUI engine.mjs drain(): a single drain batch is joined with
|
|
655
|
+
// "\n" and delivered as ONE follow-up turn, not N isolated turns.
|
|
656
|
+
// Keeps every steering/follow-up path on identical
|
|
657
|
+
// merge-then-deliver semantics. Anything that arrives AFTER this
|
|
658
|
+
// drain enqueues for the next loop pass and is merged there.
|
|
659
|
+
const _mergedTail = _mergePendingMessageEntries(_drained);
|
|
660
|
+
if (_mergedTail?.content) {
|
|
661
|
+
_pendingTail.push(_mergedTail.content);
|
|
662
|
+
const refreshed = loadSession(sessionId);
|
|
663
|
+
if (refreshed && refreshed.closed !== true) {
|
|
664
|
+
activeSession = refreshed;
|
|
665
|
+
runtime.session = refreshed;
|
|
666
|
+
}
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
_unlinkParentAbortListener(_getRuntimeEntry(sessionId));
|
|
671
|
+
return _result;
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
// Clear the controller only if it's still ours (closeSession may have
|
|
675
|
+
// swapped it). Leave the rest of the runtime entry intact so agent type=list
|
|
676
|
+
// can still surface the final stage (done/error/cancelling).
|
|
677
|
+
const entry = _getRuntimeEntry(sessionId);
|
|
678
|
+
if (entry && entry.generation === askGeneration) {
|
|
679
|
+
_unlinkParentAbortListener(entry);
|
|
680
|
+
entry.controller = null;
|
|
681
|
+
// Detach the live session reference; ask is over.
|
|
682
|
+
entry.session = null;
|
|
683
|
+
}
|
|
684
|
+
unlock();
|
|
685
|
+
}
|
|
686
|
+
}
|