dsh-continual-evolve 0.1.0 → 0.2.0
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 +149 -19
- package/README.zh.md +64 -21
- package/lib/apply.js +2 -0
- package/lib/approval.d.ts +19 -0
- package/lib/auto.d.ts +61 -1
- package/lib/auto.js +108 -2
- package/lib/benchmark.d.ts +14 -0
- package/lib/command.js +234 -5
- package/lib/evaluate.d.ts +36 -7
- package/lib/evaluate.js +157 -43
- package/lib/fate.d.ts +126 -0
- package/lib/fate.js +338 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +18 -2
- package/lib/mount.js +5 -0
- package/lib/planner.d.ts +8 -1
- package/lib/planner.js +27 -0
- package/lib/render.js +2 -1
- package/lib/review.d.ts +1 -1
- package/lib/review.js +17 -0
- package/lib/score.d.ts +22 -4
- package/lib/score.js +48 -7
- package/lib/skill.d.ts +10 -2
- package/lib/skill.js +34 -2
- package/lib/skillquality.d.ts +81 -0
- package/lib/skillquality.js +311 -0
- package/lib/tool.js +6 -3
- package/lib/types.d.ts +31 -0
- package/lib/types.js +19 -0
- package/lib/validate.js +25 -1
- package/lib/wrapup.d.ts +210 -0
- package/lib/wrapup.js +439 -0
- package/package.json +24 -14
package/lib/auto.js
CHANGED
|
@@ -17,12 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
+
import { slug } from "./types.js";
|
|
20
21
|
import { planWithLlm } from "./planner.js";
|
|
21
22
|
import { reviewAutoRefine, serializeSurface } from "./review.js";
|
|
22
23
|
import { goalServiceOf } from "./goal.js";
|
|
23
24
|
import { notifyAutoReview } from "./notify.js";
|
|
25
|
+
import { runLocalFatePhase } from "./fate.js";
|
|
24
26
|
import { entrySourceOf } from "./source.js";
|
|
25
27
|
import { mergeHarnessStates } from "./state.js";
|
|
28
|
+
/** Turns a rejected skill candidate stays silent before being offered again. */
|
|
29
|
+
export const SKILL_CONSULT_COOLDOWN_TURNS = 10;
|
|
26
30
|
/**
|
|
27
31
|
* Count completed turns from agent/status transitions alone. The runtime
|
|
28
32
|
* emits `agent/status` with a `{status}` payload and (per host consumers like
|
|
@@ -133,7 +137,14 @@ export function registerAutoReview(ctx, engine, config) {
|
|
|
133
137
|
function stateFor(map, sessionId) {
|
|
134
138
|
let state = map.get(sessionId);
|
|
135
139
|
if (!state) {
|
|
136
|
-
state = {
|
|
140
|
+
state = {
|
|
141
|
+
turns: 0,
|
|
142
|
+
lastReviewAt: 0,
|
|
143
|
+
running: false,
|
|
144
|
+
skillRejects: new Map(),
|
|
145
|
+
lastFateAt: 0,
|
|
146
|
+
fateRejects: new Map(),
|
|
147
|
+
};
|
|
137
148
|
map.set(sessionId, state);
|
|
138
149
|
}
|
|
139
150
|
return state;
|
|
@@ -150,7 +161,19 @@ function stateFor(map, sessionId) {
|
|
|
150
161
|
export function loadGateHarnessView(engine, sessionId) {
|
|
151
162
|
return mergeHarnessStates(engine.load("global", undefined), engine.load("local", sessionId));
|
|
152
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* One gate run = review phase + local-fate phase (#11 P2). The review phase
|
|
166
|
+
* judges and applies local refinements; the local-fate phase then gives the
|
|
167
|
+
* session's existing local entries a running exit (promote/archive proposals,
|
|
168
|
+
* consulted before they land). Running fate AFTER the review keeps the
|
|
169
|
+
* review's baseline fresh — fate re-loads the store and never races the
|
|
170
|
+
* review's optimistic-concurrency checks.
|
|
171
|
+
*/
|
|
153
172
|
async function runGate(ctx, engine, agent, config, state, reason, record) {
|
|
173
|
+
await runReviewPhase(ctx, engine, agent, config, state, reason, record);
|
|
174
|
+
await runLocalFatePhase(ctx, engine, agent, config, state, reason, record);
|
|
175
|
+
}
|
|
176
|
+
async function runReviewPhase(ctx, engine, agent, config, state, reason, record) {
|
|
154
177
|
const sessionId = agent.id;
|
|
155
178
|
const turnsSinceLastReview = state.turns - state.lastReviewAt;
|
|
156
179
|
const logger = ctx.logger("continual-evolve");
|
|
@@ -189,9 +212,31 @@ async function runGate(ctx, engine, agent, config, state, reason, record) {
|
|
|
189
212
|
history,
|
|
190
213
|
...(review.instructions ? { instructions: review.instructions } : {}),
|
|
191
214
|
global: false,
|
|
215
|
+
// Read the skill-creator template facts (fallback: builtin distilled
|
|
216
|
+
// guide) so skill proposals follow the standard.
|
|
217
|
+
skillsRoot: join(engine.baseDir, "skills"),
|
|
192
218
|
});
|
|
219
|
+
// Skills are governed resources: an auto-created skill is OFFERED to the
|
|
220
|
+
// user for a decision (固化/不固化) before it lands — the gate never
|
|
221
|
+
// writes a skill silently. Without consent the skill edits are withheld
|
|
222
|
+
// and the rest of the proposal proceeds as usual.
|
|
223
|
+
const { skillEdits, otherEdits } = splitSkillEdits(proposal);
|
|
224
|
+
const skillConsented = await consultSkillEdits(ctx, agent, skillEdits, state);
|
|
225
|
+
const finalProposal = skillConsented
|
|
226
|
+
? proposal
|
|
227
|
+
: {
|
|
228
|
+
...proposal,
|
|
229
|
+
edits: otherEdits,
|
|
230
|
+
summary: skillEdits.length > 0 ? `${proposal.summary} (skill edits withheld — pending user decision)` : proposal.summary,
|
|
231
|
+
};
|
|
232
|
+
if (finalProposal.edits.length === 0) {
|
|
233
|
+
const withheld = skillEdits.length > 0 ? " (skill proposal withheld — user not consulted or declined)" : "";
|
|
234
|
+
logger.info(`auto-review declined (${reason}) [${sessionId}] after ${turnsSinceLastReview} turns: no consented edits${withheld} — ${review.rationale}`);
|
|
235
|
+
record({ sessionId, reason, turnsSinceLastReview, outcome: "declined", rationale: `${review.rationale}${withheld}` });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
193
238
|
const source = entrySourceOf(agent, sessionId);
|
|
194
|
-
const result = engine.apply("local", sessionId,
|
|
239
|
+
const result = engine.apply("local", sessionId, finalProposal, {
|
|
195
240
|
scope: "local",
|
|
196
241
|
baselineState: localState,
|
|
197
242
|
...(source ? { source } : {}),
|
|
@@ -214,4 +259,65 @@ async function readTrajectory(ctx, agent, maxChars) {
|
|
|
214
259
|
const snapshot = await sessionQuery.readSurface(agent.id);
|
|
215
260
|
return serializeSurface(snapshot.events, maxChars);
|
|
216
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Split a proposal into skill edits and everything else. Skill edits are the
|
|
264
|
+
* governed part: they need explicit user consent before the gate applies
|
|
265
|
+
* them, while the remaining edits flow through the normal auto path.
|
|
266
|
+
*/
|
|
267
|
+
export function splitSkillEdits(proposal) {
|
|
268
|
+
return {
|
|
269
|
+
skillEdits: proposal.edits.filter((edit) => edit.kind === "skill"),
|
|
270
|
+
otherEdits: proposal.edits.filter((edit) => edit.kind !== "skill"),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Ask the user whether to solidify proposed skill edits (guidance or
|
|
275
|
+
* executable) into the harness. Returns true when every skill edit is
|
|
276
|
+
* consented. Never writes a skill silently:
|
|
277
|
+
* - no question service available → false (conservative);
|
|
278
|
+
* - the same candidate was rejected within the cooldown window → false
|
|
279
|
+
* without asking again (no nagging);
|
|
280
|
+
* - the user declines → false and the rejection is recorded for cooldown;
|
|
281
|
+
* - the question call fails/aborts → false (conservative).
|
|
282
|
+
*/
|
|
283
|
+
export async function consultSkillEdits(ctx, agent, skillEdits, gate) {
|
|
284
|
+
if (skillEdits.length === 0)
|
|
285
|
+
return true;
|
|
286
|
+
const key = skillEdits.map((edit) => edit.id ?? slug(edit.title ?? edit.kind, edit.kind)).join("|");
|
|
287
|
+
const lastReject = gate.skillRejects.get(key);
|
|
288
|
+
if (lastReject !== undefined && gate.turns - lastReject < SKILL_CONSULT_COOLDOWN_TURNS) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const userQuestions = ctx.userQuestions;
|
|
292
|
+
if (!userQuestions) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const description = skillEdits
|
|
296
|
+
.map((edit) => {
|
|
297
|
+
const form = edit.skill_kind === "guidance" ? "guidance 技能(SKILL.md 文档)" : "可执行技能";
|
|
298
|
+
return `- ${edit.action}「${edit.title ?? edit.id}」(${form})`;
|
|
299
|
+
})
|
|
300
|
+
.join("\n");
|
|
301
|
+
try {
|
|
302
|
+
const answer = await userQuestions.ask({
|
|
303
|
+
questions: [
|
|
304
|
+
{
|
|
305
|
+
id: "evolve-skill-consult",
|
|
306
|
+
question: `自进化检测到反复出现的流程/技能候选,建议沉淀:\n\n${description}\n\n是否固化?`,
|
|
307
|
+
options: [{ label: "固化" }, { label: "不固化" }],
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
agent,
|
|
311
|
+
});
|
|
312
|
+
const item = answer.answers?.find((entry) => entry.id === "evolve-skill-consult");
|
|
313
|
+
const consented = item?.selected?.includes("固化") ?? false;
|
|
314
|
+
if (!consented) {
|
|
315
|
+
gate.skillRejects.set(key, gate.turns);
|
|
316
|
+
}
|
|
317
|
+
return consented;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
217
323
|
//# sourceMappingURL=auto.js.map
|
package/lib/benchmark.d.ts
CHANGED
|
@@ -16,9 +16,23 @@ export interface BenchmarkDefinition {
|
|
|
16
16
|
export interface CellScore {
|
|
17
17
|
caseId: string;
|
|
18
18
|
run: number;
|
|
19
|
+
/**
|
|
20
|
+
* Failure-cell protocol (gap A2): "ok" = a real score; "failed" = the
|
|
21
|
+
* unit could not produce one (rubric decrypt error, child crash, protocol
|
|
22
|
+
* error). A failed cell is NOT a zero — aggregation excludes it and
|
|
23
|
+
* counts it, and the acceptance rule rejects a round with more failures
|
|
24
|
+
* than the threshold instead of silently averaging a 0 into the mean.
|
|
25
|
+
*/
|
|
26
|
+
status: "ok" | "failed";
|
|
19
27
|
score: number;
|
|
20
28
|
passed: boolean;
|
|
21
29
|
notes: string;
|
|
30
|
+
/**
|
|
31
|
+
* Trace evidence pointer (gap A4): the executor child's session id whose
|
|
32
|
+
* transcript produced this cell's evidence — the score can be drilled
|
|
33
|
+
* back to the exact session steps that earned it.
|
|
34
|
+
*/
|
|
35
|
+
sessionId?: string;
|
|
22
36
|
}
|
|
23
37
|
export interface EvaluationEntry {
|
|
24
38
|
label: string;
|
package/lib/command.js
CHANGED
|
@@ -2,7 +2,9 @@ import { ARCHIVED_AT_KEY } from "./types.js";
|
|
|
2
2
|
import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
|
|
3
3
|
import { planWithLlm } from "./planner.js";
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
import { requireGlobalApproval } from "./approval.js";
|
|
7
|
+
import { assessLocalEntries, candidateKey, filterPromotable, listLocalCandidates, splitArchiveGuards, splitPromoteBlocked, splitPromoteProposals, wholePromoteProposals } from "./wrapup.js";
|
|
6
8
|
import { saveHarnessState } from "./state.js";
|
|
7
9
|
import { loadLedger, mountSkill, unmountSkill } from "./mount.js";
|
|
8
10
|
import { blockEvolutionGoal, completeEvolutionGoal, goalServiceOf, goalStatusText, upsertEvolutionGoal } from "./goal.js";
|
|
@@ -18,6 +20,8 @@ const USAGE = `Usage:
|
|
|
18
20
|
/evolve history [global] show applied refinements (rollback ids)
|
|
19
21
|
/evolve rollback <id> [global] deterministically revert a refinement
|
|
20
22
|
/evolve plan [msg] run the LLM planner against the current store
|
|
23
|
+
/evolve wrapup assess this session's local entries: promote reusable ones
|
|
24
|
+
to the global store (approval required), archive one-offs
|
|
21
25
|
/evolve archive <id> [global] hide an entry from injection (data kept, restorable)
|
|
22
26
|
/evolve unarchive <id> [global] restore an archived entry
|
|
23
27
|
/evolve log [tail N] show the recent plugin log (default 50 lines)
|
|
@@ -255,6 +259,8 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
255
259
|
...(instructions ? { instructions } : {}),
|
|
256
260
|
global: scope === "global",
|
|
257
261
|
signal: invocation.signal,
|
|
262
|
+
// skill-creator template facts (fallback: builtin guide).
|
|
263
|
+
skillsRoot: join(engine.baseDir, "skills"),
|
|
258
264
|
});
|
|
259
265
|
if (scope === "global" && opts.requireGlobalApproval && proposal.edits.length > 0) {
|
|
260
266
|
await requireGlobalApproval(ctx, invocation.agent, invocation.signal, `/evolve plan global 将应用 ${proposal.edits.length} 条编辑到跨会话 store:${proposal.summary}`);
|
|
@@ -266,6 +272,9 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
|
|
|
266
272
|
});
|
|
267
273
|
return success(renderResult(result));
|
|
268
274
|
}
|
|
275
|
+
case "wrapup": {
|
|
276
|
+
return await executeWrapupCommand(ctx, engine, invocation);
|
|
277
|
+
}
|
|
269
278
|
case "goal": {
|
|
270
279
|
return executeGoalCommand(ctx, invocation, rest);
|
|
271
280
|
}
|
|
@@ -320,6 +329,211 @@ function executeGoalCommand(ctx, invocation, rest) {
|
|
|
320
329
|
return error(cause instanceof Error ? cause.message : String(cause));
|
|
321
330
|
}
|
|
322
331
|
}
|
|
332
|
+
async function executeWrapupCommand(ctx, engine, invocation) {
|
|
333
|
+
const sessionId = invocation.agent.id;
|
|
334
|
+
const localState = engine.load("local", sessionId);
|
|
335
|
+
const globalState = engine.load("global", undefined);
|
|
336
|
+
const candidates = listLocalCandidates(localState, globalState);
|
|
337
|
+
if (candidates.length === 0) {
|
|
338
|
+
return success(`(nothing to wrap up: ${sessionId}'s local store has no active, un-promoted entries — use /evolve list to inspect it)`);
|
|
339
|
+
}
|
|
340
|
+
// 1. Classify: the model judges each audited candidate's fate.
|
|
341
|
+
const assessment = await assessLocalEntries(ctx, invocation.agent, candidates, { signal: invocation.signal });
|
|
342
|
+
const byKey = new Map(candidates.map((candidate) => [candidateKey(candidate.kind, candidate.id), candidate]));
|
|
343
|
+
// 2. Partition by action. Deterministic guards re-check the LIVE global
|
|
344
|
+
// store right before anything lands (state may have changed mid-call).
|
|
345
|
+
const { promotable, skipped } = filterPromotable(assessment.items, globalState, candidates);
|
|
346
|
+
const promoteItems = promotable.filter((item) => item.verdict === "promote");
|
|
347
|
+
const archiveItems = assessment.items.filter((item) => item.verdict === "archive");
|
|
348
|
+
// Split promotion (A-form): archive a mixed entry but promote ONLY the
|
|
349
|
+
// cleaned durable part the model extracted. Guarded the same way as whole
|
|
350
|
+
// promotes — a split that would duplicate a globally covered topic is
|
|
351
|
+
// dropped and the entry archives plain.
|
|
352
|
+
const splitItems = [];
|
|
353
|
+
const splitSkipped = [];
|
|
354
|
+
for (const item of archiveItems) {
|
|
355
|
+
if (!item.promote)
|
|
356
|
+
continue;
|
|
357
|
+
const candidate = byKey.get(item.key);
|
|
358
|
+
if (!candidate) {
|
|
359
|
+
splitSkipped.push({ key: item.key, reason: "not in the audited candidate list" });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const blocked = splitPromoteBlocked(item, globalState, candidate.kind);
|
|
363
|
+
if (blocked) {
|
|
364
|
+
splitSkipped.push({ key: item.key, reason: blocked });
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
splitItems.push({ item, candidate });
|
|
368
|
+
}
|
|
369
|
+
// Plain archives (no split payload): the symmetric guard — an archive that
|
|
370
|
+
// is NOT globally covered AND was distilled from real user messages must
|
|
371
|
+
// not proceed silently.
|
|
372
|
+
const plainArchives = archiveItems.filter((item) => !item.promote);
|
|
373
|
+
const { silent: silentArchives, review: reviewArchives } = splitArchiveGuards(plainArchives, candidates);
|
|
374
|
+
const keepItems = assessment.items.filter((item) => item.verdict === "keep");
|
|
375
|
+
// 3. Report the assessment before touching anything.
|
|
376
|
+
const lines = [
|
|
377
|
+
`wrapup assessment (${sessionId}): ${candidates.length} candidates${candidates.some((c) => c.coveredGlobally) ? `, ${candidates.filter((c) => c.coveredGlobally).length} covered globally` : ""}`,
|
|
378
|
+
`${assessment.rationale}`,
|
|
379
|
+
];
|
|
380
|
+
for (const [heading, items] of [
|
|
381
|
+
["PROMOTE (to global)", promoteItems],
|
|
382
|
+
["SPLIT (archive + promote durable part)", splitItems.map((split) => split.item)],
|
|
383
|
+
["ARCHIVE", silentArchives],
|
|
384
|
+
["ARCHIVE (needs review)", reviewArchives],
|
|
385
|
+
["KEEP", keepItems],
|
|
386
|
+
]) {
|
|
387
|
+
lines.push(`${heading}: ${items.length}`);
|
|
388
|
+
for (const item of items) {
|
|
389
|
+
const candidate = byKey.get(item.key);
|
|
390
|
+
const title = candidate ? candidate.title : item.key;
|
|
391
|
+
const splitNote = item.promote ? ` → 拆出提升「${item.promote.title}」` : "";
|
|
392
|
+
lines.push(`- ${item.key} "${title}"${splitNote} — ${item.reason}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const skip of skipped) {
|
|
396
|
+
lines.push(`- promote skipped: ${skip.key} — ${skip.reason}`);
|
|
397
|
+
}
|
|
398
|
+
for (const skip of splitSkipped) {
|
|
399
|
+
lines.push(`- split skipped: ${skip.key} — ${skip.reason}`);
|
|
400
|
+
}
|
|
401
|
+
lines.push("");
|
|
402
|
+
const applied = [];
|
|
403
|
+
// 4. Global writes: governed resource — ONE human approval gate covers
|
|
404
|
+
// every create (whole promotes AND split promotions). On approval:
|
|
405
|
+
// - whole promote → create global copy + stamp local promotedTo+archivedAt;
|
|
406
|
+
// - split → create the cleaned durable part + archive the original with
|
|
407
|
+
// promotedTo. On rejection: whole promotes are not written, and each
|
|
408
|
+
// split's original STILL archives plain (its snapshot half deserves
|
|
409
|
+
// the archive; the durable half is reported for manual handling).
|
|
410
|
+
const wholeCreates = promoteItems.map((item) => ({ item, candidate: byKey.get(item.key) }));
|
|
411
|
+
const splitCreates = splitItems;
|
|
412
|
+
const allCreates = new Set([...wholeCreates.map((c) => c.item.key), ...splitCreates.map((c) => c.item.key)]);
|
|
413
|
+
if (allCreates.size > 0) {
|
|
414
|
+
const what = `wrapup 将写入跨会话 global store(共 ${allCreates.size} 条:${promoteItems.length} 条整条提升 + ${splitItems.length} 条拆解提升):\n${[
|
|
415
|
+
...promoteItems.map((item) => `- 整条提升 ${item.key} "${byKey.get(item.key)?.title ?? item.key}"`),
|
|
416
|
+
...splitItems.map((split) => `- 拆解提升 ${split.item.key} → 清洗「${split.item.promote?.title}」(原条目随之归档)`),
|
|
417
|
+
].join("\n")}`;
|
|
418
|
+
let promoteAllowed = true;
|
|
419
|
+
try {
|
|
420
|
+
await requireGlobalApproval(ctx, invocation.agent, invocation.signal, what);
|
|
421
|
+
}
|
|
422
|
+
catch (cause) {
|
|
423
|
+
promoteAllowed = false;
|
|
424
|
+
const message = `global 写入未批准 — 整条提升与拆解提升均未写入 (${cause instanceof Error ? cause.message : String(cause)})`;
|
|
425
|
+
applied.push(message);
|
|
426
|
+
lines.push(message);
|
|
427
|
+
}
|
|
428
|
+
if (promoteAllowed) {
|
|
429
|
+
// Whole promotes: create global entry, retire the local copy.
|
|
430
|
+
// Shared proposal builders keep the wrap-up command and the gate's
|
|
431
|
+
// local-fate dimension writing IDENTICAL edits.
|
|
432
|
+
for (const { item, candidate } of wholeCreates) {
|
|
433
|
+
if (!candidate)
|
|
434
|
+
continue;
|
|
435
|
+
const proposals = wholePromoteProposals(item, candidate, sessionId);
|
|
436
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
437
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
438
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
439
|
+
scope: "local",
|
|
440
|
+
baselineState: localState,
|
|
441
|
+
});
|
|
442
|
+
applied.push(`promoted ${item.key} → global:${createdId} (${globalResult.id}; local stamped ${localResult.id})`);
|
|
443
|
+
}
|
|
444
|
+
// Split promotions: create the cleaned durable part, retire the
|
|
445
|
+
// original local entry (its snapshot half is archived along).
|
|
446
|
+
for (const { item, candidate } of splitCreates) {
|
|
447
|
+
if (!item.promote)
|
|
448
|
+
continue;
|
|
449
|
+
const proposals = splitPromoteProposals(item, candidate, sessionId);
|
|
450
|
+
const globalResult = engine.apply("global", undefined, proposals.global, { scope: "global" });
|
|
451
|
+
const createdId = globalResult.appliedEdits.find((edit) => edit.applied)?.id ?? candidate.id;
|
|
452
|
+
const localResult = engine.apply("local", sessionId, proposals.localStamp(createdId), {
|
|
453
|
+
scope: "local",
|
|
454
|
+
baselineState: localState,
|
|
455
|
+
});
|
|
456
|
+
applied.push(`split ${item.key}: promoted cleaned part → global:${createdId} (${globalResult.id}); original archived (${localResult.id})`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
// Rejected: whole promotes stay un-written; each split's original
|
|
461
|
+
// still archives plain (reported, data restorable).
|
|
462
|
+
for (const { item, candidate } of splitCreates) {
|
|
463
|
+
if (!candidate)
|
|
464
|
+
continue;
|
|
465
|
+
const result = engine.apply("local", sessionId, {
|
|
466
|
+
summary: `wrapup: split promotion not approved — archive original ${item.key} plain`,
|
|
467
|
+
rationale: item.reason,
|
|
468
|
+
expectedOutcome: `The original leaves injection; the cleaned part was NOT written (reported for manual handling).`,
|
|
469
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
470
|
+
}, { scope: "local", baselineState: localState });
|
|
471
|
+
applied.push(`split ${item.key}: promotion not approved — original archived plain (${result.id})`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// 5. Silent archives: deterministic local action (hidden from injection,
|
|
476
|
+
// data kept restorable) — covered topics and operational entries need no
|
|
477
|
+
// confirmation, matching the original behavior.
|
|
478
|
+
for (const item of silentArchives) {
|
|
479
|
+
const candidate = byKey.get(item.key);
|
|
480
|
+
if (!candidate)
|
|
481
|
+
continue;
|
|
482
|
+
const result = engine.apply("local", sessionId, {
|
|
483
|
+
summary: `wrapup: archive local ${item.key} — ${item.reason}`,
|
|
484
|
+
rationale: item.reason,
|
|
485
|
+
expectedOutcome: `The entry stops being injected but stays restorable.`,
|
|
486
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
487
|
+
}, { scope: "local", baselineState: localState });
|
|
488
|
+
applied.push(`archived ${item.key} (${result.id})`);
|
|
489
|
+
}
|
|
490
|
+
// 6. Review archives (symmetric guard): not covered globally + distilled
|
|
491
|
+
// from real user messages — the user decides before this content is
|
|
492
|
+
// hidden from future sessions. No question service → conservative keep.
|
|
493
|
+
const userQuestions = ctx.userQuestions;
|
|
494
|
+
for (const item of reviewArchives) {
|
|
495
|
+
const candidate = byKey.get(item.key);
|
|
496
|
+
if (!candidate)
|
|
497
|
+
continue;
|
|
498
|
+
if (!userQuestions) {
|
|
499
|
+
applied.push(`kept ${item.key} — archive pending user confirmation (no question service)`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const questionId = "evolve-wrapup-archive-review";
|
|
503
|
+
let archiveConfirmed = false;
|
|
504
|
+
try {
|
|
505
|
+
const answer = await userQuestions.ask({
|
|
506
|
+
questions: [
|
|
507
|
+
{
|
|
508
|
+
id: questionId,
|
|
509
|
+
question: `wrapup:条目「${candidate.title}」未被全局覆盖且源自真实对话,直接归档会隐藏它(数据保留、可恢复)。确认归档?`,
|
|
510
|
+
options: [{ label: "归档" }, { label: "保留" }],
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
agent: invocation.agent,
|
|
514
|
+
signal: invocation.signal,
|
|
515
|
+
});
|
|
516
|
+
archiveConfirmed = answer.answers?.find((entry) => entry.id === questionId)?.selected?.includes("归档") ?? false;
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
archiveConfirmed = false;
|
|
520
|
+
}
|
|
521
|
+
if (archiveConfirmed) {
|
|
522
|
+
const result = engine.apply("local", sessionId, {
|
|
523
|
+
summary: `wrapup: archive local ${item.key} (user-confirmed) — ${item.reason}`,
|
|
524
|
+
rationale: item.reason,
|
|
525
|
+
expectedOutcome: `The entry stops being injected but stays restorable.`,
|
|
526
|
+
edits: [{ action: "archive", kind: candidate.kind, id: candidate.id }],
|
|
527
|
+
}, { scope: "local", baselineState: localState });
|
|
528
|
+
applied.push(`archived ${item.key} (user-confirmed, ${result.id})`);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
applied.push(`kept ${item.key} — user declined the archive`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
lines.push(...(applied.length > 0 ? applied : ["(no changes applied — all entries kept)"]));
|
|
535
|
+
return success(lines.join("\n"));
|
|
536
|
+
}
|
|
323
537
|
async function executeMountCommand(ctx, engine, invocation, rest) {
|
|
324
538
|
const sub = rest[0] ?? "";
|
|
325
539
|
if (sub === "list") {
|
|
@@ -351,6 +565,11 @@ async function executeMountCommand(ctx, engine, invocation, rest) {
|
|
|
351
565
|
return error(cause instanceof Error ? cause.message : String(cause));
|
|
352
566
|
}
|
|
353
567
|
}
|
|
568
|
+
/** " (N failed)" suffix when an evaluation entry carries failed cells. */
|
|
569
|
+
function failedTextOf(entry) {
|
|
570
|
+
const failed = entry.cells.filter((cell) => cell.status === "failed").length;
|
|
571
|
+
return failed > 0 ? ` (${failed} failed)` : "";
|
|
572
|
+
}
|
|
354
573
|
async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
|
|
355
574
|
const sub = rest[0] ?? "";
|
|
356
575
|
const args = rest.slice(1);
|
|
@@ -409,13 +628,13 @@ async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
|
|
|
409
628
|
const board = loadScoreboard(baseDir, bid);
|
|
410
629
|
const lines = [];
|
|
411
630
|
if (board.reference) {
|
|
412
|
-
lines.push(`reference "${board.reference.label}": overall=${board.reference.overall ?? "?"} cells=${board.reference.cells.length}`);
|
|
631
|
+
lines.push(`reference "${board.reference.label}": overall=${board.reference.overall ?? "?"} cells=${board.reference.cells.length}${failedTextOf(board.reference)}`);
|
|
413
632
|
}
|
|
414
633
|
else {
|
|
415
634
|
lines.push("(no reference evaluation yet)");
|
|
416
635
|
}
|
|
417
636
|
for (const c of board.candidates) {
|
|
418
|
-
lines.push(`candidate "${c.label}": overall=${c.overall ?? "?"} cells=${c.cells.length}${c.refinementId ? ` (${c.refinementId})` : ""}`);
|
|
637
|
+
lines.push(`candidate "${c.label}": overall=${c.overall ?? "?"} cells=${c.cells.length}${failedTextOf(c)}${c.refinementId ? ` (${c.refinementId})` : ""}`);
|
|
419
638
|
}
|
|
420
639
|
for (const d of board.decisions) {
|
|
421
640
|
lines.push(`decision: ${d.accepted ? "ACCEPTED" : "rejected"} ${d.candidateLabel} — ${d.reasons.join("; ") || "ok"}`);
|
|
@@ -449,19 +668,29 @@ async function executeBenchmarkCommand(ctx, engine, invocation, rest, runtime) {
|
|
|
449
668
|
signal: invocation.signal,
|
|
450
669
|
});
|
|
451
670
|
const entry = entryFromCells(label, outcome.cells, candidateId);
|
|
671
|
+
const failedCells = outcome.cells.filter((cell) => cell.status === "failed").length;
|
|
452
672
|
const lines = [
|
|
453
|
-
`evaluation "${label}": ${outcome.cells.length} cells, overall=${entry.overall ?? "?"}`,
|
|
673
|
+
`evaluation "${label}": ${outcome.cells.length} cells${failedCells > 0 ? `, ${failedCells} failed` : ""}, overall=${entry.overall ?? "?"}`,
|
|
454
674
|
...Object.entries(entry.aggregate)
|
|
455
|
-
.filter(([key]) => key !== "overall")
|
|
675
|
+
.filter(([key]) => key !== "overall" && key !== "failed" && key !== "total")
|
|
456
676
|
.map(([key, value]) => ` ${key}: ${value ?? "?"}`),
|
|
457
677
|
];
|
|
678
|
+
if (failedCells > 0) {
|
|
679
|
+
for (const cell of outcome.cells.filter((cell) => cell.status === "failed")) {
|
|
680
|
+
lines.push(` [failed] ${cell.caseId} r${cell.run}: ${cell.notes}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
458
683
|
if (candidateId) {
|
|
459
684
|
if (!board.reference) {
|
|
460
685
|
lines.push("(no reference yet — this run only recorded the candidate)");
|
|
461
686
|
board.candidates.push(entry);
|
|
462
687
|
}
|
|
463
688
|
else {
|
|
464
|
-
const decision = decide(board.reference, entry, {
|
|
689
|
+
const decision = decide(board.reference, entry, {
|
|
690
|
+
passThreshold: definition.passThreshold,
|
|
691
|
+
regressionTolerance: 0,
|
|
692
|
+
maxFailedCells: 0,
|
|
693
|
+
});
|
|
465
694
|
board.candidates.push(entry);
|
|
466
695
|
board.decisions.push({
|
|
467
696
|
candidateLabel: label,
|
package/lib/evaluate.d.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The evaluation matrix runner: executes every case × run as a
|
|
3
|
-
* structured-output subagent, with the provider/model frozen to the
|
|
4
|
-
* agent's own route.
|
|
5
|
-
*
|
|
2
|
+
* The evaluation matrix runner: executes every case × run as a TWO-STAGE
|
|
3
|
+
* structured-output subagent pair, with the provider/model frozen to the
|
|
4
|
+
* calling agent's own route.
|
|
5
|
+
*
|
|
6
|
+
* Stage 1 — executor: a fresh child performs the case task with its tools
|
|
7
|
+
* and records CONCRETE EVIDENCE of what it did and found. It NEVER sees the
|
|
8
|
+
* rubric (gap A1, evaluator/scorer separation): the agent under test cannot
|
|
9
|
+
* optimize its behavior toward the grading criteria, and cannot grade its
|
|
10
|
+
* own execution.
|
|
11
|
+
*
|
|
12
|
+
* Stage 2 — reviewer: an INDEPENDENT child grades the executor's evidence
|
|
13
|
+
* strictly against the rubric. The rubric plaintext is decrypted in the host
|
|
14
|
+
* and flows ONLY into this reviewer branch; the executor branch never
|
|
15
|
+
* touches it. A separate model instance grading someone else's evidence
|
|
16
|
+
* removes the self-serving bias of self-scoring.
|
|
17
|
+
*
|
|
18
|
+
* Failure-cell protocol (gap A2): a unit that cannot produce a score
|
|
19
|
+
* (decrypt error, executor crash, reviewer crash, protocol error) returns a
|
|
20
|
+
* cell with `status: "failed"` — NEVER a zero — so aggregation can exclude
|
|
21
|
+
* it and the acceptance rule can reject rounds with too many failures instead
|
|
22
|
+
* of silently averaging a 0 into the mean.
|
|
23
|
+
*
|
|
24
|
+
* Each cell carries the executor child's session id (gap A4): the score can
|
|
25
|
+
* be drilled back to the exact session transcript that produced the evidence.
|
|
6
26
|
*
|
|
7
27
|
* Uses the host-plane `subagents` service (available in every profile) with
|
|
8
28
|
* the native `outputSchema` structured-output seam: the provider validates
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
29
|
+
* each child's reply against its cell schema, so the host never parses model
|
|
30
|
+
* text for evaluations. (The workflow engine was rejected because the web
|
|
31
|
+
* profile keeps it in a per-agent isolated realm a host plugin cannot
|
|
12
32
|
* resolve.)
|
|
13
33
|
*/
|
|
14
34
|
import type { Context } from "@deepseek-ai/cordis";
|
|
@@ -32,7 +52,16 @@ export interface EvaluationOutcome {
|
|
|
32
52
|
}
|
|
33
53
|
/** How many evaluation units may run concurrently (bounded subagent fan-out). */
|
|
34
54
|
export declare const DEFAULT_EVALUATION_CONCURRENCY = 4;
|
|
55
|
+
/** Evidence handed to the reviewer is capped so the grading call stays bounded. */
|
|
56
|
+
export declare const MAX_EVIDENCE_CHARS = 8000;
|
|
35
57
|
export declare function evaluateState(ctx: Context, agent: Agent, options: EvaluateOptions): Promise<EvaluationOutcome>;
|
|
58
|
+
export interface ExecutorResult {
|
|
59
|
+
caseId: string;
|
|
60
|
+
run: number;
|
|
61
|
+
evidence: string;
|
|
62
|
+
}
|
|
63
|
+
/** Validate a provider-validated executor result; returns undefined when malformed. */
|
|
64
|
+
export declare function normalizeExecutor(value: unknown, caseId: string, run: number): ExecutorResult | undefined;
|
|
36
65
|
/** Validate a provider-validated structured cell; returns undefined when malformed. */
|
|
37
66
|
export declare function normalizeCell(value: unknown, caseId: string, run: number, passThreshold: number): CellScore | undefined;
|
|
38
67
|
//# sourceMappingURL=evaluate.d.ts.map
|