@yeaft/webchat-agent 0.1.706 → 0.1.709
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/connection/message-router.js +17 -1
- package/package.json +1 -1
- package/unify/engine.js +75 -1
- package/unify/tools/route-forward.js +18 -0
- package/unify/tools/types.js +17 -0
- package/unify/web-bridge.js +607 -99
|
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
36
36
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
|
|
39
|
-
import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger } from '../unify/web-bridge.js';
|
|
39
|
+
import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFeatureMessage, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
|
|
40
40
|
|
|
41
41
|
export async function handleMessage(msg) {
|
|
42
42
|
switch (msg.type) {
|
|
@@ -326,7 +326,23 @@ export async function handleMessage(msg) {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
case 'update_llm_config': {
|
|
329
|
+
// Capture the user's intent BEFORE updateLlmConfig — the return
|
|
330
|
+
// envelope ALWAYS populates `language` (falls back to 'en'), so
|
|
331
|
+
// gating on the *output* would broadcast on every provider /
|
|
332
|
+
// primaryModel / fastModel save, not just locale flips. Gate on
|
|
333
|
+
// the *input* `language` field instead.
|
|
334
|
+
const incomingLanguage = typeof msg.config?.language === 'string' && msg.config.language
|
|
335
|
+
? msg.config.language
|
|
336
|
+
: null;
|
|
329
337
|
const result = updateLlmConfig(msg.config || {}, ctx.CONFIG?.yeaftDir);
|
|
338
|
+
// task-708: live locale propagation. When the user flips the UI
|
|
339
|
+
// language dropdown, push the new value into every cached Engine
|
|
340
|
+
// (per-VP pool + 1:1 chat session.engine) so the very next turn
|
|
341
|
+
// renders the system prompt in the chosen language without the
|
|
342
|
+
// user reloading the session.
|
|
343
|
+
if (!result.error && incomingLanguage) {
|
|
344
|
+
broadcastLanguageChange(result.language);
|
|
345
|
+
}
|
|
330
346
|
sendToServer({ type: 'llm_config_updated', ...result });
|
|
331
347
|
break;
|
|
332
348
|
}
|
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -366,6 +366,22 @@ export class Engine {
|
|
|
366
366
|
return !!this.#currentAbortCtrl && !this.#currentAbortCtrl.signal.aborted;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Mutate the engine's effective language at runtime. The next call to
|
|
371
|
+
* #buildSystemPrompt reads this.#config.language live, so the very next
|
|
372
|
+
* turn renders in the new language without reconstructing the engine.
|
|
373
|
+
*
|
|
374
|
+
* Used by the live-locale broadcast path: when the user flips the UI
|
|
375
|
+
* language dropdown, web → server → message-router calls
|
|
376
|
+
* broadcastLanguageChange(lang) (web-bridge.js) which fans out to every
|
|
377
|
+
* Engine in the per-VP pool plus the 1:1-chat session engine.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} lang — 'en' | 'zh'
|
|
380
|
+
*/
|
|
381
|
+
setLanguage(lang) {
|
|
382
|
+
if (typeof lang !== 'string' || !lang) return;
|
|
383
|
+
this.#config.language = lang;
|
|
384
|
+
}
|
|
369
385
|
|
|
370
386
|
/**
|
|
371
387
|
* Unregister a tool.
|
|
@@ -682,6 +698,11 @@ export class Engine {
|
|
|
682
698
|
inboundEnvelope: vpCtx?.inboundEnvelope,
|
|
683
699
|
taskId: vpCtx?.taskId,
|
|
684
700
|
taskMembers: vpCtx?.taskMembers,
|
|
701
|
+
// task-707: tool-callable end-turn signal. The engine threads this
|
|
702
|
+
// setter when constructing toolCtx so a tool (e.g. route_forward)
|
|
703
|
+
// can mark "after this batch, end the turn — do NOT call adapter
|
|
704
|
+
// again". Honored at the top of the tool-loop continuation.
|
|
705
|
+
requestEndTurn: vpCtx?.requestEndTurn,
|
|
685
706
|
// Sub-agent plumbing — Agent tool needs these to spawn a child
|
|
686
707
|
// Engine that inherits the parent's adapter / stores / toolset.
|
|
687
708
|
parentEngineDeps: {
|
|
@@ -1180,6 +1201,13 @@ export class Engine {
|
|
|
1180
1201
|
let currentModel = this.#config.model;
|
|
1181
1202
|
let cumulativeInputTokens = 0;
|
|
1182
1203
|
let cumulativeOutputTokens = 0;
|
|
1204
|
+
// task-707: tool-callable end-turn signal. Tools (currently only
|
|
1205
|
+
// `route_forward`) can set this via toolCtx.requestEndTurn(reason)
|
|
1206
|
+
// to break out of the tool-loop after the current batch finishes
|
|
1207
|
+
// — without invoking another adapter.stream(). Used to hand off
|
|
1208
|
+
// control to other VPs cleanly. Reset to null at the top of every
|
|
1209
|
+
// outer-loop iteration so the flag never carries across turns.
|
|
1210
|
+
let endTurnRequested = null;
|
|
1183
1211
|
|
|
1184
1212
|
while (true) {
|
|
1185
1213
|
turnNumber++;
|
|
@@ -1678,7 +1706,28 @@ export class Engine {
|
|
|
1678
1706
|
}
|
|
1679
1707
|
|
|
1680
1708
|
// Execute tool calls and feed results back
|
|
1681
|
-
|
|
1709
|
+
// task-707: requestEndTurn is a per-batch closure that lets a tool
|
|
1710
|
+
// signal "end this turn after the current batch — no adapter retry".
|
|
1711
|
+
// We re-create the closure each iteration because endTurnRequested
|
|
1712
|
+
// is a per-query local (reset implicitly at the top of #runQuery).
|
|
1713
|
+
const toolCtx = this.#buildToolContext(signal, {
|
|
1714
|
+
router,
|
|
1715
|
+
senderVpId,
|
|
1716
|
+
inboundEnvelope,
|
|
1717
|
+
taskId,
|
|
1718
|
+
taskMembers,
|
|
1719
|
+
vpPersona,
|
|
1720
|
+
contextWindow: currentContextWindow,
|
|
1721
|
+
requestEndTurn: (reason) => {
|
|
1722
|
+
// First call wins — preserve the kind/reason of the first tool
|
|
1723
|
+
// that asked to end the turn. Late callers (a second
|
|
1724
|
+
// route_forward in the same batch) keep dispatching but don't
|
|
1725
|
+
// overwrite the recorded reason.
|
|
1726
|
+
if (endTurnRequested == null) {
|
|
1727
|
+
endTurnRequested = reason || { kind: 'tool_handoff' };
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
});
|
|
1682
1731
|
|
|
1683
1732
|
// task-325a: track whether we aborted mid tool-loop so we can
|
|
1684
1733
|
// break out of the outer while-loop cleanly once the current
|
|
@@ -1822,6 +1871,31 @@ export class Engine {
|
|
|
1822
1871
|
conversationMessages.push({ role: 'user', content: reminder });
|
|
1823
1872
|
}
|
|
1824
1873
|
|
|
1874
|
+
// task-707: tool-callable end-turn signal. If a tool in this batch
|
|
1875
|
+
// called toolCtx.requestEndTurn(reason), break out of the outer
|
|
1876
|
+
// while-loop now — DON'T call adapter.stream() again. The
|
|
1877
|
+
// assistant(tool_use)+tool(tool_result) pairs are already in
|
|
1878
|
+
// conversationMessages, so the next user-initiated turn sees a
|
|
1879
|
+
// clean wire shape. Used by `route_forward` to hand off control
|
|
1880
|
+
// to other VPs without continuing to generate.
|
|
1881
|
+
//
|
|
1882
|
+
// Order matters: this runs BEFORE T1 reflection (which would
|
|
1883
|
+
// collapse the arc into a summary that's only valuable across
|
|
1884
|
+
// multi-iteration tool loops) and BEFORE the abortedDuringTools
|
|
1885
|
+
// check (so a clean handoff doesn't get reported as 'aborted').
|
|
1886
|
+
if (endTurnRequested) {
|
|
1887
|
+
const handoffDetail = typeof endTurnRequested === 'object'
|
|
1888
|
+
? endTurnRequested
|
|
1889
|
+
: { kind: 'tool_handoff', reason: String(endTurnRequested) };
|
|
1890
|
+
yield {
|
|
1891
|
+
type: 'turn_end',
|
|
1892
|
+
turnNumber,
|
|
1893
|
+
stopReason: 'tool_handoff',
|
|
1894
|
+
detail: handoffDetail,
|
|
1895
|
+
};
|
|
1896
|
+
break;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1825
1899
|
// PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
|
|
1826
1900
|
// query() lifetime, the moment queryToolCount crosses
|
|
1827
1901
|
// TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
|
|
@@ -106,6 +106,24 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
|
|
|
106
106
|
detail: result.detail || null,
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
// task-707: hand off control. Successful forward means the originating
|
|
110
|
+
// turn should NOT continue generating — the target VPs are now in
|
|
111
|
+
// charge. Signal the engine to break the tool-loop after this batch.
|
|
112
|
+
// The structured payload feeds web-bridge's `group_handoff` UX event
|
|
113
|
+
// so the frontend can render "↪ 已转交给 @vp-x、@vp-y" without
|
|
114
|
+
// re-parsing a string.
|
|
115
|
+
if (typeof ctx.requestEndTurn === 'function') {
|
|
116
|
+
try {
|
|
117
|
+
ctx.requestEndTurn({
|
|
118
|
+
kind: 'route_forward',
|
|
119
|
+
fromVpId: senderVpId,
|
|
120
|
+
dispatched: result.dispatched.slice(),
|
|
121
|
+
broadcast: Boolean(result.report?.broadcast),
|
|
122
|
+
text,
|
|
123
|
+
reason: reason || null,
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never block the tool path on a UX hint */ }
|
|
126
|
+
}
|
|
109
127
|
return JSON.stringify({
|
|
110
128
|
ok: true,
|
|
111
129
|
dispatched: result.dispatched,
|
package/unify/tools/types.js
CHANGED
|
@@ -27,6 +27,23 @@
|
|
|
27
27
|
* @property {number} [contextWindow] — current model's context window in
|
|
28
28
|
* tokens (used by ToolRegistry.execute to cap a single tool result at a
|
|
29
29
|
* fraction of the window so one runaway grep can't blow the wire).
|
|
30
|
+
* @property {(reason?: string|object) => void} [requestEndTurn]
|
|
31
|
+
* — tool-callable signal that the current engine turn should end after
|
|
32
|
+
* this batch of tool calls completes (no follow-up adapter.stream call).
|
|
33
|
+
* Used by `route_forward` to hand off control to other VPs without
|
|
34
|
+
* continuing to generate. The engine wires this when it builds toolCtx
|
|
35
|
+
* and yields a `turn_end` event with `stopReason: 'tool_handoff'` and
|
|
36
|
+
* the supplied reason as `detail`. `reason` may be a structured object
|
|
37
|
+
* `{kind, ...}` so downstream observers (web-bridge) can render UI hints
|
|
38
|
+
* (e.g. "↪ 已转交给 @vp-b") without re-parsing strings.
|
|
39
|
+
* @property {string} [senderVpId] — id of the VP whose turn is currently
|
|
40
|
+
* running. Used by `route_forward` to stamp the forwarded message and
|
|
41
|
+
* by the loop guard to key per-sender throttling.
|
|
42
|
+
* @property {object} [inboundEnvelope] — the envelope that triggered this
|
|
43
|
+
* turn (groupId / msgId / causedBy chain). Threaded into route_forward
|
|
44
|
+
* so causedBy chains extend correctly.
|
|
45
|
+
* @property {object} [router] — per-group router (createRouter() output)
|
|
46
|
+
* for VP-to-VP forwarding. Set by the bridge when running inside a group.
|
|
30
47
|
*/
|
|
31
48
|
|
|
32
49
|
/**
|
package/unify/web-bridge.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { Engine } from './engine.js';
|
|
24
25
|
import { loadSession } from './session.js';
|
|
25
26
|
import { sendToServer } from '../connection/buffer.js';
|
|
26
27
|
import ctx from '../context.js';
|
|
@@ -57,6 +58,14 @@ let session = null;
|
|
|
57
58
|
/**
|
|
58
59
|
* Single in-flight AbortController. A new user message cancels the prior
|
|
59
60
|
* round (if any). H2.f.2: replaces the per-thread Map.
|
|
61
|
+
*
|
|
62
|
+
* Note: post-707, group fan-out turns no longer flow through this slot —
|
|
63
|
+
* they each get their own controller in `vpAborts` keyed by
|
|
64
|
+
* `${groupId}::${vpId}`. The `currentAbortCtrl` here is only mutated by
|
|
65
|
+
* 1:1 chat paths, the test seeder, and the session-reset cleanup. Don't
|
|
66
|
+
* reach for it from new group-flow code; selective abort, abort-all,
|
|
67
|
+
* and abort-turn already operate against `vpAborts` correctly.
|
|
68
|
+
*
|
|
60
69
|
* @type {AbortController | null}
|
|
61
70
|
*/
|
|
62
71
|
let currentAbortCtrl = null;
|
|
@@ -70,6 +79,115 @@ let currentAbortCtrl = null;
|
|
|
70
79
|
*/
|
|
71
80
|
const turnAbortCtrls = new Map();
|
|
72
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Per-VP inbox + driver + engine pool (group multi-VP delivery).
|
|
84
|
+
*
|
|
85
|
+
* Replaces the pre-707 one-shot `captured[]` array. The coordinator's
|
|
86
|
+
* `deliver(vpId, envelope)` callback now pushes into `vpInboxes`, and a
|
|
87
|
+
* per-VP driver (long-lived async function) drains the inbox one
|
|
88
|
+
* envelope at a time — exactly the shape used by sub-agent runner's
|
|
89
|
+
* `pendingPrompts` + `driveSubAgent`. With this in place:
|
|
90
|
+
* 1. `route_forward` pushes via the same `deliver` → enqueueForVp
|
|
91
|
+
* path the user dispatch uses, so VP-to-VP hand-offs actually run
|
|
92
|
+
* the target VP's driver instead of being dropped.
|
|
93
|
+
* 2. Each VP gets its own Engine (via `vpEngines`) so private state
|
|
94
|
+
* (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
|
|
95
|
+
* `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not collide
|
|
96
|
+
* across concurrent VP turns. Engines are keyed by
|
|
97
|
+
* `${groupId}::${vpId}` rather than vpId alone because Engine
|
|
98
|
+
* cannot serve two concurrent queries safely — even if AMS state
|
|
99
|
+
* partitions correctly by groupKey, the non-group-keyed private
|
|
100
|
+
* state would collide if the same VP ran turns in two groups in
|
|
101
|
+
* parallel.
|
|
102
|
+
*/
|
|
103
|
+
/** @type {Map<string, Array<{envelope: object, opts: object}>>} */
|
|
104
|
+
const vpInboxes = new Map();
|
|
105
|
+
/** @type {Map<string, Promise<void>>} */
|
|
106
|
+
const vpDrivers = new Map();
|
|
107
|
+
/** @type {Map<string, import('./engine.js').Engine>} */
|
|
108
|
+
const vpEngines = new Map();
|
|
109
|
+
/** @type {Map<string, AbortController>} */
|
|
110
|
+
const vpAborts = new Map();
|
|
111
|
+
/**
|
|
112
|
+
* Per-group cached coordinator + router. Created on first
|
|
113
|
+
* `handleUnifyGroupChat` for a given groupId; reused across user messages
|
|
114
|
+
* AND across `route_forward` deliveries inside running VP turns (the
|
|
115
|
+
* router is wired into engine ctx; if we recreated coord per turn the
|
|
116
|
+
* route_forward path would deliver into a freshly-created `captured[]`
|
|
117
|
+
* that nobody consumes — exactly the pre-707 bug).
|
|
118
|
+
*
|
|
119
|
+
* Purge sites:
|
|
120
|
+
* - `invalidateGroupContext(groupId)` — called from every group CRUD
|
|
121
|
+
* handler that mutates roster / meta / lifecycle state on disk
|
|
122
|
+
* (rename, update announcement, archive, delete, add/remove member,
|
|
123
|
+
* set default VP).
|
|
124
|
+
* - `handleUnifyGroupChat` — invalidates inline when its own
|
|
125
|
+
* auto-add / default-VP-heal pass mutated the roster.
|
|
126
|
+
* - `resetUnifySession` and `__testResetVpState` clear the whole map.
|
|
127
|
+
*
|
|
128
|
+
* @type {Map<string, { coord: ReturnType<typeof createCoordinator>,
|
|
129
|
+
* router: ReturnType<typeof createRouter>,
|
|
130
|
+
* groupHandle: object }>}
|
|
131
|
+
*/
|
|
132
|
+
const groupContexts = new Map();
|
|
133
|
+
|
|
134
|
+
function vpKey(groupId, vpId) {
|
|
135
|
+
return `${groupId}::${vpId}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drop the cached coordinator + router for a group AND abort/clear any
|
|
140
|
+
* in-flight VP turns belonging to it. Call this from every CRUD handler
|
|
141
|
+
* that mutates the group's roster, meta, or lifecycle state on disk —
|
|
142
|
+
* the cached coord holds a closed `groupHandle`, so without invalidation
|
|
143
|
+
* later route_forward / ingest calls would read zombie meta (stale
|
|
144
|
+
* roster, pre-rename announcement, kicked members still routable).
|
|
145
|
+
*
|
|
146
|
+
* Idempotent — safe to call when no entry exists.
|
|
147
|
+
*/
|
|
148
|
+
function invalidateGroupContext(groupId) {
|
|
149
|
+
if (!groupId) return;
|
|
150
|
+
groupContexts.delete(groupId);
|
|
151
|
+
const prefix = `${groupId}::`;
|
|
152
|
+
for (const [k, ctrl] of vpAborts) {
|
|
153
|
+
if (!k.startsWith(prefix)) continue;
|
|
154
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
155
|
+
vpAborts.delete(k);
|
|
156
|
+
}
|
|
157
|
+
for (const [k, inbox] of vpInboxes) {
|
|
158
|
+
if (!k.startsWith(prefix)) continue;
|
|
159
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
160
|
+
}
|
|
161
|
+
// Engines are NOT torn down here on purpose. They hold subordinate
|
|
162
|
+
// state (AMS adjustments) that should survive a meta change and a
|
|
163
|
+
// closed groupHandle — they don't reach the on-disk group meta
|
|
164
|
+
// directly. They *are* dropped on `resetUnifySession`.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Live-locale broadcast: push a new language onto every Engine instance
|
|
169
|
+
* the agent currently holds.
|
|
170
|
+
*
|
|
171
|
+
* Called from `agent/connection/message-router.js` after `update_llm_config`
|
|
172
|
+
* persists `language` to ~/.yeaft/config.json. Without this, the per-VP
|
|
173
|
+
* engine pool (constructed once per VP and cached) keeps serving its
|
|
174
|
+
* old language until the session is reloaded — that's the bug fix from
|
|
175
|
+
* task-708 group-locale-sync. The 1:1-chat session.engine is also
|
|
176
|
+
* updated so Chat-mode prompts pick up the new language on the very
|
|
177
|
+
* next turn.
|
|
178
|
+
*
|
|
179
|
+
* No-op when `language` is falsy.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} language — 'en' | 'zh'
|
|
182
|
+
*/
|
|
183
|
+
export function broadcastLanguageChange(language) {
|
|
184
|
+
if (!language) return;
|
|
185
|
+
for (const eng of vpEngines.values()) {
|
|
186
|
+
try { eng.setLanguage?.(language); } catch { /* best-effort */ }
|
|
187
|
+
}
|
|
188
|
+
try { session?.engine?.setLanguage?.(language); } catch { /* best-effort */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
73
191
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
74
192
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
75
193
|
|
|
@@ -122,6 +240,253 @@ function isPermissionErrorMsg(msg) {
|
|
|
122
240
|
return lower.includes('eacces') || lower.includes('eperm') || lower.includes('permission denied');
|
|
123
241
|
}
|
|
124
242
|
|
|
243
|
+
// ============================================================
|
|
244
|
+
// task-707: per-VP inbox + driver helpers (group multi-VP fix)
|
|
245
|
+
// ============================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get-or-create the per-VP Engine. Each VP owns its own Engine instance
|
|
249
|
+
* so private state (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
|
|
250
|
+
* `#abortReason`, `#adjustRanByGroup`, `#execLog`) doesn't collide when
|
|
251
|
+
* VP-A and VP-B run concurrent turns. All engines share the session's
|
|
252
|
+
* adapter / trace / config / stores so memory recall, conversation
|
|
253
|
+
* persistence, and tool registry remain consistent.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} groupId
|
|
256
|
+
* @param {string} vpId
|
|
257
|
+
* @returns {import('./engine.js').Engine}
|
|
258
|
+
*/
|
|
259
|
+
function getOrCreateVpEngine(groupId, vpId) {
|
|
260
|
+
const key = vpKey(groupId, vpId);
|
|
261
|
+
let eng = vpEngines.get(key);
|
|
262
|
+
if (eng) return eng;
|
|
263
|
+
if (!session) throw new Error('getOrCreateVpEngine: session not loaded');
|
|
264
|
+
eng = new Engine({
|
|
265
|
+
adapter: session.adapter,
|
|
266
|
+
trace: session.trace,
|
|
267
|
+
config: session.config,
|
|
268
|
+
conversationStore: session.conversationStore,
|
|
269
|
+
memoryIndex: session.memoryIndex || null,
|
|
270
|
+
amsRegistry: session.amsRegistry,
|
|
271
|
+
toolRegistry: session.toolRegistry,
|
|
272
|
+
skillManager: session.skillManager,
|
|
273
|
+
mcpManager: session.mcpManager,
|
|
274
|
+
yeaftDir: session.yeaftDir,
|
|
275
|
+
});
|
|
276
|
+
vpEngines.set(key, eng);
|
|
277
|
+
return eng;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get-or-create the persistent per-group coordinator + router.
|
|
282
|
+
*
|
|
283
|
+
* The coordinator MUST be reused across user turns AND across in-flight
|
|
284
|
+
* tool calls (route_forward) — its `deliver` callback is the only way
|
|
285
|
+
* envelopes reach `vpInboxes`. If we recreated it per `handleUnifyGroupChat`
|
|
286
|
+
* call (the pre-707 design), `route_forward` running mid-turn would
|
|
287
|
+
* deliver into a doomed `captured[]` while the new dispatch ran against
|
|
288
|
+
* a fresh coordinator. The persistent coordinator + module-level inboxes
|
|
289
|
+
* close that gap.
|
|
290
|
+
*
|
|
291
|
+
* Caller is responsible for passing in a freshly-opened groupHandle on
|
|
292
|
+
* first creation; subsequent calls reuse the cached coord.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} groupId
|
|
295
|
+
* @param {object} groupHandle — only used on first creation
|
|
296
|
+
* @returns {{ coord: object, router: object, groupHandle: object }}
|
|
297
|
+
*/
|
|
298
|
+
function getOrCreateGroupContext(groupId, groupHandle) {
|
|
299
|
+
let entry = groupContexts.get(groupId);
|
|
300
|
+
if (entry) return entry;
|
|
301
|
+
const coord = createCoordinator(groupHandle, {
|
|
302
|
+
deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
|
|
303
|
+
});
|
|
304
|
+
const router = createRouter({ coordinator: coord });
|
|
305
|
+
entry = { coord, router, groupHandle };
|
|
306
|
+
groupContexts.set(groupId, entry);
|
|
307
|
+
return entry;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Push an envelope onto a VP's inbox and ensure its driver is running.
|
|
312
|
+
*
|
|
313
|
+
* Side effect: emits `vp_typing_start` immediately (NOT after the driver
|
|
314
|
+
* picks up the envelope). This makes the UX match the user's expectation
|
|
315
|
+
* — the typing indicator turns on the instant the message is queued, so
|
|
316
|
+
* `route_forward` makes the target VP's typing dot light up before the
|
|
317
|
+
* engine even starts that turn.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} groupId
|
|
320
|
+
* @param {string} vpId
|
|
321
|
+
* @param {object} envelope — coordinator envelope `{groupId, taskId, msg, trigger}`
|
|
322
|
+
*/
|
|
323
|
+
function enqueueForVp(groupId, vpId, envelope) {
|
|
324
|
+
const key = vpKey(groupId, vpId);
|
|
325
|
+
let inbox = vpInboxes.get(key);
|
|
326
|
+
if (!inbox) {
|
|
327
|
+
inbox = [];
|
|
328
|
+
vpInboxes.set(key, inbox);
|
|
329
|
+
}
|
|
330
|
+
// Mint a turnId now so the typing event the UI sees is paired with
|
|
331
|
+
// the same id the driver uses when it later runs the turn.
|
|
332
|
+
const turnId = `${randomUUID().slice(0, 8)}:${vpId}`;
|
|
333
|
+
inbox.push({ envelope, turnId });
|
|
334
|
+
|
|
335
|
+
// Typing fires on enqueue. The matching `vp_typing_end` is emitted by
|
|
336
|
+
// the driver's runVpTurn `finally` block — every enqueue eventually
|
|
337
|
+
// results in exactly one runVpTurn execution, so the per-VP counter
|
|
338
|
+
// (`web/stores/helpers/vp-typing.js`) stays balanced.
|
|
339
|
+
try {
|
|
340
|
+
sendUnifyEvent({
|
|
341
|
+
type: 'vp_typing_start',
|
|
342
|
+
groupId,
|
|
343
|
+
vpId,
|
|
344
|
+
turnId,
|
|
345
|
+
ts: Date.now(),
|
|
346
|
+
}, { groupId, vpId, turnId });
|
|
347
|
+
} catch { /* never crash WS pipeline */ }
|
|
348
|
+
|
|
349
|
+
ensureDriverRunning(groupId, vpId);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Spin up a driver loop for a VP if one isn't already running. Idempotent.
|
|
354
|
+
* The driver pulls envelopes from the inbox one at a time and runs each
|
|
355
|
+
* through `runVpTurn`. When the inbox empties, the driver exits and is
|
|
356
|
+
* removed from `vpDrivers`. The next `enqueueForVp` will spawn a fresh
|
|
357
|
+
* driver.
|
|
358
|
+
*
|
|
359
|
+
* Mirrors `sub-agent/runner.js`'s `driveSubAgent` shape: shift → run →
|
|
360
|
+
* loop until empty. No internal sleep — all "wakeups" are driven by
|
|
361
|
+
* external `enqueueForVp` calls.
|
|
362
|
+
*/
|
|
363
|
+
function ensureDriverRunning(groupId, vpId) {
|
|
364
|
+
const key = vpKey(groupId, vpId);
|
|
365
|
+
if (vpDrivers.has(key)) return;
|
|
366
|
+
const promise = (async () => {
|
|
367
|
+
while (true) {
|
|
368
|
+
const inbox = vpInboxes.get(key);
|
|
369
|
+
if (!inbox || inbox.length === 0) break;
|
|
370
|
+
const { envelope, turnId } = inbox.shift();
|
|
371
|
+
const vpAbort = new AbortController();
|
|
372
|
+
vpAborts.set(key, vpAbort);
|
|
373
|
+
// Mirror into turnAbortCtrls for the existing per-turn Stop button.
|
|
374
|
+
turnAbortCtrls.set(turnId, vpAbort);
|
|
375
|
+
// Snapshot history at the moment this turn starts. Later turns in
|
|
376
|
+
// the same driver loop see updated history (post-append from the
|
|
377
|
+
// previous turn).
|
|
378
|
+
const baseSnapshot = [...conversationMessages];
|
|
379
|
+
const trigger = envelope?.trigger || 'fallback';
|
|
380
|
+
// Synthesize the prompt. For coordinator-emitted envelopes the
|
|
381
|
+
// text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
|
|
382
|
+
// the legacy fan-out path so the model sees the same surface form
|
|
383
|
+
// it always has.
|
|
384
|
+
const text = envelope?.msg?.text || '';
|
|
385
|
+
const prompt = `@vp-${vpId} ${text}`;
|
|
386
|
+
try {
|
|
387
|
+
await runVpTurn({
|
|
388
|
+
prompt,
|
|
389
|
+
groupId,
|
|
390
|
+
vpId,
|
|
391
|
+
turnId,
|
|
392
|
+
envelope,
|
|
393
|
+
vpAbort,
|
|
394
|
+
baseSnapshot,
|
|
395
|
+
});
|
|
396
|
+
} catch (err) {
|
|
397
|
+
console.warn('[Unify] driveVp: runVpTurn failed', vpId, err?.message || err);
|
|
398
|
+
} finally {
|
|
399
|
+
turnAbortCtrls.delete(turnId);
|
|
400
|
+
// Only clear the entry if it's still ours. A fresh user message
|
|
401
|
+
// can install a new controller for this VP between our abort
|
|
402
|
+
// and our finally (selective-abort pre-pass aborts the OLD
|
|
403
|
+
// controller, then ingest enqueues a NEW envelope which mints
|
|
404
|
+
// a fresh controller). Without this guard we'd drop the new
|
|
405
|
+
// controller and a later abort-all wouldn't see it.
|
|
406
|
+
if (vpAborts.get(key) === vpAbort) vpAborts.delete(key);
|
|
407
|
+
try {
|
|
408
|
+
sendUnifyEvent({
|
|
409
|
+
type: 'vp_typing_end',
|
|
410
|
+
groupId,
|
|
411
|
+
vpId,
|
|
412
|
+
turnId,
|
|
413
|
+
ts: Date.now(),
|
|
414
|
+
}, { groupId, vpId, turnId });
|
|
415
|
+
} catch { /* never crash WS pipeline */ }
|
|
416
|
+
}
|
|
417
|
+
// Emit a group_message event so the persisted message shows up in
|
|
418
|
+
// the UI's group log AT THE POINT the turn actually ran (so a
|
|
419
|
+
// queued envelope doesn't "appear" before the prior turn finished
|
|
420
|
+
// its writes). Trigger reflects coord's classification. The emit
|
|
421
|
+
// happens regardless of whether the turn aborted — the envelope
|
|
422
|
+
// is real (coord persisted it before deliver), so the user log
|
|
423
|
+
// should show it even if the assistant reply was cut short.
|
|
424
|
+
try {
|
|
425
|
+
if (text && envelope?.msg) {
|
|
426
|
+
sendUnifyEvent({
|
|
427
|
+
type: 'group_message',
|
|
428
|
+
groupId,
|
|
429
|
+
vpId,
|
|
430
|
+
speakerVpId: vpId,
|
|
431
|
+
text,
|
|
432
|
+
mentions: Array.isArray(envelope?.msg?.mentions) ? envelope.msg.mentions : [],
|
|
433
|
+
trigger,
|
|
434
|
+
ts: Date.now(),
|
|
435
|
+
}, { groupId, vpId, turnId });
|
|
436
|
+
}
|
|
437
|
+
} catch { /* never crash WS pipeline */ }
|
|
438
|
+
}
|
|
439
|
+
vpDrivers.delete(key);
|
|
440
|
+
// Re-arm guard: the inbox could have been pushed into between the
|
|
441
|
+
// top-of-loop empty check and this delete (synchronous re-entry
|
|
442
|
+
// from a sendUnifyEvent listener, or a microtask scheduled while
|
|
443
|
+
// we were in `finally`). Without this, the new envelope would be
|
|
444
|
+
// stranded — `enqueueForVp` saw `vpDrivers.has(key)` return true
|
|
445
|
+
// because we hadn't deleted yet, then we delete and exit. Self-
|
|
446
|
+
// rearm if there is fresh work.
|
|
447
|
+
const tail = vpInboxes.get(key);
|
|
448
|
+
if (tail && tail.length > 0) ensureDriverRunning(groupId, vpId);
|
|
449
|
+
})();
|
|
450
|
+
vpDrivers.set(key, promise);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Test-only: drain all currently-queued VP work to completion. Tests use
|
|
455
|
+
* this as a barrier between "scenario triggered" and "now assert state".
|
|
456
|
+
* Production code never calls this — driver lifecycles are tied to
|
|
457
|
+
* inbox emptiness, not external signals.
|
|
458
|
+
*/
|
|
459
|
+
export async function __testDrainVpDrivers() {
|
|
460
|
+
// Snapshot the in-flight driver promises and wait. New drivers
|
|
461
|
+
// spawned during the wait (by route_forward inside a turn) get
|
|
462
|
+
// picked up on the next iteration.
|
|
463
|
+
while (vpDrivers.size > 0) {
|
|
464
|
+
const promises = Array.from(vpDrivers.values());
|
|
465
|
+
await Promise.all(promises.map((p) => p.catch(() => {})));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Test-only: reset all per-VP / per-group caches. Aborts in-flight
|
|
471
|
+
* drivers and drains them before clearing so the next test doesn't see
|
|
472
|
+
* a half-aborted controller writing to a now-cleared map.
|
|
473
|
+
*/
|
|
474
|
+
export async function __testResetVpState() {
|
|
475
|
+
for (const ctrl of vpAborts.values()) {
|
|
476
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* */ }
|
|
477
|
+
}
|
|
478
|
+
for (const inbox of vpInboxes.values()) {
|
|
479
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
480
|
+
}
|
|
481
|
+
await __testDrainVpDrivers();
|
|
482
|
+
vpInboxes.clear();
|
|
483
|
+
vpDrivers.clear();
|
|
484
|
+
vpEngines.clear();
|
|
485
|
+
vpAborts.clear();
|
|
486
|
+
groupContexts.clear();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
125
490
|
/**
|
|
126
491
|
* Send a unify_output message carrying claude_output-format data.
|
|
127
492
|
* Envelope fields: conversationId, groupId, vpId, turnId — the last two
|
|
@@ -318,6 +683,7 @@ export function handleUnifyRenameGroup(msg) {
|
|
|
318
683
|
try {
|
|
319
684
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
320
685
|
const group = renameGroup(yeaftDir, groupId, name);
|
|
686
|
+
invalidateGroupContext(groupId);
|
|
321
687
|
sendGroupCrudResult({ op: 'rename', requestId, ok: true, group });
|
|
322
688
|
sendGroupSnapshotBroadcast();
|
|
323
689
|
} catch (err) {
|
|
@@ -357,6 +723,7 @@ export function handleUnifyUpdateGroup(msg) {
|
|
|
357
723
|
if (hasAnnouncement) {
|
|
358
724
|
group = updateGroupAnnouncement(yeaftDir, groupId, patch.announcement);
|
|
359
725
|
}
|
|
726
|
+
invalidateGroupContext(groupId);
|
|
360
727
|
sendGroupCrudResult({ op: 'update', requestId, ok: true, group });
|
|
361
728
|
sendGroupSnapshotBroadcast();
|
|
362
729
|
} catch (err) {
|
|
@@ -370,6 +737,7 @@ export function handleUnifyArchiveGroup(msg) {
|
|
|
370
737
|
try {
|
|
371
738
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
372
739
|
const result = archiveGroup(yeaftDir, groupId);
|
|
740
|
+
invalidateGroupContext(groupId);
|
|
373
741
|
sendGroupCrudResult({ op: 'archive', requestId, ok: true, groupId: result.groupId });
|
|
374
742
|
sendGroupSnapshotBroadcast();
|
|
375
743
|
} catch (err) {
|
|
@@ -396,6 +764,15 @@ export function handleUnifyDeleteGroup(msg) {
|
|
|
396
764
|
} catch (cascadeErr) {
|
|
397
765
|
console.warn(`[Yeaft] cascade delete for group ${groupId} failed: ${cascadeErr.message}`);
|
|
398
766
|
}
|
|
767
|
+
// Drop the cached coord/router and abort/clear any in-flight VP
|
|
768
|
+
// turns for the deleted group. Engines for the deleted group are
|
|
769
|
+
// also dropped — unlike rename/announcement updates, the group is
|
|
770
|
+
// gone for good and there's nothing to preserve.
|
|
771
|
+
invalidateGroupContext(groupId);
|
|
772
|
+
const prefix = `${groupId}::`;
|
|
773
|
+
for (const k of Array.from(vpEngines.keys())) {
|
|
774
|
+
if (k.startsWith(prefix)) vpEngines.delete(k);
|
|
775
|
+
}
|
|
399
776
|
sendGroupCrudResult({
|
|
400
777
|
op: 'delete',
|
|
401
778
|
requestId,
|
|
@@ -416,6 +793,7 @@ export function handleUnifyAddMember(msg) {
|
|
|
416
793
|
try {
|
|
417
794
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
418
795
|
const group = addMember(yeaftDir, groupId, vpId);
|
|
796
|
+
invalidateGroupContext(groupId);
|
|
419
797
|
sendGroupCrudResult({ op: 'add_member', requestId, ok: true, group });
|
|
420
798
|
sendGroupRosterChanged(group);
|
|
421
799
|
} catch (err) {
|
|
@@ -430,6 +808,10 @@ export function handleUnifyRemoveMember(msg) {
|
|
|
430
808
|
try {
|
|
431
809
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
432
810
|
const group = removeMember(yeaftDir, groupId, vpId);
|
|
811
|
+
invalidateGroupContext(groupId);
|
|
812
|
+
// Also drop the kicked VP's engine — the next time they're added
|
|
813
|
+
// back they should start with fresh per-VP state.
|
|
814
|
+
vpEngines.delete(vpKey(groupId, vpId));
|
|
433
815
|
sendGroupCrudResult({ op: 'remove_member', requestId, ok: true, group });
|
|
434
816
|
sendGroupRosterChanged(group);
|
|
435
817
|
} catch (err) {
|
|
@@ -444,6 +826,7 @@ export function handleUnifySetDefaultVp(msg) {
|
|
|
444
826
|
try {
|
|
445
827
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
446
828
|
const group = setGroupDefaultVp(yeaftDir, groupId, vpId);
|
|
829
|
+
invalidateGroupContext(groupId);
|
|
447
830
|
sendGroupCrudResult({ op: 'set_default_vp', requestId, ok: true, group });
|
|
448
831
|
sendGroupRosterChanged(group);
|
|
449
832
|
} catch (err) {
|
|
@@ -561,11 +944,41 @@ function handleEngineEvent(event, hctx) {
|
|
|
561
944
|
break;
|
|
562
945
|
|
|
563
946
|
case 'turn_start':
|
|
564
|
-
case 'turn_end':
|
|
565
947
|
case 'stop':
|
|
566
948
|
// No UI action needed; outer loop sends the final result.
|
|
567
949
|
break;
|
|
568
950
|
|
|
951
|
+
case 'turn_end':
|
|
952
|
+
// When a tool (currently only `route_forward`) signals
|
|
953
|
+
// requestEndTurn, the engine emits turn_end with
|
|
954
|
+
// stopReason='tool_handoff' and a structured `detail` payload.
|
|
955
|
+
// Surface that to the frontend as a `group_handoff` event so the
|
|
956
|
+
// originating VP's bubble can render "↪ 已转交给 @vp-b、@vp-c".
|
|
957
|
+
// Other turn_end variants are ignored (the outer loop handles
|
|
958
|
+
// result/end_turn semantics already).
|
|
959
|
+
//
|
|
960
|
+
// The `version` field is the wire schema version. Today there is
|
|
961
|
+
// only one shape. Future variants (e.g. a second hand-off tool)
|
|
962
|
+
// can bump it without breaking older frontends — they ignore
|
|
963
|
+
// unknown versions.
|
|
964
|
+
if (event.stopReason === 'tool_handoff' && event.detail && typeof event.detail === 'object') {
|
|
965
|
+
const detail = event.detail;
|
|
966
|
+
if (detail.kind === 'route_forward') {
|
|
967
|
+
sendUnifyEvent({
|
|
968
|
+
type: 'group_handoff',
|
|
969
|
+
version: 1,
|
|
970
|
+
kind: 'route_forward',
|
|
971
|
+
fromVpId: detail.fromVpId || hctx.vpId,
|
|
972
|
+
toVpIds: Array.isArray(detail.dispatched) ? detail.dispatched.slice() : [],
|
|
973
|
+
broadcast: Boolean(detail.broadcast),
|
|
974
|
+
text: typeof detail.text === 'string' ? detail.text : '',
|
|
975
|
+
reason: detail.reason || null,
|
|
976
|
+
ts: Date.now(),
|
|
977
|
+
}, envelope);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
break;
|
|
981
|
+
|
|
569
982
|
case 'usage':
|
|
570
983
|
sendUnifyEvent({
|
|
571
984
|
type: 'context_usage',
|
|
@@ -774,19 +1187,6 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
774
1187
|
|
|
775
1188
|
await ensureSessionLoaded();
|
|
776
1189
|
|
|
777
|
-
// Cancel any prior in-flight dispatch BEFORE we fan out.
|
|
778
|
-
if (currentAbortCtrl && !currentAbortCtrl.signal.aborted) {
|
|
779
|
-
try { currentAbortCtrl.abort(); } catch { /* best-effort */ }
|
|
780
|
-
}
|
|
781
|
-
// Also abort any lingering per-VP controllers from the prior dispatch.
|
|
782
|
-
for (const ctrl of turnAbortCtrls.values()) {
|
|
783
|
-
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
784
|
-
}
|
|
785
|
-
turnAbortCtrls.clear();
|
|
786
|
-
|
|
787
|
-
const dispatchAbortCtrl = new AbortController();
|
|
788
|
-
currentAbortCtrl = dispatchAbortCtrl;
|
|
789
|
-
|
|
790
1190
|
// Open the group; seed grp_default on the fly if absent. Track
|
|
791
1191
|
// seedFailed separately so a seed crash surfaces a different message
|
|
792
1192
|
// than a genuinely-missing group.
|
|
@@ -825,22 +1225,22 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
825
1225
|
}
|
|
826
1226
|
|
|
827
1227
|
// Auto-add @-mentioned VPs from the library, heal missing defaultVpId.
|
|
1228
|
+
let rosterMutated = false;
|
|
828
1229
|
try {
|
|
829
1230
|
const meta = groupHandle.getMeta();
|
|
830
1231
|
const wantsAdd = mentions.filter(
|
|
831
1232
|
(m) => m && m !== 'all' && !meta.roster.includes(m)
|
|
832
1233
|
);
|
|
833
1234
|
if (wantsAdd.length) {
|
|
834
|
-
let mutated = false;
|
|
835
1235
|
for (const vpId of wantsAdd) {
|
|
836
1236
|
try {
|
|
837
1237
|
const vp = readVp(vpId);
|
|
838
1238
|
if (!vp) continue;
|
|
839
1239
|
addMember(yeaftDir, groupId, vpId);
|
|
840
|
-
|
|
1240
|
+
rosterMutated = true;
|
|
841
1241
|
} catch { /* skip strangers */ }
|
|
842
1242
|
}
|
|
843
|
-
if (
|
|
1243
|
+
if (rosterMutated) {
|
|
844
1244
|
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
845
1245
|
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
846
1246
|
sendGroupRosterChanged(groupHandle.getMeta());
|
|
@@ -853,17 +1253,71 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
853
1253
|
try { groupHandle.close && groupHandle.close(); } catch { /* best-effort */ }
|
|
854
1254
|
groupHandle = openGroup(join(yeaftDir, 'groups'), groupId);
|
|
855
1255
|
sendGroupRosterChanged(groupHandle.getMeta());
|
|
1256
|
+
rosterMutated = true;
|
|
856
1257
|
} catch { /* best-effort */ }
|
|
857
1258
|
}
|
|
858
1259
|
} catch (err) {
|
|
859
1260
|
console.warn('[Unify] unify_group_chat: auto-roster heal failed', err?.message || err);
|
|
860
1261
|
}
|
|
861
1262
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1263
|
+
// task-707: per-group persistent coordinator/router. Created once per
|
|
1264
|
+
// groupId; reused across user messages AND across in-flight tool calls
|
|
1265
|
+
// (route_forward delivers via this same coord). If the roster mutated
|
|
1266
|
+
// we replace the cached coord so it points at the freshly-opened
|
|
1267
|
+
// groupHandle.
|
|
1268
|
+
if (rosterMutated) {
|
|
1269
|
+
groupContexts.delete(groupId);
|
|
1270
|
+
}
|
|
1271
|
+
const groupCtx = getOrCreateGroupContext(groupId, groupHandle);
|
|
1272
|
+
const coord = groupCtx.coord;
|
|
1273
|
+
|
|
1274
|
+
// Selective abort — replace the pre-707 blanket abort that killed
|
|
1275
|
+
// every in-flight turn on every new user message (Bug 2). We peek at
|
|
1276
|
+
// which VPs THIS message will retrigger via the coordinator's
|
|
1277
|
+
// `parseMentions` and only abort those VP's current turns. VPs busy
|
|
1278
|
+
// on unrelated work keep running.
|
|
1279
|
+
//
|
|
1280
|
+
// Intentional limitation: we do NOT walk the route_forward causedBy
|
|
1281
|
+
// chain. If VP-A is mid-turn, route_forwarded to VP-B, and the user
|
|
1282
|
+
// now mentions only @vp-a, we abort VP-A but VP-B keeps generating
|
|
1283
|
+
// even though VP-B's inbound was caused by the now-overridden VP-A
|
|
1284
|
+
// turn. The argument for letting VP-B run: VP-B may already have
|
|
1285
|
+
// useful work in flight (a partial reply); aborting it on a chain
|
|
1286
|
+
// override discards that work for no user-visible benefit. If a
|
|
1287
|
+
// future product decision wants to cancel transitively, walk
|
|
1288
|
+
// `vpInboxes[*].envelope.causedBy` against the selectively-aborted
|
|
1289
|
+
// VP set and fan the abort out.
|
|
1290
|
+
//
|
|
1291
|
+
// Note: this is a best-effort pre-pass. The real dispatch list comes
|
|
1292
|
+
// from `coord.ingest(...).dispatched` below, but at that point we
|
|
1293
|
+
// would already have called deliver() and started new typing events
|
|
1294
|
+
// for the same VPs we're about to abort — racy. Using the pre-pass
|
|
1295
|
+
// result keeps the abort window before any deliver() side effects.
|
|
1296
|
+
try {
|
|
1297
|
+
const meta = groupHandle.getMeta();
|
|
1298
|
+
const willTarget = mentions.includes('all')
|
|
1299
|
+
? meta.roster.slice()
|
|
1300
|
+
: mentions.filter((m) => meta.roster.includes(m));
|
|
1301
|
+
for (const vpId of willTarget) {
|
|
1302
|
+
const k = vpKey(groupId, vpId);
|
|
1303
|
+
const ctrl = vpAborts.get(k);
|
|
1304
|
+
if (ctrl && !ctrl.signal.aborted) {
|
|
1305
|
+
try { ctrl.abort(); } catch { /* best-effort */ }
|
|
1306
|
+
}
|
|
1307
|
+
// Also drop any queued envelopes for VPs being replaced — the
|
|
1308
|
+
// user's new message supersedes them.
|
|
1309
|
+
const inbox = vpInboxes.get(k);
|
|
1310
|
+
if (inbox && inbox.length > 0) {
|
|
1311
|
+
inbox.length = 0;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
|
|
1316
|
+
}
|
|
866
1317
|
|
|
1318
|
+
// Ingest user text. The coordinator persists, applies mention/fanout
|
|
1319
|
+
// rules, and calls deliver() (== enqueueForVp) for each chosen VP —
|
|
1320
|
+
// which both (a) emits vp_typing_start and (b) ensures a driver runs.
|
|
867
1321
|
let report;
|
|
868
1322
|
try {
|
|
869
1323
|
report = coord.ingest({
|
|
@@ -883,7 +1337,8 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
883
1337
|
}
|
|
884
1338
|
|
|
885
1339
|
const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
|
|
886
|
-
|
|
1340
|
+
const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
|
|
1341
|
+
if (dispatchedIds.length === 0 && !fallbackId) {
|
|
887
1342
|
// Coordinator chose nobody and provided no fallback — should not happen
|
|
888
1343
|
// with a healthy roster. Surface the failure explicitly rather than
|
|
889
1344
|
// silently retrying as a single-VP turn (the legacy fallback masked
|
|
@@ -896,73 +1351,15 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
896
1351
|
return;
|
|
897
1352
|
}
|
|
898
1353
|
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
try {
|
|
909
|
-
sendUnifyEvent({
|
|
910
|
-
type: 'group_message',
|
|
911
|
-
groupId,
|
|
912
|
-
vpId,
|
|
913
|
-
speakerVpId: vpId,
|
|
914
|
-
text,
|
|
915
|
-
mentions,
|
|
916
|
-
trigger: envelope?.trigger || 'fallback',
|
|
917
|
-
ts: Date.now(),
|
|
918
|
-
}, { groupId, vpId, turnId });
|
|
919
|
-
} catch { /* never crash WS pipeline */ }
|
|
920
|
-
|
|
921
|
-
try {
|
|
922
|
-
sendUnifyEvent({
|
|
923
|
-
type: 'vp_typing_start',
|
|
924
|
-
groupId,
|
|
925
|
-
vpId,
|
|
926
|
-
turnId,
|
|
927
|
-
ts: Date.now(),
|
|
928
|
-
}, { groupId, vpId, turnId });
|
|
929
|
-
} catch { /* never crash WS pipeline */ }
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// Per-VP parallel fan-out. Each VP gets its own AbortController
|
|
933
|
-
// (stoppable individually via per-VP Stop button) and reads from
|
|
934
|
-
// the shared baseSnapshot. On completion (or abort), results are
|
|
935
|
-
// atomically appended to conversationMessages.
|
|
936
|
-
await Promise.all(captured.map(async ({ vpId }) => {
|
|
937
|
-
const turnId = `${dispatchId}:${vpId}`;
|
|
938
|
-
const vpAbort = new AbortController();
|
|
939
|
-
turnAbortCtrls.set(turnId, vpAbort);
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
await runVpTurn({
|
|
943
|
-
prompt: `@vp-${vpId} ${text}`,
|
|
944
|
-
groupId,
|
|
945
|
-
vpId,
|
|
946
|
-
turnId,
|
|
947
|
-
groupCoordinator: coord,
|
|
948
|
-
vpAbort,
|
|
949
|
-
baseSnapshot,
|
|
950
|
-
});
|
|
951
|
-
} catch (err) {
|
|
952
|
-
console.warn('[Unify] unify_group_chat: per-vp dispatch failed', vpId, err?.message || err);
|
|
953
|
-
} finally {
|
|
954
|
-
turnAbortCtrls.delete(turnId);
|
|
955
|
-
try {
|
|
956
|
-
sendUnifyEvent({
|
|
957
|
-
type: 'vp_typing_end',
|
|
958
|
-
groupId,
|
|
959
|
-
vpId,
|
|
960
|
-
turnId,
|
|
961
|
-
ts: Date.now(),
|
|
962
|
-
}, { groupId, vpId, turnId });
|
|
963
|
-
} catch { /* never crash WS pipeline */ }
|
|
964
|
-
}
|
|
965
|
-
}));
|
|
1354
|
+
// Wait for the drivers initially scheduled by THIS user message to
|
|
1355
|
+
// drain. We pass the dispatched id list (plus any fallback) for
|
|
1356
|
+
// documentation; the wait function itself blocks on every driver in
|
|
1357
|
+
// the group, so route_forward fan-outs the user didn't mention still
|
|
1358
|
+
// get drained before we return.
|
|
1359
|
+
const primaryTargets = dispatchedIds.length > 0
|
|
1360
|
+
? dispatchedIds.slice()
|
|
1361
|
+
: (fallbackId ? [fallbackId] : []);
|
|
1362
|
+
await waitForVpDrivers(groupId, primaryTargets);
|
|
966
1363
|
|
|
967
1364
|
// Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
|
|
968
1365
|
// history past 20 turns / 80K tokens. Runs in the background — does
|
|
@@ -973,10 +1370,52 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
973
1370
|
scheduleCompactAfterTurn(groupId);
|
|
974
1371
|
}
|
|
975
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* Wait for the drivers of `primaryTargets` AND any drivers spawned by
|
|
1375
|
+
* their downstream route_forward chains to all complete. We re-poll
|
|
1376
|
+
* because a route_forward inside VP-A's turn enqueues VP-B, which spawns
|
|
1377
|
+
* a new driver while we're waiting for VP-A. Loop terminates when every
|
|
1378
|
+
* group-local driver is idle.
|
|
1379
|
+
*
|
|
1380
|
+
* `primaryTargets` is informational — for filtering we wait on EVERY
|
|
1381
|
+
* driver in the group, since route_forward fan-out targets the user
|
|
1382
|
+
* never directly mentioned still need to drain before this user message
|
|
1383
|
+
* is considered handled.
|
|
1384
|
+
*
|
|
1385
|
+
* Bounded by the per-driver QUERY_TIMEOUT_MS that runVpTurn enforces,
|
|
1386
|
+
* so even a misbehaving model can't pin this forever.
|
|
1387
|
+
*/
|
|
1388
|
+
async function waitForVpDrivers(groupId, _primaryTargets) {
|
|
1389
|
+
while (true) {
|
|
1390
|
+
// Snapshot the current set of drivers belonging to this group.
|
|
1391
|
+
const promises = [];
|
|
1392
|
+
const prefix = `${groupId}::`;
|
|
1393
|
+
for (const [key, p] of vpDrivers.entries()) {
|
|
1394
|
+
if (key.startsWith(prefix)) promises.push(p);
|
|
1395
|
+
}
|
|
1396
|
+
if (promises.length === 0) return;
|
|
1397
|
+
await Promise.all(promises.map((p) => p.catch(() => {})));
|
|
1398
|
+
// Re-check: if a route_forward in one of the awaited turns
|
|
1399
|
+
// enqueued more work for another VP, a new driver may have been
|
|
1400
|
+
// started. Loop again. Otherwise exit.
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
976
1404
|
/**
|
|
977
1405
|
* Build the per-query VP context for the Engine.
|
|
1406
|
+
*
|
|
1407
|
+
* @param {object} args
|
|
1408
|
+
* @param {string} args.vpId
|
|
1409
|
+
* @param {object} args.groupCoordinator — the persistent coordinator for the
|
|
1410
|
+
* group; used here for `group.getMeta()` (defaultVpId, announcement) and
|
|
1411
|
+
* to bind the per-group router into toolCtx.
|
|
1412
|
+
* @param {string} [args.groupId]
|
|
1413
|
+
* @param {object} [args.envelope] — the inbound coordinator envelope that
|
|
1414
|
+
* triggered this turn. Threaded into toolCtx as `inboundEnvelope` so
|
|
1415
|
+
* `route_forward` can extend `causedBy` chains correctly. Optional only
|
|
1416
|
+
* for pre-707 callers that no longer exist in production.
|
|
978
1417
|
*/
|
|
979
|
-
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
1418
|
+
export function buildVpQueryOpts({ vpId, groupCoordinator, groupId, envelope }) {
|
|
980
1419
|
// Read the group meta once and reuse for both defaultVpId fallback and
|
|
981
1420
|
// announcement injection. Each .getMeta() reload reads + parses the
|
|
982
1421
|
// group.json file, so calling it twice per turn is wasteful — and
|
|
@@ -1040,6 +1479,14 @@ export function buildVpQueryOpts({ vpId, groupCoordinator, groupId }) {
|
|
|
1040
1479
|
// Router build failure is non-fatal.
|
|
1041
1480
|
}
|
|
1042
1481
|
}
|
|
1482
|
+
// task-707: thread the inbound envelope into toolCtx so `route_forward`
|
|
1483
|
+
// can stamp `causedBy` chains and the loop guard can key per-sender
|
|
1484
|
+
// throttling against the originating envelope. Safe to omit on a
|
|
1485
|
+
// user-initiated turn — route_forward will fall back to a synthetic
|
|
1486
|
+
// envelope inside router.forward.
|
|
1487
|
+
if (envelope && typeof envelope === 'object') {
|
|
1488
|
+
out.inboundEnvelope = envelope;
|
|
1489
|
+
}
|
|
1043
1490
|
return out;
|
|
1044
1491
|
}
|
|
1045
1492
|
|
|
@@ -1105,15 +1552,23 @@ async function ensureSessionLoaded() {
|
|
|
1105
1552
|
* coordinator-bound router, stream events to the frontend, and append the
|
|
1106
1553
|
* result to the flat conversation history.
|
|
1107
1554
|
*
|
|
1108
|
-
* Private — only `
|
|
1109
|
-
* own AbortController (`vpAbort`) so it can be
|
|
1110
|
-
* shared `baseSnapshot` is the conversation
|
|
1111
|
-
* VP sees another VP's in-flight output.
|
|
1112
|
-
* aborted), the VP's output is atomically
|
|
1555
|
+
* Private — only the per-VP driver in `ensureDriverRunning` calls this.
|
|
1556
|
+
* Each VP-turn gets its own AbortController (`vpAbort`) so it can be
|
|
1557
|
+
* stopped individually. The shared `baseSnapshot` is the conversation
|
|
1558
|
+
* history at fan-out start — no VP sees another VP's in-flight output.
|
|
1559
|
+
* After the turn finishes (or is aborted), the VP's output is atomically
|
|
1560
|
+
* appended to `conversationMessages`.
|
|
1561
|
+
*
|
|
1562
|
+
* task-707: takes a coordinator `envelope` rather than the coordinator
|
|
1563
|
+
* itself; the persistent coord lives in `groupContexts[groupId]`. Uses
|
|
1564
|
+
* `getOrCreateVpEngine(groupId, vpId)` so each VP runs against its own
|
|
1565
|
+
* Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
|
|
1566
|
+
* `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
|
|
1567
|
+
* collide when VP-A and VP-B run concurrent turns.
|
|
1113
1568
|
*
|
|
1114
|
-
* @param {{ prompt: string, groupId: string, vpId: string, turnId: string,
|
|
1569
|
+
* @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
|
|
1115
1570
|
*/
|
|
1116
|
-
async function runVpTurn({ prompt, groupId, vpId, turnId,
|
|
1571
|
+
async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
|
|
1117
1572
|
if (!prompt?.trim()) return;
|
|
1118
1573
|
|
|
1119
1574
|
const envelope = { groupId, vpId, turnId };
|
|
@@ -1143,7 +1598,19 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
|
|
|
1143
1598
|
const toolCallsAccum = [];
|
|
1144
1599
|
const toolResultsAccum = [];
|
|
1145
1600
|
|
|
1146
|
-
|
|
1601
|
+
// task-707: per-VP engine + persistent group coord. The coord is
|
|
1602
|
+
// created in handleUnifyGroupChat via getOrCreateGroupContext and
|
|
1603
|
+
// cached on `groupContexts`; we pull it here so route_forward
|
|
1604
|
+
// (router built from this same coord) lands envelopes back on the
|
|
1605
|
+
// right inbox set.
|
|
1606
|
+
const groupCtx = groupContexts.get(groupId);
|
|
1607
|
+
const groupCoordinator = groupCtx?.coord || null;
|
|
1608
|
+
const queryOpts = buildVpQueryOpts({
|
|
1609
|
+
vpId,
|
|
1610
|
+
groupCoordinator,
|
|
1611
|
+
groupId,
|
|
1612
|
+
envelope: inboundEnvelope,
|
|
1613
|
+
});
|
|
1147
1614
|
const handlerCtx = {
|
|
1148
1615
|
assistantTextParts,
|
|
1149
1616
|
toolCallsAccum,
|
|
@@ -1160,7 +1627,8 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, groupCoordinator, vpAb
|
|
|
1160
1627
|
const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
|
|
1161
1628
|
messageTokenBudget: session?.config?.messageTokenBudget,
|
|
1162
1629
|
});
|
|
1163
|
-
|
|
1630
|
+
const vpEngine = getOrCreateVpEngine(groupId, vpId);
|
|
1631
|
+
for await (const event of vpEngine.query({
|
|
1164
1632
|
prompt,
|
|
1165
1633
|
messages: trimmedMessages,
|
|
1166
1634
|
signal: vpAbort.signal,
|
|
@@ -1413,6 +1881,28 @@ async function runCompactNow(groupId) {
|
|
|
1413
1881
|
}
|
|
1414
1882
|
}
|
|
1415
1883
|
|
|
1884
|
+
/**
|
|
1885
|
+
* Abort every in-flight VP turn and clear all queued envelopes across
|
|
1886
|
+
* every group. Shared by `handleUnifyAbortThread` and
|
|
1887
|
+
* `handleUnifyAbortAll` — both have the same "stop everything" intent
|
|
1888
|
+
* after the H2.f.2 collapse to single-conversation. Pushes
|
|
1889
|
+
* `vp:<key>` strings into the supplied `aborted` array for the
|
|
1890
|
+
* unify_aborted event.
|
|
1891
|
+
*
|
|
1892
|
+
* @param {string[]} aborted — output array, mutated in place
|
|
1893
|
+
*/
|
|
1894
|
+
function abortAllVpRuntime(aborted) {
|
|
1895
|
+
for (const [key, ctrl] of vpAborts) {
|
|
1896
|
+
try {
|
|
1897
|
+
if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(`vp:${key}`); }
|
|
1898
|
+
} catch { /* best-effort */ }
|
|
1899
|
+
}
|
|
1900
|
+
vpAborts.clear();
|
|
1901
|
+
for (const inbox of vpInboxes.values()) {
|
|
1902
|
+
if (Array.isArray(inbox)) inbox.length = 0;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1416
1906
|
/**
|
|
1417
1907
|
* H2.f.2: user-initiated abort. The pre-H2 multi-thread version took a
|
|
1418
1908
|
* `threadId` parameter; the new version aborts the single in-flight
|
|
@@ -1433,6 +1923,7 @@ export function handleUnifyAbortThread(_msg = {}) {
|
|
|
1433
1923
|
try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
|
|
1434
1924
|
}
|
|
1435
1925
|
turnAbortCtrls.clear();
|
|
1926
|
+
abortAllVpRuntime(aborted);
|
|
1436
1927
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
|
|
1437
1928
|
return { aborted, all: false };
|
|
1438
1929
|
}
|
|
@@ -1452,6 +1943,7 @@ export function handleUnifyAbortAll() {
|
|
|
1452
1943
|
try { if (!ctrl.signal.aborted) { ctrl.abort(); aborted.push(turnId); } } catch { /* best-effort */ }
|
|
1453
1944
|
}
|
|
1454
1945
|
turnAbortCtrls.clear();
|
|
1946
|
+
abortAllVpRuntime(aborted);
|
|
1455
1947
|
sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
|
|
1456
1948
|
return { aborted, all: true };
|
|
1457
1949
|
}
|
|
@@ -1803,6 +2295,22 @@ export async function resetUnifySession() {
|
|
|
1803
2295
|
}
|
|
1804
2296
|
unifyConversationId = null;
|
|
1805
2297
|
conversationMessages = [];
|
|
2298
|
+
// Re-arm the permission warning. The user might have fixed the
|
|
2299
|
+
// ~/.yeaft/ permissions in the interim and is now restarting the
|
|
2300
|
+
// session — they should see the diagnostic again if it still fails.
|
|
2301
|
+
_permissionDiagnosticSent = false;
|
|
2302
|
+
// Drop all per-VP / per-group transient state when the session is
|
|
2303
|
+
// replaced. Drivers may still be running with a stale engine
|
|
2304
|
+
// reference; abort them so they exit cleanly. The new session gets
|
|
2305
|
+
// fresh inboxes / engines / coords on first dispatch.
|
|
2306
|
+
for (const [, ctrl] of vpAborts) {
|
|
2307
|
+
try { if (!ctrl.signal.aborted) ctrl.abort(); } catch { /* best-effort */ }
|
|
2308
|
+
}
|
|
2309
|
+
vpAborts.clear();
|
|
2310
|
+
vpInboxes.clear();
|
|
2311
|
+
vpDrivers.clear();
|
|
2312
|
+
vpEngines.clear();
|
|
2313
|
+
groupContexts.clear();
|
|
1806
2314
|
|
|
1807
2315
|
try {
|
|
1808
2316
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|