@yeaft/webchat-agent 1.0.641 → 1.0.643
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 +4 -1
- package/index.js +1 -1
- package/local-runtime/server/handlers/agent-output.js +7 -5
- package/local-runtime/server/handlers/client-conversation.js +47 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +191 -133
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +272 -24
- package/yeaft/conversation/session-history.js +336 -0
- package/yeaft/engine.js +28 -18
- package/yeaft/routing/router.js +4 -0
- package/yeaft/sessions/index.js +1 -0
- package/yeaft/sessions/session-crud.js +68 -3
- package/yeaft/tools/agent.js +4 -1
- package/yeaft/web-bridge.js +255 -54
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session 历史复制/合并的纯变换。持久 ID 只在所属 stream 内有意义;
|
|
3
|
+
* origin 跨 fork/merge 保留,绝不把相同提问文本当作共同历史。
|
|
4
|
+
*/
|
|
5
|
+
import { isVisibleConversationRow } from './internal-control.js';
|
|
6
|
+
|
|
7
|
+
/** Create a history-boundary error with a stable machine-readable code. */
|
|
8
|
+
export function historyError(code, message) {
|
|
9
|
+
return Object.assign(new Error(message), { code });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Whether a durable row is a human request rather than protocol/control input. */
|
|
13
|
+
export function isHumanRequest(row) {
|
|
14
|
+
return row?.role === 'user' && isVisibleConversationRow(row);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Preserve fork lineage, or establish an origin in the source stream's lifetime. */
|
|
18
|
+
export function messageOrigin(row, sessionId, streamId) {
|
|
19
|
+
const origin = row?.origin;
|
|
20
|
+
if (origin && typeof origin.streamId === 'string' && origin.streamId
|
|
21
|
+
&& typeof origin.messageId === 'string' && origin.messageId) return { ...origin };
|
|
22
|
+
return { sessionId, streamId, messageId: row.id, ...(row.turnId ? { turnId: row.turnId } : {}) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cross-Session rows are historical projections, never native continuations.
|
|
27
|
+
* Signatures/encrypted reasoning are bound to the original owner and exact
|
|
28
|
+
* tool IDs. Keep the source transcript (linked by origin) as provenance, not
|
|
29
|
+
* an opaque payload in the new Session. Reconstruct only canonical text/tools.
|
|
30
|
+
* This deliberately does not relax replayProviderState's owner/digest fences.
|
|
31
|
+
*/
|
|
32
|
+
export function historicalReplayRow(row) {
|
|
33
|
+
const { providerState, thinkingBlocks, ...history } = row;
|
|
34
|
+
return history;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function originKey(origin) {
|
|
38
|
+
return JSON.stringify([origin.streamId, origin.messageId]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stableJson(value) {
|
|
42
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
return `{${Object.keys(value).sort().filter(key => value[key] !== undefined)
|
|
45
|
+
.map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sentAt(row) {
|
|
51
|
+
return row.time ?? row.timestamp ?? row.ts ?? '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolScope(row, callId) {
|
|
55
|
+
return JSON.stringify([row.turnId || '', row.speakerVpId || row.vpId || '', row.threadId || 'main', callId]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ambiguous(message) {
|
|
59
|
+
throw historyError('ambiguous_merge_history', message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 原始 transcript 不被 sanitize(sanitize 会丢数据)。返回每个结果对应的
|
|
63
|
+
// assistant 调用位置,允许不同 VP/turn 重用同一个 provider tool-call ID。
|
|
64
|
+
function toolLinks(rows) {
|
|
65
|
+
const calls = new Map();
|
|
66
|
+
const results = new Map();
|
|
67
|
+
const paired = new Set();
|
|
68
|
+
for (const [index, row] of rows.entries()) {
|
|
69
|
+
for (const [callIndex, call] of (row.toolCalls || []).entries()) {
|
|
70
|
+
const key = toolScope(row, call.id);
|
|
71
|
+
if (!call.id || calls.has(key)) ambiguous('Duplicate tool call identity within a request');
|
|
72
|
+
calls.set(key, { index, callIndex });
|
|
73
|
+
}
|
|
74
|
+
if (row.role !== 'tool') continue;
|
|
75
|
+
const key = toolScope(row, row.toolCallId);
|
|
76
|
+
let call = calls.get(key);
|
|
77
|
+
// Some legacy tool rows lack speaker/turn metadata. Only accept an
|
|
78
|
+
// unambiguous preceding call, never a globally colliding bare tool id.
|
|
79
|
+
if (!call) {
|
|
80
|
+
const candidates = [...calls.values()].filter(candidate => {
|
|
81
|
+
const owner = rows[candidate.index];
|
|
82
|
+
return owner.toolCalls[candidate.callIndex].id === row.toolCallId
|
|
83
|
+
&& (!row.turnId || owner.turnId === row.turnId)
|
|
84
|
+
&& (!(row.speakerVpId || row.vpId) || (owner.speakerVpId || owner.vpId) === (row.speakerVpId || row.vpId))
|
|
85
|
+
&& (!row.threadId || (owner.threadId || 'main') === row.threadId);
|
|
86
|
+
});
|
|
87
|
+
if (candidates.length === 1) [call] = candidates;
|
|
88
|
+
}
|
|
89
|
+
if (!call || paired.has(call)) ambiguous('Unpaired or ambiguous tool result within a request');
|
|
90
|
+
results.set(index, call);
|
|
91
|
+
paired.add(call);
|
|
92
|
+
}
|
|
93
|
+
if ([...calls.values()].some(call => !paired.has(call))) ambiguous('Incomplete tool history within a request');
|
|
94
|
+
return { calls, results };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestTurns(input) {
|
|
98
|
+
const { rows, sessionId, streamId } = input;
|
|
99
|
+
if (new Set(rows.map(row => row.id)).size !== rows.length) ambiguous('Duplicate persisted identity within one Session');
|
|
100
|
+
const turns = [];
|
|
101
|
+
const byRow = new Map();
|
|
102
|
+
const byClient = new Map();
|
|
103
|
+
for (const [index, row] of rows.entries()) {
|
|
104
|
+
if (!isHumanRequest(row)) continue;
|
|
105
|
+
const candidates = row.clientMessageId ? (byClient.get(row.clientMessageId) || []) : [];
|
|
106
|
+
// Historical multi-VP prompt variants share a client identity and source
|
|
107
|
+
// stream. Distinct origins in a merged Session must remain distinct even
|
|
108
|
+
// if two clients happened to reuse the same clientMessageId.
|
|
109
|
+
const sameStream = candidate => !candidate.user.origin || !row.origin
|
|
110
|
+
|| candidate.user.origin.streamId === row.origin.streamId;
|
|
111
|
+
if (candidates.some(candidate => sameStream(candidate) && sentAt(candidate.user) !== sentAt(row))) {
|
|
112
|
+
ambiguous('Reused clientMessageId within one Session');
|
|
113
|
+
}
|
|
114
|
+
let turn = candidates.find(candidate => sameStream(candidate) && sentAt(candidate.user) === sentAt(row));
|
|
115
|
+
if (!turn) {
|
|
116
|
+
turn = { user: row, rows: [], origins: new Set(), explicitOrigins: false, index, input };
|
|
117
|
+
turns.push(turn);
|
|
118
|
+
if (row.clientMessageId) byClient.set(row.clientMessageId, [...candidates, turn]);
|
|
119
|
+
}
|
|
120
|
+
byRow.set(row.id, turn);
|
|
121
|
+
turn.origins.add(originKey(messageOrigin(row, sessionId, streamId)));
|
|
122
|
+
turn.explicitOrigins ||= !!row.origin;
|
|
123
|
+
}
|
|
124
|
+
if (!turns.length) {
|
|
125
|
+
if (rows.length) ambiguous('History without a durable human request cannot be merged');
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const records = new Map(rows.map(row => [row.id, row]));
|
|
129
|
+
function causalTurn(row, seen = new Set()) {
|
|
130
|
+
if (!row?.causalRootId || seen.has(row.id)) return null;
|
|
131
|
+
seen.add(row.id);
|
|
132
|
+
const direct = byRow.get(row.causalRootId);
|
|
133
|
+
if (direct) return direct;
|
|
134
|
+
const candidates = byClient.get(row.causalRootId) || [];
|
|
135
|
+
if (candidates.length > 1) ambiguous('Ambiguous client causal reference');
|
|
136
|
+
return candidates[0] || causalTurn(records.get(row.causalRootId), seen);
|
|
137
|
+
}
|
|
138
|
+
const byVpTurn = new Map();
|
|
139
|
+
const vpTurnKey = row => row.turnId ? JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]) : null;
|
|
140
|
+
// Learn explicit causal ownership before assigning interleaved legacy tails.
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const turn = causalTurn(row);
|
|
143
|
+
const key = vpTurnKey(row);
|
|
144
|
+
if (!turn || !key) continue;
|
|
145
|
+
if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('A VP turn belongs to multiple human requests');
|
|
146
|
+
byVpTurn.set(key, turn);
|
|
147
|
+
}
|
|
148
|
+
let current = null;
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
if (isHumanRequest(row)) current = byRow.get(row.id);
|
|
151
|
+
const key = vpTurnKey(row);
|
|
152
|
+
const turn = byRow.get(row.id) || causalTurn(row) || byVpTurn.get(key) || current;
|
|
153
|
+
if (!turn) {
|
|
154
|
+
if (isVisibleConversationRow(row) && row.role !== 'system') ambiguous('History begins inside a human request');
|
|
155
|
+
turns[0].rows.push(row);
|
|
156
|
+
byRow.set(row.id, turns[0]);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (key) {
|
|
160
|
+
if (byVpTurn.has(key) && byVpTurn.get(key) !== turn) ambiguous('Ambiguous interleaved VP history');
|
|
161
|
+
byVpTurn.set(key, turn);
|
|
162
|
+
}
|
|
163
|
+
turn.rows.push(row);
|
|
164
|
+
byRow.set(row.id, turn);
|
|
165
|
+
}
|
|
166
|
+
for (const turn of turns) {
|
|
167
|
+
turn.links = toolLinks(turn.rows);
|
|
168
|
+
turn.vpTurns = new Set(turn.rows.filter(row => row.role === 'assistant' && row.turnId)
|
|
169
|
+
.map(row => JSON.stringify([row.speakerVpId || row.vpId || '', row.origin?.turnId || row.turnId])));
|
|
170
|
+
// Legacy forks preserved these identities, even though persisted row IDs
|
|
171
|
+
// were remapped. Require sent-time evidence too; no text-only matching.
|
|
172
|
+
turn.legacyKey = turn.user.clientMessageId && sentAt(turn.user)
|
|
173
|
+
? stableJson(['client', turn.user.clientMessageId, sentAt(turn.user)])
|
|
174
|
+
: (turn.vpTurns.size && sentAt(turn.user)
|
|
175
|
+
? stableJson(['turns', [...turn.vpTurns].sort(), sentAt(turn.user)]) : null);
|
|
176
|
+
}
|
|
177
|
+
return turns;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function commonRequest(left, right) {
|
|
181
|
+
if ([...left.origins].some(key => right.origins.has(key))) return true;
|
|
182
|
+
if (left.user.clientMessageId && right.user.clientMessageId
|
|
183
|
+
&& left.user.clientMessageId !== right.user.clientMessageId) return false;
|
|
184
|
+
// Explicit, different origins prove these are distinct user requests.
|
|
185
|
+
if (left.explicitOrigins && right.explicitOrigins) return false;
|
|
186
|
+
if (left.legacyKey && left.legacyKey === right.legacyKey) return true;
|
|
187
|
+
// A fork made at an assistant boundary may contain only one VP's answer.
|
|
188
|
+
// Recognize overlap, then reject unequal request bodies below instead of
|
|
189
|
+
// treating that partial shared request as two independent user requests.
|
|
190
|
+
return !!sentAt(left.user) && sentAt(left.user) === sentAt(right.user)
|
|
191
|
+
&& [...left.vpTurns].some(key => right.vpTurns.has(key));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function equivalentRows(left, right, aliases) {
|
|
195
|
+
if (left.rows.length !== right.rows.length) return false;
|
|
196
|
+
function normalized(turn) {
|
|
197
|
+
const rowRefs = new Map();
|
|
198
|
+
for (const candidate of turn.input.turns) {
|
|
199
|
+
const canonical = aliases.get(candidate) || candidate;
|
|
200
|
+
const requestKey = originKey(messageOrigin(canonical.user, canonical.input.sessionId, canonical.input.streamId));
|
|
201
|
+
candidate.rows.forEach((row, index) => rowRefs.set(row.id, `${requestKey}:${index}`));
|
|
202
|
+
}
|
|
203
|
+
const clientRefs = new Map();
|
|
204
|
+
for (const candidate of turn.input.turns) {
|
|
205
|
+
if (!candidate.user.clientMessageId) continue;
|
|
206
|
+
const clientId = candidate.user.clientMessageId;
|
|
207
|
+
const canonical = rowRefs.get(candidate.user.id);
|
|
208
|
+
clientRefs.set(clientId, clientRefs.has(clientId) && clientRefs.get(clientId) !== canonical ? null : canonical);
|
|
209
|
+
}
|
|
210
|
+
const remap = value => rowRefs.get(value) || value;
|
|
211
|
+
const callRefs = new Map([...turn.links.calls].map(([key, call]) => [key, `call:${call.index}:${call.callIndex}`]));
|
|
212
|
+
return turn.rows.map((row, index) => {
|
|
213
|
+
const copy = historicalReplayRow(row);
|
|
214
|
+
for (const key of ['id', 'seq', 'sessionId', 'origin', 'cold']) delete copy[key];
|
|
215
|
+
if (row.turnId) copy.turnId = row.origin?.turnId || row.turnId;
|
|
216
|
+
if (row.causalRootId) copy.causalRootId = rowRefs.get(row.causalRootId) || clientRefs.get(row.causalRootId) || row.causalRootId;
|
|
217
|
+
for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
|
|
218
|
+
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
219
|
+
}
|
|
220
|
+
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
221
|
+
if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
|
|
222
|
+
const { path, ...metadata } = att;
|
|
223
|
+
return metadata;
|
|
224
|
+
});
|
|
225
|
+
if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: callRefs.get(toolScope(row, call.id)) }));
|
|
226
|
+
const call = turn.links.results.get(index);
|
|
227
|
+
if (call) copy.toolCallId = `call:${call.index}:${call.callIndex}`;
|
|
228
|
+
if (copy.presentationCallId && callRefs.has(toolScope(row, copy.presentationCallId))) {
|
|
229
|
+
copy.presentationCallId = callRefs.get(toolScope(row, copy.presentationCallId));
|
|
230
|
+
}
|
|
231
|
+
return copy;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return stableJson(normalized(left)) === stableJson(normalized(right));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 规划两份 idle Session transcript 的完整请求合并;不写磁盘。
|
|
239
|
+
* @param {Array<{sessionId:string, streamId:string, rows:object[]}>} inputs
|
|
240
|
+
* @returns {{turns:object[], aliases:Map<object,object>, sharedTurnCount:number}}
|
|
241
|
+
*/
|
|
242
|
+
export function planSessionMerge(inputs) {
|
|
243
|
+
for (const input of inputs) input.turns = requestTurns(input);
|
|
244
|
+
const [left, right] = inputs.map(input => input.turns);
|
|
245
|
+
const aliases = new Map();
|
|
246
|
+
for (const other of right) {
|
|
247
|
+
const matches = left.filter(turn => commonRequest(turn, other));
|
|
248
|
+
if (matches.length > 1) ambiguous('Shared request identity is not unique');
|
|
249
|
+
if (matches.length) {
|
|
250
|
+
if ([...aliases.values()].includes(matches[0])) ambiguous('Shared request occurs more than once');
|
|
251
|
+
aliases.set(other, matches[0]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const [other, source] of aliases) {
|
|
255
|
+
if (!equivalentRows(source, other, aliases)) ambiguous('Shared human request has divergent or partial branch history');
|
|
256
|
+
}
|
|
257
|
+
// Only an aligned shared prefix precedes the chronological merge. A shared
|
|
258
|
+
// request can also occur in the middle after earlier merges (AB + BC); moving
|
|
259
|
+
// every shared request to the front would incorrectly produce B, A, C.
|
|
260
|
+
let prefixLength = 0;
|
|
261
|
+
while (prefixLength < left.length && aliases.get(right[prefixLength]) === left[prefixLength]) prefixLength++;
|
|
262
|
+
const common = left.slice(0, prefixLength);
|
|
263
|
+
const tails = [...left.slice(prefixLength), ...right.slice(prefixLength).filter(turn => !aliases.has(turn))];
|
|
264
|
+
const timestamp = turn => {
|
|
265
|
+
const value = sentAt(turn.user);
|
|
266
|
+
const parsed = typeof value === 'number' ? value : Date.parse(value);
|
|
267
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
268
|
+
};
|
|
269
|
+
// Stable tie break: source argument order, then original request order.
|
|
270
|
+
tails.sort((a, b) => timestamp(a) - timestamp(b)
|
|
271
|
+
|| inputs.indexOf(a.input) - inputs.indexOf(b.input) || a.index - b.index);
|
|
272
|
+
return { turns: [...common, ...tails], aliases, sharedTurnCount: aliases.size };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 为计划分配新 persisted IDs 并重写内部引用;每个源有独立命名空间。
|
|
277
|
+
* 附件由调用者按实际源所有权复制;provider 私有状态转为 canonical 历史。
|
|
278
|
+
* @param {object} plan planSessionMerge 的返回值
|
|
279
|
+
* @param {string} targetSessionId 新 Session
|
|
280
|
+
* @param {number} firstSeq 已保留的起始序号
|
|
281
|
+
* @returns {{rows:object[], idMaps:Map<string,Map<string,string>>}}
|
|
282
|
+
*/
|
|
283
|
+
export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
|
|
284
|
+
const idMaps = new Map();
|
|
285
|
+
const mapping = input => {
|
|
286
|
+
if (!idMaps.has(input.sessionId)) idMaps.set(input.sessionId, new Map());
|
|
287
|
+
return idMaps.get(input.sessionId);
|
|
288
|
+
};
|
|
289
|
+
let seq = firstSeq;
|
|
290
|
+
for (const turn of plan.turns) {
|
|
291
|
+
for (const row of turn.rows) mapping(turn.input).set(row.id, `m${String(seq++).padStart(4, '0')}`);
|
|
292
|
+
}
|
|
293
|
+
for (const [other, source] of plan.aliases) {
|
|
294
|
+
other.rows.forEach((row, index) => mapping(other.input).set(row.id, mapping(source.input).get(source.rows[index].id)));
|
|
295
|
+
}
|
|
296
|
+
const clientMaps = new Map();
|
|
297
|
+
for (const input of new Set([...plan.turns, ...plan.aliases.keys()].map(turn => turn.input))) {
|
|
298
|
+
const clients = new Map();
|
|
299
|
+
for (const turn of input.turns) {
|
|
300
|
+
if (!turn.user.clientMessageId) continue;
|
|
301
|
+
const clientId = turn.user.clientMessageId;
|
|
302
|
+
const targetId = mapping(input).get(turn.user.id);
|
|
303
|
+
clients.set(clientId, clients.has(clientId) && clients.get(clientId) !== targetId ? null : targetId);
|
|
304
|
+
}
|
|
305
|
+
clientMaps.set(input, clients);
|
|
306
|
+
}
|
|
307
|
+
const rows = [];
|
|
308
|
+
for (const turn of plan.turns) {
|
|
309
|
+
const ids = mapping(turn.input);
|
|
310
|
+
const vpTurns = new Map();
|
|
311
|
+
const remap = id => ids.get(id) || id;
|
|
312
|
+
const callId = call => `merge_${ids.get(turn.rows[call.index].id)}_${call.callIndex}`;
|
|
313
|
+
const calls = new Map([...turn.links.calls].map(([key, call]) => [key, callId(call)]));
|
|
314
|
+
for (const [index, row] of turn.rows.entries()) {
|
|
315
|
+
const copy = { ...historicalReplayRow(row), id: ids.get(row.id), sessionId: targetSessionId,
|
|
316
|
+
origin: messageOrigin(row, turn.input.sessionId, turn.input.streamId) };
|
|
317
|
+
delete copy.seq;
|
|
318
|
+
if (row.turnId) {
|
|
319
|
+
const key = JSON.stringify([row.speakerVpId || row.vpId || '', row.turnId]);
|
|
320
|
+
if (!vpTurns.has(key)) vpTurns.set(key, `merge_${copy.id}_turn`);
|
|
321
|
+
copy.turnId = vpTurns.get(key);
|
|
322
|
+
}
|
|
323
|
+
if (row.causalRootId) copy.causalRootId = ids.get(row.causalRootId)
|
|
324
|
+
|| clientMaps.get(turn.input)?.get(row.causalRootId) || row.causalRootId;
|
|
325
|
+
for (const key of ['foldedMessageIds', 'sourceMessageIds']) {
|
|
326
|
+
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
327
|
+
}
|
|
328
|
+
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
329
|
+
if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
|
|
330
|
+
if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
|
|
331
|
+
if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));
|
|
332
|
+
rows.push(copy);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { rows, idMaps };
|
|
336
|
+
}
|
package/yeaft/engine.js
CHANGED
|
@@ -1375,6 +1375,7 @@ export class Engine {
|
|
|
1375
1375
|
effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
|
|
1376
1376
|
requestIdentity: vpCtx?.requestIdentity,
|
|
1377
1377
|
queryTurnId: vpCtx?.queryTurnId,
|
|
1378
|
+
causalRootId: vpCtx?.causalRootId || null,
|
|
1378
1379
|
webResearch: vpCtx?.webResearch,
|
|
1379
1380
|
historyBeforeSeq: vpCtx?.historyBeforeSeq,
|
|
1380
1381
|
yeaftDir: this.#yeaftDir,
|
|
@@ -1651,6 +1652,7 @@ export class Engine {
|
|
|
1651
1652
|
const persistedContinuation = this.#persistConversationMessage(continuation, {
|
|
1652
1653
|
sessionId: update.sessionId || this.#sessionId,
|
|
1653
1654
|
turnId: update.turnId || null,
|
|
1655
|
+
causalRootId: update.causalRootId || null,
|
|
1654
1656
|
});
|
|
1655
1657
|
if (persistedContinuation?.id) continuation._persistedMessageId = persistedContinuation.id;
|
|
1656
1658
|
conversationMessages.push(continuation);
|
|
@@ -4090,6 +4092,7 @@ export class Engine {
|
|
|
4090
4092
|
effortDecision: snapshotEffortDecision(requestEffortDecision),
|
|
4091
4093
|
requestIdentity,
|
|
4092
4094
|
queryTurnId,
|
|
4095
|
+
causalRootId,
|
|
4093
4096
|
webResearch,
|
|
4094
4097
|
historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
|
|
4095
4098
|
router,
|
|
@@ -5195,7 +5198,7 @@ export class Engine {
|
|
|
5195
5198
|
*
|
|
5196
5199
|
* @param {string} taskId
|
|
5197
5200
|
* @param {string|Array} content — pre-formatted task result body
|
|
5198
|
-
* @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string }} [opts]
|
|
5201
|
+
* @param {{ preview?: string, sessionId?: string, vpId?: string, threadId?: string, taskKind?: string, taskStatus?: string, turnId?: string, causalRootId?: string }} [opts]
|
|
5199
5202
|
* @returns {boolean}
|
|
5200
5203
|
*/
|
|
5201
5204
|
notifyAsyncTaskCompleted(taskId, content, opts = {}) {
|
|
@@ -5235,6 +5238,11 @@ export class Engine {
|
|
|
5235
5238
|
taskKind: opts.taskKind,
|
|
5236
5239
|
taskStatus: opts.taskStatus,
|
|
5237
5240
|
turnId: opts.turnId || meta.turnId || null,
|
|
5241
|
+
// Registration owns provenance even when newer human input arrives before
|
|
5242
|
+
// completion. Retain it until provider acceptance or undelivered rescue.
|
|
5243
|
+
causalRootId: meta.causalRootId
|
|
5244
|
+
|| (typeof opts.causalRootId === 'string' ? opts.causalRootId.trim() : null)
|
|
5245
|
+
|| this.#currentCausalRootId || null,
|
|
5238
5246
|
};
|
|
5239
5247
|
this.#acceptedAsyncTaskResults.set(taskId, delivery);
|
|
5240
5248
|
this.#asyncTaskToolMeta.delete(taskId);
|
|
@@ -5248,6 +5256,7 @@ export class Engine {
|
|
|
5248
5256
|
sessionId: delivery.sessionId,
|
|
5249
5257
|
vpId: delivery.vpId,
|
|
5250
5258
|
turnId: delivery.turnId,
|
|
5259
|
+
causalRootId: delivery.causalRootId,
|
|
5251
5260
|
// Capture durable row ownership at completion time, independently of
|
|
5252
5261
|
// any provider-only compacted request copy.
|
|
5253
5262
|
folded: !hasLiveToolRow,
|
|
@@ -5268,7 +5277,7 @@ export class Engine {
|
|
|
5268
5277
|
* Register a result-producing async task as belonging to the current query.
|
|
5269
5278
|
* Called by tools such as SpawnAgent via `toolCtx.registerAsyncTask`.
|
|
5270
5279
|
* @param {string} taskId
|
|
5271
|
-
* @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string }} [meta]
|
|
5280
|
+
* @param {{ id?: string, name?: string, threadId?: string, toolCallId?: string, toolName?: string, sessionId?: string, vpId?: string, turnId?: string, causalRootId?: string }} [meta]
|
|
5272
5281
|
* @returns {void}
|
|
5273
5282
|
*/
|
|
5274
5283
|
#registerAsyncTask(taskId, meta = {}) {
|
|
@@ -5277,22 +5286,23 @@ export class Engine {
|
|
|
5277
5286
|
const toolCallId = typeof meta.toolCallId === 'string' && meta.toolCallId
|
|
5278
5287
|
? meta.toolCallId
|
|
5279
5288
|
: (typeof meta.id === 'string' && meta.id ? meta.id : null);
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5289
|
+
this.#asyncTaskToolMeta.set(taskId, {
|
|
5290
|
+
toolCallId,
|
|
5291
|
+
toolName: typeof meta.toolName === 'string' && meta.toolName ? meta.toolName : (typeof meta.name === 'string' ? meta.name : undefined),
|
|
5292
|
+
threadId: typeof meta.threadId === 'string' && meta.threadId ? meta.threadId : undefined,
|
|
5293
|
+
sessionId: typeof meta.sessionId === 'string' && meta.sessionId
|
|
5294
|
+
? meta.sessionId
|
|
5295
|
+
: (this.#sessionId || null),
|
|
5296
|
+
vpId: typeof meta.vpId === 'string' && meta.vpId ? meta.vpId : (this.#vpId || null),
|
|
5297
|
+
// query() exposes the wire turn id while the task is registered.
|
|
5298
|
+
// Keep provenance even for legacy missing/folded tool rows.
|
|
5299
|
+
turnId: typeof meta.turnId === 'string' && meta.turnId
|
|
5300
|
+
? meta.turnId
|
|
5301
|
+
: (this.#currentQueryTurnId || null),
|
|
5302
|
+
causalRootId: typeof meta.causalRootId === 'string' && meta.causalRootId.trim()
|
|
5303
|
+
? meta.causalRootId.trim()
|
|
5304
|
+
: (this.#currentCausalRootId || null),
|
|
5305
|
+
});
|
|
5296
5306
|
try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
|
|
5297
5307
|
}
|
|
5298
5308
|
|
package/yeaft/routing/router.js
CHANGED
|
@@ -180,6 +180,10 @@ export function createRouter(deps = {}) {
|
|
|
180
180
|
role: 'assistant', // VP-authored — persists as assistant turn
|
|
181
181
|
text,
|
|
182
182
|
taskId: args.taskId ?? null,
|
|
183
|
+
// Runtime provenance follows the human request through handoffs; the
|
|
184
|
+
// coordinator forwards underscore fields without persisting them.
|
|
185
|
+
_causalRootId: args.inboundEnvelope?._causalRootId
|
|
186
|
+
|| args.inboundEnvelope?._persistedUserMessage?.id || null,
|
|
183
187
|
// route_forward is already visible as the source VP's tool action.
|
|
184
188
|
// Persist the synthetic handoff for audit/dispatch, but keep it out
|
|
185
189
|
// of UI replay and future visible history so it doesn't render as a
|
package/yeaft/sessions/index.js
CHANGED
|
@@ -580,6 +580,14 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
580
580
|
* Create an independent Session from an existing Session's durable state.
|
|
581
581
|
* The Server inherits Project membership when it receives the copy result.
|
|
582
582
|
* Server-owned assets retain their original ownership; message references are preserved.
|
|
583
|
+
* @param {string} yeaftDir Agent-owned data root
|
|
584
|
+
* @param {string} sourceSessionId Existing Session
|
|
585
|
+
* @param {{name?:string, throughBoundary?:object, throughTurnId?:string, throughMessageId?:string|null, beforeMessageId?:string}} [options]
|
|
586
|
+
* throughBoundary forwards the opaque raw-prefix token returned synchronously by
|
|
587
|
+
* ConversationStore.captureSessionCopyBoundary. A requested throughTurnId must
|
|
588
|
+
* end within that prefix or fail closed (never truncate an active turn).
|
|
589
|
+
* Legacy throughMessageId/null and beforeMessageId fences remain supported.
|
|
590
|
+
* @returns {object} New Session metadata with copiedMessageCount
|
|
583
591
|
*/
|
|
584
592
|
export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
585
593
|
const sourceYeaftDir = resolveSessionYeaftDir(yeaftDir, sourceSessionId);
|
|
@@ -618,9 +626,10 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
|
618
626
|
}
|
|
619
627
|
|
|
620
628
|
const transcript = new ConversationStore(sourceYeaftDir);
|
|
621
|
-
const transcriptOptions =
|
|
622
|
-
|
|
623
|
-
|
|
629
|
+
const transcriptOptions = {};
|
|
630
|
+
for (const key of ['throughBoundary', 'throughTurnId', 'throughMessageId', 'beforeMessageId']) {
|
|
631
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) transcriptOptions[key] = options[key];
|
|
632
|
+
}
|
|
624
633
|
const { copiedCount } = transcript.copySession(sourceSessionId, copied.id, transcriptOptions);
|
|
625
634
|
return { ...requireSessionMeta(sourceYeaftDir, copied.id), copiedMessageCount: copiedCount };
|
|
626
635
|
} catch (error) {
|
|
@@ -633,6 +642,62 @@ export function copySession(yeaftDir, sourceSessionId, options = {}) {
|
|
|
633
642
|
}
|
|
634
643
|
}
|
|
635
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Create a NEW Session from two idle Sessions in this Agent data root.
|
|
647
|
+
* Source config, announcement, default VP and workDir win; roster is the
|
|
648
|
+
* source-first union. Server inherits source Project membership separately.
|
|
649
|
+
* Neither source transcript nor metadata is modified. Any failure after target
|
|
650
|
+
* creation removes the new Session (including attachments and discovery entry).
|
|
651
|
+
* Ambiguous/partial shared requests fail closed with ambiguous_merge_history.
|
|
652
|
+
*
|
|
653
|
+
* @param {string} yeaftDir Agent-owned data root
|
|
654
|
+
* @param {string} sourceId Source of config/project/workdir semantics
|
|
655
|
+
* @param {string} otherId Other idle Session; must differ from sourceId
|
|
656
|
+
* @param {{name?:string, libDir?:string}} [options]
|
|
657
|
+
* @returns {object} New Session metadata, copiedMessageCount and sharedTurnCount
|
|
658
|
+
* @throws {SessionCrudError} invalid_merge_source, not_found, or ambiguous_merge_history
|
|
659
|
+
*/
|
|
660
|
+
export function mergeSessions(yeaftDir, sourceId, otherId, options = {}) {
|
|
661
|
+
if (typeof sourceId !== 'string' || typeof otherId !== 'string'
|
|
662
|
+
|| !/^[A-Za-z0-9_-]+$/.test(sourceId) || !/^[A-Za-z0-9_-]+$/.test(otherId)
|
|
663
|
+
|| sourceId === otherId) {
|
|
664
|
+
throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge requires two distinct Sessions');
|
|
665
|
+
}
|
|
666
|
+
const sourceRoot = resolveSessionYeaftDir(yeaftDir, sourceId);
|
|
667
|
+
const otherRoot = resolveSessionYeaftDir(yeaftDir, otherId);
|
|
668
|
+
if (resolve(sourceRoot) !== resolve(otherRoot)) {
|
|
669
|
+
throw new SessionCrudError('invalid_merge_source', sourceId, 'Merge sources must belong to the same Agent data root');
|
|
670
|
+
}
|
|
671
|
+
const sourceMeta = requireSessionMeta(sourceRoot, sourceId);
|
|
672
|
+
const otherMeta = requireSessionMeta(otherRoot, otherId);
|
|
673
|
+
const sourceConfig = loadSessionConfig(sourceRoot, sourceId);
|
|
674
|
+
const roster = [...new Set([...(sourceMeta.roster || []), ...(otherMeta.roster || [])])];
|
|
675
|
+
const merged = createSessionFromSpec(sourceRoot, {
|
|
676
|
+
name: String(options.name || '').trim() || `${sourceMeta.name} merged`,
|
|
677
|
+
roster,
|
|
678
|
+
defaultVpId: sourceMeta.defaultVpId || roster[0] || null,
|
|
679
|
+
workDir: sourceMeta.workDir || '',
|
|
680
|
+
}, { ...options, preserveEmptyRoster: true });
|
|
681
|
+
try {
|
|
682
|
+
saveSessionConfig(sourceRoot, merged.id, sourceConfig);
|
|
683
|
+
const target = requireSession(sourceRoot, merged.id);
|
|
684
|
+
try {
|
|
685
|
+
const meta = { ...target.getMeta(), announcement: sourceMeta.announcement || '', metadataUpdatedAt: new Date().toISOString() };
|
|
686
|
+
target.saveMeta(meta);
|
|
687
|
+
addOrUpdateManifestSession(sourceRoot, meta, target.dir);
|
|
688
|
+
} finally { target.close(); }
|
|
689
|
+
const { copiedCount, sharedTurnCount } = new ConversationStore(sourceRoot)
|
|
690
|
+
.mergeSessions(sourceId, otherId, merged.id, options);
|
|
691
|
+
return { ...requireSessionMeta(sourceRoot, merged.id), copiedMessageCount: copiedCount, sharedTurnCount };
|
|
692
|
+
} catch (error) {
|
|
693
|
+
deleteSession(sourceRoot, merged.id, options);
|
|
694
|
+
if (error?.code === 'ambiguous_merge_history' || error?.code === 'invalid_merge_source') {
|
|
695
|
+
throw new SessionCrudError(error.code, sourceId, error.message);
|
|
696
|
+
}
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
636
701
|
function requireSessionMeta(yeaftDir, sessionId) {
|
|
637
702
|
const handle = requireSession(yeaftDir, sessionId);
|
|
638
703
|
try { return handle.getMeta(); } finally { handle.close(); }
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -521,7 +521,10 @@ stops or finalizes work no longer needed.`,
|
|
|
521
521
|
title: spec.mission || spec.task || name,
|
|
522
522
|
resultDelivery: TASK_RESULT_DELIVERY.MODEL_REENTRY,
|
|
523
523
|
runtime: { subAgentId: agentId, name, cwd: agent.cwd },
|
|
524
|
-
source: {
|
|
524
|
+
source: {
|
|
525
|
+
threadId: callerScope.parentThreadId || ctx?.threadId || 'main',
|
|
526
|
+
...(ctx.causalRootId ? { causalRootId: ctx.causalRootId } : {}),
|
|
527
|
+
},
|
|
525
528
|
});
|
|
526
529
|
if (task?.id) {
|
|
527
530
|
agent.taskId = task.id;
|