dsh-continual-evolve 0.1.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/LICENSE +21 -0
- package/README.md +290 -0
- package/README.zh.md +240 -0
- package/cordis.patch.yml +9 -0
- package/lib/apply.d.ts +24 -0
- package/lib/apply.js +131 -0
- package/lib/approval.d.ts +14 -0
- package/lib/approval.js +27 -0
- package/lib/auto.d.ts +34 -0
- package/lib/auto.js +217 -0
- package/lib/benchmark.d.ts +72 -0
- package/lib/benchmark.js +167 -0
- package/lib/command.d.ts +36 -0
- package/lib/command.js +549 -0
- package/lib/evaluate.d.ts +38 -0
- package/lib/evaluate.js +142 -0
- package/lib/goal.d.ts +72 -0
- package/lib/goal.js +72 -0
- package/lib/index.d.ts +93 -0
- package/lib/index.js +116 -0
- package/lib/inject.d.ts +124 -0
- package/lib/inject.js +231 -0
- package/lib/logfile.d.ts +71 -0
- package/lib/logfile.js +159 -0
- package/lib/mount.d.ts +42 -0
- package/lib/mount.js +198 -0
- package/lib/notify.d.ts +31 -0
- package/lib/notify.js +42 -0
- package/lib/plan.d.ts +16 -0
- package/lib/plan.js +121 -0
- package/lib/planner.d.ts +30 -0
- package/lib/planner.js +110 -0
- package/lib/pool.d.ts +7 -0
- package/lib/pool.js +25 -0
- package/lib/render.d.ts +15 -0
- package/lib/render.js +83 -0
- package/lib/review.d.ts +37 -0
- package/lib/review.js +127 -0
- package/lib/rollback.d.ts +11 -0
- package/lib/rollback.js +69 -0
- package/lib/rubric.d.ts +29 -0
- package/lib/rubric.js +119 -0
- package/lib/score.d.ts +31 -0
- package/lib/score.js +81 -0
- package/lib/service.d.ts +30 -0
- package/lib/service.js +42 -0
- package/lib/skill.d.ts +10 -0
- package/lib/skill.js +75 -0
- package/lib/source.d.ts +29 -0
- package/lib/source.js +42 -0
- package/lib/state.d.ts +34 -0
- package/lib/state.js +154 -0
- package/lib/store.d.ts +20 -0
- package/lib/store.js +74 -0
- package/lib/tool.d.ts +15 -0
- package/lib/tool.js +163 -0
- package/lib/types.d.ts +137 -0
- package/lib/types.js +62 -0
- package/lib/validate.d.ts +11 -0
- package/lib/validate.js +55 -0
- package/package.json +67 -0
package/lib/apply.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { SOURCE_SESSION_KEY, SOURCE_SEQS_KEY, ARCHIVED_AT_KEY, cloneEntry, isArchived, slug } from "./types.js";
|
|
2
|
+
import { entryChangedSince } from "./state.js";
|
|
3
|
+
import { validateEdit } from "./validate.js";
|
|
4
|
+
export function applyRefinementProposal(state, proposal, options) {
|
|
5
|
+
const appliedEdits = [];
|
|
6
|
+
const touched = new Set();
|
|
7
|
+
const now = new Date().toISOString();
|
|
8
|
+
for (const edit of proposal.edits) {
|
|
9
|
+
const computedId = edit.id ?? (edit.action === "create" ? slug(edit.title ?? edit.kind, edit.kind) : undefined);
|
|
10
|
+
const id = computedId ?? "";
|
|
11
|
+
const validationError = validateEdit(edit, computedId);
|
|
12
|
+
if (validationError) {
|
|
13
|
+
appliedEdits.push({ ...edit, id, applied: false, error: validationError });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const records = state.entries[edit.kind];
|
|
17
|
+
const before = cloneEntry(records[id]);
|
|
18
|
+
const entryKey = `${edit.kind}:${id}`;
|
|
19
|
+
const baseline = cloneEntry(options.baselineState?.entries[edit.kind][id]);
|
|
20
|
+
if (options.baselineState && !touched.has(entryKey) && JSON.stringify(before ?? null) !== JSON.stringify(baseline ?? null)) {
|
|
21
|
+
appliedEdits.push({
|
|
22
|
+
...edit,
|
|
23
|
+
id,
|
|
24
|
+
...(before ? { before } : {}),
|
|
25
|
+
applied: false,
|
|
26
|
+
error: "entry changed during planning",
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (edit.action === "delete") {
|
|
31
|
+
if (!before) {
|
|
32
|
+
appliedEdits.push({ ...edit, id, applied: false, error: "entry not found" });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
delete records[id];
|
|
36
|
+
touched.add(entryKey);
|
|
37
|
+
appliedEdits.push({ ...edit, id, before, applied: true });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (edit.action === "archive") {
|
|
41
|
+
// Archive hides an entry from injection but never deletes it:
|
|
42
|
+
// metadata.archivedAt is stamped through the normal apply path, so
|
|
43
|
+
// the edit gets a before/after snapshot, a version bump, and a
|
|
44
|
+
// rollback inverse like any other edit (restoring the snapshot
|
|
45
|
+
// clears the stamp). Idempotency is explicit: re-archiving an
|
|
46
|
+
// already-archived entry is an error, not a silent no-op.
|
|
47
|
+
if (!before) {
|
|
48
|
+
appliedEdits.push({ ...edit, id, applied: false, error: "entry not found" });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (isArchived(before)) {
|
|
52
|
+
appliedEdits.push({ ...edit, id, before, applied: false, error: "entry already archived" });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const after = {
|
|
56
|
+
...before,
|
|
57
|
+
metadata: { ...before.metadata, [ARCHIVED_AT_KEY]: now },
|
|
58
|
+
updated_at: now,
|
|
59
|
+
version: before.version + 1,
|
|
60
|
+
};
|
|
61
|
+
records[id] = after;
|
|
62
|
+
touched.add(entryKey);
|
|
63
|
+
appliedEdits.push({ ...edit, id, before, after: cloneEntry(after) ?? after, applied: true });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (edit.action === "create" && before) {
|
|
67
|
+
appliedEdits.push({ ...edit, id, before, applied: false, error: "entry already exists" });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (edit.action === "update" && !before) {
|
|
71
|
+
appliedEdits.push({ ...edit, id, applied: false, error: "entry not found" });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Trajectory citation: stamped on create (the distillation moment),
|
|
75
|
+
// never re-stamped on update (the entry keeps its original source).
|
|
76
|
+
// Model-supplied metadata wins on key collision.
|
|
77
|
+
const sourceMetadata = !before && options.source
|
|
78
|
+
? {
|
|
79
|
+
[SOURCE_SESSION_KEY]: options.source.sessionId,
|
|
80
|
+
...(options.source.seqs && options.source.seqs.length > 0
|
|
81
|
+
? { [SOURCE_SEQS_KEY]: options.source.seqs }
|
|
82
|
+
: {}),
|
|
83
|
+
}
|
|
84
|
+
: {};
|
|
85
|
+
const after = {
|
|
86
|
+
id,
|
|
87
|
+
kind: edit.kind,
|
|
88
|
+
title: edit.title ?? before?.title ?? id,
|
|
89
|
+
content: edit.content ?? before?.content ?? "",
|
|
90
|
+
path: edit.path ?? before?.path ?? "general",
|
|
91
|
+
scope: before?.scope ?? options.scope ?? "local",
|
|
92
|
+
reference: edit.reference ?? before?.reference ?? {},
|
|
93
|
+
arguments: edit.arguments ?? before?.arguments ?? {},
|
|
94
|
+
metadata: { ...sourceMetadata, ...(edit.metadata ?? before?.metadata ?? {}) },
|
|
95
|
+
source: "evolve",
|
|
96
|
+
created_at: before?.created_at ?? now,
|
|
97
|
+
updated_at: now,
|
|
98
|
+
version: before ? before.version + 1 : 1,
|
|
99
|
+
};
|
|
100
|
+
records[id] = after;
|
|
101
|
+
touched.add(entryKey);
|
|
102
|
+
appliedEdits.push({
|
|
103
|
+
...edit,
|
|
104
|
+
id,
|
|
105
|
+
...(before ? { before } : {}),
|
|
106
|
+
after: cloneEntry(after) ?? after,
|
|
107
|
+
applied: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const changes = appliedEdits.filter((edit) => edit.applied).map((edit) => `${edit.action} ${edit.kind}:${edit.id}`);
|
|
111
|
+
state.refinements.push({
|
|
112
|
+
id: options.id,
|
|
113
|
+
trigger: proposal.summary,
|
|
114
|
+
changes,
|
|
115
|
+
evidence: proposal.rationale,
|
|
116
|
+
outcome: proposal.expectedOutcome,
|
|
117
|
+
created_at: now,
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
id: options.id,
|
|
121
|
+
summary: proposal.summary,
|
|
122
|
+
rationale: proposal.rationale,
|
|
123
|
+
expectedOutcome: proposal.expectedOutcome,
|
|
124
|
+
appliedEdits,
|
|
125
|
+
harnessStatePath: "",
|
|
126
|
+
...(options.rollbackOf ? { rollbackOf: options.rollbackOf } : {}),
|
|
127
|
+
...(options.scope ? { scope: options.scope } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export { entryChangedSince };
|
|
131
|
+
//# sourceMappingURL=apply.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human approval gate for cross-session (global) evolution edits. Forward
|
|
3
|
+
* edits to the shared global store require an explicit human "批准" before
|
|
4
|
+
* they are applied; rollbacks (which restore prior recorded state) do not.
|
|
5
|
+
* The engine itself stays a pure library — this is a policy at the boundary.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
8
|
+
import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
9
|
+
/**
|
|
10
|
+
* Ask the user to approve a global edit. Throws when the service is missing,
|
|
11
|
+
* the user declines, or the question cannot be answered.
|
|
12
|
+
*/
|
|
13
|
+
export declare function requireGlobalApproval(ctx: Context, agent: Agent | undefined, signal: AbortSignal | undefined, what: string): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=approval.d.ts.map
|
package/lib/approval.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the user to approve a global edit. Throws when the service is missing,
|
|
3
|
+
* the user declines, or the question cannot be answered.
|
|
4
|
+
*/
|
|
5
|
+
export async function requireGlobalApproval(ctx, agent, signal, what) {
|
|
6
|
+
const userQuestions = ctx.userQuestions;
|
|
7
|
+
if (!userQuestions) {
|
|
8
|
+
throw new Error("global evolution edits require the userQuestions service (load @deepseek-ai/dsh-user-questions)");
|
|
9
|
+
}
|
|
10
|
+
const answer = await userQuestions.ask({
|
|
11
|
+
questions: [
|
|
12
|
+
{
|
|
13
|
+
id: "approve-global-evolve",
|
|
14
|
+
question: `批准写入跨会话全局 store?\n\n${what}`,
|
|
15
|
+
options: [{ label: "批准" }, { label: "拒绝" }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
...(agent ? { agent } : {}),
|
|
19
|
+
...(signal ? { signal } : {}),
|
|
20
|
+
});
|
|
21
|
+
const item = answer.answers?.find((entry) => entry.id === "approve-global-evolve");
|
|
22
|
+
const approved = item?.selected?.includes("批准") ?? false;
|
|
23
|
+
if (!approved) {
|
|
24
|
+
throw new Error("global evolution edit rejected by the user");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=approval.js.map
|
package/lib/auto.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { HarnessState } from "./types.js";
|
|
3
|
+
import type { EvolutionEngine } from "./service.js";
|
|
4
|
+
export interface AutoReviewConfig {
|
|
5
|
+
intervalTurns: number;
|
|
6
|
+
maxInputChars: number;
|
|
7
|
+
budgetTokens: number;
|
|
8
|
+
/** Queue a visible follow-up notice after an approved, applied gate run. */
|
|
9
|
+
notifyOnAutoReview: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface GateState {
|
|
12
|
+
turns: number;
|
|
13
|
+
lastReviewAt: number;
|
|
14
|
+
running: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Count completed turns from agent/status transitions alone. The runtime
|
|
18
|
+
* emits `agent/status` with a `{status}` payload and (per host consumers like
|
|
19
|
+
* dsh-host-apiproxy) an injected `agent` subject; `agent/turn-stopping` does
|
|
20
|
+
* not reliably carry the agent, so it is NOT used for counting.
|
|
21
|
+
*/
|
|
22
|
+
export declare function advanceGateState(state: GateState, status: string): boolean;
|
|
23
|
+
export declare function registerAutoReview(ctx: Context, engine: EvolutionEngine, config: AutoReviewConfig): void;
|
|
24
|
+
/**
|
|
25
|
+
* The state view the gate and planner judge: the session's local entries
|
|
26
|
+
* merged with the global store, each entry carrying its real scope. Without
|
|
27
|
+
* the global half the gate cannot see that a topic is already covered
|
|
28
|
+
* cross-session and happily re-sediments a local duplicate of it.
|
|
29
|
+
*
|
|
30
|
+
* The merged view is read-only context — applying still targets the raw
|
|
31
|
+
* local state (baseline checks compare local entries only).
|
|
32
|
+
*/
|
|
33
|
+
export declare function loadGateHarnessView(engine: EvolutionEngine, sessionId: string): HarnessState;
|
|
34
|
+
//# sourceMappingURL=auto.d.ts.map
|
package/lib/auto.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The automatic review driver: watches agent turns and session compaction,
|
|
3
|
+
* runs the cheap review gate, and — when the gate approves — runs the
|
|
4
|
+
* local-scope planner and applies the result. All auxiliary work is
|
|
5
|
+
* fire-and-forget with error containment: an auto-review failure never
|
|
6
|
+
* disturbs the agent loop.
|
|
7
|
+
*
|
|
8
|
+
* Every gate decision (approved / declined / failed) is appended to
|
|
9
|
+
* `<dshHome>/evolve/reviews.jsonl` so auto-review activity is durably
|
|
10
|
+
* auditable — the server console is not a reliable place to look.
|
|
11
|
+
*
|
|
12
|
+
* Hook wiring:
|
|
13
|
+
* - `agent/turn-stopping` increments a per-session turn counter (sync, cheap).
|
|
14
|
+
* - `agent/status` (idle) checks the interval and may start the gate.
|
|
15
|
+
* - `session/event` (compaction/start) starts an unconditional gate run so
|
|
16
|
+
* experiences about to be summarized away are persisted first.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { planWithLlm } from "./planner.js";
|
|
21
|
+
import { reviewAutoRefine, serializeSurface } from "./review.js";
|
|
22
|
+
import { goalServiceOf } from "./goal.js";
|
|
23
|
+
import { notifyAutoReview } from "./notify.js";
|
|
24
|
+
import { entrySourceOf } from "./source.js";
|
|
25
|
+
import { mergeHarnessStates } from "./state.js";
|
|
26
|
+
/**
|
|
27
|
+
* Count completed turns from agent/status transitions alone. The runtime
|
|
28
|
+
* emits `agent/status` with a `{status}` payload and (per host consumers like
|
|
29
|
+
* dsh-host-apiproxy) an injected `agent` subject; `agent/turn-stopping` does
|
|
30
|
+
* not reliably carry the agent, so it is NOT used for counting.
|
|
31
|
+
*/
|
|
32
|
+
export function advanceGateState(state, status) {
|
|
33
|
+
if (status === "running") {
|
|
34
|
+
state.running = true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (status === "idle" && state.running) {
|
|
38
|
+
state.running = false;
|
|
39
|
+
state.turns += 1;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export function registerAutoReview(ctx, engine, config) {
|
|
45
|
+
const perSession = new Map();
|
|
46
|
+
const logger = ctx.logger("continual-evolve");
|
|
47
|
+
const reviewsPath = join(engine.baseDir, "evolve", "reviews.jsonl");
|
|
48
|
+
const record = (entry) => {
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(join(engine.baseDir, "evolve"), { recursive: true });
|
|
51
|
+
appendFileSync(reviewsPath, `${JSON.stringify({ ...entry, timestamp: new Date().toISOString() })}\n`, "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch (cause) {
|
|
54
|
+
logger.warn(`failed to record auto-review: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// Turn counting uses `agent/turn-stopping` — empirically the only event
|
|
58
|
+
// whose payload carries the agent subject in every dispatch (verified: the
|
|
59
|
+
// gate fired under it at 20:56). `agent/status` serves as the idle trigger.
|
|
60
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
61
|
+
const agent = payload.agent;
|
|
62
|
+
if (!agent) {
|
|
63
|
+
logger.warn(`auto-review gate: agent/turn-stopping payload missing agent; skipping count`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const state = stateFor(perSession, agent.id);
|
|
67
|
+
state.turns += 1;
|
|
68
|
+
});
|
|
69
|
+
ctx.on("agent/status", (payload) => {
|
|
70
|
+
const agent = payload.agent;
|
|
71
|
+
if (!agent || payload.status !== "idle")
|
|
72
|
+
return;
|
|
73
|
+
const state = stateFor(perSession, agent.id);
|
|
74
|
+
// v3 optional: an active evolution goal drives the gate EVERY round
|
|
75
|
+
// (the goal's round machine keeps the session continuing); without a
|
|
76
|
+
// goal the plain turn interval applies.
|
|
77
|
+
const goalDriven = goalServiceOf(ctx)?.get(agent)?.phase === "active";
|
|
78
|
+
if (!goalDriven && state.turns - state.lastReviewAt < config.intervalTurns)
|
|
79
|
+
return;
|
|
80
|
+
// Run the gate outside the listener turn: agent is idle, work is auxiliary.
|
|
81
|
+
// Every failure is durably recorded — nothing fails silently.
|
|
82
|
+
void runGate(ctx, engine, agent, config, state, "turn_interval", record).catch((cause) => {
|
|
83
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
84
|
+
logger.warn(`auto-review failed for ${agent.id}: ${message}`);
|
|
85
|
+
record({
|
|
86
|
+
sessionId: agent.id,
|
|
87
|
+
reason: "turn_interval",
|
|
88
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
89
|
+
outcome: "failed",
|
|
90
|
+
rationale: `gate error: ${message}`,
|
|
91
|
+
});
|
|
92
|
+
state.lastReviewAt = state.turns; // back off until the interval elapses again
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// Diagnostic: the armed marker proves registerAutoReview ran with the
|
|
96
|
+
// configured interval; a restart that writes it but nothing after means the
|
|
97
|
+
// trigger events are not reaching this listener.
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(join(engine.baseDir, "evolve"), { recursive: true });
|
|
100
|
+
appendFileSync(reviewsPath, `${JSON.stringify({
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
sessionId: "boot",
|
|
103
|
+
reason: "boot",
|
|
104
|
+
turnsSinceLastReview: 0,
|
|
105
|
+
outcome: "armed",
|
|
106
|
+
rationale: `auto-review gate registered (interval=${config.intervalTurns})`,
|
|
107
|
+
})}\n`, "utf8");
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
logger.warn(`failed to write armed marker: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
111
|
+
}
|
|
112
|
+
ctx.on("session/event", (session, event) => {
|
|
113
|
+
if (event.type !== "compaction/start")
|
|
114
|
+
return;
|
|
115
|
+
const agents = ctx.agents;
|
|
116
|
+
const agent = agents?.get(session.id);
|
|
117
|
+
if (!agent)
|
|
118
|
+
return; // no live agent for that session (e.g. cold read)
|
|
119
|
+
const state = stateFor(perSession, agent.id);
|
|
120
|
+
// Compaction is unconditional: persist what is about to be summarized away.
|
|
121
|
+
void runGate(ctx, engine, agent, config, state, "compact", record).catch((cause) => {
|
|
122
|
+
logger.warn(`auto-review failed at compaction for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
123
|
+
record({
|
|
124
|
+
sessionId: agent.id,
|
|
125
|
+
reason: "compact",
|
|
126
|
+
turnsSinceLastReview: state.turns - state.lastReviewAt,
|
|
127
|
+
outcome: "failed",
|
|
128
|
+
rationale: `gate error at compaction: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function stateFor(map, sessionId) {
|
|
134
|
+
let state = map.get(sessionId);
|
|
135
|
+
if (!state) {
|
|
136
|
+
state = { turns: 0, lastReviewAt: 0, running: false };
|
|
137
|
+
map.set(sessionId, state);
|
|
138
|
+
}
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The state view the gate and planner judge: the session's local entries
|
|
143
|
+
* merged with the global store, each entry carrying its real scope. Without
|
|
144
|
+
* the global half the gate cannot see that a topic is already covered
|
|
145
|
+
* cross-session and happily re-sediments a local duplicate of it.
|
|
146
|
+
*
|
|
147
|
+
* The merged view is read-only context — applying still targets the raw
|
|
148
|
+
* local state (baseline checks compare local entries only).
|
|
149
|
+
*/
|
|
150
|
+
export function loadGateHarnessView(engine, sessionId) {
|
|
151
|
+
return mergeHarnessStates(engine.load("global", undefined), engine.load("local", sessionId));
|
|
152
|
+
}
|
|
153
|
+
async function runGate(ctx, engine, agent, config, state, reason, record) {
|
|
154
|
+
const sessionId = agent.id;
|
|
155
|
+
const turnsSinceLastReview = state.turns - state.lastReviewAt;
|
|
156
|
+
const logger = ctx.logger("continual-evolve");
|
|
157
|
+
const trajectory = await readTrajectory(ctx, agent, config.maxInputChars).catch((cause) => {
|
|
158
|
+
logger.warn(`auto-review skipped for ${sessionId}: trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
159
|
+
record({ sessionId, reason, turnsSinceLastReview, outcome: "failed", rationale: `trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}` });
|
|
160
|
+
return undefined;
|
|
161
|
+
});
|
|
162
|
+
if (!trajectory) {
|
|
163
|
+
state.lastReviewAt = state.turns;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// The gate judges the merged view (global + local, scopes labeled) so it
|
|
167
|
+
// can recognize topics already covered globally and decline duplicates;
|
|
168
|
+
// applying still targets the raw local store.
|
|
169
|
+
const localState = engine.load("local", sessionId);
|
|
170
|
+
const harnessState = loadGateHarnessView(engine, sessionId);
|
|
171
|
+
const history = engine.history("local", sessionId);
|
|
172
|
+
const review = await reviewAutoRefine(ctx, {
|
|
173
|
+
agent,
|
|
174
|
+
state: harnessState,
|
|
175
|
+
history,
|
|
176
|
+
trajectory,
|
|
177
|
+
context: { reason, turnsSinceLastReview },
|
|
178
|
+
budgetTokens: config.budgetTokens,
|
|
179
|
+
});
|
|
180
|
+
state.lastReviewAt = state.turns;
|
|
181
|
+
if (!review.shouldRefine) {
|
|
182
|
+
logger.info(`auto-review declined (${reason}) [${sessionId}] after ${turnsSinceLastReview} turns: ${review.rationale}`);
|
|
183
|
+
record({ sessionId, reason, turnsSinceLastReview, outcome: "declined", rationale: review.rationale });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const proposal = await planWithLlm(ctx, {
|
|
187
|
+
agent,
|
|
188
|
+
state: harnessState,
|
|
189
|
+
history,
|
|
190
|
+
...(review.instructions ? { instructions: review.instructions } : {}),
|
|
191
|
+
global: false,
|
|
192
|
+
});
|
|
193
|
+
const source = entrySourceOf(agent, sessionId);
|
|
194
|
+
const result = engine.apply("local", sessionId, proposal, {
|
|
195
|
+
scope: "local",
|
|
196
|
+
baselineState: localState,
|
|
197
|
+
...(source ? { source } : {}),
|
|
198
|
+
});
|
|
199
|
+
logger.info(`auto-review approved (${reason}) [${sessionId}] after ${turnsSinceLastReview} turns; auto-refine ${result.id}: ${result.appliedEdits.filter((e) => e.applied).length} applied, ${result.appliedEdits.filter((e) => !e.applied).length} failed — ${review.rationale}`);
|
|
200
|
+
record({ sessionId, reason, turnsSinceLastReview, outcome: "approved", rationale: review.rationale, refinementId: result.id });
|
|
201
|
+
// Visibility: tell the user what the gate just persisted. Only the
|
|
202
|
+
// turn-interval path notifies — a compaction-triggered gate must not wake
|
|
203
|
+
// the agent mid-compaction — and only when something was actually applied
|
|
204
|
+
// (a notice for zero edits is noise). Failure is contained in notifyAutoReview.
|
|
205
|
+
if (config.notifyOnAutoReview && reason === "turn_interval" && result.appliedEdits.some((e) => e.applied)) {
|
|
206
|
+
notifyAutoReview(ctx, agent, result, turnsSinceLastReview);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function readTrajectory(ctx, agent, maxChars) {
|
|
210
|
+
const sessionQuery = ctx.sessionQuery;
|
|
211
|
+
if (!sessionQuery) {
|
|
212
|
+
throw new Error("sessionQuery unavailable");
|
|
213
|
+
}
|
|
214
|
+
const snapshot = await sessionQuery.readSurface(agent.id);
|
|
215
|
+
return serializeSurface(snapshot.events, maxChars);
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=auto.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { EvolutionEngine } from "./service.js";
|
|
2
|
+
export interface BenchmarkCase {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
statement: string;
|
|
6
|
+
rubric: string;
|
|
7
|
+
}
|
|
8
|
+
export interface BenchmarkDefinition {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
runs: number;
|
|
13
|
+
passThreshold: number;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CellScore {
|
|
17
|
+
caseId: string;
|
|
18
|
+
run: number;
|
|
19
|
+
score: number;
|
|
20
|
+
passed: boolean;
|
|
21
|
+
notes: string;
|
|
22
|
+
}
|
|
23
|
+
export interface EvaluationEntry {
|
|
24
|
+
label: string;
|
|
25
|
+
refinementId?: string;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
cells: CellScore[];
|
|
28
|
+
/** Code-owned aggregates (model never writes these). */
|
|
29
|
+
aggregate: Record<string, number | null>;
|
|
30
|
+
overall: number | null;
|
|
31
|
+
}
|
|
32
|
+
export interface Scoreboard {
|
|
33
|
+
reference?: EvaluationEntry;
|
|
34
|
+
candidates: EvaluationEntry[];
|
|
35
|
+
decisions: {
|
|
36
|
+
candidateLabel: string;
|
|
37
|
+
refinementId?: string;
|
|
38
|
+
accepted: boolean;
|
|
39
|
+
reasons: string[];
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}[];
|
|
42
|
+
}
|
|
43
|
+
export interface AutoRollbackOutcome {
|
|
44
|
+
rolledBack: boolean;
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Close the acceptance loop: when the code-owned decision rejects a
|
|
49
|
+
* candidate refinement, revert it deterministically. The rollback is the
|
|
50
|
+
* same engine path as `/evolve rollback` (inverse edits rebuilt from the
|
|
51
|
+
* applied result — no LLM re-guessing), so it snapshots, versions, and
|
|
52
|
+
* audits like any other mutation. Failures (e.g. the refinement belongs to
|
|
53
|
+
* another session's history) are reported, never thrown: the command shows
|
|
54
|
+
* the manual fallback instead.
|
|
55
|
+
*/
|
|
56
|
+
export declare function rollbackRejectedCandidate(engine: EvolutionEngine, sessionId: string | undefined, candidateId: string): AutoRollbackOutcome;
|
|
57
|
+
export declare function benchmarkDir(baseDir: string, bid: string): string;
|
|
58
|
+
export declare function sanitizeId(raw: string): string;
|
|
59
|
+
export declare function createBenchmark(baseDir: string, opts: {
|
|
60
|
+
title: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
runs?: number;
|
|
63
|
+
passThreshold?: number;
|
|
64
|
+
}): BenchmarkDefinition;
|
|
65
|
+
export declare function listBenchmarks(baseDir: string): BenchmarkDefinition[];
|
|
66
|
+
export declare function loadBenchmark(baseDir: string, bid: string): BenchmarkDefinition | undefined;
|
|
67
|
+
export declare function addCase(baseDir: string, bid: string, title: string, statement: string, rubric: string, rubricKey?: Buffer): BenchmarkCase;
|
|
68
|
+
export declare function listCases(baseDir: string, bid: string): BenchmarkCase[];
|
|
69
|
+
export declare function loadScoreboard(baseDir: string, bid: string): Scoreboard;
|
|
70
|
+
export declare function saveScoreboard(baseDir: string, bid: string, board: Scoreboard): void;
|
|
71
|
+
export declare function removeBenchmark(baseDir: string, bid: string): void;
|
|
72
|
+
//# sourceMappingURL=benchmark.d.ts.map
|
package/lib/benchmark.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark store: file-backed case definitions and scoreboards under
|
|
3
|
+
* `<baseDir>/evolve/benchmarks/<bid>/`.
|
|
4
|
+
*
|
|
5
|
+
* Layout:
|
|
6
|
+
* benchmark.json title, runs (repeats per case), passThreshold
|
|
7
|
+
* cases/<cid>/statement.md public task text
|
|
8
|
+
* cases/<cid>/rubric.json encrypted scoring criteria (AES-256-GCM, see src/rubric.ts)
|
|
9
|
+
* scoreboard.json code-owned aggregates + acceptance history
|
|
10
|
+
*
|
|
11
|
+
* Rubric isolation is code-enforced: rubric plaintext never reaches the
|
|
12
|
+
* disk. Only the evaluation runner decrypts (into the child prompt); the
|
|
13
|
+
* optimizer can read the file and sees ciphertext only. Legacy files that
|
|
14
|
+
* predate encryption carry plaintext and are still readable.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { encryptRubric, DEV_RUBRIC_KEY, deriveKey } from "./rubric.js";
|
|
19
|
+
/**
|
|
20
|
+
* Close the acceptance loop: when the code-owned decision rejects a
|
|
21
|
+
* candidate refinement, revert it deterministically. The rollback is the
|
|
22
|
+
* same engine path as `/evolve rollback` (inverse edits rebuilt from the
|
|
23
|
+
* applied result — no LLM re-guessing), so it snapshots, versions, and
|
|
24
|
+
* audits like any other mutation. Failures (e.g. the refinement belongs to
|
|
25
|
+
* another session's history) are reported, never thrown: the command shows
|
|
26
|
+
* the manual fallback instead.
|
|
27
|
+
*/
|
|
28
|
+
export function rollbackRejectedCandidate(engine, sessionId, candidateId) {
|
|
29
|
+
try {
|
|
30
|
+
const result = engine.rollback("local", sessionId, candidateId);
|
|
31
|
+
const applied = result.appliedEdits.filter((edit) => edit.applied).length;
|
|
32
|
+
return {
|
|
33
|
+
rolledBack: true,
|
|
34
|
+
message: `auto-rollback: reverted refinement ${candidateId} — ${applied} edits restored to the pre-refinement snapshot`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
return {
|
|
39
|
+
rolledBack: false,
|
|
40
|
+
message: `auto-rollback failed: ${cause instanceof Error ? cause.message : String(cause)} — roll back manually with /evolve rollback <${candidateId}>`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function benchmarkDir(baseDir, bid) {
|
|
45
|
+
return join(baseDir, "evolve", "benchmarks", bid);
|
|
46
|
+
}
|
|
47
|
+
export function sanitizeId(raw) {
|
|
48
|
+
const id = raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40);
|
|
49
|
+
if (!id) {
|
|
50
|
+
throw new Error("benchmark id must be a non-empty slug");
|
|
51
|
+
}
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
export function createBenchmark(baseDir, opts) {
|
|
55
|
+
const id = sanitizeId(opts.title);
|
|
56
|
+
const dir = benchmarkDir(baseDir, id);
|
|
57
|
+
if (existsSync(dir)) {
|
|
58
|
+
throw new Error(`benchmark ${id} already exists`);
|
|
59
|
+
}
|
|
60
|
+
const definition = {
|
|
61
|
+
id,
|
|
62
|
+
title: opts.title.trim(),
|
|
63
|
+
description: opts.description?.trim() ?? "",
|
|
64
|
+
runs: opts.runs ?? 1,
|
|
65
|
+
passThreshold: opts.passThreshold ?? 60,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
writeFileSync(join(dir, "benchmark.json"), `${JSON.stringify(definition, null, 2)}\n`, "utf8");
|
|
70
|
+
writeFileSync(join(dir, "scoreboard.json"), `${JSON.stringify({ candidates: [], decisions: [] }, null, 2)}\n`, "utf8");
|
|
71
|
+
return definition;
|
|
72
|
+
}
|
|
73
|
+
export function listBenchmarks(baseDir) {
|
|
74
|
+
const root = join(baseDir, "evolve", "benchmarks");
|
|
75
|
+
if (!existsSync(root))
|
|
76
|
+
return [];
|
|
77
|
+
return readdirSafe(root)
|
|
78
|
+
.map((id) => loadBenchmark(baseDir, id))
|
|
79
|
+
.filter((b) => b !== undefined);
|
|
80
|
+
}
|
|
81
|
+
export function loadBenchmark(baseDir, bid) {
|
|
82
|
+
const path = join(benchmarkDir(baseDir, bid), "benchmark.json");
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return undefined;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function addCase(baseDir, bid, title, statement, rubric, rubricKey) {
|
|
93
|
+
const definition = loadBenchmark(baseDir, bid);
|
|
94
|
+
if (!definition) {
|
|
95
|
+
throw new Error(`benchmark ${bid} not found`);
|
|
96
|
+
}
|
|
97
|
+
const id = sanitizeId(title);
|
|
98
|
+
const caseDir = join(benchmarkDir(baseDir, bid), "cases", id);
|
|
99
|
+
if (existsSync(caseDir)) {
|
|
100
|
+
throw new Error(`case ${id} already exists in ${bid}`);
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(caseDir, { recursive: true });
|
|
103
|
+
writeFileSync(join(caseDir, "statement.md"), statement, "utf8");
|
|
104
|
+
// Rubric plaintext never touches the disk; callers pass a resolved key
|
|
105
|
+
// (config → env → per-installation key file → dev fallback, see
|
|
106
|
+
// resolveRubricKey) and the dev key here is only a defensive last resort.
|
|
107
|
+
const stored = rubricKey ? encryptRubric(rubric, rubricKey) : encryptRubric(rubric, deriveKey(DEV_RUBRIC_KEY));
|
|
108
|
+
writeFileSync(join(caseDir, "rubric.json"), `${JSON.stringify(stored, null, 2)}\n`, "utf8");
|
|
109
|
+
return { id, title: title.trim(), statement, rubric };
|
|
110
|
+
}
|
|
111
|
+
export function listCases(baseDir, bid) {
|
|
112
|
+
const casesDir = join(benchmarkDir(baseDir, bid), "cases");
|
|
113
|
+
if (!existsSync(casesDir))
|
|
114
|
+
return [];
|
|
115
|
+
return readdirSafe(casesDir)
|
|
116
|
+
.map((id) => {
|
|
117
|
+
const statementPath = join(casesDir, id, "statement.md");
|
|
118
|
+
const rubricPath = join(casesDir, id, "rubric.json");
|
|
119
|
+
if (!existsSync(statementPath) || !existsSync(rubricPath))
|
|
120
|
+
return undefined;
|
|
121
|
+
try {
|
|
122
|
+
const statement = readFileSync(statementPath, "utf8");
|
|
123
|
+
const rubric = JSON.parse(readFileSync(rubricPath, "utf8"));
|
|
124
|
+
return { id, title: id, statement, rubric };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
.filter((c) => c !== undefined);
|
|
131
|
+
}
|
|
132
|
+
export function loadScoreboard(baseDir, bid) {
|
|
133
|
+
const path = join(benchmarkDir(baseDir, bid), "scoreboard.json");
|
|
134
|
+
if (!existsSync(path)) {
|
|
135
|
+
return { candidates: [], decisions: [] };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
139
|
+
return {
|
|
140
|
+
...(raw.reference ? { reference: raw.reference } : {}),
|
|
141
|
+
candidates: raw.candidates ?? [],
|
|
142
|
+
decisions: raw.decisions ?? [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { candidates: [], decisions: [] };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function saveScoreboard(baseDir, bid, board) {
|
|
150
|
+
writeFileSync(join(benchmarkDir(baseDir, bid), "scoreboard.json"), `${JSON.stringify(board, null, 2)}\n`, "utf8");
|
|
151
|
+
}
|
|
152
|
+
export function removeBenchmark(baseDir, bid) {
|
|
153
|
+
const dir = benchmarkDir(baseDir, bid);
|
|
154
|
+
if (existsSync(dir)) {
|
|
155
|
+
rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
import { readdirSync } from "node:fs";
|
|
159
|
+
function readdirSafe(dir) {
|
|
160
|
+
try {
|
|
161
|
+
return readdirSync(dir);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=benchmark.js.map
|