@yeaft/webchat-agent 0.1.593 → 0.1.595
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/archive/tool-results.js +158 -0
- package/unify/archive/trace.js +70 -0
- package/unify/archive/turn-archive.js +97 -0
- package/unify/compact/decisions-log.js +98 -0
- package/unify/compact/orchestrator.js +147 -0
- package/unify/compact/triggers.js +54 -0
- package/unify/compact/turn-group.js +85 -0
- package/unify/prompts.js +40 -1
- package/unify/router/continuity.js +108 -0
- package/unify/router/thinking.js +78 -0
- package/unify/router/vp-planner.js +59 -0
- package/unify/templates/harness/router-handoff.md +34 -0
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive/tool-results.js — DESIGN.md §4.3.
|
|
3
|
+
*
|
|
4
|
+
* Tool results balloon context (file dumps, web pages, grep output).
|
|
5
|
+
* When `turn_age > 5` AND content length > 2000 chars, archive the body
|
|
6
|
+
* to `…/archive/tool-results/<toolCallId>.md` and replace it in-place
|
|
7
|
+
* with a stub. The `[user, assistant(toolCalls), tool…]` pairing is
|
|
8
|
+
* preserved (the stub is still a `role:'tool'` message with the same
|
|
9
|
+
* `toolCallId`) so the engine never trips the OpenAI/Anthropic schema.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure I/O + bookkeeping. It does NOT mutate the
|
|
12
|
+
* messages array directly; it returns a new array so callers can swap
|
|
13
|
+
* atomically.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'fs';
|
|
17
|
+
import { join, dirname } from 'path';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TURN_AGE_MIN = 5;
|
|
20
|
+
const DEFAULT_LENGTH_MIN = 2000;
|
|
21
|
+
const STUB_PREVIEW_LEN = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a single archived body lives. The "scope" here is the directory
|
|
25
|
+
* containing the `archive/` folder — typically a group dir
|
|
26
|
+
* (`groups/<gid>`) but task-scoped tools archive under `tasks/<tid>/`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ root: string, scopeDir: string, toolCallId: string }} args
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function toolArchivePath({ root, scopeDir, toolCallId }) {
|
|
32
|
+
if (!root || !scopeDir || !toolCallId) {
|
|
33
|
+
throw new Error('toolArchivePath: root + scopeDir + toolCallId required');
|
|
34
|
+
}
|
|
35
|
+
return join(root, scopeDir, 'archive', 'tool-results', `${toolCallId}.md`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compute the per-message `turn_age` for tool messages: how many
|
|
40
|
+
* `user` messages have appeared *after* the tool message (1-based — a
|
|
41
|
+
* tool message produced this turn has age 0).
|
|
42
|
+
*
|
|
43
|
+
* @param {object[]} messages
|
|
44
|
+
* @returns {number[]} same length as messages; age 0 for non-tool entries
|
|
45
|
+
*/
|
|
46
|
+
export function computeTurnAges(messages) {
|
|
47
|
+
if (!Array.isArray(messages)) return [];
|
|
48
|
+
const ages = new Array(messages.length).fill(0);
|
|
49
|
+
let userSeen = 0;
|
|
50
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
51
|
+
const m = messages[i];
|
|
52
|
+
if (m && m.role === 'user') {
|
|
53
|
+
userSeen += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (m && m.role === 'tool') ages[i] = userSeen;
|
|
57
|
+
}
|
|
58
|
+
return ages;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{
|
|
63
|
+
* root: string,
|
|
64
|
+
* scopeDir: string,
|
|
65
|
+
* message: object,
|
|
66
|
+
* }} args
|
|
67
|
+
* @returns {Promise<{ stub: object, archivedBytes: number, path: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function archiveOne({ root, scopeDir, message }) {
|
|
70
|
+
if (!message || message.role !== 'tool') {
|
|
71
|
+
throw new Error('archiveOne: tool message required');
|
|
72
|
+
}
|
|
73
|
+
const toolCallId = message.toolCallId;
|
|
74
|
+
if (!toolCallId) throw new Error('archiveOne: toolCallId required');
|
|
75
|
+
const body = typeof message.content === 'string' ? message.content : JSON.stringify(message.content);
|
|
76
|
+
const path = toolArchivePath({ root, scopeDir, toolCallId });
|
|
77
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
78
|
+
await fs.writeFile(path, body, 'utf8');
|
|
79
|
+
const sizeStr = formatSize(body.length);
|
|
80
|
+
const preview = body.slice(0, STUB_PREVIEW_LEN).replace(/\s+/g, ' ');
|
|
81
|
+
const stub = {
|
|
82
|
+
role: 'tool',
|
|
83
|
+
toolCallId,
|
|
84
|
+
content: `[archived: ${sizeStr}; preview: "${preview}"; retrieve via tool_trace("${toolCallId}")]`,
|
|
85
|
+
isError: !!message.isError,
|
|
86
|
+
};
|
|
87
|
+
return { stub, archivedBytes: body.length, path };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a previously-archived tool result body.
|
|
92
|
+
*
|
|
93
|
+
* @param {{ root: string, scopeDir: string, toolCallId: string }} args
|
|
94
|
+
* @returns {Promise<string|null>} body, or null if not found
|
|
95
|
+
*/
|
|
96
|
+
export async function readArchivedTool({ root, scopeDir, toolCallId }) {
|
|
97
|
+
const path = toolArchivePath({ root, scopeDir, toolCallId });
|
|
98
|
+
try {
|
|
99
|
+
return await fs.readFile(path, 'utf8');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err && err.code === 'ENOENT') return null;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sweep messages, archive every tool message that meets the age/length
|
|
108
|
+
* thresholds, return a new messages array with stubs swapped in. Untouched
|
|
109
|
+
* messages keep object identity.
|
|
110
|
+
*
|
|
111
|
+
* @param {{
|
|
112
|
+
* root: string,
|
|
113
|
+
* scopeDir: string,
|
|
114
|
+
* messages: object[],
|
|
115
|
+
* turnAgeMin?: number,
|
|
116
|
+
* lengthMin?: number,
|
|
117
|
+
* }} args
|
|
118
|
+
* @returns {Promise<{
|
|
119
|
+
* nextMessages: object[],
|
|
120
|
+
* archivedCount: number,
|
|
121
|
+
* archivedBytes: number,
|
|
122
|
+
* }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function archiveToolResults({
|
|
125
|
+
root, scopeDir, messages,
|
|
126
|
+
turnAgeMin = DEFAULT_TURN_AGE_MIN, lengthMin = DEFAULT_LENGTH_MIN,
|
|
127
|
+
}) {
|
|
128
|
+
if (!Array.isArray(messages)) throw new Error('archiveToolResults: messages array required');
|
|
129
|
+
const ages = computeTurnAges(messages);
|
|
130
|
+
let mutated = false;
|
|
131
|
+
let archivedCount = 0;
|
|
132
|
+
let archivedBytes = 0;
|
|
133
|
+
const out = messages.slice();
|
|
134
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
135
|
+
const m = out[i];
|
|
136
|
+
if (!m || m.role !== 'tool' || !m.toolCallId) continue;
|
|
137
|
+
if (typeof m.content !== 'string') continue;
|
|
138
|
+
if (m.content.startsWith('[archived:')) continue; // already a stub
|
|
139
|
+
if (ages[i] <= turnAgeMin) continue;
|
|
140
|
+
if (m.content.length <= lengthMin) continue;
|
|
141
|
+
const r = await archiveOne({ root, scopeDir, message: m });
|
|
142
|
+
out[i] = r.stub;
|
|
143
|
+
archivedCount += 1;
|
|
144
|
+
archivedBytes += r.archivedBytes;
|
|
145
|
+
mutated = true;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
nextMessages: mutated ? out : messages,
|
|
149
|
+
archivedCount,
|
|
150
|
+
archivedBytes,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatSize(bytes) {
|
|
155
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
156
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
157
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive/trace.js — DESIGN.md §4.4.
|
|
3
|
+
*
|
|
4
|
+
* Retrieval helpers backing the `tool_trace` / `message_trace` tools.
|
|
5
|
+
* The tool-registry wires these into the worker's toolset; this module
|
|
6
|
+
* is a thin wrapper around the archive readers with the ACL gate in
|
|
7
|
+
* place (only `vp/<other>/` is hard-blocked, per §1.2).
|
|
8
|
+
*
|
|
9
|
+
* Per design: the tools take a `toolCallId` / `turnId` plus the scope
|
|
10
|
+
* context (the worker passes its current scope dir). We never search
|
|
11
|
+
* across scopes — the caller knows which group/task/VP the lookup is
|
|
12
|
+
* for. Cross-VP lookups (`scopeDir = 'vp/<other>'`) throw `acl_blocked`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readArchivedTool } from './tool-results.js';
|
|
16
|
+
import { readArchivedTurn } from './turn-archive.js';
|
|
17
|
+
|
|
18
|
+
const VP_PREFIX = 'vp/';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} scopeDir e.g. 'vp/grace' or 'groups/eng' or 'tasks/t_1'
|
|
22
|
+
* @param {string|null|undefined} currentVpId the VP making the call
|
|
23
|
+
*/
|
|
24
|
+
function aclCheck(scopeDir, currentVpId) {
|
|
25
|
+
if (typeof scopeDir !== 'string' || !scopeDir) {
|
|
26
|
+
throw new Error('trace: scopeDir required');
|
|
27
|
+
}
|
|
28
|
+
if (!scopeDir.startsWith(VP_PREFIX)) return;
|
|
29
|
+
const owner = scopeDir.slice(VP_PREFIX.length).split('/')[0];
|
|
30
|
+
if (!owner) return;
|
|
31
|
+
if (currentVpId && owner !== currentVpId) {
|
|
32
|
+
const e = new Error(`acl_blocked: ${scopeDir}`);
|
|
33
|
+
/** @type {any} */ (e).code = 'acl_blocked';
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{
|
|
40
|
+
* root: string,
|
|
41
|
+
* scopeDir: string,
|
|
42
|
+
* toolCallId: string,
|
|
43
|
+
* currentVpId?: string,
|
|
44
|
+
* }} args
|
|
45
|
+
* @returns {Promise<{ ok: boolean, body?: string, error?: string }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function toolTrace({ root, scopeDir, toolCallId, currentVpId }) {
|
|
48
|
+
aclCheck(scopeDir, currentVpId);
|
|
49
|
+
if (!toolCallId) return { ok: false, error: 'missing_toolCallId' };
|
|
50
|
+
const body = await readArchivedTool({ root, scopeDir, toolCallId });
|
|
51
|
+
if (body == null) return { ok: false, error: 'not_found' };
|
|
52
|
+
return { ok: true, body };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{
|
|
57
|
+
* root: string,
|
|
58
|
+
* scopeDir: string,
|
|
59
|
+
* turnId: string,
|
|
60
|
+
* currentVpId?: string,
|
|
61
|
+
* }} args
|
|
62
|
+
* @returns {Promise<{ ok: boolean, header?: object, messages?: object[], error?: string }>}
|
|
63
|
+
*/
|
|
64
|
+
export async function messageTrace({ root, scopeDir, turnId, currentVpId }) {
|
|
65
|
+
aclCheck(scopeDir, currentVpId);
|
|
66
|
+
if (!turnId) return { ok: false, error: 'missing_turnId' };
|
|
67
|
+
const out = await readArchivedTurn({ root, scopeDir, turnId });
|
|
68
|
+
if (!out) return { ok: false, error: 'not_found' };
|
|
69
|
+
return { ok: true, header: out.header, messages: out.messages };
|
|
70
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* archive/turn-archive.js — DESIGN.md §4.2 + §4.4.
|
|
3
|
+
*
|
|
4
|
+
* When compact archives a cooling turn-group, the full content is
|
|
5
|
+
* written to `…/archive/<turnId>.md` so a worker can later replay it via
|
|
6
|
+
* `message_trace({turnId})`. Each archived turn is one markdown file
|
|
7
|
+
* with a YAML-ish header followed by JSON-encoded message bodies. We
|
|
8
|
+
* keep the format intentionally simple: any caller that can read JSON
|
|
9
|
+
* lines can replay it.
|
|
10
|
+
*
|
|
11
|
+
* Format:
|
|
12
|
+
*
|
|
13
|
+
* ---
|
|
14
|
+
* turnId: <id>
|
|
15
|
+
* archivedAt: <ISO>
|
|
16
|
+
* messageCount: <n>
|
|
17
|
+
* ---
|
|
18
|
+
* <line-delimited JSON, one message per line>
|
|
19
|
+
*
|
|
20
|
+
* We do NOT strip `_meta` here — DESIGN.md §9.15 says "Compact archive
|
|
21
|
+
* carries `_meta` into the archived turn — useful for `message_trace`
|
|
22
|
+
* replays".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { promises as fs } from 'fs';
|
|
26
|
+
import { join, dirname } from 'path';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ root: string, scopeDir: string, turnId: string }} args
|
|
30
|
+
*/
|
|
31
|
+
export function turnArchivePath({ root, scopeDir, turnId }) {
|
|
32
|
+
if (!root || !scopeDir || !turnId) {
|
|
33
|
+
throw new Error('turnArchivePath: root + scopeDir + turnId required');
|
|
34
|
+
}
|
|
35
|
+
return join(root, scopeDir, 'archive', `${turnId}.md`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{
|
|
40
|
+
* root: string,
|
|
41
|
+
* scopeDir: string,
|
|
42
|
+
* turnId: string,
|
|
43
|
+
* messages: object[],
|
|
44
|
+
* archivedAt?: string,
|
|
45
|
+
* }} args
|
|
46
|
+
* @returns {Promise<{ path: string, byteLength: number }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function archiveTurn({ root, scopeDir, turnId, messages, archivedAt }) {
|
|
49
|
+
if (!Array.isArray(messages)) throw new Error('archiveTurn: messages array required');
|
|
50
|
+
const path = turnArchivePath({ root, scopeDir, turnId });
|
|
51
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
52
|
+
const header = [
|
|
53
|
+
'---',
|
|
54
|
+
`turnId: ${turnId}`,
|
|
55
|
+
`archivedAt: ${archivedAt || new Date().toISOString()}`,
|
|
56
|
+
`messageCount: ${messages.length}`,
|
|
57
|
+
'---',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
const body = messages.map(m => JSON.stringify(m)).join('\n');
|
|
61
|
+
const content = header + body + (messages.length ? '\n' : '');
|
|
62
|
+
await fs.writeFile(path, content, 'utf8');
|
|
63
|
+
return { path, byteLength: content.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ root: string, scopeDir: string, turnId: string }} args
|
|
68
|
+
* @returns {Promise<{ header: object, messages: object[] } | null>}
|
|
69
|
+
*/
|
|
70
|
+
export async function readArchivedTurn({ root, scopeDir, turnId }) {
|
|
71
|
+
const path = turnArchivePath({ root, scopeDir, turnId });
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = await fs.readFile(path, 'utf8');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err && err.code === 'ENOENT') return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
const m = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
80
|
+
if (!m) return { header: {}, messages: [] };
|
|
81
|
+
const header = {};
|
|
82
|
+
for (const ln of m[1].split('\n')) {
|
|
83
|
+
const idx = ln.indexOf(':');
|
|
84
|
+
if (idx < 0) continue;
|
|
85
|
+
header[ln.slice(0, idx).trim()] = ln.slice(idx + 1).trim();
|
|
86
|
+
}
|
|
87
|
+
const messages = [];
|
|
88
|
+
for (const ln of m[2].split('\n')) {
|
|
89
|
+
if (!ln.trim()) continue;
|
|
90
|
+
try {
|
|
91
|
+
messages.push(JSON.parse(ln));
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip torn line.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { header, messages };
|
|
97
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact/decisions-log.js — DESIGN.md §9.1.
|
|
3
|
+
*
|
|
4
|
+
* Per-task append-only "decisions log". When two VPs run in parallel
|
|
5
|
+
* for the same `targetTaskId` (e.g. a router fan-out), both may want to
|
|
6
|
+
* write a decision. Atomic file create on `entries/*.md` solves the
|
|
7
|
+
* single-entry case; the decisions log is the sequencing layer that
|
|
8
|
+
* lets compact (track 2) read a chronological record and produce one
|
|
9
|
+
* canonical `tasks/<tid>/summary.md`.
|
|
10
|
+
*
|
|
11
|
+
* Format: line-delimited JSON (`decisions.jsonl`). Each line:
|
|
12
|
+
*
|
|
13
|
+
* {"ts": "<ISO>", "vpId": "<id>", "kind": "<decision-kind>", "text": "<…>"}
|
|
14
|
+
*
|
|
15
|
+
* Append uses `fs.appendFile` which is atomic for small writes on
|
|
16
|
+
* POSIX. Readers tolerate partial last-line corruption (very unlikely
|
|
17
|
+
* but cheap to handle): unparseable lines are skipped with a warning.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { promises as fs } from 'fs';
|
|
21
|
+
import { join, dirname } from 'path';
|
|
22
|
+
|
|
23
|
+
const FILE = 'decisions.jsonl';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} root
|
|
27
|
+
* @param {string} taskId
|
|
28
|
+
* @returns {string} absolute path
|
|
29
|
+
*/
|
|
30
|
+
export function decisionsLogPath(root, taskId) {
|
|
31
|
+
if (!root) throw new Error('decisionsLogPath: root required');
|
|
32
|
+
if (!taskId) throw new Error('decisionsLogPath: taskId required');
|
|
33
|
+
return join(root, 'tasks', taskId, FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Append a decision row. Creates the parent directory on first write.
|
|
38
|
+
*
|
|
39
|
+
* @param {{
|
|
40
|
+
* root: string,
|
|
41
|
+
* taskId: string,
|
|
42
|
+
* vpId: string,
|
|
43
|
+
* kind: string,
|
|
44
|
+
* text: string,
|
|
45
|
+
* ts?: string,
|
|
46
|
+
* }} args
|
|
47
|
+
* @returns {Promise<{ path: string, line: string }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function appendDecision({ root, taskId, vpId, kind, text, ts }) {
|
|
50
|
+
if (!vpId) throw new Error('appendDecision: vpId required');
|
|
51
|
+
if (!kind) throw new Error('appendDecision: kind required');
|
|
52
|
+
const path = decisionsLogPath(root, taskId);
|
|
53
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
54
|
+
const row = {
|
|
55
|
+
ts: ts || new Date().toISOString(),
|
|
56
|
+
vpId,
|
|
57
|
+
kind,
|
|
58
|
+
text: typeof text === 'string' ? text : '',
|
|
59
|
+
};
|
|
60
|
+
const line = JSON.stringify(row) + '\n';
|
|
61
|
+
await fs.appendFile(path, line, 'utf8');
|
|
62
|
+
return { path, line };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the decisions log. Returns [] on missing file. Skips lines that
|
|
67
|
+
* fail JSON parse (logged via console.warn) so a torn write never
|
|
68
|
+
* blocks the reader.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ root: string, taskId: string }} args
|
|
71
|
+
* @returns {Promise<Array<{ts: string, vpId: string, kind: string, text: string}>>}
|
|
72
|
+
*/
|
|
73
|
+
export async function readDecisions({ root, taskId }) {
|
|
74
|
+
const path = decisionsLogPath(root, taskId);
|
|
75
|
+
let content;
|
|
76
|
+
try {
|
|
77
|
+
content = await fs.readFile(path, 'utf8');
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err && err.code === 'ENOENT') return [];
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
const out = [];
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
for (const ln of lines) {
|
|
85
|
+
if (!ln.trim()) continue;
|
|
86
|
+
try {
|
|
87
|
+
const row = JSON.parse(ln);
|
|
88
|
+
if (row && typeof row === 'object') out.push(row);
|
|
89
|
+
} catch {
|
|
90
|
+
// Tolerate the very last line being torn; warn but continue.
|
|
91
|
+
// (We could distinguish "torn last line" vs "corrupt mid-line"
|
|
92
|
+
// but the read path doesn't need to.)
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn(`decisions-log: skipped unparseable line in ${path}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact/orchestrator.js — DESIGN.md §4.2.
|
|
3
|
+
*
|
|
4
|
+
* One trigger, one pass, three tracks. The orchestrator owns the
|
|
5
|
+
* sequencing; the actual LLM-driven summarisation and extraction are
|
|
6
|
+
* supplied as injectables so this file stays small, deterministic, and
|
|
7
|
+
* testable without network.
|
|
8
|
+
*
|
|
9
|
+
* Track 1 — message compaction (always runs):
|
|
10
|
+
* 1. Find the cooling turn-groups (older than the hot window).
|
|
11
|
+
* 2. Generate a `compact_summary` of those groups.
|
|
12
|
+
* 3. Archive each cooling group atomically; replace it in the live
|
|
13
|
+
* messages array with a single placeholder message.
|
|
14
|
+
*
|
|
15
|
+
* Track 2 — task summary refresh (when `taskId` is provided):
|
|
16
|
+
* 4. Refresh `tasks/<tid>/summary.md` from the cooling groups + the
|
|
17
|
+
* prior summary. Atomic via the existing `writeSummary` helper.
|
|
18
|
+
*
|
|
19
|
+
* Track 3 — memory extraction (always runs):
|
|
20
|
+
* 5. Extract durable facts/lessons/preferences and write them as
|
|
21
|
+
* scope entries through the supplied `extract` callback. The
|
|
22
|
+
* callback owns scope routing and `index.md` upserts; this file
|
|
23
|
+
* just hands it the cooling groups.
|
|
24
|
+
*
|
|
25
|
+
* Atomicity rule (§9.2): each cooling turn-group is archived as a
|
|
26
|
+
* unit. We never break a `[user, assistant(toolCalls), tool…]` triple.
|
|
27
|
+
* Track 1 is the only place that mutates `messages`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { groupTurns, pickCoolingGroups, indicesFromGroups } from './turn-group.js';
|
|
31
|
+
import { writeSummary } from '../memory/scope-tree.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {{
|
|
35
|
+
* summarise: (coolingMessages: object[]) => Promise<string>,
|
|
36
|
+
* archive: (groupIndex: number, coolingMessages: object[]) => Promise<{ turnId: string }>,
|
|
37
|
+
* extract?: (coolingMessages: object[]) => Promise<{ written: number }>,
|
|
38
|
+
* refreshTaskSummary?: (coolingMessages: object[], priorSummary: string) => Promise<string>,
|
|
39
|
+
* readPriorTaskSummary?: () => Promise<string>,
|
|
40
|
+
* }} CompactHooks
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{
|
|
45
|
+
* messages: object[],
|
|
46
|
+
* keepHot?: number,
|
|
47
|
+
* taskId?: string | null,
|
|
48
|
+
* root?: string,
|
|
49
|
+
* hooks: CompactHooks,
|
|
50
|
+
* }} args
|
|
51
|
+
* @returns {Promise<{
|
|
52
|
+
* archivedGroups: number,
|
|
53
|
+
* archivedMessages: number,
|
|
54
|
+
* compactSummary: string,
|
|
55
|
+
* extractedCount: number,
|
|
56
|
+
* taskSummaryRefreshed: boolean,
|
|
57
|
+
* nextMessages: object[],
|
|
58
|
+
* }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function runCompact({ messages, keepHot = 10, taskId = null, root, hooks }) {
|
|
61
|
+
if (!Array.isArray(messages)) {
|
|
62
|
+
throw new Error('runCompact: messages array required');
|
|
63
|
+
}
|
|
64
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
65
|
+
throw new Error('runCompact: hooks required');
|
|
66
|
+
}
|
|
67
|
+
if (typeof hooks.summarise !== 'function') {
|
|
68
|
+
throw new Error('runCompact: hooks.summarise required');
|
|
69
|
+
}
|
|
70
|
+
if (typeof hooks.archive !== 'function') {
|
|
71
|
+
throw new Error('runCompact: hooks.archive required');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const groups = groupTurns(messages);
|
|
75
|
+
const { hot, cooling } = pickCoolingGroups(groups, keepHot);
|
|
76
|
+
|
|
77
|
+
// Nothing to compact — return early. We still report `nextMessages` so
|
|
78
|
+
// callers can treat the result uniformly (no copy unless we changed
|
|
79
|
+
// anything).
|
|
80
|
+
if (cooling.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
archivedGroups: 0,
|
|
83
|
+
archivedMessages: 0,
|
|
84
|
+
compactSummary: '',
|
|
85
|
+
extractedCount: 0,
|
|
86
|
+
taskSummaryRefreshed: false,
|
|
87
|
+
nextMessages: messages,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Slice out the cooling messages once for the summariser/extractor.
|
|
92
|
+
const coolingIdx = indicesFromGroups(cooling);
|
|
93
|
+
const coolingMessages = coolingIdx.map(i => messages[i]);
|
|
94
|
+
|
|
95
|
+
// Track 1.2 — summarise.
|
|
96
|
+
const compactSummary = await hooks.summarise(coolingMessages);
|
|
97
|
+
|
|
98
|
+
// Track 1.3 — archive each cooling group atomically.
|
|
99
|
+
const archiveResults = [];
|
|
100
|
+
for (let i = 0; i < cooling.length; i += 1) {
|
|
101
|
+
const g = cooling[i];
|
|
102
|
+
const groupMsgs = messages.slice(g.start, g.end);
|
|
103
|
+
const r = await hooks.archive(i, groupMsgs);
|
|
104
|
+
archiveResults.push({ ...g, turnId: r?.turnId });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Track 2 — refresh task summary if applicable.
|
|
108
|
+
let taskSummaryRefreshed = false;
|
|
109
|
+
if (taskId && root && typeof hooks.refreshTaskSummary === 'function') {
|
|
110
|
+
const prior = typeof hooks.readPriorTaskSummary === 'function'
|
|
111
|
+
? await hooks.readPriorTaskSummary() : '';
|
|
112
|
+
const next = await hooks.refreshTaskSummary(coolingMessages, prior);
|
|
113
|
+
if (typeof next === 'string' && next.trim()) {
|
|
114
|
+
await writeSummary({ kind: 'task', id: taskId }, next, { root });
|
|
115
|
+
taskSummaryRefreshed = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Track 3 — memory extraction.
|
|
120
|
+
let extractedCount = 0;
|
|
121
|
+
if (typeof hooks.extract === 'function') {
|
|
122
|
+
const extractResult = await hooks.extract(coolingMessages);
|
|
123
|
+
if (extractResult && Number.isFinite(extractResult.written)) {
|
|
124
|
+
extractedCount = extractResult.written;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Track 1.3 (cont.) — produce the new messages array with the cooling
|
|
129
|
+
// window replaced by a single `compact_summary` placeholder.
|
|
130
|
+
const placeholder = {
|
|
131
|
+
role: 'system',
|
|
132
|
+
kind: 'compact_summary',
|
|
133
|
+
content: compactSummary || '',
|
|
134
|
+
};
|
|
135
|
+
// Hot starts at the index right after the last cooling group.
|
|
136
|
+
const cutoff = cooling[cooling.length - 1].end;
|
|
137
|
+
const nextMessages = [placeholder, ...messages.slice(cutoff)];
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
archivedGroups: cooling.length,
|
|
141
|
+
archivedMessages: coolingIdx.length,
|
|
142
|
+
compactSummary: compactSummary || '',
|
|
143
|
+
extractedCount,
|
|
144
|
+
taskSummaryRefreshed,
|
|
145
|
+
nextMessages,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact/triggers.js — DESIGN.md §4.1.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: do I need to compact, and which triggers fired?
|
|
5
|
+
* Caller (orchestrator) decides what to do next; this module never
|
|
6
|
+
* touches disk or messages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_MESSAGES = 50;
|
|
10
|
+
const DEFAULT_TOKEN_RATIO = 0.9;
|
|
11
|
+
const DEFAULT_IDLE_MS = 2 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{
|
|
15
|
+
* messages: object[],
|
|
16
|
+
* tokenCount: number,
|
|
17
|
+
* contextLimit: number,
|
|
18
|
+
* lastActivityAt?: number,
|
|
19
|
+
* now?: number,
|
|
20
|
+
* explicit?: boolean,
|
|
21
|
+
* maxMessages?: number,
|
|
22
|
+
* tokenRatio?: number,
|
|
23
|
+
* idleMs?: number,
|
|
24
|
+
* }} state
|
|
25
|
+
* @returns {{ trigger: boolean, reasons: string[] }}
|
|
26
|
+
*/
|
|
27
|
+
export function evaluateCompactTriggers(state = {}) {
|
|
28
|
+
const reasons = [];
|
|
29
|
+
const messages = Array.isArray(state.messages) ? state.messages : [];
|
|
30
|
+
const tokenCount = Number.isFinite(state.tokenCount) ? state.tokenCount : 0;
|
|
31
|
+
const contextLimit = Number.isFinite(state.contextLimit) && state.contextLimit > 0
|
|
32
|
+
? state.contextLimit : 0;
|
|
33
|
+
const tokenRatio = Number.isFinite(state.tokenRatio) ? state.tokenRatio : DEFAULT_TOKEN_RATIO;
|
|
34
|
+
const maxMessages = Number.isFinite(state.maxMessages) ? state.maxMessages : DEFAULT_MAX_MESSAGES;
|
|
35
|
+
const idleMs = Number.isFinite(state.idleMs) ? state.idleMs : DEFAULT_IDLE_MS;
|
|
36
|
+
|
|
37
|
+
if (state.explicit) reasons.push('explicit');
|
|
38
|
+
|
|
39
|
+
if (contextLimit > 0 && tokenCount > tokenRatio * contextLimit) {
|
|
40
|
+
reasons.push('token_threshold');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (messages.length > maxMessages) {
|
|
44
|
+
reasons.push('message_count');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Number.isFinite(state.lastActivityAt) && Number.isFinite(state.now)) {
|
|
48
|
+
if (state.now - state.lastActivityAt > idleMs) {
|
|
49
|
+
reasons.push('idle');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { trigger: reasons.length > 0, reasons };
|
|
54
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact/turn-group.js — DESIGN.md §9.2.
|
|
3
|
+
*
|
|
4
|
+
* Group messages into turns whose unit of archiving is "atomic": each
|
|
5
|
+
* group is either kept entirely live or archived entirely. Archiving
|
|
6
|
+
* an assistant message that contained `toolCalls` while leaving the
|
|
7
|
+
* tool results live (or vice versa) breaks the OpenAI invariant that
|
|
8
|
+
* `tool_call_id`s must be paired.
|
|
9
|
+
*
|
|
10
|
+
* A "turn group" starts on a `user` message and extends through every
|
|
11
|
+
* subsequent assistant + tool message until the next user message. The
|
|
12
|
+
* grouping function returns an array of `{ start, end, indices }` pairs
|
|
13
|
+
* where `[start, end)` is a half-open range over the input array.
|
|
14
|
+
*
|
|
15
|
+
* Edge cases:
|
|
16
|
+
* - Leading non-user messages (e.g. a system or tool prelude written
|
|
17
|
+
* by an init hook) form a group of their own at index 0.
|
|
18
|
+
* - Trailing assistant/tool messages (incomplete turn) form the final
|
|
19
|
+
* group — same rule.
|
|
20
|
+
* - Empty input → empty result.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object[]} messages
|
|
25
|
+
* @returns {Array<{ start: number, end: number, role: string }>}
|
|
26
|
+
* `role` reflects the group's anchor (the `user` message, if any;
|
|
27
|
+
* otherwise the first message in the group).
|
|
28
|
+
*/
|
|
29
|
+
export function groupTurns(messages) {
|
|
30
|
+
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
31
|
+
const groups = [];
|
|
32
|
+
let cur = { start: 0, end: 0, role: messages[0]?.role || 'unknown' };
|
|
33
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
34
|
+
const m = messages[i];
|
|
35
|
+
if (!m || typeof m !== 'object') {
|
|
36
|
+
// Treat as belonging to the current group (don't break pairing).
|
|
37
|
+
cur.end = i + 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (m.role === 'user' && cur.end > cur.start) {
|
|
41
|
+
groups.push(cur);
|
|
42
|
+
cur = { start: i, end: i + 1, role: 'user' };
|
|
43
|
+
} else {
|
|
44
|
+
cur.end = i + 1;
|
|
45
|
+
if (m.role === 'user') cur.role = 'user';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (cur.end > cur.start) groups.push(cur);
|
|
49
|
+
return groups;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pick the cut point: keep `keepHot` newest groups live, return the
|
|
54
|
+
* rest as "cooling" candidates for archive.
|
|
55
|
+
*
|
|
56
|
+
* Returns: `{ hot: groups[], cooling: groups[] }`. Both arrays use the
|
|
57
|
+
* same `{start, end, role}` shape from `groupTurns`.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array<{start: number, end: number, role: string}>} groups
|
|
60
|
+
* @param {number} keepHot
|
|
61
|
+
*/
|
|
62
|
+
export function pickCoolingGroups(groups, keepHot = 10) {
|
|
63
|
+
if (!Array.isArray(groups)) return { hot: [], cooling: [] };
|
|
64
|
+
const k = Math.max(0, Math.floor(keepHot));
|
|
65
|
+
if (groups.length <= k) return { hot: groups.slice(), cooling: [] };
|
|
66
|
+
const cut = groups.length - k;
|
|
67
|
+
return {
|
|
68
|
+
hot: groups.slice(cut),
|
|
69
|
+
cooling: groups.slice(0, cut),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flatten a list of groups back into the underlying message indices.
|
|
75
|
+
*
|
|
76
|
+
* @param {Array<{start: number, end: number}>} groups
|
|
77
|
+
* @returns {number[]}
|
|
78
|
+
*/
|
|
79
|
+
export function indicesFromGroups(groups) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const g of groups) {
|
|
82
|
+
for (let i = g.start; i < g.end; i += 1) out.push(i);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
package/unify/prompts.js
CHANGED
|
@@ -163,6 +163,8 @@ const RAW_TEMPLATES = {
|
|
|
163
163
|
// buildRouterPrompt callers will simply omit the section.
|
|
164
164
|
harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
|
|
165
165
|
harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
|
|
166
|
+
// Phase 3b — coordinator harness rule for inter-VP forwarding.
|
|
167
|
+
harnessRouterHandoff: readTemplate('harness/router-handoff.md', { required: false }),
|
|
166
168
|
};
|
|
167
169
|
|
|
168
170
|
/**
|
|
@@ -768,6 +770,39 @@ export function buildWorkerPrompt(params = {}) {
|
|
|
768
770
|
return parts.join('\n\n');
|
|
769
771
|
}
|
|
770
772
|
|
|
773
|
+
/**
|
|
774
|
+
* Render the previous turn's router plan as a `## prior_plan` block, so
|
|
775
|
+
* the router can decide whether to extend it or start fresh
|
|
776
|
+
* (DESIGN.md §9.15). Returns '' when there is no prior plan to render.
|
|
777
|
+
*
|
|
778
|
+
* @param {object|null|undefined} priorPlan
|
|
779
|
+
* @param {'en'|'zh'} [language='en']
|
|
780
|
+
* @returns {string}
|
|
781
|
+
*/
|
|
782
|
+
export function renderPriorPlan(priorPlan, language = 'en') {
|
|
783
|
+
if (!priorPlan || typeof priorPlan !== 'object') return '';
|
|
784
|
+
const header = language === 'zh' ? '## 上一轮 plan' : '## prior_plan';
|
|
785
|
+
const lines = [];
|
|
786
|
+
if (priorPlan.vpId) lines.push(`vpId: ${priorPlan.vpId}`);
|
|
787
|
+
const fq = priorPlan.forwardQuery;
|
|
788
|
+
if (fq && (fq.userOriginal || fq.intent)) {
|
|
789
|
+
if (fq.intent) lines.push(`intent: ${fq.intent}`);
|
|
790
|
+
if (fq.userOriginal) lines.push(`userOriginal: ${fq.userOriginal}`);
|
|
791
|
+
}
|
|
792
|
+
const pre = priorPlan.preselect;
|
|
793
|
+
if (pre) {
|
|
794
|
+
if (Array.isArray(pre.memoryPaths) && pre.memoryPaths.length) {
|
|
795
|
+
lines.push(`memoryPaths: ${pre.memoryPaths.join(', ')}`);
|
|
796
|
+
}
|
|
797
|
+
if (Array.isArray(pre.taskIds) && pre.taskIds.length) {
|
|
798
|
+
lines.push(`taskIds: ${pre.taskIds.join(', ')}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (priorPlan.thinking) lines.push(`thinking: ${priorPlan.thinking}`);
|
|
802
|
+
if (!lines.length) return '';
|
|
803
|
+
return `${header}\n${lines.join('\n')}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
771
806
|
/**
|
|
772
807
|
* Router prompt entry point (DESIGN.md Phase 1).
|
|
773
808
|
*
|
|
@@ -780,12 +815,13 @@ export function buildWorkerPrompt(params = {}) {
|
|
|
780
815
|
* language?: 'en'|'zh',
|
|
781
816
|
* summaries?: {user?: string, group?: string, vp?: string},
|
|
782
817
|
* routerContext?: string,
|
|
818
|
+
* priorPlan?: object|null,
|
|
783
819
|
* includeShape?: boolean,
|
|
784
820
|
* }} params
|
|
785
821
|
* @returns {string}
|
|
786
822
|
*/
|
|
787
823
|
export function buildRouterPrompt(params = {}) {
|
|
788
|
-
const { language = 'en', summaries, routerContext, includeShape = true } = params;
|
|
824
|
+
const { language = 'en', summaries, routerContext, priorPlan, includeShape = true } = params;
|
|
789
825
|
const parts = [];
|
|
790
826
|
|
|
791
827
|
if (includeShape) {
|
|
@@ -796,6 +832,9 @@ export function buildRouterPrompt(params = {}) {
|
|
|
796
832
|
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
797
833
|
if (summaryBlock) parts.push(summaryBlock);
|
|
798
834
|
|
|
835
|
+
const priorBlock = renderPriorPlan(priorPlan, language);
|
|
836
|
+
if (priorBlock) parts.push(priorBlock);
|
|
837
|
+
|
|
799
838
|
if (typeof routerContext === 'string' && routerContext.trim()) {
|
|
800
839
|
parts.push(routerContext.trim());
|
|
801
840
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/continuity.js — DESIGN.md §9.15 priorPlan carry-back.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b scope:
|
|
5
|
+
* - `attachRouterPlan(message, plan)` — write the plan as `_meta.routerPlan`
|
|
6
|
+
* on the assistant message that produced it.
|
|
7
|
+
* - `extractPriorPlan(messages, vpId)` — find the most recent assistant
|
|
8
|
+
* message belonging to the given VP and return its `_meta.routerPlan`.
|
|
9
|
+
* - `stripMetaForWire(messages)` — drop `_meta` before sending to the LLM
|
|
10
|
+
* (it's bookkeeping, never model-visible).
|
|
11
|
+
*
|
|
12
|
+
* The skip-router heuristic (§9.15 #1) is intentionally NOT implemented in
|
|
13
|
+
* Phase 3b — DESIGN.md §8 line 391 says "do NOT ship the skip-router
|
|
14
|
+
* heuristic yet". We just plumb the metadata; the dispatcher can decide.
|
|
15
|
+
*
|
|
16
|
+
* Per-VP attribution: an assistant message belongs to a VP when its
|
|
17
|
+
* `_meta.routerPlan.vpId` matches; we never guess from content. First turn
|
|
18
|
+
* of a fresh group has no priorPlan — that is the expected cold-start.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @typedef {{
|
|
22
|
+
* vpId: string,
|
|
23
|
+
* forwardQuery?: { userOriginal?: string, intent?: string },
|
|
24
|
+
* preselect?: { memoryPaths?: string[], taskIds?: string[] },
|
|
25
|
+
* thinking?: 'high'|'max'|null,
|
|
26
|
+
* thinkingReason?: string,
|
|
27
|
+
* }} RouterPlanLike
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Attach a router plan to an assistant message. Mutates `message` in place
|
|
32
|
+
* and returns it. We mutate (rather than clone) because the caller is the
|
|
33
|
+
* engine appending to its own `conversationMessages` array — cloning would
|
|
34
|
+
* just discard the work.
|
|
35
|
+
*
|
|
36
|
+
* Tool messages do not carry plans (no plan attached to a tool result).
|
|
37
|
+
*
|
|
38
|
+
* @param {object} message
|
|
39
|
+
* @param {RouterPlanLike|null|undefined} plan
|
|
40
|
+
* @returns {object}
|
|
41
|
+
*/
|
|
42
|
+
export function attachRouterPlan(message, plan) {
|
|
43
|
+
if (!message || typeof message !== 'object') return message;
|
|
44
|
+
if (message.role !== 'assistant') return message;
|
|
45
|
+
if (!plan || typeof plan !== 'object' || !plan.vpId) return message;
|
|
46
|
+
message._meta = message._meta || {};
|
|
47
|
+
message._meta.routerPlan = {
|
|
48
|
+
vpId: plan.vpId,
|
|
49
|
+
forwardQuery: plan.forwardQuery
|
|
50
|
+
? {
|
|
51
|
+
userOriginal: plan.forwardQuery.userOriginal || '',
|
|
52
|
+
intent: plan.forwardQuery.intent || '',
|
|
53
|
+
} : undefined,
|
|
54
|
+
preselect: plan.preselect
|
|
55
|
+
? {
|
|
56
|
+
memoryPaths: Array.isArray(plan.preselect.memoryPaths)
|
|
57
|
+
? [...plan.preselect.memoryPaths] : [],
|
|
58
|
+
taskIds: Array.isArray(plan.preselect.taskIds)
|
|
59
|
+
? [...plan.preselect.taskIds] : [],
|
|
60
|
+
} : undefined,
|
|
61
|
+
thinking: plan.thinking ?? null,
|
|
62
|
+
thinkingReason: plan.thinkingReason || '',
|
|
63
|
+
};
|
|
64
|
+
return message;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walk `messages` from the end, return the most recent assistant message's
|
|
69
|
+
* `_meta.routerPlan` whose `vpId` matches. Returns null if none found —
|
|
70
|
+
* that's a cold start, not an error.
|
|
71
|
+
*
|
|
72
|
+
* @param {object[]} messages
|
|
73
|
+
* @param {string} vpId
|
|
74
|
+
* @returns {RouterPlanLike | null}
|
|
75
|
+
*/
|
|
76
|
+
export function extractPriorPlan(messages, vpId) {
|
|
77
|
+
if (!Array.isArray(messages) || !vpId) return null;
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (!m || m.role !== 'assistant') continue;
|
|
81
|
+
const plan = m._meta && m._meta.routerPlan;
|
|
82
|
+
if (plan && plan.vpId === vpId) return plan;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Return a copy of the messages array with `_meta` stripped from every
|
|
89
|
+
* message. The serialisers (anthropic/openai-responses) read this; it is
|
|
90
|
+
* NEVER part of the wire payload. Cheap because we only shallow-clone the
|
|
91
|
+
* messages that actually have `_meta`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object[]} messages
|
|
94
|
+
* @returns {object[]}
|
|
95
|
+
*/
|
|
96
|
+
export function stripMetaForWire(messages) {
|
|
97
|
+
if (!Array.isArray(messages)) return messages;
|
|
98
|
+
let mutated = false;
|
|
99
|
+
const out = messages.map(m => {
|
|
100
|
+
if (m && typeof m === 'object' && '_meta' in m) {
|
|
101
|
+
mutated = true;
|
|
102
|
+
const { _meta, ...rest } = m;
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
return m;
|
|
106
|
+
});
|
|
107
|
+
return mutated ? out : messages;
|
|
108
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/thinking.js — DESIGN.md §9.16 thinking-mode precedence chain.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the final `thinking` value the engine should pass to the
|
|
5
|
+
* adapter, given the four signal sources:
|
|
6
|
+
*
|
|
7
|
+
* 1. UI override (highest) — submitOptions / topbar selector
|
|
8
|
+
* 2. Router plan — — per-plan thinking field
|
|
9
|
+
* 3. VP default — — vp/<id>/role.md frontmatter
|
|
10
|
+
* 4. Global default (lowest) — config.thinking.default
|
|
11
|
+
*
|
|
12
|
+
* Allowed values: `'high' | 'max' | null`. (`null` ⇒ adapter drops the
|
|
13
|
+
* field; provider-specific normalisation happens at the adapter via
|
|
14
|
+
* `models.js#normalizeEffort`.)
|
|
15
|
+
*
|
|
16
|
+
* Continuity rule (§9.16): when no UI override is in force AND the router
|
|
17
|
+
* did not change its recommendation versus the prior plan, keep the prior
|
|
18
|
+
* plan's value. Anthropic prompt cache keys include the thinking field;
|
|
19
|
+
* unstable values cause prefix re-encoding every turn.
|
|
20
|
+
*
|
|
21
|
+
* The `allowRouterEscalate: false` config gate hard-blocks the router
|
|
22
|
+
* from bumping below→`max`. UI overrides bypass that gate (they're the
|
|
23
|
+
* user's direct intent, not a heuristic).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALLOWED = new Set([null, 'high', 'max']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {*} v
|
|
30
|
+
* @returns {'high'|'max'|null}
|
|
31
|
+
*/
|
|
32
|
+
function clean(v) {
|
|
33
|
+
if (v === undefined) return null;
|
|
34
|
+
return ALLOWED.has(v) ? v : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{
|
|
39
|
+
* uiOverride?: 'high'|'max'|null,
|
|
40
|
+
* routerPlan?: 'high'|'max'|null,
|
|
41
|
+
* priorPlan?: 'high'|'max'|null,
|
|
42
|
+
* vpDefault?: 'high'|'max'|null,
|
|
43
|
+
* globalDefault?: 'high'|'max'|null,
|
|
44
|
+
* allowRouterEscalate?: boolean,
|
|
45
|
+
* }} signals
|
|
46
|
+
* @returns {{ value: 'high'|'max'|null, source: 'ui'|'router'|'prior'|'vp'|'global'|'default' }}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveThinking(signals = {}) {
|
|
49
|
+
const ui = clean(signals.uiOverride);
|
|
50
|
+
if (ui) return { value: ui, source: 'ui' };
|
|
51
|
+
|
|
52
|
+
const router = clean(signals.routerPlan);
|
|
53
|
+
const prior = clean(signals.priorPlan);
|
|
54
|
+
const vp = clean(signals.vpDefault);
|
|
55
|
+
const global_ = clean(signals.globalDefault);
|
|
56
|
+
const escalateOk = signals.allowRouterEscalate !== false;
|
|
57
|
+
|
|
58
|
+
// Continuity: if router agrees with prior or is silent, prefer prior to
|
|
59
|
+
// keep the cache key stable.
|
|
60
|
+
if (router && prior && router === prior) {
|
|
61
|
+
return { value: prior, source: 'prior' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (router) {
|
|
65
|
+
// allowRouterEscalate=false hard-blocks router from emitting 'max'
|
|
66
|
+
// when the baseline is 'high'.
|
|
67
|
+
const baseline = prior || vp || global_ || 'high';
|
|
68
|
+
if (!escalateOk && router === 'max' && baseline !== 'max') {
|
|
69
|
+
return { value: baseline, source: prior ? 'prior' : (vp ? 'vp' : 'global') };
|
|
70
|
+
}
|
|
71
|
+
return { value: router, source: 'router' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (prior) return { value: prior, source: 'prior' };
|
|
75
|
+
if (vp) return { value: vp, source: 'vp' };
|
|
76
|
+
if (global_) return { value: global_, source: 'global' };
|
|
77
|
+
return { value: 'high', source: 'default' };
|
|
78
|
+
}
|
|
@@ -280,3 +280,62 @@ export async function runPlansSequential(plans, runOne, opts = {}) {
|
|
|
280
280
|
}
|
|
281
281
|
return { results, errors };
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parallel fan-out runner (Phase 3.5). Calls `runOne(plan, index)` for each
|
|
286
|
+
* plan concurrently, with optional `concurrency` cap. Results are returned
|
|
287
|
+
* in input order regardless of completion order. Errors from `runOne` are
|
|
288
|
+
* caught per-plan and DO NOT abort siblings (DESIGN.md §9.1 — concurrent
|
|
289
|
+
* VP turns must be independent).
|
|
290
|
+
*
|
|
291
|
+
* NOTE: parallel mode loses the `prior[]` channel that the sequential
|
|
292
|
+
* runner provides. Callers that need plan N to read plan N-1's output must
|
|
293
|
+
* use `runPlansSequential`. The dispatcher chooses based on whether the
|
|
294
|
+
* plans share a `targetTaskId` (parallel-safe) or pipeline data
|
|
295
|
+
* (sequential-only).
|
|
296
|
+
*
|
|
297
|
+
* @param {VpPlan[]} plans
|
|
298
|
+
* @param {(plan: VpPlan, index: number) => Promise<*>} runOne
|
|
299
|
+
* @param {{ groupMemberIds?: string[], concurrency?: number }} [opts]
|
|
300
|
+
* @returns {Promise<{ results: any[], errors: Array<{ index: number, error: Error }> }>}
|
|
301
|
+
*/
|
|
302
|
+
export async function runPlansParallel(plans, runOne, opts = {}) {
|
|
303
|
+
if (!Array.isArray(plans)) throw new Error('runPlansParallel: plans array required');
|
|
304
|
+
if (typeof runOne !== 'function') throw new Error('runPlansParallel: runOne fn required');
|
|
305
|
+
const memberSet = Array.isArray(opts.groupMemberIds)
|
|
306
|
+
? new Set(opts.groupMemberIds) : null;
|
|
307
|
+
const concurrency = Number.isFinite(opts.concurrency) && opts.concurrency > 0
|
|
308
|
+
? Math.floor(opts.concurrency) : Infinity;
|
|
309
|
+
|
|
310
|
+
const results = new Array(plans.length);
|
|
311
|
+
const errors = [];
|
|
312
|
+
let nextIdx = 0;
|
|
313
|
+
|
|
314
|
+
const runSlot = async () => {
|
|
315
|
+
// Workers pull tasks from a shared queue index — preserves backpressure
|
|
316
|
+
// when concurrency < plans.length without per-task scheduling overhead.
|
|
317
|
+
while (true) {
|
|
318
|
+
const i = nextIdx;
|
|
319
|
+
nextIdx += 1;
|
|
320
|
+
if (i >= plans.length) return;
|
|
321
|
+
const plan = plans[i];
|
|
322
|
+
if (memberSet && !memberSet.has(plan.vpId)) {
|
|
323
|
+
results[i] = { index: i, vpId: plan.vpId, skipped: 'not_member' };
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
results[i] = await runOne(plan, i);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
errors.push({ index: i, error: err });
|
|
330
|
+
results[i] = { index: i, vpId: plan.vpId, error: err };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const workerCount = Math.min(plans.length, concurrency);
|
|
336
|
+
const workers = [];
|
|
337
|
+
for (let w = 0; w < workerCount; w += 1) workers.push(runSlot());
|
|
338
|
+
await Promise.all(workers);
|
|
339
|
+
|
|
340
|
+
return { results, errors };
|
|
341
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Harness — Router Handoff
|
|
3
|
+
|
|
4
|
+
If, while drafting your reply, you realise you are the wrong VP for this
|
|
5
|
+
turn, hand off instead of guessing. Call `route_forward(targetVpId, reason)`
|
|
6
|
+
with a short, actionable reason. The next turn becomes the receiving VP's
|
|
7
|
+
turn with your reason as the inbound envelope — they act with no other
|
|
8
|
+
context from you.
|
|
9
|
+
|
|
10
|
+
Use this when:
|
|
11
|
+
|
|
12
|
+
- The user's question is outside your expertise and another VP in the
|
|
13
|
+
group clearly owns it.
|
|
14
|
+
- Your read of the situation is "this is comms not kernel" / "this is
|
|
15
|
+
legal not engineering" — name the boundary.
|
|
16
|
+
|
|
17
|
+
Do NOT use this to dodge hard questions you legitimately own. The router
|
|
18
|
+
already picked you; only forward when the topic genuinely belongs to
|
|
19
|
+
someone else.
|
|
20
|
+
<!-- lang:zh -->
|
|
21
|
+
# Harness — Router 转交
|
|
22
|
+
|
|
23
|
+
如果你在起草回复时发现本轮应该由其他 VP 来回答,请直接转交,而不是
|
|
24
|
+
强答。调用 `route_forward(targetVpId, reason)` 并给出简短可操作的原因。
|
|
25
|
+
下一轮变为目标 VP 的回合,你给的 reason 即为他们看到的入站信封——他
|
|
26
|
+
们不会读到你的其他上下文。
|
|
27
|
+
|
|
28
|
+
适用场景:
|
|
29
|
+
|
|
30
|
+
- 用户的问题超出你的专业范围,群里另一个 VP 显然更合适。
|
|
31
|
+
- 你判断「这是沟通不是内核」/「这是法务不是工程」——说出边界。
|
|
32
|
+
|
|
33
|
+
不要用它来回避你确实该回答的问题。Router 既然选了你,只有当话题
|
|
34
|
+
确实属于他人时才转交。
|