mixdog 0.9.12 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +11 -7
- package/src/lib/rules-builder.cjs +15 -11
- package/src/mixdog-session-runtime.mjs +10 -0
- package/src/rules/agent/00-common.md +5 -17
- package/src/rules/agent/00-core.md +21 -0
- package/src/rules/lead/lead-brief.md +7 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
- package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
- package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
- package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
- package/src/runtime/channels/index.mjs +28 -1
- package/src/runtime/channels/lib/memory-client.mjs +200 -15
- package/src/runtime/memory/index.mjs +11 -11
- package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/workflows/default/WORKFLOW.md +2 -0
- package/src/workflows/sequential/WORKFLOW.md +2 -0
- package/src/workflows/solo/WORKFLOW.md +2 -0
|
@@ -4,7 +4,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
|
|
|
4
4
|
import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
|
|
5
5
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
6
6
|
import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
|
|
7
|
-
import { resolveSessionMaxLoopIterations
|
|
7
|
+
import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
|
|
8
8
|
import { isAgentOwner } from '../agent-owner.mjs';
|
|
9
9
|
import { markSessionToolCall, updateSessionStage, SessionClosedError, bumpUsageMetricsEpoch } from './manager.mjs';
|
|
10
10
|
import {
|
|
@@ -48,8 +48,7 @@ import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
|
|
|
48
48
|
import {
|
|
49
49
|
crossTurnSignature,
|
|
50
50
|
crossTurnDedupStub,
|
|
51
|
-
|
|
52
|
-
SOFT_CAP_REFUSAL_STUB,
|
|
51
|
+
ITERATION_CAP_REFUSAL_STUB,
|
|
53
52
|
} from './loop/completion-guards.mjs';
|
|
54
53
|
import { isEditProgressTool } from './loop/completion-guards.mjs';
|
|
55
54
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
@@ -137,7 +136,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
137
136
|
let lastUsage;
|
|
138
137
|
let firstTurnUsage;
|
|
139
138
|
let response;
|
|
140
|
-
let contractNudges = 0;
|
|
141
139
|
let contextOverflowRetryUsed = false;
|
|
142
140
|
// Set when the hard iteration-cap break below fires. Consumed at the final
|
|
143
141
|
// return to tag terminationReason='iteration_cap' so a worker that exhausts
|
|
@@ -240,14 +238,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
240
238
|
const _crossTurnCalls = new Map();
|
|
241
239
|
const _CROSS_TURN_CAP = 500;
|
|
242
240
|
let _dedupStubTotal = 0;
|
|
243
|
-
// Step 3: worker soft-cap wrap-up state.
|
|
244
|
-
const _softCapEnabled = isWorkerSoftCapSession(sessionRef);
|
|
245
|
-
let _softCapActive = false; // tools disabled + wrap-up injected
|
|
246
|
-
let _softCapGraceTurns = 0; // text-only grace turns consumed (max 2)
|
|
247
|
-
let _terminatedBySoftCap = false;
|
|
248
241
|
// Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
|
|
249
242
|
// hard iteration cap fires, so the session ends with text, not empty.
|
|
250
243
|
let _capFinalTurnUsed = false;
|
|
244
|
+
// True while the granted hard-cap final turn is active (no tool defs).
|
|
245
|
+
let _capFinalToolsDisabled = false;
|
|
251
246
|
// Completion-first steering ladder controller. Owns the (cumulative) level-1
|
|
252
247
|
// fire count, the all-read-only / serial-single / same-file-grep streaks,
|
|
253
248
|
// and the level-2 latch. Threaded via live getters so it reads the loop's
|
|
@@ -257,7 +252,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
257
252
|
sessionAgent,
|
|
258
253
|
tools,
|
|
259
254
|
getIterations: () => iterations,
|
|
260
|
-
softCapEnabled: _softCapEnabled,
|
|
261
255
|
getEditCount: () => _editCount,
|
|
262
256
|
readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
|
|
263
257
|
pushUserMessage: (msg) => messages.push(msg),
|
|
@@ -272,8 +266,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
272
266
|
// forced termination is the hard cap at maxLoopIterations (default 200):
|
|
273
267
|
// a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
|
|
274
268
|
// of the cap steering the model to converge — warnings only, nothing is
|
|
275
|
-
// cut off early.
|
|
276
|
-
// ladder
|
|
269
|
+
// cut off early. Other runaway protection is behavior-based (steering
|
|
270
|
+
// ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
|
|
277
271
|
let _iterWarnStage = 0;
|
|
278
272
|
const _iterWarnAt = [
|
|
279
273
|
Math.floor(maxLoopIterations * 0.5),
|
|
@@ -286,7 +280,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
286
280
|
// Final-answer turn: instead of breaking mid-transcript (which
|
|
287
281
|
// yields an empty final for locator-style agents that never got to
|
|
288
282
|
// answer), give the model ONE tool-less text turn to wrap up, then
|
|
289
|
-
// stop
|
|
283
|
+
// stop (empty sendTools + refusal stubs if tools are requested).
|
|
290
284
|
if (_capFinalTurnUsed) {
|
|
291
285
|
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
292
286
|
terminatedByCap = true;
|
|
@@ -302,8 +296,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
302
296
|
break;
|
|
303
297
|
}
|
|
304
298
|
_capFinalTurnUsed = true;
|
|
305
|
-
|
|
306
|
-
messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached
|
|
299
|
+
_capFinalToolsDisabled = true;
|
|
300
|
+
messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached — tools disabled; answer with your best result from context.\n</system-reminder>', meta: 'hook' });
|
|
307
301
|
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
|
|
308
302
|
}
|
|
309
303
|
if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
|
|
@@ -324,25 +318,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
324
318
|
});
|
|
325
319
|
} catch { /* best-effort */ }
|
|
326
320
|
}
|
|
327
|
-
// Worker soft cap (Step 3): non-lead sessions that reach the soft-cap
|
|
328
|
-
// iteration count switch to a text-only wrap-up. On the FIRST crossing
|
|
329
|
-
// we disable tool defs (below, via _softCapActive) and inject the
|
|
330
|
-
// assistant-visible wrap-up directive as a user message so the next
|
|
331
|
-
// send produces a final text summary. Lead/TUI sessions never enter.
|
|
332
|
-
const _earlySoftCap = _steeringLadder.earlySoftCapArmed();
|
|
333
|
-
if (_softCapEnabled && !_softCapActive && (iterations >= WORKER_SOFT_CAP_ITERATIONS || _earlySoftCap)) {
|
|
334
|
-
_softCapActive = true;
|
|
335
|
-
messages.push({ role: 'user', content: `<system-reminder>\n${SOFT_CAP_WRAPUP_MESSAGE}\n</system-reminder>`, meta: 'hook' });
|
|
336
|
-
try {
|
|
337
|
-
appendAgentTrace({
|
|
338
|
-
sessionId,
|
|
339
|
-
iteration: iterations,
|
|
340
|
-
kind: 'steer',
|
|
341
|
-
payload: { tag: 'soft_cap_wrapup', soft_cap: WORKER_SOFT_CAP_ITERATIONS, early: _earlySoftCap, level2_fires: _steeringLadder.level2FireCount },
|
|
342
|
-
agent: sessionAgent || null,
|
|
343
|
-
});
|
|
344
|
-
} catch { /* best-effort */ }
|
|
345
|
-
}
|
|
346
321
|
// Drain queued steering/prompts BEFORE the
|
|
347
322
|
// pre-send compact check. The compact decision must see the exact
|
|
348
323
|
// message set that the next provider.send would receive, including
|
|
@@ -757,9 +732,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
757
732
|
} else {
|
|
758
733
|
delete opts.toolChoice;
|
|
759
734
|
}
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
const sendTools =
|
|
735
|
+
// Hard-cap final turn: send NO tool definitions so the provider can
|
|
736
|
+
// only emit text. Overrides the forced-first-tool path.
|
|
737
|
+
const sendTools = _capFinalToolsDisabled
|
|
763
738
|
? []
|
|
764
739
|
: (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
|
|
765
740
|
// Eager-dispatch queue: when the provider streams a tool-call event,
|
|
@@ -934,7 +909,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
934
909
|
if (
|
|
935
910
|
sendErr?.streamStalled === true
|
|
936
911
|
&& sendErr.pendingToolUse !== true
|
|
937
|
-
|
|
912
|
+
// NOT gated on unsafeToRetry: live-text stalls stamp
|
|
913
|
+
// unsafeToRetry=true (replay would double-render), but
|
|
914
|
+
// ACCEPTING the already-streamed partial is exactly the safe
|
|
915
|
+
// move (CC rule). Only an emitted tool call blocks acceptance.
|
|
916
|
+
&& sendErr.emittedToolCall !== true
|
|
938
917
|
&& typeof sendErr.partialContent === 'string'
|
|
939
918
|
&& sendErr.partialContent.trim().length > 0
|
|
940
919
|
&& !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
|
|
@@ -1138,14 +1117,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1138
1117
|
} catch { /* best-effort — never break the loop */ }
|
|
1139
1118
|
}
|
|
1140
1119
|
// No tool calls. For PUBLIC agents, the agent contract
|
|
1141
|
-
// (rules/agent/00-
|
|
1142
|
-
//
|
|
1120
|
+
// (rules/agent/00-core.md) requires either a tool call or a final
|
|
1121
|
+
// handoff text (fragments).
|
|
1143
1122
|
// A text-only turn without those tags violates the contract (e.g.
|
|
1144
1123
|
// Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
|
|
1145
1124
|
// call) and used to leave the session idle until the idle sweep
|
|
1146
|
-
// collected it. Re-prompt the worker with a contract reminder
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1125
|
+
// collected it. Re-prompt the worker with a contract reminder on each
|
|
1126
|
+
// empty turn (hard iteration cap bounds total turns). Hidden roles
|
|
1127
|
+
// (cycle1-agent / cycle2-agent / explorer /
|
|
1149
1128
|
// scheduler-task / webhook-handler) are exempt:
|
|
1150
1129
|
// their own role rules define a different output contract (pipe-
|
|
1151
1130
|
// separated chunker output, structured pipe-format, etc.) and a
|
|
@@ -1162,9 +1141,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1162
1141
|
// - has content + non-hidden role → valid final, break.
|
|
1163
1142
|
// - empty content + hidden role → contract allows text-only
|
|
1164
1143
|
// terminal turn, break.
|
|
1165
|
-
// - empty content + non-hidden role →
|
|
1166
|
-
// reminders waste turns and fragment the working context, so
|
|
1167
|
-
// the second empty turn is accepted as terminal.
|
|
1144
|
+
// - empty content + non-hidden role → contract nudge, continue.
|
|
1168
1145
|
const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
|
|
1169
1146
|
const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
1170
1147
|
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
@@ -1181,13 +1158,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1181
1158
|
continue;
|
|
1182
1159
|
}
|
|
1183
1160
|
if (!hasContent && !isHidden) {
|
|
1184
|
-
if (contractNudges >= 1) break;
|
|
1185
|
-
contractNudges += 1;
|
|
1186
1161
|
let nudgeMsg;
|
|
1187
1162
|
if (isIncompleteStop) {
|
|
1188
|
-
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit
|
|
1163
|
+
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
|
|
1189
1164
|
} else {
|
|
1190
|
-
nudgeMsg = '[mixdog-runtime] Your previous response was empty (no
|
|
1165
|
+
nudgeMsg = '[mixdog-runtime] Your previous response was empty (no handoff text and no tool call). Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.';
|
|
1191
1166
|
}
|
|
1192
1167
|
messages.push({ role: 'user', content: nudgeMsg });
|
|
1193
1168
|
continue;
|
|
@@ -1232,34 +1207,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1232
1207
|
: {}),
|
|
1233
1208
|
};
|
|
1234
1209
|
messages.push(_assistantTurnMsg);
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1238
|
-
// pairing stays valid) and consume a grace turn. After 2 grace turns,
|
|
1239
|
-
// terminate via the soft-cap path so a model that never complies stops.
|
|
1240
|
-
if (_softCapActive) {
|
|
1210
|
+
// Hard-cap final turn: tools are disabled but the model still emitted
|
|
1211
|
+
// tool calls. Do NOT execute them — push a refusal stub for each.
|
|
1212
|
+
if (_capFinalToolsDisabled) {
|
|
1241
1213
|
for (const _c of calls) {
|
|
1242
1214
|
pushToolResultMessage({
|
|
1243
1215
|
role: 'tool',
|
|
1244
|
-
content:
|
|
1216
|
+
content: ITERATION_CAP_REFUSAL_STUB,
|
|
1245
1217
|
toolCallId: _c.id,
|
|
1246
1218
|
toolKind: 'error',
|
|
1247
1219
|
});
|
|
1248
1220
|
}
|
|
1249
|
-
_softCapGraceTurns += 1;
|
|
1250
|
-
try {
|
|
1251
|
-
appendAgentTrace({
|
|
1252
|
-
sessionId,
|
|
1253
|
-
iteration: iterations,
|
|
1254
|
-
kind: 'steer',
|
|
1255
|
-
payload: { tag: 'soft_cap_wrapup', grace_turn: _softCapGraceTurns },
|
|
1256
|
-
agent: sessionAgent || null,
|
|
1257
|
-
});
|
|
1258
|
-
} catch { /* best-effort */ }
|
|
1259
|
-
if (_softCapGraceTurns >= 2) {
|
|
1260
|
-
_terminatedBySoftCap = true;
|
|
1261
|
-
break;
|
|
1262
|
-
}
|
|
1263
1221
|
if (sessionId) updateSessionStage(sessionId, 'connecting');
|
|
1264
1222
|
continue;
|
|
1265
1223
|
}
|
|
@@ -1851,10 +1809,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1851
1809
|
}
|
|
1852
1810
|
}
|
|
1853
1811
|
// Completion-first steering hints (missed-parallelism / all-read-only /
|
|
1854
|
-
// serial-rewording). At most ONE hint per turn
|
|
1855
|
-
//
|
|
1856
|
-
|
|
1857
|
-
_steeringLadder.emitPostBatchSteering(calls, _softCapActive);
|
|
1812
|
+
// serial-rewording). At most ONE hint per turn. The ladder controller
|
|
1813
|
+
// owns the cumulative counters and streaks.
|
|
1814
|
+
_steeringLadder.emitPostBatchSteering(calls, false);
|
|
1858
1815
|
// Mid-turn steering is drained at the next loop's pre-send point,
|
|
1859
1816
|
// AFTER any auto-compact pass. Draining here would put the steering
|
|
1860
1817
|
// user turn after the fresh tool results before compaction runs; then
|
|
@@ -1868,8 +1825,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1868
1825
|
// "completed" (see classifyTerminationReason in ./loop/termination.mjs).
|
|
1869
1826
|
const terminationReason = classifyTerminationReason(response, {
|
|
1870
1827
|
terminatedByCap,
|
|
1871
|
-
terminatedBySoftCap: _terminatedBySoftCap,
|
|
1872
|
-
softCapActive: _softCapActive,
|
|
1873
1828
|
sessionAgent,
|
|
1874
1829
|
});
|
|
1875
1830
|
return {
|
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
effectiveBudget as compactEffectiveBudget,
|
|
13
13
|
compactTypeIsRecallFastTrack,
|
|
14
14
|
compactTypeIsSemantic,
|
|
15
|
-
drainSessionCycle1,
|
|
16
15
|
} from '../compact.mjs';
|
|
17
16
|
import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactBufferRatio } from '../context-utils.mjs';
|
|
18
17
|
import { executeInternalTool } from '../../internal-tools.mjs';
|
|
18
|
+
import { truncateToKb, DIGEST_DEFAULT_MAX_KB } from '../loop/recall-fasttrack.mjs';
|
|
19
19
|
import {
|
|
20
20
|
positiveContextWindow,
|
|
21
21
|
compactTriggerForSession,
|
|
@@ -81,52 +81,40 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
|
|
|
81
81
|
} catch (err) {
|
|
82
82
|
try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
|
|
83
83
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
|
|
91
|
-
let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
|
|
92
|
-
let cycle1Text = '';
|
|
84
|
+
// Digest injection (mirrors loop/recall-fasttrack.mjs): no dump + cycle1
|
|
85
|
+
// drain — that ran memory-pipeline LLM chunking inside the compaction.
|
|
86
|
+
// ingest_session above stored the full transcript; background cycle1
|
|
87
|
+
// chunks it on its own schedule and recall serves anything beyond the
|
|
88
|
+
// digest.
|
|
89
|
+
let recallText = '';
|
|
93
90
|
let cycle1Error = null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
min_batch: 1,
|
|
107
|
-
session_cap: 1,
|
|
108
|
-
batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
|
|
109
|
-
rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
|
|
110
|
-
window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
|
|
111
|
-
concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
recallText = drained.recallText;
|
|
115
|
-
cycle1Text = drained.cycle1Text;
|
|
116
|
-
if (drained.error) {
|
|
117
|
-
cycle1Error = drained.error;
|
|
118
|
-
try { process.stderr.write(`[session] recall-fasttrack cycle1 error (sess=${sessionId}): ${drained.error}\n`); } catch {}
|
|
119
|
-
}
|
|
120
|
-
if (drained.rawRemaining > 0) {
|
|
121
|
-
try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
|
|
122
|
-
}
|
|
123
|
-
} catch (err) {
|
|
124
|
-
try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
|
|
125
|
-
}
|
|
126
|
-
} else {
|
|
127
|
-
cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
|
|
91
|
+
try {
|
|
92
|
+
const browsed = await executeInternalTool('memory', {
|
|
93
|
+
action: 'search',
|
|
94
|
+
sessionId,
|
|
95
|
+
limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
|
|
96
|
+
includeMembers: true,
|
|
97
|
+
includeRaw: true,
|
|
98
|
+
}, callerCtx);
|
|
99
|
+
recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
cycle1Error = err?.message || String(err);
|
|
102
|
+
try { process.stderr.write(`[session] recall-digest browse failed (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
|
|
128
103
|
}
|
|
129
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
query,
|
|
106
|
+
querySha,
|
|
107
|
+
cycle1Error,
|
|
108
|
+
recallText: [
|
|
109
|
+
`session_id=${sessionId}`,
|
|
110
|
+
`Full history is in memory — use the recall tool for details beyond this digest.`,
|
|
111
|
+
// Same byte cap as the loop digest path (recallDigestMaxKb,
|
|
112
|
+
// default = shared tool-output limit) — without it the memory
|
|
113
|
+
// renderer bounds the browse at ~200 rows × 1000 chars, letting a
|
|
114
|
+
// manual//clear compact process a far larger digest than loop's.
|
|
115
|
+
truncateToKb(recallText, positiveContextWindow(session?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB),
|
|
116
|
+
].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
|
|
117
|
+
};
|
|
130
118
|
}
|
|
131
119
|
// Element-identity change detection (same approach as loop.mjs messagesArrayChanged): two
|
|
132
120
|
// arrays are "unchanged" only when same length AND every slot is the same object
|
|
@@ -95,6 +95,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
|
95
95
|
'grep',
|
|
96
96
|
'read',
|
|
97
97
|
'apply_patch',
|
|
98
|
+
'shell',
|
|
98
99
|
'explore',
|
|
99
100
|
'search',
|
|
100
101
|
'web_fetch',
|
|
@@ -131,7 +132,7 @@ export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = n
|
|
|
131
132
|
return tools;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function recursiveWrapperToolNameForPublicAgent(agent) {
|
|
135
|
+
export function recursiveWrapperToolNameForPublicAgent(agent) {
|
|
135
136
|
if (!agent) return null;
|
|
136
137
|
const key = String(agent).trim();
|
|
137
138
|
if (key === 'explore') return 'explore';
|
|
@@ -161,11 +162,11 @@ export function finalizeSessionToolList(tools, {
|
|
|
161
162
|
const denySet = new Set(callerDeny.map(n => n.toLowerCase()));
|
|
162
163
|
out = out.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
// NOTE: the self-wrapper anti-recursion deny is intentionally NOT applied
|
|
166
|
+
// here. Stripping a role's own wrapper tool (e.g. explore) from the schema
|
|
167
|
+
// would fork the read-only cache group into one shard per wrapper role.
|
|
168
|
+
// The bundle stays bit-identical; recursion is broken at call time in
|
|
169
|
+
// pre-dispatch-deny.mjs (recursiveWrapperToolNameForPublicAgent) instead.
|
|
169
170
|
if (ownerIsAgent) {
|
|
170
171
|
out = out.filter(t => !t?.annotations?.agentHidden);
|
|
171
172
|
out = orderSessionTools(out);
|
|
@@ -649,24 +649,37 @@ export function createSession(opts) {
|
|
|
649
649
|
const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
|
|
650
650
|
const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
|
|
651
651
|
const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildAgentSpecific(agentRulesAgent) : '';
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
const toolSpec = ownerIsAgent
|
|
658
|
-
? 'full'
|
|
659
|
-
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
660
|
-
|
|
661
|
-
// Prompt permission is metadata only. Preset tool restrictions must NOT
|
|
662
|
-
// enter the prompt, or they split the shared agent cache tail; they map
|
|
663
|
-
// to toolPermission below and are enforced only at call time.
|
|
664
|
-
const permission = opts.permission
|
|
665
|
-
|| null;
|
|
652
|
+
// Prompt permission is metadata for the write bundle, but a read-only role
|
|
653
|
+
// is stamped BEFORE the toolSpec decision so its schema ships the narrowed
|
|
654
|
+
// bundle. Resolve toolPermission (with profile/preset fallbacks) first, and
|
|
655
|
+
// let the stored/logged `permission` reflect that resolved value — not just
|
|
656
|
+
// opts.permission — so diagnostics show the effective read/write class.
|
|
666
657
|
const toolPermission = opts.permission
|
|
667
658
|
|| profile?.permission
|
|
668
659
|
|| permissionFromToolSpec(toolPreset)
|
|
669
660
|
|| null;
|
|
661
|
+
const permission = toolPermission;
|
|
662
|
+
|
|
663
|
+
// Agent sessions do not inherit arbitrary role/profile/preset tool
|
|
664
|
+
// narrowing — that would shatter provider prefix reuse into one shard per
|
|
665
|
+
// role. Instead they collapse onto exactly TWO stable, bit-identical
|
|
666
|
+
// bundles, one cache group each:
|
|
667
|
+
// - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
|
|
668
|
+
// session resolving to permission 'read') -> 'readonly' bundle:
|
|
669
|
+
// read builtins (code_graph/find/glob/list/grep/read) + retrieval
|
|
670
|
+
// (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
|
|
671
|
+
// MCP-write. applyToolPermissionNarrowing('read') below trims the
|
|
672
|
+
// bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
|
|
673
|
+
// bit-identical across these roles regardless of MCP registry state.
|
|
674
|
+
// - write roles (worker / heavy-worker / maintainer / …) -> 'full'
|
|
675
|
+
// bundle: the historical full schema.
|
|
676
|
+
// Call-time permission enforcement below is UNCHANGED (defense in depth):
|
|
677
|
+
// applyToolPermissionNarrowing still runs so the bundle choice never
|
|
678
|
+
// widens effective access.
|
|
679
|
+
const isReadOnlyAgentBundle = ownerIsAgent && toolPermission === 'read';
|
|
680
|
+
const toolSpec = ownerIsAgent
|
|
681
|
+
? (isReadOnlyAgentBundle ? 'readonly' : 'full')
|
|
682
|
+
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
670
683
|
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
671
684
|
// Fail-closed permission intersection: when a session declares an explicit
|
|
672
685
|
// object-form permission, intersect the
|
|
@@ -193,6 +193,22 @@ export const PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
193
193
|
{ minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: STALL_ABORT_MS },
|
|
194
194
|
);
|
|
195
195
|
|
|
196
|
+
// First-MEANINGFUL-frame watchdog (WS). The inter-chunk timer resets on EVERY
|
|
197
|
+
// received frame (keepalive/metadata/rate_limits keep the socket "alive"), so a
|
|
198
|
+
// server that ACKs response.create with only keepalive/metadata frames — never
|
|
199
|
+
// a response.created or a content/tool delta — resets provider idle forever and
|
|
200
|
+
// the pre-response first-byte window never wins. This distinct timer is cleared
|
|
201
|
+
// ONLY by a MEANINGFUL response event (response.created or the first content /
|
|
202
|
+
// tool-arg delta); keepalive/metadata frames do NOT touch it. On expiry the
|
|
203
|
+
// stream is treated as stalled (streamStalledError → existing retry/fallback).
|
|
204
|
+
// Kept comfortably below the agent stall first-byte abort (300s default) so the
|
|
205
|
+
// provider layer catches the wedge before the agent watchdog does. Env-tunable.
|
|
206
|
+
export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
|
|
207
|
+
'MIXDOG_PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS',
|
|
208
|
+
120_000,
|
|
209
|
+
{ minMs: 10_000, maxMs: STALL_WARN_MS },
|
|
210
|
+
);
|
|
211
|
+
|
|
196
212
|
// First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
|
|
197
213
|
// a transient 5xx burst lets parallel workers thundering-herd the backend in
|
|
198
214
|
// lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
|
|
@@ -70,6 +70,7 @@ const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.ur
|
|
|
70
70
|
const {
|
|
71
71
|
appendEntry: memoryAppendEntry,
|
|
72
72
|
ingestTranscript: memoryIngestTranscript,
|
|
73
|
+
drainBuffer: memoryDrainBuffer,
|
|
73
74
|
} = await import(memoryClientModulePath);
|
|
74
75
|
// Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
|
|
75
76
|
// after an unhandled rejection whose async state was already corrupted
|
|
@@ -188,7 +189,19 @@ if (!_isWorkerMode) {
|
|
|
188
189
|
// owner_lead_alive() sees a live owner and uses the full connect budget
|
|
189
190
|
// instead of the 5s no-owner grace (fixes missing recap/core on restart).
|
|
190
191
|
// backendReady intentionally omitted — readiness stays gated until connect.
|
|
191
|
-
|
|
192
|
+
try {
|
|
193
|
+
refreshActiveInstance(INSTANCE_ID);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
const code = e?.code;
|
|
196
|
+
const transient =
|
|
197
|
+
code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT";
|
|
198
|
+
if (!transient) throw e;
|
|
199
|
+
try {
|
|
200
|
+
process.stderr.write(
|
|
201
|
+
`mixdog channels: refreshActiveInstance at startup failed (non-fatal, ${code}): ${e instanceof Error ? e.message : String(e)}\n`,
|
|
202
|
+
);
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
192
205
|
startCliWorker();
|
|
193
206
|
}
|
|
194
207
|
const INSTRUCTIONS = "";
|
|
@@ -314,6 +327,10 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
|
314
327
|
const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
|
|
315
328
|
forwarder.startWatch();
|
|
316
329
|
void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
|
|
330
|
+
// Opportunistic drain: an ingest that had to buffer (memory port not yet
|
|
331
|
+
// published) leaves entry-/ingest- files behind; kick the drainer so they
|
|
332
|
+
// replay as soon as the port appears, without waiting for the periodic tick.
|
|
333
|
+
void memoryDrainBuffer().catch(() => {});
|
|
317
334
|
// onlyIfOwned: binds happen on the owned path, but discovery/poll loops
|
|
318
335
|
// above can outlast an ownership handoff — never overwrite a newer owner.
|
|
319
336
|
refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
|
|
@@ -549,6 +566,7 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
|
549
566
|
let bindingReadyStatus = "pending";
|
|
550
567
|
let _bindingReadyResolve;
|
|
551
568
|
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
569
|
+
let _memoryDrainTimer = null;
|
|
552
570
|
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
553
571
|
// ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
|
|
554
572
|
// Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
|
|
@@ -723,6 +741,13 @@ async function startOwnedRuntime(options = {}) {
|
|
|
723
741
|
return;
|
|
724
742
|
}
|
|
725
743
|
startOwnerHeartbeat();
|
|
744
|
+
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
745
|
+
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
746
|
+
// drainBuffer(); unref'd so it never holds the worker open.
|
|
747
|
+
if (!_memoryDrainTimer) {
|
|
748
|
+
_memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
|
|
749
|
+
_memoryDrainTimer.unref?.();
|
|
750
|
+
}
|
|
726
751
|
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
727
752
|
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
728
753
|
// webhook/binding launches below would revive owner state after stop).
|
|
@@ -808,6 +833,7 @@ async function startOwnedRuntime(options = {}) {
|
|
|
808
833
|
try { stopOwnerHeartbeat(); } catch {}
|
|
809
834
|
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
810
835
|
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
836
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
811
837
|
} finally {
|
|
812
838
|
bridgeRuntimeStarting = false;
|
|
813
839
|
}
|
|
@@ -837,6 +863,7 @@ async function stopOwnedRuntime(reason) {
|
|
|
837
863
|
// file handle + timers for the rest of the process lifetime.
|
|
838
864
|
try { forwarder.stopWatch(); } catch {}
|
|
839
865
|
stopOwnerHeartbeat();
|
|
866
|
+
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
840
867
|
scheduler.stop();
|
|
841
868
|
stopSnapshotWriter();
|
|
842
869
|
await stopWebhookAndEventRuntime();
|