mixdog 0.9.12 → 0.9.14
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/defaults/cycle3-review-prompt.md +14 -0
- package/src/defaults/memory-promote-prompt.md +9 -9
- 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/pending-messages.mjs +132 -11
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
- 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 +18 -13
- package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
- package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
- package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
- package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/runtime/memory/lib/memory.mjs +7 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -0
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/tui/dist/index.mjs +203 -10
- package/src/tui/engine/tui-steering-persist.mjs +175 -0
- package/src/tui/engine.mjs +46 -2
- package/src/ui/statusline.mjs +2 -4
- 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
|
|
@@ -4,10 +4,13 @@ import { join } from 'path';
|
|
|
4
4
|
import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
|
|
5
5
|
import { updateJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
|
|
6
6
|
import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
|
|
7
|
+
import { loadSession } from '../store.mjs';
|
|
7
8
|
|
|
8
9
|
const _sessionPendingMessages = new Map();
|
|
9
10
|
const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
|
|
10
11
|
const PENDING_MESSAGES_MODE = 0o600;
|
|
12
|
+
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
11
14
|
const _pendingPersistBuffers = new Map();
|
|
12
15
|
let _pendingPersistImmediate = null;
|
|
13
16
|
|
|
@@ -19,25 +22,58 @@ function isValidPendingSessionId(sessionId) {
|
|
|
19
22
|
return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
function isTuiSteeringPendingKey(sessionId) {
|
|
26
|
+
return typeof sessionId === 'string' && sessionId.startsWith('tui_');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeTuiSteeringQueueEntry(entry) {
|
|
30
|
+
if (typeof entry === 'string') {
|
|
31
|
+
const text = entry.trim();
|
|
32
|
+
return text || null;
|
|
33
|
+
}
|
|
34
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
35
|
+
if (typeof entry.text === 'string' && entry.text.trim()) {
|
|
36
|
+
const text = entry.text.trim();
|
|
37
|
+
const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null;
|
|
38
|
+
return id ? { id, text } : text;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function normalizePendingStore(raw) {
|
|
23
44
|
const sessions = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object'
|
|
24
45
|
? raw.sessions
|
|
25
46
|
: {};
|
|
26
|
-
const
|
|
47
|
+
const storeUpdatedAt = Number(raw?.updatedAt) || Date.now();
|
|
48
|
+
const touchedRaw = raw && typeof raw === 'object' && raw.sessionTouchedAt && typeof raw.sessionTouchedAt === 'object'
|
|
49
|
+
? raw.sessionTouchedAt
|
|
50
|
+
: {};
|
|
51
|
+
const out = { version: 1, updatedAt: storeUpdatedAt, sessions: {}, sessionTouchedAt: {} };
|
|
27
52
|
for (const [sid, value] of Object.entries(sessions)) {
|
|
28
53
|
if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
|
|
29
|
-
const q =
|
|
30
|
-
.map((
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
const q = isTuiSteeringPendingKey(sid)
|
|
55
|
+
? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
|
|
56
|
+
: value
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
if (typeof entry === 'string') return entry;
|
|
59
|
+
if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
|
|
60
|
+
return '';
|
|
61
|
+
})
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
if (q.length > 0) {
|
|
64
|
+
out.sessions[sid] = q;
|
|
65
|
+
const touched = Number(touchedRaw[sid]);
|
|
66
|
+
out.sessionTouchedAt[sid] = Number.isFinite(touched) && touched > 0 ? touched : storeUpdatedAt;
|
|
67
|
+
}
|
|
37
68
|
}
|
|
38
69
|
return out;
|
|
39
70
|
}
|
|
40
71
|
|
|
72
|
+
function touchPendingSessionEntry(next, sessionId, now = Date.now()) {
|
|
73
|
+
if (!next.sessionTouchedAt || typeof next.sessionTouchedAt !== 'object') next.sessionTouchedAt = {};
|
|
74
|
+
next.sessionTouchedAt[sessionId] = now;
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
function normalizePendingMessageEntry(entry) {
|
|
42
78
|
if (typeof entry === 'string') {
|
|
43
79
|
const text = entry.trim();
|
|
@@ -86,7 +122,9 @@ function persistPendingMessages(sessionId, messages) {
|
|
|
86
122
|
const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
|
|
87
123
|
q.push(...persistedMessages);
|
|
88
124
|
next.sessions[sessionId] = q;
|
|
89
|
-
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
next.updatedAt = now;
|
|
127
|
+
touchPendingSessionEntry(next, sessionId, now);
|
|
90
128
|
depth = q.length;
|
|
91
129
|
return next;
|
|
92
130
|
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
@@ -143,6 +181,7 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
143
181
|
drained = q.filter((m) => typeof m === 'string' && m.length > 0);
|
|
144
182
|
if (drained.length === 0) return undefined;
|
|
145
183
|
delete next.sessions[sessionId];
|
|
184
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
146
185
|
next.updatedAt = Date.now();
|
|
147
186
|
return next;
|
|
148
187
|
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
@@ -152,6 +191,75 @@ function drainPersistedPendingMessages(sessionId) {
|
|
|
152
191
|
return drained;
|
|
153
192
|
}
|
|
154
193
|
|
|
194
|
+
function clearPersistedPendingMessages(sessionId) {
|
|
195
|
+
if (!isValidPendingSessionId(sessionId)) return;
|
|
196
|
+
try {
|
|
197
|
+
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
198
|
+
const next = normalizePendingStore(raw);
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
|
|
200
|
+
delete next.sessions[sessionId];
|
|
201
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
202
|
+
next.updatedAt = Date.now();
|
|
203
|
+
return next;
|
|
204
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
try { process.stderr.write(`[session] pending-message clear failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function shouldEvictPendingSession(sessionId, ttlMs, entryTouchedAt, now = Date.now()) {
|
|
211
|
+
if (isTuiSteeringPendingKey(sessionId)) {
|
|
212
|
+
const entryTouch = Number(entryTouchedAt) || 0;
|
|
213
|
+
if (entryTouch <= 0) return false;
|
|
214
|
+
return (now - entryTouch) > ttlMs;
|
|
215
|
+
}
|
|
216
|
+
const session = loadSession(sessionId);
|
|
217
|
+
if (session) {
|
|
218
|
+
const touched = Math.max(
|
|
219
|
+
Number(session.updatedAt) || 0,
|
|
220
|
+
Number(session.lastHeartbeatAt) || 0,
|
|
221
|
+
Number(session.createdAt) || 0,
|
|
222
|
+
);
|
|
223
|
+
return touched > 0 && (now - touched) > ttlMs;
|
|
224
|
+
}
|
|
225
|
+
const entryTouch = Number(entryTouchedAt) || 0;
|
|
226
|
+
return entryTouch > 0 && (now - entryTouch) > PENDING_ORPHAN_GRACE_MS;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function sweepOrphanedPendingMessages({ ttlMs = PENDING_ORPHAN_TTL_MS } = {}) {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
const removed = [];
|
|
232
|
+
try {
|
|
233
|
+
updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
|
|
234
|
+
const next = normalizePendingStore(raw);
|
|
235
|
+
const ids = Object.keys(next.sessions);
|
|
236
|
+
if (ids.length === 0) return undefined;
|
|
237
|
+
for (const sid of ids) {
|
|
238
|
+
const entryTouchedAt = next.sessionTouchedAt?.[sid];
|
|
239
|
+
if (shouldEvictPendingSession(sid, ttlMs, entryTouchedAt, now)) {
|
|
240
|
+
delete next.sessions[sid];
|
|
241
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sid];
|
|
242
|
+
removed.push(sid);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (removed.length === 0) return undefined;
|
|
246
|
+
next.updatedAt = now;
|
|
247
|
+
return next;
|
|
248
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
try { process.stderr.write(`[session] pending-message sweep failed: ${err?.message || err}\n`); } catch {}
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
if (removed.length > 0) {
|
|
254
|
+
try {
|
|
255
|
+
process.stderr.write(
|
|
256
|
+
`[session] pending-message sweep: removed ${removed.length} stale/orphan queue(s) (ttl=${Math.round(ttlMs / 86400000)}d) (${removed.slice(0, 5).join(', ')}${removed.length > 5 ? `, +${removed.length - 5} more` : ''})\n`,
|
|
257
|
+
);
|
|
258
|
+
} catch { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
return removed.length;
|
|
261
|
+
}
|
|
262
|
+
|
|
155
263
|
function modelVisiblePendingMessages(messages) {
|
|
156
264
|
return (Array.isArray(messages) ? messages : [])
|
|
157
265
|
.map(pendingMessageQueueEntry)
|
|
@@ -229,7 +337,20 @@ export function drainPendingMessages(sessionId) {
|
|
|
229
337
|
|
|
230
338
|
// Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
|
|
231
339
|
// entry so both Maps do not accumulate one entry per closed session.
|
|
232
|
-
export function _dropPendingMessageState(id) {
|
|
340
|
+
export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
341
|
+
if (!clearPersisted) {
|
|
342
|
+
const buffered = _pendingPersistBuffers.get(id);
|
|
343
|
+
if (buffered?.length) {
|
|
344
|
+
try { persistPendingMessages(id, buffered); } catch { /* ignore */ }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
233
347
|
try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
|
|
234
348
|
try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
|
|
349
|
+
if (clearPersisted) {
|
|
350
|
+
try { clearPersistedPendingMessages(id); } catch { /* ignore */ }
|
|
351
|
+
}
|
|
235
352
|
}
|
|
353
|
+
|
|
354
|
+
setImmediate(() => {
|
|
355
|
+
try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }
|
|
356
|
+
});
|
|
@@ -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);
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
enqueuePendingMessage,
|
|
73
73
|
drainPendingMessages,
|
|
74
74
|
_dropPendingMessageState,
|
|
75
|
+
sweepOrphanedPendingMessages,
|
|
75
76
|
} from './manager/pending-messages.mjs';
|
|
76
77
|
import {
|
|
77
78
|
bumpUsageMetricsTurnId,
|
|
@@ -649,24 +650,37 @@ export function createSession(opts) {
|
|
|
649
650
|
const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
|
|
650
651
|
const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
|
|
651
652
|
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;
|
|
653
|
+
// Prompt permission is metadata for the write bundle, but a read-only role
|
|
654
|
+
// is stamped BEFORE the toolSpec decision so its schema ships the narrowed
|
|
655
|
+
// bundle. Resolve toolPermission (with profile/preset fallbacks) first, and
|
|
656
|
+
// let the stored/logged `permission` reflect that resolved value — not just
|
|
657
|
+
// opts.permission — so diagnostics show the effective read/write class.
|
|
666
658
|
const toolPermission = opts.permission
|
|
667
659
|
|| profile?.permission
|
|
668
660
|
|| permissionFromToolSpec(toolPreset)
|
|
669
661
|
|| null;
|
|
662
|
+
const permission = toolPermission;
|
|
663
|
+
|
|
664
|
+
// Agent sessions do not inherit arbitrary role/profile/preset tool
|
|
665
|
+
// narrowing — that would shatter provider prefix reuse into one shard per
|
|
666
|
+
// role. Instead they collapse onto exactly TWO stable, bit-identical
|
|
667
|
+
// bundles, one cache group each:
|
|
668
|
+
// - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
|
|
669
|
+
// session resolving to permission 'read') -> 'readonly' bundle:
|
|
670
|
+
// read builtins (code_graph/find/glob/list/grep/read) + retrieval
|
|
671
|
+
// (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
|
|
672
|
+
// MCP-write. applyToolPermissionNarrowing('read') below trims the
|
|
673
|
+
// bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
|
|
674
|
+
// bit-identical across these roles regardless of MCP registry state.
|
|
675
|
+
// - write roles (worker / heavy-worker / maintainer / …) -> 'full'
|
|
676
|
+
// bundle: the historical full schema.
|
|
677
|
+
// Call-time permission enforcement below is UNCHANGED (defense in depth):
|
|
678
|
+
// applyToolPermissionNarrowing still runs so the bundle choice never
|
|
679
|
+
// widens effective access.
|
|
680
|
+
const isReadOnlyAgentBundle = ownerIsAgent && toolPermission === 'read';
|
|
681
|
+
const toolSpec = ownerIsAgent
|
|
682
|
+
? (isReadOnlyAgentBundle ? 'readonly' : 'full')
|
|
683
|
+
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
670
684
|
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
|
|
671
685
|
// Fail-closed permission intersection: when a session declares an explicit
|
|
672
686
|
// object-form permission, intersect the
|
|
@@ -2069,7 +2083,7 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
2069
2083
|
// Drop the in-memory pending-message queue and any buffered-persist entry
|
|
2070
2084
|
// for this session — otherwise both Maps accumulate one entry per closed
|
|
2071
2085
|
// session for the life of the mcp-server.
|
|
2072
|
-
_dropPendingMessageState(id);
|
|
2086
|
+
_dropPendingMessageState(id, { clearPersisted: tombstone });
|
|
2073
2087
|
// 4. Defer runtime map clear to next tick so any settling askSession can
|
|
2074
2088
|
// observe `closed=true` / bumped generation before we yank the entry.
|
|
2075
2089
|
// Disk tombstone remains — that's what blocks resurrection.
|
|
@@ -2209,6 +2223,7 @@ function hasActiveRuntimeWork() {
|
|
|
2209
2223
|
|
|
2210
2224
|
function _runCleanupCycle() {
|
|
2211
2225
|
if (hasActiveRuntimeWork()) return;
|
|
2226
|
+
sweepOrphanedPendingMessages();
|
|
2212
2227
|
sweepIdleSessions({ includeTombstones: true });
|
|
2213
2228
|
}
|
|
2214
2229
|
|