@yeaft/webchat-agent 0.1.522 → 0.1.523
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/vp/core-memory-recall.js +75 -0
- package/unify/vp/engine-binding.js +96 -0
- package/unify/vp/index.js +6 -0
- package/unify/vp/registry.js +5 -0
- package/unify/vp/role-instance.js +143 -14
- package/unify/vp/run-turn.js +197 -0
- package/unify/vp/system-prompt.js +191 -0
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core-memory-recall.js — single-dimension VP memory recall.
|
|
3
|
+
*
|
|
4
|
+
* Per R3 §Δ2.3: recall is scoped to the CURRENT VP only. The `scope`
|
|
5
|
+
* dimension from R2 was deleted; VP memory is a single store per VP and
|
|
6
|
+
* the recall filter is `{ vp: currentVpId, ... }` — no $or across local /
|
|
7
|
+
* global.
|
|
8
|
+
*
|
|
9
|
+
* This module is a thin shim over whatever memory store RoleInstance has
|
|
10
|
+
* (334f wraps 334o's shard-store). It duck-types the store so 334c does
|
|
11
|
+
* not hard-depend on 334f's final shape:
|
|
12
|
+
*
|
|
13
|
+
* store.query({ vp, shard?, kind?, tags?, limit?, recency? })
|
|
14
|
+
* store.search({ vp, query, limit? }) // optional
|
|
15
|
+
*
|
|
16
|
+
* Return shape: array of entries with at minimum `{ body, shard }`.
|
|
17
|
+
* Additional fields are passed through.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 7;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recall top-K core memory for a VP.
|
|
24
|
+
*
|
|
25
|
+
* @param {object|null} store — memoryStore (may be null)
|
|
26
|
+
* @param {{ vp: string, limit?: number, shard?: string, kind?: string,
|
|
27
|
+
* tags?: string[] }} opts
|
|
28
|
+
* @returns {Promise<Array<{body:string, shard?:string, [k:string]:any}>>}
|
|
29
|
+
*/
|
|
30
|
+
export async function recallCoreMemory(store, opts = {}) {
|
|
31
|
+
if (!store || typeof store.query !== 'function') return [];
|
|
32
|
+
if (!opts.vp || typeof opts.vp !== 'string') {
|
|
33
|
+
throw new Error('recallCoreMemory: opts.vp (vpId) is required');
|
|
34
|
+
}
|
|
35
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0
|
|
36
|
+
? opts.limit
|
|
37
|
+
: DEFAULT_LIMIT;
|
|
38
|
+
|
|
39
|
+
const filter = { vp: opts.vp, limit };
|
|
40
|
+
if (opts.shard) filter.shard = opts.shard;
|
|
41
|
+
if (opts.kind) filter.kind = opts.kind;
|
|
42
|
+
if (opts.tags && opts.tags.length) filter.tags = opts.tags;
|
|
43
|
+
|
|
44
|
+
// Support both sync and async stores.
|
|
45
|
+
const out = await Promise.resolve(store.query(filter));
|
|
46
|
+
if (!Array.isArray(out)) return [];
|
|
47
|
+
return out.slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Optional keyword-search variant — used by the `memory_search` tool in
|
|
52
|
+
* 334d. Also VP-scoped.
|
|
53
|
+
*
|
|
54
|
+
* @param {object|null} store
|
|
55
|
+
* @param {{ vp: string, query: string, limit?: number }} opts
|
|
56
|
+
*/
|
|
57
|
+
export async function searchCoreMemory(store, opts = {}) {
|
|
58
|
+
if (!store) return [];
|
|
59
|
+
if (!opts.vp || !opts.query) return [];
|
|
60
|
+
const limit = opts.limit || DEFAULT_LIMIT;
|
|
61
|
+
if (typeof store.search === 'function') {
|
|
62
|
+
const out = await Promise.resolve(store.search({ vp: opts.vp, query: opts.query, limit }));
|
|
63
|
+
return Array.isArray(out) ? out.slice(0, limit) : [];
|
|
64
|
+
}
|
|
65
|
+
// Fallback: query + naive substring filter.
|
|
66
|
+
if (typeof store.query === 'function') {
|
|
67
|
+
const all = await Promise.resolve(store.query({ vp: opts.vp, limit: 200 }));
|
|
68
|
+
if (!Array.isArray(all)) return [];
|
|
69
|
+
const needle = opts.query.toLowerCase();
|
|
70
|
+
return all
|
|
71
|
+
.filter((e) => (e.body || '').toLowerCase().includes(needle))
|
|
72
|
+
.slice(0, limit);
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-binding.js — bind a single Engine instance to a VP.
|
|
3
|
+
*
|
|
4
|
+
* Per §5.1, Engine is a stateless library: `engine.query({prompt, messages,
|
|
5
|
+
* signal, ...})`. Per §5.2, each RoleInstance runs ITS VP's Engine. We keep
|
|
6
|
+
* one Engine per (vpId) — NOT per RoleInstance — because the Engine itself
|
|
7
|
+
* holds no conversation state; messages live on the RoleInstance. Sharing
|
|
8
|
+
* across groups for the same VP is correct and avoids adapter duplication.
|
|
9
|
+
*
|
|
10
|
+
* When a VP is hot-reloaded (persona body changed → personaHash changed),
|
|
11
|
+
* the cached Engine is still valid — the persona is injected via system
|
|
12
|
+
* prompt, not Engine constructor. We keep the cache stable unless the
|
|
13
|
+
* caller passes a new adapter (e.g. modelHint switched).
|
|
14
|
+
*
|
|
15
|
+
* Hard constraint: 334c does NOT import ../engine.js directly, because
|
|
16
|
+
* that pulls in adapter/config/tool machinery that's owned by the caller
|
|
17
|
+
* (web-bridge / eval runner). Instead this module takes a factory:
|
|
18
|
+
*
|
|
19
|
+
* createEngine(vp) → Engine
|
|
20
|
+
*
|
|
21
|
+
* which the caller supplies when they wire up a Registry.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SCOPE = Symbol.for('yeaft.334c.default-engine-scope');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build an engine binder. Stateless helper — state lives on the registry-
|
|
28
|
+
* like cache the caller provides (defaults to an internal WeakMap-style).
|
|
29
|
+
*
|
|
30
|
+
* @param {{
|
|
31
|
+
* createEngine: (vp: import('./vp-store.js').VP) => object,
|
|
32
|
+
* cache?: Map<string, object>, // vpId → Engine
|
|
33
|
+
* }} deps
|
|
34
|
+
*/
|
|
35
|
+
export function createEngineBinder({ createEngine, cache } = {}) {
|
|
36
|
+
if (typeof createEngine !== 'function') {
|
|
37
|
+
throw new Error('createEngineBinder: createEngine(vp) factory is required');
|
|
38
|
+
}
|
|
39
|
+
const store = cache instanceof Map ? cache : new Map();
|
|
40
|
+
// Track personaHash at bind time so the caller can decide whether a
|
|
41
|
+
// later hot-reload warrants re-binding (MVP: we just stash it; engine
|
|
42
|
+
// itself is persona-agnostic).
|
|
43
|
+
const meta = new Map(); // vpId → { personaHash, modelHint }
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
/**
|
|
47
|
+
* Resolve (or lazily create) the Engine for a RoleInstance's VP and
|
|
48
|
+
* attach it onto `ri.engine`. Idempotent.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('./role-instance.js').RoleInstance} ri
|
|
51
|
+
* @returns {object} Engine
|
|
52
|
+
*/
|
|
53
|
+
bind(ri) {
|
|
54
|
+
if (!ri || !ri.vp) throw new Error('bind: role instance required');
|
|
55
|
+
const vp = ri.vp;
|
|
56
|
+
let engine = store.get(vp.id);
|
|
57
|
+
if (!engine) {
|
|
58
|
+
engine = createEngine(vp);
|
|
59
|
+
if (!engine || typeof engine.query !== 'function') {
|
|
60
|
+
throw new Error(`bind: createEngine(${vp.id}) did not return an Engine-like object (missing query)`);
|
|
61
|
+
}
|
|
62
|
+
store.set(vp.id, engine);
|
|
63
|
+
meta.set(vp.id, { personaHash: vp.personaHash, modelHint: vp.modelHint });
|
|
64
|
+
}
|
|
65
|
+
ri.engine = engine;
|
|
66
|
+
return engine;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mark a VP as "needs re-bind" — drop the cache entry. The next bind()
|
|
71
|
+
* call will invoke createEngine again. Useful when the caller knows
|
|
72
|
+
* the adapter or tool inventory has changed.
|
|
73
|
+
*/
|
|
74
|
+
invalidate(vpId) {
|
|
75
|
+
const engine = store.get(vpId);
|
|
76
|
+
store.delete(vpId);
|
|
77
|
+
meta.delete(vpId);
|
|
78
|
+
// Best-effort: if the engine exposed a dispose, call it.
|
|
79
|
+
if (engine && typeof engine.dispose === 'function') {
|
|
80
|
+
try { engine.dispose(); } catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Drop every cached engine (e.g. on session teardown). */
|
|
85
|
+
clear() {
|
|
86
|
+
for (const vpId of Array.from(store.keys())) this.invalidate(vpId);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** Diagnostics. */
|
|
90
|
+
size() { return store.size; },
|
|
91
|
+
has(vpId) { return store.has(vpId); },
|
|
92
|
+
getMeta(vpId) { return meta.get(vpId) || null; },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
void DEFAULT_SCOPE; // reserved for cross-module sharing; not used in MVP
|
|
96
|
+
}
|
package/unify/vp/index.js
CHANGED
|
@@ -6,3 +6,9 @@ export { parseRoleMd, loadVpFromDir, scanVpLibrary, count, DEFAULT_VP_LIB_DIR }
|
|
|
6
6
|
export { RoleInstance } from './role-instance.js';
|
|
7
7
|
export { Registry, defaultRegistry } from './registry.js';
|
|
8
8
|
export { VpLoader } from './vp-loader.js';
|
|
9
|
+
|
|
10
|
+
// task-334c
|
|
11
|
+
export { buildSystemPrompt } from './system-prompt.js';
|
|
12
|
+
export { recallCoreMemory, searchCoreMemory } from './core-memory-recall.js';
|
|
13
|
+
export { createEngineBinder } from './engine-binding.js';
|
|
14
|
+
export { createTurnRunner } from './run-turn.js';
|
package/unify/vp/registry.js
CHANGED
|
@@ -56,6 +56,11 @@ export class Registry {
|
|
|
56
56
|
cur.traits = next.traits;
|
|
57
57
|
cur.modelHint = next.modelHint;
|
|
58
58
|
cur.persona = next.persona;
|
|
59
|
+
// task-334c: personaHash must track persona body. 334a-followup added
|
|
60
|
+
// the field; this is the in-place update mirror. (Originally recorded
|
|
61
|
+
// as a 334h nit, but 334c's system-prompt block names the hash in
|
|
62
|
+
// STATIC — carrying a stale hash would bleed into the prompt.)
|
|
63
|
+
cur.personaHash = next.personaHash;
|
|
59
64
|
cur.mtimeMs = next.mtimeMs;
|
|
60
65
|
// dir / memoryDir / id stable
|
|
61
66
|
return cur;
|
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* role-instance.js — RoleInstance class (per-group per-VP runtime handle).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* task-334 §5 RoleInstance. 334a defined the shape; task-334c adds the
|
|
5
|
+
* lifecycle surface:
|
|
6
|
+
* - state machine: idle → queued → running → (idle | error) + standby
|
|
7
|
+
* - inputQueue: enqueue(envelope) + drain(runner) loop
|
|
8
|
+
* - abort: per-instance AbortController, exposed via abort()
|
|
9
9
|
*
|
|
10
|
-
* Hard
|
|
11
|
-
*
|
|
10
|
+
* Hard constraints (inherited from 334a):
|
|
11
|
+
* - does NOT import 334o shard-store directly (memoryStore stays nullable)
|
|
12
|
+
* - does NOT import 334d routing (inputQueue accepts whatever coordinator
|
|
13
|
+
* hands it; runner is the sole consumer)
|
|
14
|
+
*
|
|
15
|
+
* Hard constraint (334c-specific):
|
|
16
|
+
* - the runner callback is injected — RoleInstance does not know about
|
|
17
|
+
* Engine internals. See run-turn.js for the canonical runner.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
|
-
* @typedef {'idle'|'running'|'
|
|
21
|
+
* @typedef {'idle'|'queued'|'running'|'standby'|'error'} RoleInstanceState
|
|
22
|
+
*
|
|
23
|
+
* - idle: no work, ready to accept
|
|
24
|
+
* - queued: inputQueue has items, drain scheduled/running but this
|
|
25
|
+
* iteration is not yet inside the runner (brief transition)
|
|
26
|
+
* - running: inside runner for one envelope
|
|
27
|
+
* - standby: explicit pause via setState('standby'); enqueue is allowed
|
|
28
|
+
* (buffers) but drain is gated until setState('idle')
|
|
29
|
+
* - error: last turn threw; next enqueue re-enters drain and clears
|
|
16
30
|
*/
|
|
17
31
|
|
|
18
32
|
let _seq = 0;
|
|
@@ -38,24 +52,40 @@ export class RoleInstance {
|
|
|
38
52
|
// ── Runtime state (preserved across persona hot-reload) ─────
|
|
39
53
|
/** @type {RoleInstanceState} */
|
|
40
54
|
this.state = 'idle';
|
|
41
|
-
/** Conversation messages appended by
|
|
55
|
+
/** Conversation messages appended by run-turn (user + assistant pairs). */
|
|
42
56
|
this.messages = [];
|
|
43
|
-
/** Pending input queue (
|
|
57
|
+
/** Pending input queue (envelopes from coordinator). */
|
|
44
58
|
this.inputQueue = [];
|
|
45
|
-
/** Abort controller for in-flight
|
|
59
|
+
/** Abort controller for in-flight run. Created per-turn. */
|
|
46
60
|
this.abortController = null;
|
|
47
61
|
|
|
48
62
|
// ── Nullable sub-system handles (attached by later slices) ──
|
|
49
|
-
/** threadStore — 334h
|
|
63
|
+
/** threadStore — optional (334h). */
|
|
50
64
|
this.threadStore = null;
|
|
51
|
-
/** subagentPool —
|
|
65
|
+
/** subagentPool — optional. */
|
|
52
66
|
this.subagentPool = null;
|
|
53
67
|
/** memoryStore — 334f wraps 334o; NOT created here. */
|
|
54
68
|
this.memoryStore = null;
|
|
69
|
+
/** Cached Engine for this VP; bound lazily by engine-binding.js. */
|
|
70
|
+
this.engine = null;
|
|
71
|
+
/** Cached systemPrompt string; rebuilt when vp.mtimeMs changes. */
|
|
72
|
+
this.systemPrompt = null;
|
|
73
|
+
/** mtimeMs seen when systemPrompt was last built — invalidation key. */
|
|
74
|
+
this._promptBuiltForMtime = null;
|
|
55
75
|
|
|
56
76
|
// ── Telemetry ───────────────────────────────────────────────
|
|
57
77
|
this.createdAt = Date.now();
|
|
58
78
|
this.lastActivityAt = this.createdAt;
|
|
79
|
+
/** Total turns executed via drain (error turns included). */
|
|
80
|
+
this.turnCount = 0;
|
|
81
|
+
/** Last error thrown by runner; cleared on next successful turn. */
|
|
82
|
+
this.lastError = null;
|
|
83
|
+
|
|
84
|
+
// ── Drain internals ────────────────────────────────────────
|
|
85
|
+
/** Set while a drain loop is active. Prevents re-entry. */
|
|
86
|
+
this._draining = false;
|
|
87
|
+
/** Promise that resolves when the current drain loop exits. */
|
|
88
|
+
this._drainPromise = null;
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
/** Update last-activity timestamp (used by LRU eviction). */
|
|
@@ -63,12 +93,109 @@ export class RoleInstance {
|
|
|
63
93
|
this.lastActivityAt = Date.now();
|
|
64
94
|
}
|
|
65
95
|
|
|
66
|
-
/** Transition helper. */
|
|
96
|
+
/** Transition helper. Emits a state change to optional listeners. */
|
|
67
97
|
setState(next) {
|
|
68
98
|
this.state = next;
|
|
69
99
|
this.touch();
|
|
70
100
|
}
|
|
71
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Enqueue an envelope for processing. Triggers a drain if one isn't
|
|
104
|
+
* already running.
|
|
105
|
+
*
|
|
106
|
+
* Envelope shape (from coordinator.js makeEnvelope): { groupId, taskId,
|
|
107
|
+
* msg, trigger }. Opaque to RoleInstance — handed verbatim to runner.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} envelope
|
|
110
|
+
* @returns {number} new inputQueue length
|
|
111
|
+
*/
|
|
112
|
+
enqueue(envelope) {
|
|
113
|
+
if (!envelope) throw new Error('enqueue: envelope required');
|
|
114
|
+
this.inputQueue.push(envelope);
|
|
115
|
+
this.touch();
|
|
116
|
+
if (this.state === 'idle') this.setState('queued');
|
|
117
|
+
return this.inputQueue.length;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Abort the in-flight run (if any) and clear the pending queue's
|
|
122
|
+
* ability to advance further by signalling abort. The queue is NOT
|
|
123
|
+
* cleared — that's policy left to the caller (e.g. GroupCoordinator
|
|
124
|
+
* may reshedule on next user message).
|
|
125
|
+
*
|
|
126
|
+
* @param {string} [reason]
|
|
127
|
+
*/
|
|
128
|
+
abort(reason = 'user') {
|
|
129
|
+
if (this.abortController && !this.abortController.signal.aborted) {
|
|
130
|
+
try { this.abortController.abort(reason); } catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
this.touch();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Drain the inputQueue by invoking `runner(envelope)` for each. One
|
|
137
|
+
* envelope at a time, FIFO. Re-entry safe: if a drain is already in
|
|
138
|
+
* progress, return the in-flight promise instead of starting a second
|
|
139
|
+
* loop. Standby gates the loop (standby → no runner calls until a
|
|
140
|
+
* subsequent `setState('idle')` + enqueue).
|
|
141
|
+
*
|
|
142
|
+
* The runner owns state transitions into 'running' and back to 'idle'
|
|
143
|
+
* for the iteration it's handling; drain only sets the initial queued
|
|
144
|
+
* / final idle around the whole loop.
|
|
145
|
+
*
|
|
146
|
+
* @param {(envelope: object, ri: RoleInstance) => Promise<void>} runner
|
|
147
|
+
* @returns {Promise<{turns:number, errors:number}>}
|
|
148
|
+
*/
|
|
149
|
+
drain(runner) {
|
|
150
|
+
if (typeof runner !== 'function') {
|
|
151
|
+
return Promise.reject(new Error('drain: runner must be a function'));
|
|
152
|
+
}
|
|
153
|
+
if (this._draining) return this._drainPromise;
|
|
154
|
+
|
|
155
|
+
this._draining = true;
|
|
156
|
+
this._drainPromise = (async () => {
|
|
157
|
+
let turns = 0;
|
|
158
|
+
let errors = 0;
|
|
159
|
+
try {
|
|
160
|
+
while (this.inputQueue.length > 0) {
|
|
161
|
+
// Standby blocks the drain — buffer until caller reactivates.
|
|
162
|
+
if (this.state === 'standby') break;
|
|
163
|
+
const envelope = this.inputQueue.shift();
|
|
164
|
+
this.turnCount += 1;
|
|
165
|
+
turns += 1;
|
|
166
|
+
// Fresh controller per turn so abort(reason) only kills one iter
|
|
167
|
+
// and the next envelope starts with a clean signal.
|
|
168
|
+
this.abortController = new AbortController();
|
|
169
|
+
try {
|
|
170
|
+
this.setState('running');
|
|
171
|
+
await runner(envelope, this);
|
|
172
|
+
this.lastError = null;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
errors += 1;
|
|
175
|
+
this.lastError = err;
|
|
176
|
+
this.setState('error');
|
|
177
|
+
// Propagate abort — don't swallow it; but do stop draining so
|
|
178
|
+
// the caller can decide to requeue / resume.
|
|
179
|
+
if (err && err.name === 'AbortError') break;
|
|
180
|
+
// Non-abort errors: continue draining. The next turn gets a
|
|
181
|
+
// fresh state via setState('running') at the top of the loop.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Settle terminal state only if we didn't bail in 'error' or
|
|
185
|
+
// 'standby'. Keep 'standby' sticky so caller must reactivate.
|
|
186
|
+
if (this.state !== 'standby' && this.state !== 'error') {
|
|
187
|
+
this.setState('idle');
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
this._draining = false;
|
|
191
|
+
this._drainPromise = null;
|
|
192
|
+
this.abortController = null;
|
|
193
|
+
}
|
|
194
|
+
return { turns, errors };
|
|
195
|
+
})();
|
|
196
|
+
return this._drainPromise;
|
|
197
|
+
}
|
|
198
|
+
|
|
72
199
|
/**
|
|
73
200
|
* Snapshot for debug / telemetry. Excludes bulky messages.
|
|
74
201
|
*/
|
|
@@ -80,6 +207,8 @@ export class RoleInstance {
|
|
|
80
207
|
state: this.state,
|
|
81
208
|
messageCount: this.messages.length,
|
|
82
209
|
queueDepth: this.inputQueue.length,
|
|
210
|
+
turnCount: this.turnCount,
|
|
211
|
+
hasError: Boolean(this.lastError),
|
|
83
212
|
lastActivityAt: this.lastActivityAt,
|
|
84
213
|
createdAt: this.createdAt,
|
|
85
214
|
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-turn.js — execute one turn for a RoleInstance.
|
|
3
|
+
*
|
|
4
|
+
* A "turn" = consume one envelope from RoleInstance.inputQueue, build a
|
|
5
|
+
* fresh system prompt (§8), call `engine.query({prompt, messages, signal})`,
|
|
6
|
+
* accumulate the streamed text, and append the resulting assistant
|
|
7
|
+
* message both to `ri.messages` (for next-turn context) and — if the
|
|
8
|
+
* caller provides a GroupHandle — to the group's jsonl log via its
|
|
9
|
+
* `appendMessage` API (§334b public surface; hard constraint c: we never
|
|
10
|
+
* hand-write bytes).
|
|
11
|
+
*
|
|
12
|
+
* This is the canonical runner passed to `RoleInstance.drain(runner)`.
|
|
13
|
+
* Tests substitute a fake engine via createEngineBinder to exercise the
|
|
14
|
+
* drain loop / state machine without a live adapter.
|
|
15
|
+
*
|
|
16
|
+
* Streaming contract — event types handled (all optional; Engine.query is
|
|
17
|
+
* the source of truth):
|
|
18
|
+
* { type: 'text', text: string } — accumulate
|
|
19
|
+
* { type: 'text_chunk', text: string } — accumulate
|
|
20
|
+
* { type: 'tool_call', ... } — pass to onEvent
|
|
21
|
+
* { type: 'tool_end', ... } — pass to onEvent
|
|
22
|
+
* { type: 'aborted', reason } — exit loop, throw AbortError
|
|
23
|
+
* { type: 'error', error } — throw
|
|
24
|
+
* { type: 'turn_end', ... } — exit loop
|
|
25
|
+
*
|
|
26
|
+
* Unknown event types are forwarded to `onEvent(evt)` if provided.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { buildSystemPrompt } from './system-prompt.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a runner suitable for RoleInstance.drain().
|
|
33
|
+
*
|
|
34
|
+
* @param {{
|
|
35
|
+
* binder: import('./engine-binding.js').createEngineBinder extends (...args:any)=>infer R ? R : never,
|
|
36
|
+
* registry?: import('./registry.js').Registry,
|
|
37
|
+
* group?: import('../groups/group-store.js').GroupHandle, // optional persistence
|
|
38
|
+
* rosterMembers?: string[],
|
|
39
|
+
* capabilitiesLine?: string,
|
|
40
|
+
* onEvent?: (evt:any, ri:any) => void,
|
|
41
|
+
* buildPromptOverride?: typeof buildSystemPrompt,
|
|
42
|
+
* }} deps
|
|
43
|
+
*/
|
|
44
|
+
export function createTurnRunner(deps = {}) {
|
|
45
|
+
const {
|
|
46
|
+
binder,
|
|
47
|
+
registry,
|
|
48
|
+
group,
|
|
49
|
+
rosterMembers,
|
|
50
|
+
capabilitiesLine,
|
|
51
|
+
onEvent,
|
|
52
|
+
buildPromptOverride,
|
|
53
|
+
} = deps;
|
|
54
|
+
|
|
55
|
+
if (!binder || typeof binder.bind !== 'function') {
|
|
56
|
+
throw new Error('createTurnRunner: binder (from createEngineBinder) is required');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const buildPrompt = buildPromptOverride || buildSystemPrompt;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The actual runner. Called once per envelope by drain().
|
|
63
|
+
*
|
|
64
|
+
* @param {object} envelope — { groupId, taskId, msg, trigger }
|
|
65
|
+
* @param {import('./role-instance.js').RoleInstance} ri
|
|
66
|
+
*/
|
|
67
|
+
return async function runOneTurn(envelope, ri) {
|
|
68
|
+
if (!envelope || !envelope.msg) {
|
|
69
|
+
throw new Error('runOneTurn: envelope.msg missing');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const engine = binder.bind(ri);
|
|
73
|
+
|
|
74
|
+
// Fresh system prompt per turn — DYNAMIC section changes every turn.
|
|
75
|
+
const systemPrompt = await buildPrompt(ri, {
|
|
76
|
+
registry,
|
|
77
|
+
rosterMembers,
|
|
78
|
+
capabilitiesLine,
|
|
79
|
+
runtimeCtx: {
|
|
80
|
+
taskId: envelope.taskId || null,
|
|
81
|
+
isDream: false,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Prompt text = inbound message body. Engine.query spec:
|
|
86
|
+
// { prompt, messages, signal, ... }
|
|
87
|
+
// It prepends the system prompt via adapter-level wiring; we pass
|
|
88
|
+
// `systemPrompt` as an explicit first message if the engine supports
|
|
89
|
+
// it, else rely on the engine's own system injection. For §334c MVP
|
|
90
|
+
// we pass systemPrompt as metadata on messages[0] and let the engine
|
|
91
|
+
// decide — tests supply a fake engine that echoes back.
|
|
92
|
+
const prompt = String(envelope.msg.text || '').trim();
|
|
93
|
+
if (!prompt) {
|
|
94
|
+
// No-op envelope (empty msg): record a stub and return.
|
|
95
|
+
ri.messages.push({ role: 'user', text: '', ts: new Date().toISOString(), meta: envelope });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ri.messages.push({
|
|
100
|
+
role: 'user',
|
|
101
|
+
text: prompt,
|
|
102
|
+
ts: envelope.msg.ts || new Date().toISOString(),
|
|
103
|
+
from: envelope.msg.from || 'user',
|
|
104
|
+
msgId: envelope.msg.id || null,
|
|
105
|
+
trigger: envelope.trigger || null,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const signal = ri.abortController ? ri.abortController.signal : undefined;
|
|
109
|
+
|
|
110
|
+
// Build prior-messages window for the engine — MVP: the last 20.
|
|
111
|
+
const priorMessages = ri.messages.slice(-20);
|
|
112
|
+
|
|
113
|
+
let accumulated = '';
|
|
114
|
+
let aborted = false;
|
|
115
|
+
let errored = null;
|
|
116
|
+
|
|
117
|
+
const iterator = engine.query({
|
|
118
|
+
prompt,
|
|
119
|
+
messages: priorMessages,
|
|
120
|
+
signal,
|
|
121
|
+
systemPrompt,
|
|
122
|
+
metadata: {
|
|
123
|
+
vpId: ri.vpId,
|
|
124
|
+
groupId: ri.groupId,
|
|
125
|
+
taskId: envelope.taskId || null,
|
|
126
|
+
turnId: `${ri.id}:${ri.turnCount}`,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
for await (const evt of iterator) {
|
|
132
|
+
if (!evt || typeof evt !== 'object') continue;
|
|
133
|
+
switch (evt.type) {
|
|
134
|
+
case 'text':
|
|
135
|
+
case 'text_chunk':
|
|
136
|
+
case 'message':
|
|
137
|
+
if (typeof evt.text === 'string') accumulated += evt.text;
|
|
138
|
+
if (onEvent) try { onEvent(evt, ri); } catch { /* ignore */ }
|
|
139
|
+
break;
|
|
140
|
+
case 'aborted':
|
|
141
|
+
aborted = true;
|
|
142
|
+
if (onEvent) try { onEvent(evt, ri); } catch { /* ignore */ }
|
|
143
|
+
break;
|
|
144
|
+
case 'error':
|
|
145
|
+
errored = evt.error instanceof Error ? evt.error : new Error(String(evt.error || 'engine error'));
|
|
146
|
+
if (onEvent) try { onEvent(evt, ri); } catch { /* ignore */ }
|
|
147
|
+
break;
|
|
148
|
+
case 'turn_end':
|
|
149
|
+
if (onEvent) try { onEvent(evt, ri); } catch { /* ignore */ }
|
|
150
|
+
break;
|
|
151
|
+
default:
|
|
152
|
+
if (onEvent) try { onEvent(evt, ri); } catch { /* ignore */ }
|
|
153
|
+
}
|
|
154
|
+
if (aborted || errored) break;
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err && err.name === 'AbortError') {
|
|
158
|
+
aborted = true;
|
|
159
|
+
} else {
|
|
160
|
+
errored = err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (aborted) {
|
|
165
|
+
const e = new Error('aborted');
|
|
166
|
+
e.name = 'AbortError';
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
if (errored) throw errored;
|
|
170
|
+
|
|
171
|
+
// Record assistant reply (even if empty — the turn still counted).
|
|
172
|
+
const assistantMsg = {
|
|
173
|
+
role: 'assistant',
|
|
174
|
+
text: accumulated,
|
|
175
|
+
ts: new Date().toISOString(),
|
|
176
|
+
from: ri.vpId,
|
|
177
|
+
taskId: envelope.taskId || null,
|
|
178
|
+
};
|
|
179
|
+
ri.messages.push(assistantMsg);
|
|
180
|
+
|
|
181
|
+
// Hard constraint (c): persistence goes through 334b's appendMessage.
|
|
182
|
+
if (group && typeof group.appendMessage === 'function' && accumulated.trim()) {
|
|
183
|
+
try {
|
|
184
|
+
group.appendMessage({
|
|
185
|
+
from: ri.vpId,
|
|
186
|
+
role: 'assistant',
|
|
187
|
+
text: accumulated,
|
|
188
|
+
taskId: envelope.taskId || null,
|
|
189
|
+
meta: { trigger: envelope.trigger || null, replyTo: envelope.msg.id || null },
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Non-fatal: the in-memory message is still recorded on ri.messages.
|
|
193
|
+
if (onEvent) try { onEvent({ type: 'persist_error', error: err }, ri); } catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* system-prompt.js — assemble a VP's system prompt per architecture §8.
|
|
3
|
+
*
|
|
4
|
+
* Three sections:
|
|
5
|
+
* § STATIC
|
|
6
|
+
* - identity (yeaft base — short)
|
|
7
|
+
* - vp_persona (role.md persona body)
|
|
8
|
+
* - capabilities (tools) — 334d owns the real list; this slice leaves
|
|
9
|
+
* a stub that names the MVP tool surface ("route_forward" ...).
|
|
10
|
+
*
|
|
11
|
+
* § SEMI-DYNAMIC
|
|
12
|
+
* - group_roster (name + on-line status via Registry.activeCount)
|
|
13
|
+
* - (skills / mcp / user_profile — owned by other slices; 334c emits
|
|
14
|
+
* placeholder only if the caller provides them)
|
|
15
|
+
*
|
|
16
|
+
* § DYNAMIC
|
|
17
|
+
* - runtime_ctx { vpId, groupId, taskId?, isDream=false }
|
|
18
|
+
* - core_memory (recall top-K; vp single-dim per R3 §Δ2.3)
|
|
19
|
+
*
|
|
20
|
+
* Caching: the STATIC persona section is cached per RoleInstance via
|
|
21
|
+
* `ri._promptBuiltForMtime === vp.mtimeMs`. DYNAMIC is always rebuilt.
|
|
22
|
+
*
|
|
23
|
+
* Hard constraint (334c): this module does not touch 334f/334g memory
|
|
24
|
+
* internals. It calls `memoryStore.query({vp})` (duck-typed) and falls
|
|
25
|
+
* back to an empty top-K if the store is null.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { recallCoreMemory } from './core-memory-recall.js';
|
|
29
|
+
|
|
30
|
+
const CORE_MEMORY_TOP_K = 7; // §8
|
|
31
|
+
const ROSTER_STATUS_ONLINE = 'online';
|
|
32
|
+
const ROSTER_STATUS_BUSY = 'busy';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build one complete system prompt string.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('./role-instance.js').RoleInstance} ri
|
|
38
|
+
* @param {{
|
|
39
|
+
* registry?: import('./registry.js').Registry,
|
|
40
|
+
* rosterMembers?: string[], // explicit override (334b GroupHandle.roster)
|
|
41
|
+
* runtimeCtx?: { taskId?: string|null, isDream?: boolean, recentChatSummary?: string },
|
|
42
|
+
* capabilitiesLine?: string, // 334d injects tool inventory
|
|
43
|
+
* userProfile?: string, // 334l injects top-5 user-memory
|
|
44
|
+
* recentGroupChat?: string, // 334h injects N recent msgs
|
|
45
|
+
* }} opts
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export async function buildSystemPrompt(ri, opts = {}) {
|
|
49
|
+
if (!ri || !ri.vp) throw new Error('buildSystemPrompt: role instance required');
|
|
50
|
+
const vp = ri.vp;
|
|
51
|
+
|
|
52
|
+
// ─── § STATIC (cached per mtime) ────────────────────────────
|
|
53
|
+
let staticBlock = ri.systemPrompt;
|
|
54
|
+
if (!staticBlock || ri._promptBuiltForMtime !== vp.mtimeMs) {
|
|
55
|
+
staticBlock = buildStatic(vp, opts.capabilitiesLine);
|
|
56
|
+
ri.systemPrompt = staticBlock;
|
|
57
|
+
ri._promptBuiltForMtime = vp.mtimeMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── § SEMI-DYNAMIC ─────────────────────────────────────────
|
|
61
|
+
const roster = buildRoster(ri, opts.registry, opts.rosterMembers);
|
|
62
|
+
const userProfile = opts.userProfile ? `\n## user_profile\n${opts.userProfile.trim()}\n` : '';
|
|
63
|
+
|
|
64
|
+
// ─── § DYNAMIC ──────────────────────────────────────────────
|
|
65
|
+
const ctx = opts.runtimeCtx || {};
|
|
66
|
+
const runtime = buildRuntimeCtx(ri, ctx);
|
|
67
|
+
const recent = opts.recentGroupChat
|
|
68
|
+
? `\n## recent_group_chat\n${opts.recentGroupChat.trim()}\n`
|
|
69
|
+
: '';
|
|
70
|
+
const coreMem = await buildCoreMemoryBlock(ri, ctx);
|
|
71
|
+
|
|
72
|
+
return [
|
|
73
|
+
'# § STATIC',
|
|
74
|
+
staticBlock,
|
|
75
|
+
'',
|
|
76
|
+
'# § SEMI-DYNAMIC',
|
|
77
|
+
roster,
|
|
78
|
+
userProfile.trim() ? userProfile : '',
|
|
79
|
+
'',
|
|
80
|
+
'# § DYNAMIC',
|
|
81
|
+
runtime,
|
|
82
|
+
recent.trim() ? recent : '',
|
|
83
|
+
coreMem,
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join('\n')
|
|
87
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ──────────────────────────────────────────────────────────────
|
|
92
|
+
// STATIC
|
|
93
|
+
// ──────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function buildStatic(vp, capabilitiesLine) {
|
|
96
|
+
const identity =
|
|
97
|
+
'You are a Virtual Person (VP) in a yeaft multi-agent group.\n' +
|
|
98
|
+
'Respect §6 triggers: text @-mentions do NOT route. Use the `route_forward` tool for explicit dispatch.';
|
|
99
|
+
|
|
100
|
+
const persona = vp.persona && vp.persona.trim()
|
|
101
|
+
? vp.persona.trim()
|
|
102
|
+
: `(no persona body for ${vp.id})`;
|
|
103
|
+
|
|
104
|
+
const caps = (capabilitiesLine && capabilitiesLine.trim())
|
|
105
|
+
|| 'Tools: route_forward, memory_search, memory_trace, task_summary_post (if initiator).';
|
|
106
|
+
|
|
107
|
+
// personaHash travels in the static block so downstream (334h live-diff)
|
|
108
|
+
// can detect changes without re-hashing.
|
|
109
|
+
return [
|
|
110
|
+
'## identity',
|
|
111
|
+
identity,
|
|
112
|
+
'',
|
|
113
|
+
`## vp_persona (id=${vp.id}, hash=${vp.personaHash || '-'})`,
|
|
114
|
+
`Name: ${vp.name}`,
|
|
115
|
+
vp.role ? `Role: ${vp.role}` : '',
|
|
116
|
+
vp.traits && vp.traits.length ? `Traits: ${vp.traits.join(', ')}` : '',
|
|
117
|
+
'',
|
|
118
|
+
persona,
|
|
119
|
+
'',
|
|
120
|
+
'## capabilities',
|
|
121
|
+
caps,
|
|
122
|
+
].filter(Boolean).join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ──────────────────────────────────────────────────────────────
|
|
126
|
+
// SEMI-DYNAMIC — Roster
|
|
127
|
+
// ──────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function buildRoster(ri, registry, rosterMembers) {
|
|
130
|
+
const members = Array.isArray(rosterMembers) && rosterMembers.length > 0
|
|
131
|
+
? rosterMembers
|
|
132
|
+
: registry
|
|
133
|
+
? Array.from(new Set(registry.listRoleInstances()
|
|
134
|
+
.filter((r) => r.groupId === ri.groupId)
|
|
135
|
+
.map((r) => r.vpId)))
|
|
136
|
+
: [ri.vpId];
|
|
137
|
+
|
|
138
|
+
const lines = [`## 群成员 (${members.length})`];
|
|
139
|
+
for (const vpId of members) {
|
|
140
|
+
if (vpId === ri.vpId) {
|
|
141
|
+
lines.push(`- 你自己:${vpId}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const status = memberStatus(vpId, registry);
|
|
145
|
+
const name = registry?.getVp?.(vpId)?.name || vpId;
|
|
146
|
+
lines.push(`- ${name} (${vpId}) · ${status}`);
|
|
147
|
+
}
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function memberStatus(vpId, registry) {
|
|
152
|
+
if (!registry) return ROSTER_STATUS_ONLINE;
|
|
153
|
+
// §8.2: busy if any RoleInstance for this VP has state==='running' in any group.
|
|
154
|
+
// MVP: also 'running' while 'queued'? — spec says "running RoleInstance 数 > 0".
|
|
155
|
+
const ris = registry.listRoleInstances?.() || [];
|
|
156
|
+
const busy = ris.some((r) => r.vpId === vpId && r.state === 'running');
|
|
157
|
+
return busy ? ROSTER_STATUS_BUSY : ROSTER_STATUS_ONLINE;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ──────────────────────────────────────────────────────────────
|
|
161
|
+
// DYNAMIC
|
|
162
|
+
// ──────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function buildRuntimeCtx(ri, ctx) {
|
|
165
|
+
return [
|
|
166
|
+
'## runtime_ctx',
|
|
167
|
+
`vpId: ${ri.vpId}`,
|
|
168
|
+
`groupId: ${ri.groupId}`,
|
|
169
|
+
ctx.taskId ? `taskId: ${ctx.taskId}` : null,
|
|
170
|
+
`isDream: ${Boolean(ctx.isDream)}`,
|
|
171
|
+
].filter(Boolean).join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function buildCoreMemoryBlock(ri, ctx) {
|
|
175
|
+
if (!ri.memoryStore) return '';
|
|
176
|
+
const entries = await recallCoreMemory(ri.memoryStore, {
|
|
177
|
+
vp: ri.vpId,
|
|
178
|
+
limit: CORE_MEMORY_TOP_K,
|
|
179
|
+
});
|
|
180
|
+
if (!entries || entries.length === 0) return '';
|
|
181
|
+
void ctx; // task_ctx injection is 334n's scope; reserved param.
|
|
182
|
+
const lines = ['## core_memory'];
|
|
183
|
+
for (const e of entries) {
|
|
184
|
+
const shard = e.shard || 'general';
|
|
185
|
+
const body = (e.body || '').trim();
|
|
186
|
+
if (!body) continue;
|
|
187
|
+
lines.push(`- [mem:${shard}] ${body}`);
|
|
188
|
+
}
|
|
189
|
+
if (lines.length === 1) return '';
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|