mixdog 0.9.37 → 0.9.38
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/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench.mjs +65 -8
- package/scripts/explore-prompt-policy-test.mjs +6 -2
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/session-sweep.mjs +266 -0
- package/src/rules/agent/30-explorer.md +35 -17
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +43 -8
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/dist/index.mjs +16 -1
- package/src/tui/engine/agent-job-feed.mjs +10 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-flow.mjs +15 -1
- package/src/tui/engine/turn.mjs +6 -1
|
@@ -256,7 +256,16 @@ function _flushScheduled(id) {
|
|
|
256
256
|
// Single long-lived Worker serializes all saveSessionAsync calls.
|
|
257
257
|
// The worker's message queue preserves generation-race ordering.
|
|
258
258
|
let _saveWorker = null;
|
|
259
|
-
|
|
259
|
+
// In-flight writes, keyed by reqId. Value: { id, session, opts, waiters:[{resolve,reject}] }.
|
|
260
|
+
// At most ONE entry per session id at a time (single-in-flight-per-id).
|
|
261
|
+
let _saveWorkerPending = new Map();
|
|
262
|
+
// Latest-wins queued payload per session, keyed by id. Value: { session, opts, waiters:[] }.
|
|
263
|
+
// At most ONE queued write per id: a newer saveSessionAsync while a write is in
|
|
264
|
+
// flight overwrites session/opts here and appends its resolver to waiters, so
|
|
265
|
+
// every superseded caller resolves when this single queued write finally lands.
|
|
266
|
+
let _saveAsyncQueued = new Map();
|
|
267
|
+
// id → reqId of the in-flight write for that id (enforces one-in-flight-per-id).
|
|
268
|
+
let _saveAsyncInflight = new Map();
|
|
260
269
|
let _saveWorkerReqId = 0;
|
|
261
270
|
let _saveWorkerRefCount = 0;
|
|
262
271
|
|
|
@@ -273,12 +282,37 @@ function _getOrSpawnWorker() {
|
|
|
273
282
|
// becomes unref'd again once all in-flight writes settle. _saveWorker
|
|
274
283
|
// null-check covers the error/exit race where the worker died first.
|
|
275
284
|
if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
|
|
276
|
-
|
|
277
|
-
|
|
285
|
+
const { id, waiters } = p;
|
|
286
|
+
_saveAsyncInflight.delete(id);
|
|
287
|
+
// Resolve/reject every caller whose payload this write represents
|
|
288
|
+
// (the originating call plus any that coalesced onto it before it was
|
|
289
|
+
// posted). A supersede never lands here as a rejection — only a real
|
|
290
|
+
// worker failure does.
|
|
291
|
+
if (ok) { for (const w of waiters) w.resolve(); }
|
|
292
|
+
else {
|
|
293
|
+
const e = new Error(`[session-store] worker save failed: ${error}`);
|
|
294
|
+
for (const w of waiters) w.reject(e);
|
|
295
|
+
}
|
|
296
|
+
// Promote the latest-wins queued payload (if any) into the now-free
|
|
297
|
+
// in-flight slot for this id. Runs regardless of ok: the queued write
|
|
298
|
+
// is a newer, independent payload and must still be attempted so its
|
|
299
|
+
// (possibly superseded) waiters resolve when it lands.
|
|
300
|
+
const q = _saveAsyncQueued.get(id);
|
|
301
|
+
if (q) {
|
|
302
|
+
_saveAsyncQueued.delete(id);
|
|
303
|
+
try {
|
|
304
|
+
_postAsyncWrite(id, q.session, q.opts, q.waiters);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
for (const w of q.waiters) w.reject(err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
278
309
|
});
|
|
279
310
|
_saveWorker.on('error', (err) => {
|
|
280
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
311
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
281
312
|
_saveWorkerPending.clear();
|
|
313
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
314
|
+
_saveAsyncQueued.clear();
|
|
315
|
+
_saveAsyncInflight.clear();
|
|
282
316
|
_saveWorkerRefCount = 0;
|
|
283
317
|
_saveWorker = null;
|
|
284
318
|
});
|
|
@@ -290,8 +324,11 @@ function _getOrSpawnWorker() {
|
|
|
290
324
|
// saveSessionAsync registered a resolver but before the worker
|
|
291
325
|
// received the message.
|
|
292
326
|
const err = new Error(`[session-store] save worker exited with code ${code}`);
|
|
293
|
-
for (const [, p] of _saveWorkerPending) p.reject(err);
|
|
327
|
+
for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
|
|
294
328
|
_saveWorkerPending.clear();
|
|
329
|
+
for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
|
|
330
|
+
_saveAsyncQueued.clear();
|
|
331
|
+
_saveAsyncInflight.clear();
|
|
295
332
|
_saveWorkerRefCount = 0;
|
|
296
333
|
_saveWorker = null;
|
|
297
334
|
});
|
|
@@ -299,14 +336,45 @@ function _getOrSpawnWorker() {
|
|
|
299
336
|
return _saveWorker;
|
|
300
337
|
}
|
|
301
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Post one in-flight write for `id` to the worker and register it as the
|
|
341
|
+
* single in-flight entry for that id. Callers guarantee no write is already
|
|
342
|
+
* in flight for `id`. Throws (after cleaning its own map entries) if the
|
|
343
|
+
* worker postMessage fails so the caller can reject the affected waiters.
|
|
344
|
+
*/
|
|
345
|
+
function _postAsyncWrite(id, session, opts, waiters) {
|
|
346
|
+
const reqId = ++_saveWorkerReqId;
|
|
347
|
+
_saveWorkerPending.set(reqId, { id, session, opts, waiters });
|
|
348
|
+
_saveAsyncInflight.set(id, reqId);
|
|
349
|
+
try {
|
|
350
|
+
const w = _getOrSpawnWorker();
|
|
351
|
+
w.postMessage({ session, opts, reqId });
|
|
352
|
+
// Ref AFTER successful postMessage so a queue/throw failure path does
|
|
353
|
+
// not leave the worker held alive with no pending message. Paired with
|
|
354
|
+
// the unref in the message handler when count hits 0.
|
|
355
|
+
if (++_saveWorkerRefCount === 1) w.ref();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
_saveWorkerPending.delete(reqId);
|
|
358
|
+
_saveAsyncInflight.delete(id);
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
302
363
|
/**
|
|
303
364
|
* Async save via a dedicated Worker thread.
|
|
304
365
|
* Errors surface as thrown Errors — callers must not silently swallow them.
|
|
366
|
+
*
|
|
367
|
+
* Per-session latest-wins coalescing: for a given id there is at most one
|
|
368
|
+
* write in flight plus one queued follow-up. N rapid saves for the same id in
|
|
369
|
+
* a turn collapse to (in-flight + one queued-latest), keeping the single
|
|
370
|
+
* worker's backlog bounded. Per-id write ORDERING is preserved (a queued write
|
|
371
|
+
* is only posted once the prior in-flight write for that id settles); different
|
|
372
|
+
* ids interleave freely as before.
|
|
305
373
|
*/
|
|
306
374
|
export function saveSessionAsync(session, opts) {
|
|
307
375
|
_ensureLifecycleFields(session);
|
|
308
376
|
setLiveSession(session);
|
|
309
|
-
const
|
|
377
|
+
const id = session.id;
|
|
310
378
|
const safeOpts = opts || null;
|
|
311
379
|
// The Worker `postMessage` below structured-clones the whole session on the
|
|
312
380
|
// main thread. `session.liveTurnMessages` (live working transcript) and
|
|
@@ -322,18 +390,28 @@ export function saveSessionAsync(session, opts) {
|
|
|
322
390
|
? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
|
|
323
391
|
: session;
|
|
324
392
|
return new Promise((resolve, reject) => {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
393
|
+
const waiter = { resolve, reject };
|
|
394
|
+
if (_saveAsyncInflight.has(id)) {
|
|
395
|
+
// A write is already on disk for this id — coalesce into the single
|
|
396
|
+
// latest-wins queued slot. Existing queued waiters carry over so a
|
|
397
|
+
// superseded caller resolves when THIS newer write lands (never
|
|
398
|
+
// hang, never reject on supersede).
|
|
399
|
+
const q = _saveAsyncQueued.get(id);
|
|
400
|
+
if (q) {
|
|
401
|
+
q.session = clonePayload;
|
|
402
|
+
q.opts = safeOpts;
|
|
403
|
+
q.waiters.push(waiter);
|
|
404
|
+
} else {
|
|
405
|
+
_saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Idle for this id — post immediately as the in-flight write. The
|
|
410
|
+
// in-flight entry persists {session, opts} so drainSessionStore can
|
|
411
|
+
// sync-flush outstanding writes if process exit interrupts the queue.
|
|
328
412
|
try {
|
|
329
|
-
|
|
330
|
-
w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
|
|
331
|
-
// Ref AFTER successful postMessage so a queue/throw failure path
|
|
332
|
-
// does not leave the worker held alive with no pending message.
|
|
333
|
-
// Paired with the unref in the message handler when count hits 0.
|
|
334
|
-
if (++_saveWorkerRefCount === 1) w.ref();
|
|
413
|
+
_postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
|
|
335
414
|
} catch (err) {
|
|
336
|
-
_saveWorkerPending.delete(reqId);
|
|
337
415
|
reject(err);
|
|
338
416
|
}
|
|
339
417
|
});
|
|
@@ -431,18 +509,35 @@ export function drainSessionStore() {
|
|
|
431
509
|
// is sync-flushed directly here. The Promise is then rejected so the
|
|
432
510
|
// caller's await site does not leak unresolved (caller is at process
|
|
433
511
|
// exit so the rejection is informational, not actionable).
|
|
512
|
+
const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
|
|
513
|
+
// Flush in-flight writes FIRST, then the latest-wins queued payloads, so
|
|
514
|
+
// for any id with both an in-flight and a queued write the queued (newest)
|
|
515
|
+
// state is written LAST and wins on disk — no lost last write.
|
|
434
516
|
for (const [, pending] of _saveWorkerPending) {
|
|
435
|
-
if (
|
|
517
|
+
if (pending.session) {
|
|
518
|
+
try {
|
|
519
|
+
_saveSessionSync(pending.session, pending.opts);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const w of pending.waiters) {
|
|
525
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const [, q] of _saveAsyncQueued) {
|
|
436
529
|
try {
|
|
437
|
-
_saveSessionSync(
|
|
530
|
+
_saveSessionSync(q.session, q.opts);
|
|
438
531
|
} catch (err) {
|
|
439
532
|
process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
|
|
440
533
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
534
|
+
for (const w of q.waiters) {
|
|
535
|
+
try { w.reject(_drainErr); } catch { /* best-effort */ }
|
|
536
|
+
}
|
|
444
537
|
}
|
|
445
538
|
_saveWorkerPending.clear();
|
|
539
|
+
_saveAsyncQueued.clear();
|
|
540
|
+
_saveAsyncInflight.clear();
|
|
446
541
|
_saveWorkerRefCount = 0;
|
|
447
542
|
}
|
|
448
543
|
|
|
@@ -191,6 +191,43 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
191
191
|
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
192
192
|
);
|
|
193
193
|
|
|
194
|
+
// WS pool liveness (ping/pong) — closes the gap between a socket going
|
|
195
|
+
// half-open (peer gone / TLS wedge) and the semantic-idle watchdog noticing
|
|
196
|
+
// (120s). Node `ws` send() is fire-and-forget, so a dead pooled socket
|
|
197
|
+
// silently blackholes response.create frames until a downstream timeout. An
|
|
198
|
+
// idle pooled socket is pinged on this cadence; a reused socket that has been
|
|
199
|
+
// quiet longer than the stale window is ping-probed before hand-out.
|
|
200
|
+
// Gated OFF by default for codex/opencode parity — neither pings model
|
|
201
|
+
// sockets. Enable with MIXDOG_PROVIDER_WS_PING_ENABLED=1. When disabled, no
|
|
202
|
+
// liveness interval is armed and the acquire-reuse ping probe is skipped (the
|
|
203
|
+
// non-OPEN eviction scan still runs); lastAliveAt stamping is harmless.
|
|
204
|
+
const _wsPingRaw = process.env.MIXDOG_PROVIDER_WS_PING_ENABLED;
|
|
205
|
+
export const PROVIDER_WS_PING_ENABLED = _wsPingRaw === '1' || _wsPingRaw === 'true' || _wsPingRaw === 'yes';
|
|
206
|
+
|
|
207
|
+
export const PROVIDER_WS_PING_INTERVAL_MS = resolveTimeoutMs(
|
|
208
|
+
'MIXDOG_PROVIDER_WS_PING_INTERVAL_MS',
|
|
209
|
+
30_000,
|
|
210
|
+
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Missed-pong grace: after a ping, the pong must land within this bound or the
|
|
214
|
+
// socket is treated as dead (closed + evicted). Doubles as the short probe
|
|
215
|
+
// bound on the acquire-reuse path so a busy caller is never handed a wedged
|
|
216
|
+
// socket.
|
|
217
|
+
export const PROVIDER_WS_PONG_TIMEOUT_MS = resolveTimeoutMs(
|
|
218
|
+
'MIXDOG_PROVIDER_WS_PONG_TIMEOUT_MS',
|
|
219
|
+
5_000,
|
|
220
|
+
{ minMs: 1_000, maxMs: 60_000 },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Activity freshness window: a pooled socket with observed activity (pong /
|
|
224
|
+
// release) newer than this is assumed live and skips the acquire-reuse probe.
|
|
225
|
+
export const PROVIDER_WS_LIVENESS_STALE_MS = resolveTimeoutMs(
|
|
226
|
+
'MIXDOG_PROVIDER_WS_LIVENESS_STALE_MS',
|
|
227
|
+
30_000,
|
|
228
|
+
{ minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
229
|
+
);
|
|
230
|
+
|
|
194
231
|
// Single inter-chunk idle timer (300s), matching the upstream WS provider's
|
|
195
232
|
// default stream idle timeout. Mixdog resets one idle timer on every received
|
|
196
233
|
// WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
|
|
@@ -39,7 +39,7 @@ export const BUILTIN_TOOLS = [
|
|
|
39
39
|
name: 'read',
|
|
40
40
|
title: 'Mixdog Read',
|
|
41
41
|
annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
42
|
-
description: 'Read verified file path(s)
|
|
42
|
+
description: 'Read verified file path(s); guessed path → find first (same turn as other probes). Batch paths/regions as real arrays in one call. Not for directory listing.',
|
|
43
43
|
inputSchema: {
|
|
44
44
|
type: 'object',
|
|
45
45
|
properties: {
|
|
@@ -109,7 +109,7 @@ export const BUILTIN_TOOLS = [
|
|
|
109
109
|
name: 'grep',
|
|
110
110
|
title: 'Mixdog Grep',
|
|
111
111
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
112
|
-
description: 'Exact text/regex in verified scope.
|
|
112
|
+
description: 'Exact text/regex in verified scope (project root counts as verified — unscoped search needs no find). Only a guessed path fragment → find first, same turn. No path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
|
|
113
113
|
inputSchema: {
|
|
114
114
|
type: 'object',
|
|
115
115
|
properties: {
|
|
@@ -125,7 +125,7 @@ export const BUILTIN_TOOLS = [
|
|
|
125
125
|
{ type: 'string' },
|
|
126
126
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
127
127
|
],
|
|
128
|
-
description: 'Verified file/dir
|
|
128
|
+
description: 'Verified file/dir, or project root; guessed → find first.',
|
|
129
129
|
},
|
|
130
130
|
glob: {
|
|
131
131
|
anyOf: [
|
|
@@ -148,7 +148,7 @@ export const BUILTIN_TOOLS = [
|
|
|
148
148
|
name: 'glob',
|
|
149
149
|
title: 'Mixdog Glob',
|
|
150
150
|
annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
151
|
-
description: 'Exact glob from verified roots.
|
|
151
|
+
description: 'Exact glob from verified roots (project root is verified). Guessed root/name → find first, same turn. No path "." + guessed src/**. Batch arrays.',
|
|
152
152
|
inputSchema: {
|
|
153
153
|
type: 'object',
|
|
154
154
|
properties: {
|
|
@@ -164,7 +164,7 @@ export const BUILTIN_TOOLS = [
|
|
|
164
164
|
{ type: 'string' },
|
|
165
165
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
166
166
|
],
|
|
167
|
-
description: 'Verified base dir(s);
|
|
167
|
+
description: 'Verified base dir(s) or project root; guessed → find first. Batch path[].',
|
|
168
168
|
},
|
|
169
169
|
head_limit: { type: 'number', description: 'Max entries.' },
|
|
170
170
|
offset: { type: 'number', description: 'Skip entries.' },
|
|
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
|
|
|
197
197
|
name: 'list',
|
|
198
198
|
title: 'Mixdog List Directory',
|
|
199
199
|
annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
200
|
-
description: 'List verified directories.
|
|
200
|
+
description: 'List verified directories (project root included). Guessed dir → find first. Batch dirs as path[].',
|
|
201
201
|
inputSchema: {
|
|
202
202
|
type: 'object',
|
|
203
203
|
properties: {
|
|
@@ -3,7 +3,7 @@ export const CODE_GRAPH_TOOL_DEFS = [
|
|
|
3
3
|
name: 'code_graph',
|
|
4
4
|
title: 'Code Graph',
|
|
5
5
|
annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
|
|
6
|
-
description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps.
|
|
6
|
+
description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Symbols by name need no pre-verification; files[] must be verified paths. Batch symbols[]/files[].',
|
|
7
7
|
inputSchema: {
|
|
8
8
|
type: 'object',
|
|
9
9
|
properties: {
|
|
@@ -277,13 +277,13 @@ export function createProviderModels({
|
|
|
277
277
|
return providerModelsFromCacheRows(await caches.providerModelsPromise);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
function warmProviderModelCache() {
|
|
280
|
+
function warmProviderModelCache({ loadSecrets = false } = {}) {
|
|
281
281
|
if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
|
|
282
282
|
profile('warm:start');
|
|
283
283
|
const seq = ++caches.providerModelsLoadSeq;
|
|
284
|
-
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets
|
|
284
|
+
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets })
|
|
285
285
|
.then((models) => {
|
|
286
|
-
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets
|
|
286
|
+
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets })) {
|
|
287
287
|
caches.providerModelsCache = { models, at: Date.now() };
|
|
288
288
|
}
|
|
289
289
|
bootProfile('provider-models:warm-ready', { count: models.length });
|
|
@@ -306,6 +306,26 @@ export function __saveModelSettingsForTest(cfgMod, route, options = {}) {
|
|
|
306
306
|
return saveModelSettings(cfgMod, route, options);
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// Decide whether notifyFnForSession should mirror a terminal tool completion
|
|
310
|
+
// into the pending-message queue. Only the TUI execution-ui path actually
|
|
311
|
+
// injects the model-visible twin of the completion into the active loop, and it
|
|
312
|
+
// signals that with an EXPLICIT ack (modelVisibleDelivered) — never a generic
|
|
313
|
+
// truthy onNotification return. Skipping the mirror on a bare `handled===true`
|
|
314
|
+
// would let a display-only / API listener suppress the sole model-visible copy,
|
|
315
|
+
// so the model would never see the completion body. Mirror UNLESS the body was
|
|
316
|
+
// explicitly delivered; unknown/generic-handled → keep the mirror.
|
|
317
|
+
export function shouldMirrorCompletionToPendingQueue({
|
|
318
|
+
callerSessionId,
|
|
319
|
+
modelVisibleDelivered,
|
|
320
|
+
hasEnqueue,
|
|
321
|
+
text,
|
|
322
|
+
meta = {},
|
|
323
|
+
} = {}) {
|
|
324
|
+
if (!callerSessionId || !hasEnqueue) return false;
|
|
325
|
+
if (modelVisibleDelivered) return false;
|
|
326
|
+
return shouldPersistModelVisibleToolCompletion(text, meta);
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
export async function createMixdogSessionRuntime({
|
|
310
330
|
provider,
|
|
311
331
|
model,
|
|
@@ -742,7 +762,7 @@ export async function createMixdogSessionRuntime({
|
|
|
742
762
|
|
|
743
763
|
function emitRuntimeNotification(content, meta = {}) {
|
|
744
764
|
const text = String(content || '').trim();
|
|
745
|
-
if (!text) return false;
|
|
765
|
+
if (!text) return { handled: false, modelVisibleDelivered: false };
|
|
746
766
|
const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
|
|
747
767
|
let handled = false;
|
|
748
768
|
for (const listener of [...notificationListeners]) {
|
|
@@ -750,19 +770,34 @@ export async function createMixdogSessionRuntime({
|
|
|
750
770
|
if (listener(event) === true) handled = true;
|
|
751
771
|
} catch {}
|
|
752
772
|
}
|
|
753
|
-
|
|
773
|
+
// EXPLICIT model-visible-delivery ack: only the TUI execution-ui path sets
|
|
774
|
+
// event.modelVisibleDelivered when it enqueues the model-visible completion
|
|
775
|
+
// body into the active loop. A generic display-only / API listener that
|
|
776
|
+
// returns true does NOT set it, so `handled` alone must never be read as
|
|
777
|
+
// "the model saw the body".
|
|
778
|
+
const modelVisibleDelivered = event.modelVisibleDelivered === true;
|
|
779
|
+
return { handled, modelVisibleDelivered };
|
|
754
780
|
}
|
|
755
781
|
|
|
756
782
|
function notifyFnForSession(callerSessionId) {
|
|
757
783
|
return (text, meta = {}) => {
|
|
758
|
-
const handledByRuntimeListener = emitRuntimeNotification(text, meta);
|
|
784
|
+
const { handled: handledByRuntimeListener, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
|
|
759
785
|
let enqueued = false;
|
|
760
786
|
// TUI sessions consume raw execution notifications for UI/task cards via
|
|
761
787
|
// onNotification, but those raw envelopes are internal-only in pending
|
|
762
|
-
// drain.
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
788
|
+
// drain. The TUI execution-ui path injects the model-visible twin of a
|
|
789
|
+
// terminal completion into the active loop and acks with
|
|
790
|
+
// modelVisibleDelivered, so mirroring it into the pending queue would
|
|
791
|
+
// double-inject the same completion — skip only on that EXPLICIT ack. A
|
|
792
|
+
// generic display-only / API listener returning true is NOT an ack: the
|
|
793
|
+
// mirror stays as the sole model-visible delivery.
|
|
794
|
+
if (shouldMirrorCompletionToPendingQueue({
|
|
795
|
+
callerSessionId,
|
|
796
|
+
modelVisibleDelivered,
|
|
797
|
+
hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
|
|
798
|
+
text,
|
|
799
|
+
meta,
|
|
800
|
+
})) {
|
|
766
801
|
try {
|
|
767
802
|
const visible = modelVisibleToolCompletionMessage(text, meta);
|
|
768
803
|
// Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
|
|
@@ -973,7 +1008,7 @@ export async function createMixdogSessionRuntime({
|
|
|
973
1008
|
const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
|
|
974
1009
|
const content = channelNotificationModelContent(params);
|
|
975
1010
|
if (!content) return;
|
|
976
|
-
const handled = emitRuntimeNotification(content, meta);
|
|
1011
|
+
const { handled } = emitRuntimeNotification(content, meta);
|
|
977
1012
|
if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
|
|
978
1013
|
try { mgr.enqueuePendingMessage(session.id, content); } catch {}
|
|
979
1014
|
}
|
|
@@ -102,7 +102,13 @@ export function createWarmupSchedulers({
|
|
|
102
102
|
scheduleProviderModelWarmup(backgroundBusyRetryMs);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
-
|
|
105
|
+
if (!isFirstTurnCompleted() && !envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')) {
|
|
106
|
+
bootProfile('provider-models:warm-deferred', { reason: 'first-turn-pending' });
|
|
107
|
+
warmProviderModelCache();
|
|
108
|
+
scheduleProviderModelWarmup(backgroundBusyRetryMs);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
warmProviderModelCache({ loadSecrets: true });
|
|
106
112
|
}, delayMs);
|
|
107
113
|
timers.providerModelWarmupTimer.unref?.();
|
|
108
114
|
}
|
|
@@ -92,7 +92,7 @@ function escapeXml(str) {
|
|
|
92
92
|
// reminder. The full no-verdict contract lives at system level
|
|
93
93
|
// (rules/agent/30-explorer.md).
|
|
94
94
|
export function buildExplorerPrompt(query) {
|
|
95
|
-
return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer —
|
|
95
|
+
return `<query>${escapeXml(query)}</query>\nReminder: BUDGET = TWO MESSAGES NORMALLY: message 1 = your multi-tool batch, message 2 = your answer text. A 3rd message (ANY extra tool call) is a DEFECT unless message 1 returned ZERO specific-token lines — extra code_graph/grep calls to confirm or upgrade an anchor you already hold are the single biggest source of overspend. TURN 1 IS ONE BATCH, NON-NEGOTIABLE: your FIRST tool message fires, in that single message together, grep (pattern[] packing 3-6 token variants) AND code_graph symbol_search (identifiers) AND find/glob (path/name fragments) — never emit grep alone and wait for its result before adding code_graph/find. Serial one-tool-per-turn is the #1 budget defect and forfeits the turn-1 -> answer path; a first turn that is not this multi-tool batch is malformed. After that batch returns, if ANY line carries a specific query token you MUST emit the ANSWER as your very next message: a further tool call to refine, confirm, or upgrade is the primary overspend defect — a second tool turn is legal ONLY when the batch returned ZERO specific-token lines. Then RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — trace no chains, one anchor per concept; the SOLE exception: a query that EXPLICITLY asks flow/default-resolution whose turn 1 yielded only an entry anchor (not the resolved value) may take ONE turn-2 hop to the resolving site, then stop. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path (project cwd default) or pass only a path seen in an earlier result; an unverified path/name fragment rides the SAME turn-1 find query[] batch (a find-only turn is a defect) and scopes grep/glob only through the exact path find returns; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. For a CODE-location answer every line MUST carry :line (an explicit line number) — a bare filename with no :line is a DEFECT; with no line-anchored code evidence, return EXPLORATION_FAILED rather than a vague file-only or prose answer. EXCEPTION — file/dir-LOCATION queries (where does X store its config/logs/data on disk, which directory or file holds Y): an exact VERIFIED path (the file or directory itself, no :line) IS the valid answer — do not force a line number and do not FAIL. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export function normalizeExploreQueries(rawQuery) {
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -22625,6 +22625,7 @@ function agentArgsWithResultMetadata(args, parsed) {
|
|
|
22625
22625
|
|
|
22626
22626
|
// src/tui/engine/queue-helpers.mjs
|
|
22627
22627
|
var QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
|
|
22628
|
+
var STEERING_SUPPRESSED_DISPLAY = "\0mixdog:suppress-steer-display\0";
|
|
22628
22629
|
function queuePriorityValue(value) {
|
|
22629
22630
|
return QUEUE_PRIORITY[String(value || "next")] ?? QUEUE_PRIORITY.next;
|
|
22630
22631
|
}
|
|
@@ -23347,8 +23348,13 @@ function createAgentJobFeed({
|
|
|
23347
23348
|
// next tool batch; no special pending-resume bypass.
|
|
23348
23349
|
priority: "next",
|
|
23349
23350
|
key: notificationKey || void 0,
|
|
23350
|
-
displayText: delivery.displayText || text
|
|
23351
|
+
displayText: delivery.displayText || text,
|
|
23352
|
+
// The immediate Response card was already pushed above
|
|
23353
|
+
// (pushUserOrSyntheticItem). Keep this queued twin model-visible
|
|
23354
|
+
// but suppress its drain-time transcript card to avoid a duplicate.
|
|
23355
|
+
suppressDisplay: true
|
|
23351
23356
|
});
|
|
23357
|
+
if (event && typeof event === "object") event.modelVisibleDelivered = true;
|
|
23352
23358
|
}
|
|
23353
23359
|
return true;
|
|
23354
23360
|
}
|
|
@@ -23771,6 +23777,7 @@ function createSessionFlow(bag) {
|
|
|
23771
23777
|
key: options.key || null,
|
|
23772
23778
|
skipSlashCommands: options.skipSlashCommands === true,
|
|
23773
23779
|
displayText: mode === "task-notification" ? notificationDisplayText(displayText) : String(displayText || ""),
|
|
23780
|
+
suppressDisplay: options.suppressDisplay === true,
|
|
23774
23781
|
steeringPersistId: options.steeringPersistId || null,
|
|
23775
23782
|
steeringPersistRestored: options.steeringPersistRestored === true
|
|
23776
23783
|
};
|
|
@@ -23882,6 +23889,7 @@ function createSessionFlow(bag) {
|
|
|
23882
23889
|
const merged = mergePromptContents(batch);
|
|
23883
23890
|
for (const entry of batch) {
|
|
23884
23891
|
if (entry.mode === "pending-resume") continue;
|
|
23892
|
+
if (entry.suppressDisplay) continue;
|
|
23885
23893
|
pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? "user" : "injected");
|
|
23886
23894
|
}
|
|
23887
23895
|
const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
|
|
@@ -23948,6 +23956,12 @@ function createSessionFlow(bag) {
|
|
|
23948
23956
|
if (batch.length === 0) break;
|
|
23949
23957
|
for (const entry of batch) {
|
|
23950
23958
|
const content = entry.content;
|
|
23959
|
+
if (entry.suppressDisplay) {
|
|
23960
|
+
if (Array.isArray(content) ? content.length > 0 : String(content ?? "").trim().length > 0) {
|
|
23961
|
+
out.push({ text: STEERING_SUPPRESSED_DISPLAY, content });
|
|
23962
|
+
}
|
|
23963
|
+
continue;
|
|
23964
|
+
}
|
|
23951
23965
|
const value = typeof content === "string" ? content.trim() : { text: String(entry.text || "").trim(), content };
|
|
23952
23966
|
if (typeof value === "string") {
|
|
23953
23967
|
if (value.length > 0) out.push(value);
|
|
@@ -24770,6 +24784,7 @@ function createRunTurn(bag) {
|
|
|
24770
24784
|
drainSteering: (_sessionId, drainOptions) => isCurrentTurn() ? drainPendingSteering(drainOptions) : [],
|
|
24771
24785
|
onSteerMessage: (text) => {
|
|
24772
24786
|
if (!markTurnProgress("steer-message")) return;
|
|
24787
|
+
if (text === STEERING_SUPPRESSED_DISPLAY) return;
|
|
24773
24788
|
flushStreamBatch();
|
|
24774
24789
|
if (currentAssistantId) {
|
|
24775
24790
|
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
@@ -152,7 +152,17 @@ export function createAgentJobFeed({
|
|
|
152
152
|
priority: 'next',
|
|
153
153
|
key: notificationKey || undefined,
|
|
154
154
|
displayText: delivery.displayText || text,
|
|
155
|
+
// The immediate Response card was already pushed above
|
|
156
|
+
// (pushUserOrSyntheticItem). Keep this queued twin model-visible
|
|
157
|
+
// but suppress its drain-time transcript card to avoid a duplicate.
|
|
158
|
+
suppressDisplay: true,
|
|
155
159
|
});
|
|
160
|
+
// EXPLICIT ack to the emitting runtime: the model-visible completion
|
|
161
|
+
// body was injected into the active loop here, so notifyFnForSession
|
|
162
|
+
// must NOT also mirror it into the pending queue (double injection).
|
|
163
|
+
// A bare truthy return below is display/status handling only — this
|
|
164
|
+
// flag is the sole model-visible-delivery signal.
|
|
165
|
+
if (event && typeof event === 'object') event.modelVisibleDelivered = true;
|
|
156
166
|
}
|
|
157
167
|
return true;
|
|
158
168
|
}
|
|
@@ -12,6 +12,14 @@ import {
|
|
|
12
12
|
|
|
13
13
|
const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
|
|
14
14
|
|
|
15
|
+
// Sentinel display text for a steering entry that is model-visible only: its
|
|
16
|
+
// immediate transcript ("Response …") card was already pushed at delivery time
|
|
17
|
+
// (live execution completion), so the mid-turn steering drain must NOT render a
|
|
18
|
+
// second card for it. drainPendingSteering emits this as the entry's display
|
|
19
|
+
// text; turn.mjs's onSteerMessage recognizes it and skips the push while the
|
|
20
|
+
// model still receives the entry's content.
|
|
21
|
+
export const STEERING_SUPPRESSED_DISPLAY = '\u0000mixdog:suppress-steer-display\u0000';
|
|
22
|
+
|
|
15
23
|
export function queuePriorityValue(value) {
|
|
16
24
|
return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
|
|
17
25
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { presentErrorText } from '../../runtime/shared/err-text.mjs';
|
|
5
5
|
import { createSessionStats } from './session-stats.mjs';
|
|
6
|
-
import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks } from './queue-helpers.mjs';
|
|
6
|
+
import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
|
|
7
7
|
import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
|
|
8
8
|
|
|
9
9
|
export function createSessionFlow(bag) {
|
|
@@ -53,6 +53,7 @@ export function createSessionFlow(bag) {
|
|
|
53
53
|
key: options.key || null,
|
|
54
54
|
skipSlashCommands: options.skipSlashCommands === true,
|
|
55
55
|
displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
|
|
56
|
+
suppressDisplay: options.suppressDisplay === true,
|
|
56
57
|
steeringPersistId: options.steeringPersistId || null,
|
|
57
58
|
steeringPersistRestored: options.steeringPersistRestored === true,
|
|
58
59
|
};
|
|
@@ -186,6 +187,10 @@ export function createSessionFlow(bag) {
|
|
|
186
187
|
const merged = mergePromptContents(batch);
|
|
187
188
|
for (const entry of batch) {
|
|
188
189
|
if (entry.mode === 'pending-resume') continue;
|
|
190
|
+
// Live execution completions push their own immediate Response card
|
|
191
|
+
// at delivery time; the queued twin is model-visible only and must
|
|
192
|
+
// NOT render a second transcript card here (no fall-back to content).
|
|
193
|
+
if (entry.suppressDisplay) continue;
|
|
189
194
|
pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
|
|
190
195
|
}
|
|
191
196
|
const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
|
|
@@ -274,6 +279,15 @@ export function createSessionFlow(bag) {
|
|
|
274
279
|
if (batch.length === 0) break;
|
|
275
280
|
for (const entry of batch) {
|
|
276
281
|
const content = entry.content;
|
|
282
|
+
if (entry.suppressDisplay) {
|
|
283
|
+
// Model-visible twin of an already-rendered live completion: deliver
|
|
284
|
+
// content to the model but flag onSteerMessage to skip the duplicate
|
|
285
|
+
// transcript card (no fall-back to content-derived display text).
|
|
286
|
+
if (Array.isArray(content) ? content.length > 0 : String(content ?? '').trim().length > 0) {
|
|
287
|
+
out.push({ text: STEERING_SUPPRESSED_DISPLAY, content });
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
277
291
|
const value = typeof content === 'string'
|
|
278
292
|
? content.trim()
|
|
279
293
|
: { text: String(entry.text || '').trim(), content };
|
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from '.
|
|
|
7
7
|
import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
|
|
8
8
|
import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
|
|
9
9
|
import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
|
|
10
|
-
import { promptDisplayText } from './queue-helpers.mjs';
|
|
10
|
+
import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
|
|
11
11
|
import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
|
|
12
12
|
import { yieldToRenderer } from './render-timing.mjs';
|
|
13
13
|
import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
|
|
@@ -727,6 +727,11 @@ export function createRunTurn(bag) {
|
|
|
727
727
|
drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
|
|
728
728
|
onSteerMessage: (text) => {
|
|
729
729
|
if (!markTurnProgress('steer-message')) return;
|
|
730
|
+
// A suppressed live-completion twin is model-visible only; its
|
|
731
|
+
// Response card was already pushed at delivery time. Skip the
|
|
732
|
+
// duplicate transcript item (progress is still marked above since
|
|
733
|
+
// the content WAS injected into the model turn).
|
|
734
|
+
if (text === STEERING_SUPPRESSED_DISPLAY) return;
|
|
730
735
|
// Steering can be injected after a terminal no-tool response has
|
|
731
736
|
// already streamed but before runTurn finalizes. Seal the current
|
|
732
737
|
// assistant segment first so the steered user turn and the next
|