@yeaft/webchat-agent 1.0.407 → 1.0.409
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/container-manager.js +28 -4
- package/local-runtime/server/.env.example +8 -0
- package/local-runtime/server/container-agent-service.js +132 -28
- package/local-runtime/server/handlers/agent-file-terminal.js +26 -5
- package/local-runtime/server/handlers/client-workbench.js +12 -2
- package/local-runtime/server/routes/sandbox-routes.js +28 -15
- package/local-runtime/server/routes/user-routes.js +11 -5
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +83 -82
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/workbench/file-ops.js +14 -2
- package/yeaft/cli-session-runner.js +219 -10
- package/yeaft/engine.js +1 -1
- package/yeaft/routing/router.js +59 -0
- package/yeaft/sessions/coordinator.js +3 -2
|
Binary file
|
package/package.json
CHANGED
package/workbench/file-ops.js
CHANGED
|
@@ -6,7 +6,7 @@ import ctx from '../context.js';
|
|
|
6
6
|
import { resolveAndValidatePath, BINARY_EXTENSIONS } from './utils.js';
|
|
7
7
|
|
|
8
8
|
export async function handleReadFile(msg) {
|
|
9
|
-
const { conversationId, filePath, _requestUserId } = msg;
|
|
9
|
+
const { conversationId, filePath, requestId, _requestUserId, _requestClientId } = msg;
|
|
10
10
|
console.log('[Agent] handleReadFile received:', { filePath, conversationId, workDir: msg.workDir });
|
|
11
11
|
const conv = ctx.conversations.get(conversationId);
|
|
12
12
|
const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
|
|
@@ -23,7 +23,9 @@ export async function handleReadFile(msg) {
|
|
|
23
23
|
ctx.sendToServer({
|
|
24
24
|
type: 'file_content',
|
|
25
25
|
conversationId,
|
|
26
|
+
requestId,
|
|
26
27
|
_requestUserId,
|
|
28
|
+
_requestClientId,
|
|
27
29
|
filePath: resolved,
|
|
28
30
|
requestedFilePath: filePath,
|
|
29
31
|
content: buffer.toString('base64'),
|
|
@@ -57,7 +59,9 @@ export async function handleReadFile(msg) {
|
|
|
57
59
|
ctx.sendToServer({
|
|
58
60
|
type: 'file_content',
|
|
59
61
|
conversationId,
|
|
62
|
+
requestId,
|
|
60
63
|
_requestUserId,
|
|
64
|
+
_requestClientId,
|
|
61
65
|
filePath: resolved,
|
|
62
66
|
requestedFilePath: filePath,
|
|
63
67
|
content,
|
|
@@ -68,7 +72,9 @@ export async function handleReadFile(msg) {
|
|
|
68
72
|
ctx.sendToServer({
|
|
69
73
|
type: 'file_content',
|
|
70
74
|
conversationId,
|
|
75
|
+
requestId,
|
|
71
76
|
_requestUserId,
|
|
77
|
+
_requestClientId,
|
|
72
78
|
filePath,
|
|
73
79
|
requestedFilePath: filePath,
|
|
74
80
|
content: '',
|
|
@@ -78,7 +84,7 @@ export async function handleReadFile(msg) {
|
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export async function handleWriteFile(msg) {
|
|
81
|
-
const { conversationId, filePath, content, _requestUserId } = msg;
|
|
87
|
+
const { conversationId, filePath, content, requestId, _requestUserId, _requestClientId } = msg;
|
|
82
88
|
const conv = ctx.conversations.get(conversationId);
|
|
83
89
|
const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
|
|
84
90
|
|
|
@@ -89,16 +95,22 @@ export async function handleWriteFile(msg) {
|
|
|
89
95
|
ctx.sendToServer({
|
|
90
96
|
type: 'file_saved',
|
|
91
97
|
conversationId,
|
|
98
|
+
requestId,
|
|
92
99
|
_requestUserId,
|
|
100
|
+
_requestClientId,
|
|
93
101
|
filePath: resolved,
|
|
102
|
+
requestedFilePath: filePath,
|
|
94
103
|
success: true
|
|
95
104
|
});
|
|
96
105
|
} catch (e) {
|
|
97
106
|
ctx.sendToServer({
|
|
98
107
|
type: 'file_saved',
|
|
99
108
|
conversationId,
|
|
109
|
+
requestId,
|
|
100
110
|
_requestUserId,
|
|
111
|
+
_requestClientId,
|
|
101
112
|
filePath,
|
|
113
|
+
requestedFilePath: filePath,
|
|
102
114
|
success: false,
|
|
103
115
|
error: e.message
|
|
104
116
|
});
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { Engine } from './engine.js';
|
|
4
4
|
import { createRouter } from './routing/router.js';
|
|
5
|
+
import { createLoopGuard } from './routing/loop-guard.js';
|
|
5
6
|
import { createCoordinator } from './sessions/coordinator.js';
|
|
6
7
|
import { resolveMemberId } from './sessions/roster.js';
|
|
7
8
|
import { sessionsRoot } from './sessions/session-crud.js';
|
|
@@ -10,6 +11,86 @@ import { loadSessionConfig, resolveSessionConfig } from './sessions/session-conf
|
|
|
10
11
|
import { readVp } from './vp/vp-crud.js';
|
|
11
12
|
import { COLLAB_TOOL_POLICY } from './tools/registry.js';
|
|
12
13
|
|
|
14
|
+
const MAX_ROUTE_FORWARD_RESULT_CHARS = 12_000;
|
|
15
|
+
const MAX_ROUTE_FORWARD_PROMPT_CHARS = 60_000;
|
|
16
|
+
|
|
17
|
+
function cleanString(value) {
|
|
18
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function routeForwardCompletionFor(row) {
|
|
22
|
+
const envelope = row?.envelope;
|
|
23
|
+
const meta = envelope?.msg?.meta;
|
|
24
|
+
if (!meta || row?.stopReason === 'tool_handoff' || row?.stopReason === 'aborted') return null;
|
|
25
|
+
const parent = meta.injectedBy === 'route_forward_result'
|
|
26
|
+
&& meta.routeForwardParent
|
|
27
|
+
&& typeof meta.routeForwardParent === 'object'
|
|
28
|
+
? meta.routeForwardParent
|
|
29
|
+
: null;
|
|
30
|
+
if (meta.injectedBy !== 'route_forward' && !parent) return null;
|
|
31
|
+
const sourceVpId = cleanString(parent?.sourceVpId ?? meta.senderVpId);
|
|
32
|
+
const forwardId = cleanString(parent?.forwardId ?? envelope?.msg?.id);
|
|
33
|
+
if (!sourceVpId || !forwardId || sourceVpId === row.vpId) return null;
|
|
34
|
+
const rawExpectedVpIds = parent?.expectedVpIds ?? meta.routeForwardExpectedTargets;
|
|
35
|
+
const expectedVpIds = Array.isArray(rawExpectedVpIds)
|
|
36
|
+
? [...new Set(rawExpectedVpIds.map(cleanString).filter(Boolean))]
|
|
37
|
+
: [row.vpId];
|
|
38
|
+
if (!expectedVpIds.includes(row.vpId)) return null;
|
|
39
|
+
return {
|
|
40
|
+
forwardId,
|
|
41
|
+
sourceVpId,
|
|
42
|
+
sourceThreadId: cleanString(parent?.sourceThreadId ?? meta.sourceThreadId) || 'main',
|
|
43
|
+
causedBy: Array.isArray(parent?.causedBy ?? meta.causedBy)
|
|
44
|
+
? (parent?.causedBy ?? meta.causedBy).slice()
|
|
45
|
+
: [],
|
|
46
|
+
dispatchErrors: [
|
|
47
|
+
...(Array.isArray(parent?.dispatchErrors) ? parent.dispatchErrors : []),
|
|
48
|
+
...(Array.isArray(meta.routeForwardDispatchErrors) ? meta.routeForwardDispatchErrors : []),
|
|
49
|
+
],
|
|
50
|
+
truncatedAtFanOutCap: Boolean(parent?.truncatedAtFanOutCap)
|
|
51
|
+
|| Boolean(meta.routeForwardTruncatedAtFanOutCap),
|
|
52
|
+
parentRouteForward: parent?.parentRouteForward
|
|
53
|
+
|| (meta.injectedBy === 'route_forward' ? (meta.routeForwardParent || null) : null),
|
|
54
|
+
expectedVpIds,
|
|
55
|
+
vpId: row.vpId,
|
|
56
|
+
result: row.result || '',
|
|
57
|
+
error: row.error || null,
|
|
58
|
+
stopReason: row.stopReason || 'end_turn',
|
|
59
|
+
envelope,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatRouteForwardResult(aggregate, results) {
|
|
64
|
+
const sections = results.map((entry) => {
|
|
65
|
+
const rawBody = entry.error
|
|
66
|
+
? `Error: ${entry.error.message || String(entry.error)}`
|
|
67
|
+
: (entry.result || '(no text returned)');
|
|
68
|
+
const body = rawBody.length > MAX_ROUTE_FORWARD_RESULT_CHARS
|
|
69
|
+
? `${rawBody.slice(0, MAX_ROUTE_FORWARD_RESULT_CHARS)}\n[Result truncated]`
|
|
70
|
+
: rawBody;
|
|
71
|
+
return `[${entry.vpId} — ${entry.stopReason}]\n${body}`;
|
|
72
|
+
});
|
|
73
|
+
const notices = [];
|
|
74
|
+
if (aggregate.truncatedAtFanOutCap) {
|
|
75
|
+
notices.push('Warning: the requested fan-out was truncated at the Session limit.');
|
|
76
|
+
}
|
|
77
|
+
if (aggregate.dispatchErrors.length > 0) {
|
|
78
|
+
notices.push(`Dispatch errors: ${JSON.stringify(aggregate.dispatchErrors)}`);
|
|
79
|
+
}
|
|
80
|
+
const prompt = [
|
|
81
|
+
'[RouteForward result]',
|
|
82
|
+
notices.length > 0
|
|
83
|
+
? 'The accepted delegated VP work has finished, but dispatch was partial. Continue the same user request using the results and warnings below.'
|
|
84
|
+
: 'The delegated VP work has finished. Continue the same user request using the result below.',
|
|
85
|
+
...notices,
|
|
86
|
+
'',
|
|
87
|
+
...sections,
|
|
88
|
+
].join('\n');
|
|
89
|
+
return prompt.length > MAX_ROUTE_FORWARD_PROMPT_CHARS
|
|
90
|
+
? `${prompt.slice(0, MAX_ROUTE_FORWARD_PROMPT_CHARS)}\n[Combined RouteForward results truncated]`
|
|
91
|
+
: prompt;
|
|
92
|
+
}
|
|
93
|
+
|
|
13
94
|
function buildVpPersona(vpId, loaded) {
|
|
14
95
|
const vp = readVp(vpId, { libDir: join(loaded.yeaftDir, 'virtual-persons') });
|
|
15
96
|
if (!vp) return null;
|
|
@@ -79,6 +160,8 @@ export function createCliSessionRunner({
|
|
|
79
160
|
// new row carries one durable causalRootId; the legacy ids remain fallbacks
|
|
80
161
|
// for rows produced before that field existed.
|
|
81
162
|
const rootOrderByIdentity = new Map();
|
|
163
|
+
const activeTurnContexts = new Map();
|
|
164
|
+
const routeForwardGuard = createLoopGuard();
|
|
82
165
|
let nextRootOrder = 0;
|
|
83
166
|
let closed = false;
|
|
84
167
|
|
|
@@ -107,6 +190,8 @@ export function createCliSessionRunner({
|
|
|
107
190
|
const rootOrder = Number.isInteger(envelope?._cliRootOrder)
|
|
108
191
|
? envelope._cliRootOrder
|
|
109
192
|
: null;
|
|
193
|
+
const inboundMessageId = cleanString(envelope?.msg?.id);
|
|
194
|
+
const includeCurrentRoot = envelope?.msg?.meta?.injectedBy === 'route_forward_result';
|
|
110
195
|
// Engine.query() appends `prompt` itself. Exclude this root's durable user
|
|
111
196
|
// row and every later root turn, regardless of where their assistant/tool
|
|
112
197
|
// rows landed in the globally sequenced transcript. This preserves rows
|
|
@@ -115,6 +200,10 @@ export function createCliSessionRunner({
|
|
|
115
200
|
const messages = loaded.conversationStore
|
|
116
201
|
.loadSessionHistoryForVp(sessionId, vpId)
|
|
117
202
|
.filter((message) => {
|
|
203
|
+
if (inboundMessageId
|
|
204
|
+
&& (message?.id === inboundMessageId || message?.messageId === inboundMessageId)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
118
207
|
if (persistedUserClientMessageId
|
|
119
208
|
&& message?.role === 'user'
|
|
120
209
|
&& message.clientMessageId === persistedUserClientMessageId) return false;
|
|
@@ -129,11 +218,14 @@ export function createCliSessionRunner({
|
|
|
129
218
|
} else if (typeof message?.turnId === 'string') {
|
|
130
219
|
messageRootOrder = rootOrderByIdentity.get(message.turnId);
|
|
131
220
|
}
|
|
132
|
-
return !Number.isInteger(messageRootOrder)
|
|
221
|
+
return !Number.isInteger(messageRootOrder)
|
|
222
|
+
|| messageRootOrder < rootOrder
|
|
223
|
+
|| (includeCurrentRoot && messageRootOrder === rootOrder);
|
|
133
224
|
});
|
|
134
225
|
const todos = [];
|
|
135
226
|
let resultText = '';
|
|
136
227
|
let failed = null;
|
|
228
|
+
let stopReason = null;
|
|
137
229
|
const scopedCoordinator = {
|
|
138
230
|
group: coordinator.group,
|
|
139
231
|
ingest(input, opts) {
|
|
@@ -158,7 +250,7 @@ export function createCliSessionRunner({
|
|
|
158
250
|
sessionMembers: meta.roster.slice(),
|
|
159
251
|
sessionAnnouncement: meta.announcement || '',
|
|
160
252
|
vpPersona: personaFactory(vpId, loaded),
|
|
161
|
-
router: createRouter({ coordinator: scopedCoordinator }),
|
|
253
|
+
router: createRouter({ coordinator: scopedCoordinator, guard: routeForwardGuard }),
|
|
162
254
|
inboundEnvelope: envelope,
|
|
163
255
|
userAlreadyPersisted: true,
|
|
164
256
|
causalRootId,
|
|
@@ -177,6 +269,8 @@ export function createCliSessionRunner({
|
|
|
177
269
|
userEffort: options.modelEffort || null,
|
|
178
270
|
};
|
|
179
271
|
|
|
272
|
+
const turnContext = envelope?._cliTurnContext;
|
|
273
|
+
turnContext?.activeEngines.add(engine);
|
|
180
274
|
try {
|
|
181
275
|
for await (const event of engine.query(queryOptions)) {
|
|
182
276
|
if (event.type === 'text_delta') resultText += event.text || '';
|
|
@@ -185,6 +279,11 @@ export function createCliSessionRunner({
|
|
|
185
279
|
? event.error
|
|
186
280
|
: new Error(String(event.error?.message || event.error || 'Unknown Engine error'));
|
|
187
281
|
}
|
|
282
|
+
if (event.type === 'turn_end' && event.terminal && event.stopReason) {
|
|
283
|
+
stopReason = event.stopReason;
|
|
284
|
+
} else if (event.type === 'stop' && event.stopReason) {
|
|
285
|
+
stopReason = event.stopReason;
|
|
286
|
+
}
|
|
188
287
|
await options.onEvent?.({ vpId, event, sessionId, turnId: queryOptions.vpTurnId });
|
|
189
288
|
}
|
|
190
289
|
} catch (error) {
|
|
@@ -195,20 +294,53 @@ export function createCliSessionRunner({
|
|
|
195
294
|
turnId: queryOptions.vpTurnId,
|
|
196
295
|
event: { type: 'error', error, retryable: false },
|
|
197
296
|
});
|
|
297
|
+
} finally {
|
|
298
|
+
turnContext?.activeEngines.delete(engine);
|
|
198
299
|
}
|
|
199
|
-
return {
|
|
300
|
+
return {
|
|
301
|
+
vpId,
|
|
302
|
+
result: resultText,
|
|
303
|
+
error: failed,
|
|
304
|
+
stopReason: failed ? 'error' : (stopReason || 'end_turn'),
|
|
305
|
+
envelope,
|
|
306
|
+
};
|
|
200
307
|
};
|
|
201
308
|
|
|
202
309
|
const enqueue = (vpId, envelope) => {
|
|
203
310
|
if (closed) throw new Error('CLI Session runner is closed');
|
|
204
311
|
const turnContext = envelope?._cliTurnContext;
|
|
205
312
|
if (!turnContext) throw new Error('CLI Session envelope is missing its turn context');
|
|
206
|
-
if (turnContext.
|
|
313
|
+
if (turnContext.cancellation.cancelled) {
|
|
314
|
+
return Promise.resolve({
|
|
315
|
+
vpId,
|
|
316
|
+
result: '',
|
|
317
|
+
error: null,
|
|
318
|
+
stopReason: 'aborted',
|
|
319
|
+
envelope,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const routeForwardResultId = cleanString(envelope?.msg?.meta?.routeForwardId);
|
|
323
|
+
const isRouteForwardResult = envelope?.msg?.meta?.injectedBy === 'route_forward_result'
|
|
324
|
+
&& cleanString(envelope?.msg?.meta?.routeTargetVpId) === vpId
|
|
325
|
+
&& routeForwardResultId
|
|
326
|
+
&& turnContext.routeForwardReturnIds.has(routeForwardResultId);
|
|
327
|
+
if (turnContext.claimedVpIds.has(vpId) && !isRouteForwardResult) {
|
|
207
328
|
return { ok: false, error: 'target_already_claimed' };
|
|
208
329
|
}
|
|
209
|
-
turnContext.claimedVpIds.add(vpId);
|
|
330
|
+
if (!isRouteForwardResult) turnContext.claimedVpIds.add(vpId);
|
|
210
331
|
const previous = tails.get(vpId) || Promise.resolve();
|
|
211
|
-
const task = previous.catch(() => {}).then(() =>
|
|
332
|
+
const task = previous.catch(() => {}).then(() => {
|
|
333
|
+
if (turnContext.cancellation.cancelled) {
|
|
334
|
+
return {
|
|
335
|
+
vpId,
|
|
336
|
+
result: '',
|
|
337
|
+
error: null,
|
|
338
|
+
stopReason: 'aborted',
|
|
339
|
+
envelope,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return runEnvelope(vpId, envelope, turnContext.options);
|
|
343
|
+
});
|
|
212
344
|
tails.set(vpId, task);
|
|
213
345
|
pending.add(task);
|
|
214
346
|
turnContext.tasks.push(task);
|
|
@@ -221,6 +353,56 @@ export function createCliSessionRunner({
|
|
|
221
353
|
|
|
222
354
|
coordinator = createCoordinator(handle, { deliver: enqueue });
|
|
223
355
|
|
|
356
|
+
const enqueueRouteForwardReturns = (completed, turnContext) => {
|
|
357
|
+
if (turnContext.cancellation.cancelled) return;
|
|
358
|
+
for (const row of completed) {
|
|
359
|
+
const completion = routeForwardCompletionFor(row);
|
|
360
|
+
if (!completion) continue;
|
|
361
|
+
let aggregate = turnContext.routeForwardReturns.get(completion.forwardId);
|
|
362
|
+
if (!aggregate) {
|
|
363
|
+
aggregate = {
|
|
364
|
+
...completion,
|
|
365
|
+
results: new Map(),
|
|
366
|
+
};
|
|
367
|
+
turnContext.routeForwardReturns.set(completion.forwardId, aggregate);
|
|
368
|
+
}
|
|
369
|
+
aggregate.results.set(completion.vpId, completion);
|
|
370
|
+
if (turnContext.routeForwardReturnIds.has(completion.forwardId)
|
|
371
|
+
|| !aggregate.expectedVpIds.every(vpId => aggregate.results.has(vpId))) continue;
|
|
372
|
+
|
|
373
|
+
// A RouteForward can return to its source exactly once. The source
|
|
374
|
+
// remains claimed for the root turn, so this explicit, internally
|
|
375
|
+
// marked continuation is the only allowed re-entry path.
|
|
376
|
+
turnContext.routeForwardReturnIds.add(completion.forwardId);
|
|
377
|
+
const results = aggregate.expectedVpIds.map(vpId => aggregate.results.get(vpId));
|
|
378
|
+
const representative = results[0];
|
|
379
|
+
coordinator.ingest({
|
|
380
|
+
id: randomUUID(),
|
|
381
|
+
from: representative.vpId,
|
|
382
|
+
role: 'assistant',
|
|
383
|
+
text: formatRouteForwardResult(aggregate, results),
|
|
384
|
+
internal: true,
|
|
385
|
+
meta: {
|
|
386
|
+
synthetic: true,
|
|
387
|
+
injectedBy: 'route_forward_result',
|
|
388
|
+
routeTargetVpId: aggregate.sourceVpId,
|
|
389
|
+
senderVpId: representative.vpId,
|
|
390
|
+
sourceThreadId: aggregate.sourceThreadId,
|
|
391
|
+
routeForwardId: aggregate.forwardId,
|
|
392
|
+
...(aggregate.parentRouteForward
|
|
393
|
+
? { routeForwardParent: aggregate.parentRouteForward }
|
|
394
|
+
: {}),
|
|
395
|
+
causedBy: aggregate.causedBy,
|
|
396
|
+
routeForwardDispatchErrors: aggregate.dispatchErrors,
|
|
397
|
+
routeForwardTruncatedAtFanOutCap: aggregate.truncatedAtFanOutCap,
|
|
398
|
+
},
|
|
399
|
+
_cliRootOrder: representative.envelope?._cliRootOrder,
|
|
400
|
+
_cliCausalRootId: representative.envelope?._cliCausalRootId,
|
|
401
|
+
_cliTurnContext: turnContext,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
224
406
|
async function drain(tasks = pending) {
|
|
225
407
|
const results = [];
|
|
226
408
|
while (tasks.size > 0) {
|
|
@@ -263,12 +445,24 @@ export function createCliSessionRunner({
|
|
|
263
445
|
explicit: routingIntent.explicit === true,
|
|
264
446
|
});
|
|
265
447
|
}
|
|
448
|
+
const messageId = randomUUID();
|
|
449
|
+
const cancellationId = cleanString(options.cancellationId) || messageId;
|
|
450
|
+
if (activeTurnContexts.has(cancellationId)) {
|
|
451
|
+
throw new Error(`CLI Session cancellation id ${cancellationId} is already active`);
|
|
452
|
+
}
|
|
266
453
|
const turnContext = Object.freeze({
|
|
454
|
+
rootId: messageId,
|
|
455
|
+
cancellationId,
|
|
267
456
|
options: Object.freeze({ ...options }),
|
|
268
457
|
tasks: [],
|
|
269
458
|
claimedVpIds: new Set(),
|
|
459
|
+
routeForwardReturns: new Map(),
|
|
460
|
+
routeForwardReturnIds: new Set(),
|
|
461
|
+
cancellation: { cancelled: false, reason: null },
|
|
462
|
+
activeEngines: new Set(),
|
|
270
463
|
});
|
|
271
|
-
|
|
464
|
+
activeTurnContexts.set(cancellationId, turnContext);
|
|
465
|
+
try {
|
|
272
466
|
const rootOrder = nextRootOrder++;
|
|
273
467
|
rootOrderByIdentity.set(messageId, rootOrder);
|
|
274
468
|
// The shared user row is the durability boundary. Validate structured
|
|
@@ -314,14 +508,29 @@ export function createCliSessionRunner({
|
|
|
314
508
|
while (cursor < turnContext.tasks.length) {
|
|
315
509
|
const batch = turnContext.tasks.slice(cursor);
|
|
316
510
|
cursor += batch.length;
|
|
317
|
-
|
|
511
|
+
const completed = await Promise.all(batch);
|
|
512
|
+
results.push(...completed);
|
|
513
|
+
enqueueRouteForwardReturns(completed, turnContext);
|
|
318
514
|
await Promise.resolve();
|
|
319
515
|
}
|
|
320
516
|
return { report, results };
|
|
517
|
+
} finally {
|
|
518
|
+
activeTurnContexts.delete(cancellationId);
|
|
519
|
+
}
|
|
321
520
|
},
|
|
322
|
-
abort(reason = 'user') {
|
|
521
|
+
abort(reason = 'user', options = {}) {
|
|
522
|
+
const cancellationId = cleanString(options?.cancellationId);
|
|
523
|
+
const contexts = cancellationId
|
|
524
|
+
? [activeTurnContexts.get(cancellationId)].filter(Boolean)
|
|
525
|
+
: Array.from(activeTurnContexts.values());
|
|
526
|
+
const enginesToAbort = new Set();
|
|
527
|
+
for (const turnContext of contexts) {
|
|
528
|
+
turnContext.cancellation.cancelled = true;
|
|
529
|
+
turnContext.cancellation.reason = reason;
|
|
530
|
+
for (const engine of turnContext.activeEngines) enginesToAbort.add(engine);
|
|
531
|
+
}
|
|
323
532
|
let count = 0;
|
|
324
|
-
for (const engine of
|
|
533
|
+
for (const engine of enginesToAbort) {
|
|
325
534
|
if (engine.abort?.(reason)) count += 1;
|
|
326
535
|
}
|
|
327
536
|
return count;
|
package/yeaft/engine.js
CHANGED
|
@@ -2219,7 +2219,7 @@ export class Engine {
|
|
|
2219
2219
|
const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
|
|
2220
2220
|
? threadId.trim()
|
|
2221
2221
|
: MAIN_THREAD_ID;
|
|
2222
|
-
const executionOrigin = inboundEnvelope?.msg?.meta?.injectedBy
|
|
2222
|
+
const executionOrigin = ['route_forward', 'route_forward_result'].includes(inboundEnvelope?.msg?.meta?.injectedBy)
|
|
2223
2223
|
? 'route_forward'
|
|
2224
2224
|
: null;
|
|
2225
2225
|
// The bridge-provided VP turn id is also persisted on assistant messages and
|
package/yeaft/routing/router.js
CHANGED
|
@@ -28,6 +28,39 @@
|
|
|
28
28
|
import { resolveMemberId } from '../sessions/roster.js';
|
|
29
29
|
import { createLoopGuard, extendCausedBy } from './loop-guard.js';
|
|
30
30
|
|
|
31
|
+
function routeForwardParentFromEnvelope(envelope) {
|
|
32
|
+
const msg = envelope?.msg;
|
|
33
|
+
const meta = msg?.meta;
|
|
34
|
+
if (!meta || typeof meta !== 'object') return null;
|
|
35
|
+
if (meta.injectedBy === 'route_forward_result') {
|
|
36
|
+
return meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
|
|
37
|
+
? { ...meta.routeForwardParent }
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
if (meta.injectedBy !== 'route_forward') return null;
|
|
41
|
+
const forwardId = typeof msg.id === 'string' ? msg.id.trim() : '';
|
|
42
|
+
const sourceVpId = typeof meta.senderVpId === 'string' ? meta.senderVpId.trim() : '';
|
|
43
|
+
if (!forwardId || !sourceVpId) return null;
|
|
44
|
+
return {
|
|
45
|
+
forwardId,
|
|
46
|
+
sourceVpId,
|
|
47
|
+
sourceThreadId: typeof meta.sourceThreadId === 'string' && meta.sourceThreadId.trim()
|
|
48
|
+
? meta.sourceThreadId.trim()
|
|
49
|
+
: 'main',
|
|
50
|
+
expectedVpIds: Array.isArray(meta.routeForwardExpectedTargets)
|
|
51
|
+
? meta.routeForwardExpectedTargets.slice()
|
|
52
|
+
: [],
|
|
53
|
+
causedBy: Array.isArray(meta.causedBy) ? meta.causedBy.slice() : [],
|
|
54
|
+
dispatchErrors: Array.isArray(meta.routeForwardDispatchErrors)
|
|
55
|
+
? meta.routeForwardDispatchErrors.slice()
|
|
56
|
+
: [],
|
|
57
|
+
truncatedAtFanOutCap: Boolean(meta.routeForwardTruncatedAtFanOutCap),
|
|
58
|
+
parentRouteForward: meta.routeForwardParent && typeof meta.routeForwardParent === 'object'
|
|
59
|
+
? { ...meta.routeForwardParent }
|
|
60
|
+
: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
/**
|
|
32
65
|
* Build a router bound to a single GroupCoordinator + loop guard.
|
|
33
66
|
*
|
|
@@ -112,6 +145,7 @@ export function createRouter(deps = {}) {
|
|
|
112
145
|
// The guard runs against the *pre-dispatch* chain; that matches the
|
|
113
146
|
// spec's intent ("depth of forwards already taken").
|
|
114
147
|
const chain = extendCausedBy(args.inboundEnvelope || null, null);
|
|
148
|
+
const routeForwardParent = routeForwardParentFromEnvelope(args.inboundEnvelope);
|
|
115
149
|
|
|
116
150
|
// Loop guard: for broadcast, use 'all' as the target key so one VP
|
|
117
151
|
// spamming @all still gets throttled even if each cycle hits different
|
|
@@ -155,6 +189,7 @@ export function createRouter(deps = {}) {
|
|
|
155
189
|
senderVpId: from,
|
|
156
190
|
reason: args.reason || null,
|
|
157
191
|
causedBy: chain,
|
|
192
|
+
...(routeForwardParent ? { routeForwardParent } : {}),
|
|
158
193
|
sourceThreadId: typeof args.sourceThreadId === 'string' && args.sourceThreadId.trim()
|
|
159
194
|
? args.sourceThreadId.trim()
|
|
160
195
|
: null,
|
|
@@ -163,11 +198,35 @@ export function createRouter(deps = {}) {
|
|
|
163
198
|
opts,
|
|
164
199
|
);
|
|
165
200
|
|
|
201
|
+
// `deliver()` queues its work, so the target envelopes still share this
|
|
202
|
+
// stored message object when forward() returns. Record the accepted target
|
|
203
|
+
// set for the active runtime only: it lets a stream Session return one
|
|
204
|
+
// combined result to the caller after an @all fan-out finishes. The
|
|
205
|
+
// transient value is deliberately not required for durable replay.
|
|
206
|
+
if (report?.message?.meta && Array.isArray(report.dispatched)) {
|
|
207
|
+
report.message.meta.routeForwardExpectedTargets = report.dispatched.slice();
|
|
208
|
+
report.message.meta.routeForwardDispatchErrors = Array.isArray(report.errors)
|
|
209
|
+
? report.errors.slice()
|
|
210
|
+
: [];
|
|
211
|
+
report.message.meta.routeForwardTruncatedAtFanOutCap = Boolean(report.truncatedAtFanOutCap);
|
|
212
|
+
}
|
|
213
|
+
|
|
166
214
|
// Record AFTER Coordinator accepts. If Coordinator produced zero
|
|
167
215
|
// dispatches (e.g. task.members gate) we still count it as a hit —
|
|
168
216
|
// the forwarder still tried, and the guard's job is to throttle the
|
|
169
217
|
// sender's ability to keep trying.
|
|
170
218
|
guard.record({ sessionId: meta.id, targetVpId: guardKey });
|
|
219
|
+
if (!Array.isArray(report.dispatched) || report.dispatched.length === 0) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
error: 'no_targets_dispatched',
|
|
223
|
+
detail: {
|
|
224
|
+
errors: Array.isArray(report.errors) ? report.errors : [],
|
|
225
|
+
truncatedAtFanOutCap: Boolean(report.truncatedAtFanOutCap),
|
|
226
|
+
},
|
|
227
|
+
report,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
171
230
|
|
|
172
231
|
return {
|
|
173
232
|
ok: true,
|
|
@@ -79,12 +79,14 @@ export function createCoordinator(group, options = {}) {
|
|
|
79
79
|
? input._routingIntent
|
|
80
80
|
: null;
|
|
81
81
|
const isRouteForwardInjection = inputMeta.injectedBy === 'route_forward';
|
|
82
|
+
const isRouteForwardResultInjection = inputMeta.injectedBy === 'route_forward_result';
|
|
82
83
|
const isTaskResultInjection = inputMeta.injectedBy === 'task_result';
|
|
83
84
|
const fromUser = input.from === 'user'
|
|
84
85
|
|| input.role === 'user'
|
|
85
86
|
|| isRouteForwardInjection
|
|
87
|
+
|| isRouteForwardResultInjection
|
|
86
88
|
|| isTaskResultInjection;
|
|
87
|
-
const forcedRouteTarget = (isRouteForwardInjection || isTaskResultInjection)
|
|
89
|
+
const forcedRouteTarget = (isRouteForwardInjection || isRouteForwardResultInjection || isTaskResultInjection)
|
|
88
90
|
&& typeof inputMeta.routeTargetVpId === 'string'
|
|
89
91
|
? inputMeta.routeTargetVpId.trim()
|
|
90
92
|
: (isRouteForwardInjection && typeof inputMeta.routeForwardTarget === 'string'
|
|
@@ -243,4 +245,3 @@ function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
|
|
|
243
245
|
* @property {boolean=} truncatedAtFanOutCap
|
|
244
246
|
* @property {string=} skipped
|
|
245
247
|
*/
|
|
246
|
-
|