dsh-continual-evolve 0.7.1 → 0.7.3
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/README.md +23 -14
- package/README.zh.md +18 -14
- package/lib/approval.d.ts +11 -4
- package/lib/approval.js +23 -7
- package/lib/auto.d.ts +35 -5
- package/lib/auto.js +247 -82
- package/lib/command.d.ts +2 -3
- package/lib/command.js +19 -17
- package/lib/fate.d.ts +1 -1
- package/lib/fate.js +4 -4
- package/lib/index.d.ts +4 -4
- package/lib/index.js +29 -24
- package/lib/llm-text.d.ts +48 -5
- package/lib/llm-text.js +94 -27
- package/lib/memory-agent.d.ts +111 -0
- package/lib/memory-agent.js +535 -0
- package/lib/planner.d.ts +5 -0
- package/lib/planner.js +1 -1
- package/lib/review-scheduler.d.ts +38 -0
- package/lib/review-scheduler.js +192 -0
- package/lib/review.d.ts +4 -2
- package/lib/review.js +7 -6
- package/lib/runtime.d.ts +13 -13
- package/lib/runtime.js +41 -37
- package/lib/service.d.ts +9 -0
- package/lib/service.js +46 -20
- package/lib/token-usage.d.ts +1 -1
- package/lib/token-usage.js +8 -8
- package/lib/turn-snapshot.d.ts +68 -0
- package/lib/turn-snapshot.js +222 -0
- package/package.json +1 -1
package/lib/auto.js
CHANGED
|
@@ -5,38 +5,45 @@
|
|
|
5
5
|
* fire-and-forget with error containment: an auto-review failure never
|
|
6
6
|
* disturbs the agent loop.
|
|
7
7
|
*
|
|
8
|
-
* Every gate decision (approved / declined / failed) is appended to
|
|
8
|
+
* Every gate decision (approved / declined / failed / skipped) is appended to
|
|
9
9
|
* `<dshHome>/evolve/reviews.jsonl` so auto-review activity is durably
|
|
10
10
|
* auditable — the server console is not a reliable place to look.
|
|
11
11
|
*
|
|
12
12
|
* Hook wiring:
|
|
13
|
-
* - `agent/turn-stopping`
|
|
14
|
-
* - `agent/status` (idle)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* - `agent/turn-stopping` records the successful turn boundary.
|
|
14
|
+
* - `agent/status` (idle) captures an incremental snapshot and feeds the
|
|
15
|
+
* per-session latest-pending serial scheduler.
|
|
16
|
+
* - `session/event` (compaction/start) forces a snapshot flush before data is
|
|
17
|
+
* summarized away.
|
|
17
18
|
*/
|
|
18
19
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
19
20
|
import { join } from "node:path";
|
|
20
21
|
import { slug } from "./types.js";
|
|
21
22
|
import { planWithLlm } from "./planner.js";
|
|
22
|
-
import { reviewAutoRefine
|
|
23
|
-
import {
|
|
23
|
+
import { reviewAutoRefine } from "./review.js";
|
|
24
|
+
import { goalServiceOf } from "./goal.js";
|
|
24
25
|
import { notifyAutoReview } from "./notify.js";
|
|
25
26
|
import { runLocalFatePhase } from "./fate.js";
|
|
26
|
-
import { entrySourceOf } from "./source.js";
|
|
27
27
|
import { mergeHarnessStates } from "./state.js";
|
|
28
28
|
import { projectKeyOf } from "./project.js";
|
|
29
29
|
import { questionServiceOf } from "./approval.js";
|
|
30
30
|
import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
|
|
31
31
|
import { DEFAULT_REVIEWS_RETAIN, pruneJsonlFile } from "./store.js";
|
|
32
|
-
import {
|
|
32
|
+
import { isGateEnabled } from "./runtime.js";
|
|
33
|
+
import { compareReviewCursors, createReviewScheduler } from "./review-scheduler.js";
|
|
34
|
+
import { captureTurnSnapshot, sliceTurnSnapshot } from "./turn-snapshot.js";
|
|
33
35
|
import { captureAutoCase } from "./autocase.js";
|
|
36
|
+
import { applyMemoryExtractionProposal, buildMemoryManifest, runMemoryAgent, } from "./memory-agent.js";
|
|
34
37
|
/** Turns a rejected skill candidate stays silent before being offered again. */
|
|
35
38
|
export const SKILL_CONSULT_COOLDOWN_TURNS = 10;
|
|
36
39
|
export function registerAutoReview(ctx, engine, config) {
|
|
37
40
|
const perSession = new Map();
|
|
41
|
+
const schedulers = new Map();
|
|
42
|
+
const captureTails = new Map();
|
|
43
|
+
const disposedSessions = new Set();
|
|
38
44
|
const logger = ctx.logger("continual-evolve");
|
|
39
45
|
const reviewsPath = join(engine.baseDir, "evolve", "reviews.jsonl");
|
|
46
|
+
const defaultEnabled = config.enabledByDefault ?? true;
|
|
40
47
|
const record = (entry) => {
|
|
41
48
|
try {
|
|
42
49
|
mkdirSync(join(engine.baseDir, "evolve"), { recursive: true });
|
|
@@ -54,9 +61,83 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
54
61
|
// ignored — the next record retries
|
|
55
62
|
}
|
|
56
63
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const schedulerFor = (agent, state) => {
|
|
65
|
+
const existing = schedulers.get(agent.id);
|
|
66
|
+
if (existing)
|
|
67
|
+
return existing;
|
|
68
|
+
const scheduler = createReviewScheduler(async ({ snapshot, signal }) => {
|
|
69
|
+
if (!isGateEnabled(engine.baseDir, defaultEnabled))
|
|
70
|
+
return "aborted";
|
|
71
|
+
if (!snapshot.eligible) {
|
|
72
|
+
record({
|
|
73
|
+
sessionId: snapshot.sessionId,
|
|
74
|
+
reason: snapshot.reason,
|
|
75
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
76
|
+
outcome: "skipped",
|
|
77
|
+
rationale: `snapshot ${snapshot.cursor} skipped: ${snapshot.skipReason ?? "not eligible"}`,
|
|
78
|
+
});
|
|
79
|
+
return "no-op";
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await runGate(ctx, engine, agent, config, state, snapshot, record, signal);
|
|
83
|
+
return "success";
|
|
84
|
+
}
|
|
85
|
+
catch (cause) {
|
|
86
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
87
|
+
logger.warn(`auto-review failed for ${agent.id}: ${message}`);
|
|
88
|
+
record({
|
|
89
|
+
sessionId: agent.id,
|
|
90
|
+
reason: snapshot.reason,
|
|
91
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
92
|
+
outcome: "failed",
|
|
93
|
+
rationale: `gate error: ${message}`,
|
|
94
|
+
});
|
|
95
|
+
return "error";
|
|
96
|
+
}
|
|
97
|
+
}, (snapshot) => snapshot.cursor);
|
|
98
|
+
schedulers.set(agent.id, scheduler);
|
|
99
|
+
return scheduler;
|
|
100
|
+
};
|
|
101
|
+
const captureAndSchedule = (agent, state, reason) => {
|
|
102
|
+
if (disposedSessions.has(agent.id) || !isGateEnabled(engine.baseDir, defaultEnabled))
|
|
103
|
+
return;
|
|
104
|
+
const turn = state.completedTurn;
|
|
105
|
+
const previousSnapshotTurn = state.lastSnapshotTurn;
|
|
106
|
+
if (reason === "turn_snapshot" && turn <= previousSnapshotTurn)
|
|
107
|
+
return;
|
|
108
|
+
if (reason === "turn_snapshot")
|
|
109
|
+
state.lastSnapshotTurn = turn;
|
|
110
|
+
const previousCapture = captureTails.get(agent.id) ?? Promise.resolve();
|
|
111
|
+
const captureTask = previousCapture.then(async () => {
|
|
112
|
+
if (disposedSessions.has(agent.id) || !isGateEnabled(engine.baseDir, defaultEnabled))
|
|
113
|
+
return;
|
|
114
|
+
const cursor = schedulerFor(agent, state).getCursor();
|
|
115
|
+
const snapshot = await captureTurnSnapshot(ctx, agent, {
|
|
116
|
+
turn,
|
|
117
|
+
reason,
|
|
118
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
119
|
+
maxChars: config.maxInputChars,
|
|
120
|
+
});
|
|
121
|
+
if (!disposedSessions.has(agent.id))
|
|
122
|
+
schedulerFor(agent, state).schedule(snapshot);
|
|
123
|
+
}).catch((cause) => {
|
|
124
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
125
|
+
logger.warn(`auto-review snapshot failed for ${agent.id}: ${message}`);
|
|
126
|
+
record({
|
|
127
|
+
sessionId: agent.id,
|
|
128
|
+
reason,
|
|
129
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
130
|
+
outcome: "failed",
|
|
131
|
+
rationale: `snapshot error: ${message}`,
|
|
132
|
+
});
|
|
133
|
+
if (reason === "turn_snapshot" && state.lastSnapshotTurn === turn) {
|
|
134
|
+
state.lastSnapshotTurn = previousSnapshotTurn;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
captureTails.set(agent.id, captureTask);
|
|
138
|
+
};
|
|
139
|
+
// DSH emits this awaited boundary when a turn is about to close. The idle
|
|
140
|
+
// transition is the safe point at which to read the durable surface.
|
|
60
141
|
ctx.on("agent/turn-stopping", (payload) => {
|
|
61
142
|
const agent = payload.agent;
|
|
62
143
|
if (!agent) {
|
|
@@ -65,42 +146,20 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
65
146
|
}
|
|
66
147
|
const state = stateFor(perSession, agent.id);
|
|
67
148
|
state.turns += 1;
|
|
149
|
+
if (typeof payload.turn === "number")
|
|
150
|
+
state.completedTurn = Math.max(state.completedTurn, payload.turn);
|
|
68
151
|
});
|
|
69
152
|
ctx.on("agent/status", (payload) => {
|
|
70
153
|
const agent = payload.agent;
|
|
71
154
|
if (!agent || payload.status !== "idle")
|
|
72
155
|
return;
|
|
73
156
|
const state = stateFor(perSession, agent.id);
|
|
74
|
-
|
|
75
|
-
// (the goal's round machine keeps the session continuing); without a
|
|
76
|
-
// goal the plain turn interval applies.
|
|
77
|
-
const goalDriven = goalDrivesRounds(goalServiceOf(ctx)?.get(agent));
|
|
78
|
-
if (!goalDriven && state.turns - state.lastReviewAt < config.intervalTurns)
|
|
157
|
+
if (state.completedTurn <= state.lastSnapshotTurn)
|
|
79
158
|
return;
|
|
80
|
-
|
|
81
|
-
// calls, no fate assessments, no audit records. Checked after the
|
|
82
|
-
// cheap in-memory interval check so the paused path costs one file
|
|
83
|
-
// read at most. Manual tools/commands are unaffected (separate path).
|
|
84
|
-
if (isGatePaused(engine.baseDir))
|
|
85
|
-
return;
|
|
86
|
-
// Run the gate outside the listener turn: agent is idle, work is auxiliary.
|
|
87
|
-
// Every failure is durably recorded — nothing fails silently.
|
|
88
|
-
void runGate(ctx, engine, agent, config, state, "turn_interval", record).catch((cause) => {
|
|
89
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
90
|
-
logger.warn(`auto-review failed for ${agent.id}: ${message}`);
|
|
91
|
-
record({
|
|
92
|
-
sessionId: agent.id,
|
|
93
|
-
reason: "turn_interval",
|
|
94
|
-
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
95
|
-
outcome: "failed",
|
|
96
|
-
rationale: `gate error: ${message}`,
|
|
97
|
-
});
|
|
98
|
-
state.lastReviewAt = state.turns; // back off until the interval elapses again
|
|
99
|
-
});
|
|
159
|
+
captureAndSchedule(agent, state, "turn_snapshot");
|
|
100
160
|
});
|
|
101
|
-
// Diagnostic: the armed marker proves
|
|
102
|
-
//
|
|
103
|
-
// trigger events are not reaching this listener.
|
|
161
|
+
// Diagnostic: the armed marker proves the listener was registered even when
|
|
162
|
+
// its runtime default is off, distinguishing "not registered" from "off".
|
|
104
163
|
try {
|
|
105
164
|
mkdirSync(join(engine.baseDir, "evolve"), { recursive: true });
|
|
106
165
|
appendFileSync(reviewsPath, `${JSON.stringify({
|
|
@@ -109,7 +168,7 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
109
168
|
reason: "boot",
|
|
110
169
|
turnsSinceLastReview: 0,
|
|
111
170
|
outcome: "armed",
|
|
112
|
-
rationale: `auto-review gate registered (
|
|
171
|
+
rationale: `auto-review gate registered (default=${defaultEnabled ? "on" : "off"}; per-success-turn snapshots; local-fate every ${config.fateIntervalTurns} turns)`,
|
|
113
172
|
})}\n`, "utf8");
|
|
114
173
|
}
|
|
115
174
|
catch (cause) {
|
|
@@ -121,6 +180,16 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
121
180
|
catch {
|
|
122
181
|
// ignored — the next record retries
|
|
123
182
|
}
|
|
183
|
+
ctx.on("agent/disposed", (payload) => {
|
|
184
|
+
const agent = payload.agent;
|
|
185
|
+
if (!agent)
|
|
186
|
+
return;
|
|
187
|
+
disposedSessions.add(agent.id);
|
|
188
|
+
schedulers.get(agent.id)?.shutdown();
|
|
189
|
+
schedulers.delete(agent.id);
|
|
190
|
+
captureTails.delete(agent.id);
|
|
191
|
+
perSession.delete(agent.id);
|
|
192
|
+
});
|
|
124
193
|
ctx.on("session/event", (session, event) => {
|
|
125
194
|
if (event.type !== "compaction/start")
|
|
126
195
|
return;
|
|
@@ -129,21 +198,7 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
129
198
|
if (!agent)
|
|
130
199
|
return; // no live agent for that session (e.g. cold read)
|
|
131
200
|
const state = stateFor(perSession, agent.id);
|
|
132
|
-
|
|
133
|
-
// (#21): a paused gate skips even the compaction run, so "pause"
|
|
134
|
-
// is a true zero-LLM-call state.
|
|
135
|
-
if (isGatePaused(engine.baseDir))
|
|
136
|
-
return;
|
|
137
|
-
void runGate(ctx, engine, agent, config, state, "compact", record).catch((cause) => {
|
|
138
|
-
logger.warn(`auto-review failed at compaction for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
139
|
-
record({
|
|
140
|
-
sessionId: agent.id,
|
|
141
|
-
reason: "compact",
|
|
142
|
-
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
143
|
-
outcome: "failed",
|
|
144
|
-
rationale: `gate error at compaction: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
145
|
-
});
|
|
146
|
-
});
|
|
201
|
+
captureAndSchedule(agent, state, "compact");
|
|
147
202
|
});
|
|
148
203
|
}
|
|
149
204
|
/**
|
|
@@ -167,6 +222,9 @@ function stateFor(map, sessionId) {
|
|
|
167
222
|
if (!state) {
|
|
168
223
|
state = {
|
|
169
224
|
turns: 0,
|
|
225
|
+
completedTurn: 0,
|
|
226
|
+
lastSnapshotTurn: 0,
|
|
227
|
+
memoryDecisions: {},
|
|
170
228
|
lastReviewAt: 0,
|
|
171
229
|
running: false,
|
|
172
230
|
skillRejects: new Map(),
|
|
@@ -178,6 +236,21 @@ function stateFor(map, sessionId) {
|
|
|
178
236
|
}
|
|
179
237
|
return state;
|
|
180
238
|
}
|
|
239
|
+
function advanceMemoryCheckpoint(state, next) {
|
|
240
|
+
if (state.memoryCheckpoint === undefined) {
|
|
241
|
+
state.memoryCheckpoint = next;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const order = compareReviewCursors(next, state.memoryCheckpoint);
|
|
245
|
+
if (order === undefined || order > 0)
|
|
246
|
+
state.memoryCheckpoint = next;
|
|
247
|
+
}
|
|
248
|
+
function rememberMemoryDecision(state, key, decision) {
|
|
249
|
+
state.memoryDecisions[key] = decision;
|
|
250
|
+
const keys = Object.keys(state.memoryDecisions);
|
|
251
|
+
while (keys.length > 128)
|
|
252
|
+
delete state.memoryDecisions[keys.shift() ?? ""];
|
|
253
|
+
}
|
|
181
254
|
/**
|
|
182
255
|
* The state view the gate and planner judge: the session's local entries
|
|
183
256
|
* merged with the project and global stores, each entry carrying its real
|
|
@@ -199,23 +272,23 @@ export function loadGateHarnessView(engine, sessionId, opts) {
|
|
|
199
272
|
* review's baseline fresh — fate re-loads the store and never races the
|
|
200
273
|
* review's optimistic-concurrency checks.
|
|
201
274
|
*/
|
|
202
|
-
async function runGate(ctx, engine, agent, config, state,
|
|
275
|
+
async function runGate(ctx, engine, agent, config, state, snapshot, record, signal) {
|
|
203
276
|
// Reentry guard (review audit 2026-08-28 S3): a gate run holds LLM calls
|
|
204
277
|
// and possibly a user question for a long time; an idle/compaction
|
|
205
278
|
// trigger overlapping the run would start a second concurrent pipeline
|
|
206
279
|
// whose stale whole-file saves clobber the first run's writes.
|
|
207
280
|
if (state.running) {
|
|
208
|
-
|
|
209
|
-
return;
|
|
281
|
+
throw new Error(`previous gate run still in flight for ${agent.id}`);
|
|
210
282
|
}
|
|
211
283
|
state.running = true;
|
|
212
284
|
try {
|
|
213
|
-
await
|
|
285
|
+
await runMemoryExtractionPhase(ctx, engine, agent, config, state, snapshot, signal);
|
|
286
|
+
await runReviewPhase(ctx, engine, agent, config, state, snapshot, record, signal);
|
|
214
287
|
// D3: a goal stuck in "blocked" for consecutive gate runs gets one
|
|
215
288
|
// local-fate assessment (the pipeline below), so whatever led the
|
|
216
289
|
// goal astray is distilled before the session moves on.
|
|
217
|
-
await runGoalBlockedFate(ctx, engine, agent, config, state, reason, record);
|
|
218
|
-
await runLocalFatePhase(ctx, engine, agent, config, state, reason, record);
|
|
290
|
+
await runGoalBlockedFate(ctx, engine, agent, config, state, snapshot.reason, record);
|
|
291
|
+
await runLocalFatePhase(ctx, engine, agent, config, state, snapshot.reason, record);
|
|
219
292
|
}
|
|
220
293
|
finally {
|
|
221
294
|
state.running = false;
|
|
@@ -250,8 +323,87 @@ export async function runGoalBlockedFate(ctx, engine, agent, config, state, _rea
|
|
|
250
323
|
logger.info(`auto-review goal-blocked trigger [${agent.id}]: ${config.goalBlockedWrapupTurns} consecutive blocked gate runs → local-fate assessment`);
|
|
251
324
|
await runLocalFatePhase(ctx, engine, agent, config, state, "goal_blocked", record);
|
|
252
325
|
}
|
|
253
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Dedicated ZCode-style memory phase. It shares the scheduler snapshot and
|
|
328
|
+
* snapshot with the general review, but owns its request loop, manifest and
|
|
329
|
+
* memory-only tool policy. Its phase-specific checkpoint advances after a
|
|
330
|
+
* successful/no-op memory outcome; later review/fate failures do not replay
|
|
331
|
+
* old memory decisions, while memory failures leave the boundary retryable.
|
|
332
|
+
*/
|
|
333
|
+
export async function runMemoryExtractionPhase(ctx, engine, agent, config, state, snapshot, signal) {
|
|
334
|
+
const memorySnapshot = state.memoryCheckpoint ? sliceTurnSnapshot(snapshot, state.memoryCheckpoint) : snapshot;
|
|
335
|
+
if (!memorySnapshot.eligible || !memorySnapshot.trajectory) {
|
|
336
|
+
advanceMemoryCheckpoint(state, snapshot.cursor);
|
|
337
|
+
ctx.logger("continual-evolve").info(`memory agent no-op (${snapshot.reason}) [${agent.id}]: no new eligible evidence after checkpoint`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const sessionId = agent.id;
|
|
341
|
+
const projectKey = snapshot.projectKey ?? projectKeyOf(agent);
|
|
342
|
+
const baselines = {
|
|
343
|
+
local: engine.load("local", sessionId),
|
|
344
|
+
global: engine.load("global", undefined),
|
|
345
|
+
...(projectKey ? { project: engine.load("project", projectKey) } : {}),
|
|
346
|
+
};
|
|
347
|
+
const manifest = buildMemoryManifest(loadGateHarnessView(engine, sessionId, projectKey ? { projectKey } : undefined));
|
|
348
|
+
const overrideRoute = parseReviewModel(config.reviewModel, agent.options.provider);
|
|
349
|
+
const provider = overrideRoute?.provider ?? agent.options.provider;
|
|
350
|
+
const model = overrideRoute?.model ?? agent.options.model;
|
|
351
|
+
if (!provider || !model)
|
|
352
|
+
throw new Error("evolve: no provider/model route for the memory extraction agent");
|
|
353
|
+
const tokenUsage = {
|
|
354
|
+
baseDir: engine.baseDir,
|
|
355
|
+
sessionId,
|
|
356
|
+
retain: engine.retention.tokenUsage,
|
|
357
|
+
onError: (cause) => ctx
|
|
358
|
+
.logger("continual-evolve")
|
|
359
|
+
.warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
360
|
+
};
|
|
361
|
+
const run = await runMemoryAgent(ctx, {
|
|
362
|
+
provider,
|
|
363
|
+
model,
|
|
364
|
+
manifest,
|
|
365
|
+
trajectory: memorySnapshot.trajectory,
|
|
366
|
+
trajectoryEvents: memorySnapshot.events,
|
|
367
|
+
maxOutputTokens: config.budgetTokens,
|
|
368
|
+
...(signal ? { signal } : {}),
|
|
369
|
+
tokenUsage,
|
|
370
|
+
...((config.prefixCacheMode !== undefined || config.prefixMaxChars !== undefined
|
|
371
|
+
? {
|
|
372
|
+
prefixCache: {
|
|
373
|
+
...(config.prefixCacheMode !== undefined ? { mode: config.prefixCacheMode } : {}),
|
|
374
|
+
...(config.prefixMaxChars !== undefined ? { maxChars: config.prefixMaxChars } : {}),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
: {})),
|
|
378
|
+
});
|
|
379
|
+
if (run.proposal.edits.length === 0) {
|
|
380
|
+
advanceMemoryCheckpoint(state, memorySnapshot.cursor);
|
|
381
|
+
ctx.logger("continual-evolve").info(`memory agent no-op (${snapshot.reason}) [${sessionId}] after ${run.turns} turn(s): ${run.proposal.rationale}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const source = { sessionId, ...(memorySnapshot.sourceSeqs.length > 0 ? { seqs: [...memorySnapshot.sourceSeqs] } : {}) };
|
|
385
|
+
const application = await applyMemoryExtractionProposal(ctx, engine, run.proposal, {
|
|
386
|
+
agent,
|
|
387
|
+
baselines,
|
|
388
|
+
...(projectKey ? { projectKey } : {}),
|
|
389
|
+
requireApproval: config.requireGlobalApproval ?? true,
|
|
390
|
+
source,
|
|
391
|
+
decisionCursor: memorySnapshot.cursor,
|
|
392
|
+
scopeDecisions: state.memoryDecisions,
|
|
393
|
+
onScopeDecision: (key, decision) => rememberMemoryDecision(state, key, decision),
|
|
394
|
+
...(signal ? { signal } : {}),
|
|
395
|
+
});
|
|
396
|
+
for (const result of application.results) {
|
|
397
|
+
emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, `memory_agent:${snapshot.reason}`, sessionId), config.reviewsRetain ?? DEFAULT_REVIEWS_RETAIN);
|
|
398
|
+
}
|
|
399
|
+
state.memoryCheckpoint = memorySnapshot.cursor;
|
|
400
|
+
const applied = application.results.reduce((count, result) => count + result.appliedEdits.filter((edit) => edit.applied).length, 0);
|
|
401
|
+
const declined = application.declinedScopes.length > 0 ? `; declined scopes=${application.declinedScopes.join(",")}` : "";
|
|
402
|
+
ctx.logger("continual-evolve").info(`memory agent applied ${applied} edit(s) across ${application.results.length} scope batch(es) [${sessionId}] after ${run.turns} turn(s)${declined}: ${run.proposal.rationale}`);
|
|
403
|
+
}
|
|
404
|
+
async function runReviewPhase(ctx, engine, agent, config, state, snapshot, record, signal) {
|
|
254
405
|
const sessionId = agent.id;
|
|
406
|
+
const reason = snapshot.reason;
|
|
255
407
|
const turnsSinceLastReview = state.turns - state.lastReviewAt;
|
|
256
408
|
const logger = ctx.logger("continual-evolve");
|
|
257
409
|
const tokenUsage = {
|
|
@@ -260,14 +412,9 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
260
412
|
retain: engine.retention.tokenUsage,
|
|
261
413
|
onError: (cause) => logger.warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
262
414
|
};
|
|
263
|
-
const trajectory =
|
|
264
|
-
logger.warn(`auto-review skipped for ${sessionId}: trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
265
|
-
record({ sessionId, reason, turnsSinceLastReview, outcome: "failed", rationale: `trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}` });
|
|
266
|
-
return undefined;
|
|
267
|
-
});
|
|
415
|
+
const trajectory = snapshot.trajectory;
|
|
268
416
|
if (!trajectory) {
|
|
269
|
-
|
|
270
|
-
return;
|
|
417
|
+
throw new Error("eligible snapshot has no trajectory");
|
|
271
418
|
}
|
|
272
419
|
// The gate judges the merged view (global + local, scopes labeled) so it
|
|
273
420
|
// can recognize topics already covered globally and decline duplicates;
|
|
@@ -283,6 +430,8 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
283
430
|
state: harnessState,
|
|
284
431
|
history,
|
|
285
432
|
trajectory,
|
|
433
|
+
trajectoryEvents: snapshot.events,
|
|
434
|
+
...(signal ? { signal } : {}),
|
|
286
435
|
context: { reason, turnsSinceLastReview },
|
|
287
436
|
budgetTokens: config.budgetTokens,
|
|
288
437
|
tokenUsage,
|
|
@@ -302,10 +451,13 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
302
451
|
record({ sessionId, reason, turnsSinceLastReview, outcome: "declined", rationale: review.rationale });
|
|
303
452
|
return;
|
|
304
453
|
}
|
|
305
|
-
const
|
|
454
|
+
const plannedProposal = await planWithLlm(ctx, {
|
|
306
455
|
agent,
|
|
307
456
|
state: harnessState,
|
|
308
457
|
history,
|
|
458
|
+
trajectory: snapshot.trajectory,
|
|
459
|
+
trajectoryEvents: snapshot.events,
|
|
460
|
+
...(signal ? { signal } : {}),
|
|
309
461
|
...(review.instructions ? { instructions: review.instructions } : {}),
|
|
310
462
|
global: false,
|
|
311
463
|
// Read the skill-creator template facts (fallback: builtin distilled
|
|
@@ -321,6 +473,8 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
321
473
|
}
|
|
322
474
|
: {})),
|
|
323
475
|
});
|
|
476
|
+
const proposal = stripDedicatedMemoryEdits(plannedProposal);
|
|
477
|
+
const memoryOnlyPlan = isDedicatedMemoryOnlyProposal(plannedProposal);
|
|
324
478
|
// Skills are governed resources: an auto-created skill is OFFERED to the
|
|
325
479
|
// user for a decision (固化/不固化) before it lands — the gate never
|
|
326
480
|
// writes a skill silently. Without consent the skill edits are withheld
|
|
@@ -339,7 +493,7 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
339
493
|
logger.info(`auto-review declined (${reason}) [${sessionId}]: no consented edits${withheld} — ${review.rationale}`);
|
|
340
494
|
// P1 auto-case capture: an attempted evolution that never landed is a
|
|
341
495
|
// regression asset. Contained — capture failure must not disturb the gate.
|
|
342
|
-
if (config.autoCase) {
|
|
496
|
+
if (config.autoCase && !memoryOnlyPlan) {
|
|
343
497
|
try {
|
|
344
498
|
const captured = captureAutoCase({
|
|
345
499
|
baseDir: engine.baseDir,
|
|
@@ -358,7 +512,7 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
358
512
|
record({ sessionId, reason, turnsSinceLastReview, outcome: "declined", rationale: `${review.rationale}${withheld}` });
|
|
359
513
|
return;
|
|
360
514
|
}
|
|
361
|
-
const source =
|
|
515
|
+
const source = { sessionId, ...(snapshot.sourceSeqs.length > 0 ? { seqs: [...snapshot.sourceSeqs] } : {}) };
|
|
362
516
|
const result = engine.apply("local", sessionId, finalProposal, {
|
|
363
517
|
scope: "local",
|
|
364
518
|
baselineState: localState,
|
|
@@ -369,20 +523,31 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
|
|
|
369
523
|
// Gap C4: emit structured evolve_complete event for third-party consumers.
|
|
370
524
|
emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, `auto_review:${reason}`, sessionId), config.reviewsRetain ?? DEFAULT_REVIEWS_RETAIN);
|
|
371
525
|
// Visibility: tell the user what the gate just persisted. Only the
|
|
372
|
-
// turn
|
|
526
|
+
// turn snapshot path notifies — a compaction-triggered gate must not wake
|
|
373
527
|
// the agent mid-compaction — and only when something was actually applied
|
|
374
528
|
// (a notice for zero edits is noise). Failure is contained in notifyAutoReview.
|
|
375
|
-
if (config.notifyOnAutoReview && reason === "
|
|
529
|
+
if (config.notifyOnAutoReview && reason === "turn_snapshot" && result.appliedEdits.some((e) => e.applied)) {
|
|
376
530
|
notifyAutoReview(ctx, agent, result, turnsSinceLastReview);
|
|
377
531
|
}
|
|
378
532
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
533
|
+
/** True when the general planner returned only edits owned by the memory phase. */
|
|
534
|
+
export function isDedicatedMemoryOnlyProposal(proposal) {
|
|
535
|
+
return proposal.edits.length > 0 && proposal.edits.every((edit) => edit.kind === "memory");
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Remove memory edits from the general auto-review planner. The dedicated
|
|
539
|
+
* memory phase owns this responsibility and has already run against the same
|
|
540
|
+
* snapshot; accepting a second memory proposal would reintroduce duplicates.
|
|
541
|
+
*/
|
|
542
|
+
export function stripDedicatedMemoryEdits(proposal) {
|
|
543
|
+
const edits = proposal.edits.filter((edit) => edit.kind !== "memory");
|
|
544
|
+
if (edits.length === proposal.edits.length)
|
|
545
|
+
return proposal;
|
|
546
|
+
return {
|
|
547
|
+
...proposal,
|
|
548
|
+
edits,
|
|
549
|
+
summary: `${proposal.summary} (memory edits owned by dedicated extractor)`,
|
|
550
|
+
};
|
|
386
551
|
}
|
|
387
552
|
/**
|
|
388
553
|
* Split a proposal into skill edits and everything else. Skill edits are the
|
package/lib/command.d.ts
CHANGED
|
@@ -18,9 +18,8 @@ export interface CommandRuntimeOptions {
|
|
|
18
18
|
/** Mechanical promotion guards for wrapup/fate (2026-08-22 policy). */
|
|
19
19
|
promotionPolicy: PromotionPolicy;
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* switch still works.
|
|
21
|
+
* Initial auto-review default from plugin config. The listener is always
|
|
22
|
+
* registered; this is not a registration gate.
|
|
24
23
|
*/
|
|
25
24
|
autoReview?: boolean;
|
|
26
25
|
}
|
package/lib/command.js
CHANGED
|
@@ -376,18 +376,22 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
376
376
|
}
|
|
377
377
|
case "pause":
|
|
378
378
|
case "resume": {
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// The switch is independent of the static registration flag. A
|
|
380
|
+
// resume explicitly enables automatic snapshots; a pause leaves
|
|
381
|
+
// the enable bit intact so status explains the paused state.
|
|
382
382
|
const pausing = sub === "pause";
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
const defaultEnabled = runtime.autoReview ?? false;
|
|
384
|
+
const current = loadGateRuntime(engine.baseDir, defaultEnabled);
|
|
385
|
+
if (pausing && current.paused) {
|
|
386
|
+
return success("auto-review gate is already paused (no change).");
|
|
386
387
|
}
|
|
387
|
-
|
|
388
|
+
if (!pausing && current.enabled && !current.paused) {
|
|
389
|
+
return success("auto-review gate is already running (no change).");
|
|
390
|
+
}
|
|
391
|
+
saveGateRuntime(engine.baseDir, pausing, pausing ? current.enabled : true);
|
|
388
392
|
return success(pausing
|
|
389
|
-
? "auto-review gate paused: no automatic
|
|
390
|
-
: "auto-review gate resumed:
|
|
393
|
+
? "auto-review gate paused: no automatic snapshots, fate assessments, or gate LLM calls until /evolve resume. Manual evolve_* tools and /evolve commands keep working."
|
|
394
|
+
: "auto-review gate resumed: successful turns will produce incremental snapshots immediately.");
|
|
391
395
|
}
|
|
392
396
|
case "status": {
|
|
393
397
|
return success(renderGateStatus(engine, sessionId, projectKeyOf(invocation.agent), runtime));
|
|
@@ -454,16 +458,14 @@ function demoteEntry(engine, id, sessionId, projectKey) {
|
|
|
454
458
|
return error(`entry ${id} not found in the global, project, or local store`);
|
|
455
459
|
}
|
|
456
460
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* the runtime switch says whether the human paused it since.
|
|
461
|
+
* Status answers whether the always-registered listener is enabled at runtime
|
|
462
|
+
* and what stores/retention it currently sees.
|
|
460
463
|
*/
|
|
461
464
|
function renderGateStatus(engine, sessionId, projectKey, runtime) {
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
: `gate: ${patch === "unknown" ? "patch flag unknown" : "enabled in patch config"} · ${paused ? "PAUSED by /evolve pause (resume with /evolve resume)" : "running"}`;
|
|
465
|
+
const configuredDefault = runtime.autoReview === undefined ? "unknown" : runtime.autoReview ? "on" : "off";
|
|
466
|
+
const current = loadGateRuntime(engine.baseDir, runtime.autoReview ?? false);
|
|
467
|
+
const effective = current.paused ? "PAUSED (resume with /evolve resume)" : current.enabled ? "running" : "off (enable with /evolve resume)";
|
|
468
|
+
const gateLine = `gate: listener registered · config default ${configuredDefault} · runtime ${effective}`;
|
|
467
469
|
const countEntries = (state) => Object.values(state.entries).reduce((n, byKind) => n + Object.keys(byKind).length, 0);
|
|
468
470
|
const lines = [gateLine];
|
|
469
471
|
lines.push(`stores: global ${countEntries(engine.load("global", undefined))} entries · local(${sessionId}) ${countEntries(engine.load("local", sessionId))} entries`);
|
package/lib/fate.d.ts
CHANGED
|
@@ -119,7 +119,7 @@ export interface FateApplyResult {
|
|
|
119
119
|
export declare function applyLocalFates(engine: EvolutionEngine, sessionId: string, plan: FatePlan, localState: HarnessState, mode: FateApplyMode): FateApplyResult;
|
|
120
120
|
/**
|
|
121
121
|
* The gate's local-fate phase. Runs after the review phase on every gate
|
|
122
|
-
* trigger (
|
|
122
|
+
* trigger (turn_snapshot / compact), subject to cadence and cooldown. All
|
|
123
123
|
* failures are contained and recorded — a broken fate dimension never
|
|
124
124
|
* disturbs the agent loop.
|
|
125
125
|
*/
|
package/lib/fate.js
CHANGED
|
@@ -192,7 +192,7 @@ export function applyLocalFates(engine, sessionId, plan, localState, mode) {
|
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
194
|
* The gate's local-fate phase. Runs after the review phase on every gate
|
|
195
|
-
* trigger (
|
|
195
|
+
* trigger (turn_snapshot / compact), subject to cadence and cooldown. All
|
|
196
196
|
* failures are contained and recorded — a broken fate dimension never
|
|
197
197
|
* disturbs the agent loop.
|
|
198
198
|
*/
|
|
@@ -255,7 +255,7 @@ export async function runLocalFatePhase(ctx, engine, agent, config, state, reaso
|
|
|
255
255
|
rationale: `fate: ${assessment.rationale} (${applied.join("; ")})`,
|
|
256
256
|
refinementId: results.map((result) => result.id).join(","),
|
|
257
257
|
});
|
|
258
|
-
if (config.notifyOnAutoReview && reason === "turn_interval" && applied.length > 0) {
|
|
258
|
+
if (config.notifyOnAutoReview && (reason === "turn_snapshot" || reason === "turn_interval") && applied.length > 0) {
|
|
259
259
|
notifyFateApplied(ctx, agent, applied);
|
|
260
260
|
}
|
|
261
261
|
return;
|
|
@@ -309,7 +309,7 @@ export async function runLocalFatePhase(ctx, engine, agent, config, state, reaso
|
|
|
309
309
|
rationale: `fate: ${assessment.rationale} (${applied.join("; ")})`,
|
|
310
310
|
refinementId: results.map((result) => result.id).join(","),
|
|
311
311
|
});
|
|
312
|
-
if (config.notifyOnAutoReview && reason === "turn_interval") {
|
|
312
|
+
if (config.notifyOnAutoReview && (reason === "turn_snapshot" || reason === "turn_interval")) {
|
|
313
313
|
notifyFateApplied(ctx, agent, applied);
|
|
314
314
|
}
|
|
315
315
|
return;
|
|
@@ -333,7 +333,7 @@ export function buildFateNotice(applied) {
|
|
|
333
333
|
"请用一句话简短确认即可,不要调用任何工具。",
|
|
334
334
|
].join("\n");
|
|
335
335
|
}
|
|
336
|
-
/** Queue the follow-up notice turn (
|
|
336
|
+
/** Queue the follow-up notice turn (successful-turn path only). */
|
|
337
337
|
function notifyFateApplied(ctx, agent, applied) {
|
|
338
338
|
try {
|
|
339
339
|
agent.followup(createUserMessage({
|
package/lib/index.d.ts
CHANGED
|
@@ -10,9 +10,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
10
10
|
baseDir: z<string, string, "plain">;
|
|
11
11
|
/** System-prompt section order for the evolution guidance. */
|
|
12
12
|
sectionOrder: z<number, number, "defined">;
|
|
13
|
-
/**
|
|
13
|
+
/** Initial automatic-review default when no runtime switch exists (off by default: it costs model calls). */
|
|
14
14
|
autoReview: z<boolean, boolean, "defined">;
|
|
15
|
-
/**
|
|
15
|
+
/** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
|
|
16
16
|
reviewIntervalTurns: z<number, number, "defined">;
|
|
17
17
|
/** Trajectory slice handed to the gate, in characters. */
|
|
18
18
|
maxReviewInputChars: z<number, number, "defined">;
|
|
@@ -107,9 +107,9 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
107
107
|
baseDir: z<string, string, "plain">;
|
|
108
108
|
/** System-prompt section order for the evolution guidance. */
|
|
109
109
|
sectionOrder: z<number, number, "defined">;
|
|
110
|
-
/**
|
|
110
|
+
/** Initial automatic-review default when no runtime switch exists (off by default: it costs model calls). */
|
|
111
111
|
autoReview: z<boolean, boolean, "defined">;
|
|
112
|
-
/**
|
|
112
|
+
/** Legacy/local-fate cadence fallback; successful-turn review is snapshot-driven. */
|
|
113
113
|
reviewIntervalTurns: z<number, number, "defined">;
|
|
114
114
|
/** Trajectory slice handed to the gate, in characters. */
|
|
115
115
|
maxReviewInputChars: z<number, number, "defined">;
|