@yeaft/webchat-agent 1.0.408 → 1.0.410
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 +24 -2
- package/llm-config-cli.js +36 -3
- package/local-runtime/server/handlers/agent-file-terminal.js +26 -5
- package/local-runtime/server/handlers/agent-output.js +14 -0
- package/local-runtime/server/handlers/agent-sync.js +6 -1
- package/local-runtime/server/handlers/client-conversation.js +4 -0
- package/local-runtime/server/handlers/client-misc.js +27 -0
- package/local-runtime/server/handlers/client-workbench.js +12 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +188 -86
- 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/workbench/file-ops.js +14 -2
- package/yeaft/cli-session-runner.js +219 -10
- package/yeaft/config-api.js +109 -48
- package/yeaft/config.js +56 -9
- package/yeaft/engine.js +99 -44
- package/yeaft/plugins.js +170 -0
- package/yeaft/routing/router.js +59 -0
- package/yeaft/session.js +7 -4
- package/yeaft/sessions/coordinator.js +3 -2
- package/yeaft/sessions/feature-flag.js +42 -9
- package/yeaft/tools/mcp-tools.js +1 -0
- package/yeaft/tools/registry.js +51 -7
- package/yeaft/tools/types.js +6 -0
- package/yeaft/web-bridge.js +317 -89
- package/yeaft/work-center/runner.js +5 -1
|
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/config-api.js
CHANGED
|
@@ -13,8 +13,33 @@ import { join } from 'path';
|
|
|
13
13
|
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
14
14
|
import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
|
|
15
15
|
import { normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
|
|
16
|
+
import { normalizePluginConfig } from './plugins.js';
|
|
16
17
|
import { isGitHubCopilotProvider, serializeKnownProviderForPersistence } from './llm/known-providers.js';
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Read config.json before any public mutation. A missing file is a valid
|
|
21
|
+
* first-run state, but an existing malformed file, non-object root, or invalid
|
|
22
|
+
* Plugins schema must never be replaced by an unrelated Settings/MCP write.
|
|
23
|
+
* Otherwise the runtime's fail-closed policy could silently become inheritance
|
|
24
|
+
* (all capabilities enabled) on the next config reload.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} configPath
|
|
27
|
+
* @returns {Record<string, unknown>}
|
|
28
|
+
* @throws {Error} when an existing config cannot be safely preserved
|
|
29
|
+
*/
|
|
30
|
+
function readConfigForWrite(configPath) {
|
|
31
|
+
if (!existsSync(configPath)) return {};
|
|
32
|
+
const json = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
33
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)
|
|
34
|
+
|| Object.getPrototypeOf(json) !== Object.prototype) {
|
|
35
|
+
throw new Error('config.json must contain an object');
|
|
36
|
+
}
|
|
37
|
+
if (Object.prototype.hasOwnProperty.call(json, 'plugins')) {
|
|
38
|
+
normalizePluginConfig(json.plugins);
|
|
39
|
+
}
|
|
40
|
+
return json;
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
/**
|
|
19
44
|
* Read the LLM-relevant portion of config.json.
|
|
20
45
|
*
|
|
@@ -119,15 +144,13 @@ export function updateLlmConfig(update, dir) {
|
|
|
119
144
|
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
120
145
|
const configPath = join(root, 'config.json');
|
|
121
146
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
existing = {};
|
|
130
|
-
}
|
|
147
|
+
// Preserve all existing fields only when the on-disk document and its
|
|
148
|
+
// Plugins policy are valid. Never turn a failed read into a fresh config.
|
|
149
|
+
let existing;
|
|
150
|
+
try {
|
|
151
|
+
existing = readConfigForWrite(configPath);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
// Validate providers structure
|
|
@@ -265,14 +288,14 @@ export function updateYeaftSettings(update, dir) {
|
|
|
265
288
|
}
|
|
266
289
|
}
|
|
267
290
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
291
|
+
// Preserve all existing fields only when the on-disk document and its
|
|
292
|
+
// Plugins policy are valid. A Settings update must not repair bad JSON into
|
|
293
|
+
// a config whose missing Plugins fields inherit all capabilities.
|
|
294
|
+
let existing;
|
|
295
|
+
try {
|
|
296
|
+
existing = readConfigForWrite(configPath);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
276
299
|
}
|
|
277
300
|
|
|
278
301
|
const prev = normaliseYeaftSection(existing.yeaft);
|
|
@@ -336,7 +359,12 @@ export function updateTelemetrySettings(update, dir) {
|
|
|
336
359
|
}
|
|
337
360
|
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
338
361
|
const configPath = join(root, 'config.json');
|
|
339
|
-
|
|
362
|
+
let existing;
|
|
363
|
+
try {
|
|
364
|
+
existing = readConfigForWrite(configPath);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
367
|
+
}
|
|
340
368
|
const merged = normaliseTelemetrySection({
|
|
341
369
|
...(existing.telemetry && typeof existing.telemetry === 'object' ? existing.telemetry : {}),
|
|
342
370
|
...update,
|
|
@@ -427,13 +455,11 @@ export function updateSearchSettings(update, dir) {
|
|
|
427
455
|
return { error: 'tavilyApiKey must be a string' };
|
|
428
456
|
}
|
|
429
457
|
|
|
430
|
-
let existing
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
existing = {};
|
|
436
|
-
}
|
|
458
|
+
let existing;
|
|
459
|
+
try {
|
|
460
|
+
existing = readConfigForWrite(configPath);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
437
463
|
}
|
|
438
464
|
const prev = (existing && typeof existing.search === 'object' && existing.search) || {};
|
|
439
465
|
const merged = { ...prev };
|
|
@@ -500,6 +526,49 @@ export async function fetchTavilyUsage(dir) {
|
|
|
500
526
|
}
|
|
501
527
|
}
|
|
502
528
|
|
|
529
|
+
// ─── Agent plugin selection ────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Read the Agent-local plugin allowlists. Missing category fields mean
|
|
533
|
+
* inheritance (all discovered capabilities remain available).
|
|
534
|
+
*/
|
|
535
|
+
export function getPluginConfig(dir) {
|
|
536
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
537
|
+
const configPath = join(root, 'config.json');
|
|
538
|
+
try {
|
|
539
|
+
const json = readConfigForWrite(configPath);
|
|
540
|
+
return { plugins: normalizePluginConfig(json.plugins) };
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return { error: `Failed to read plugin config: ${err?.message || err}` };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Persist Agent-local plugin allowlists without touching providers, MCP server
|
|
548
|
+
* definitions, or any other config.json field.
|
|
549
|
+
*/
|
|
550
|
+
export function updatePluginConfig(plugins, dir) {
|
|
551
|
+
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
552
|
+
const configPath = join(root, 'config.json');
|
|
553
|
+
let normalized;
|
|
554
|
+
let existing;
|
|
555
|
+
try {
|
|
556
|
+
existing = readConfigForWrite(configPath);
|
|
557
|
+
normalized = normalizePluginConfig(plugins);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return { error: `Failed to read plugin config: ${err?.message || err}` };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (Object.keys(normalized).length === 0) delete existing.plugins;
|
|
563
|
+
else existing.plugins = normalized;
|
|
564
|
+
try {
|
|
565
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
566
|
+
} catch (err) {
|
|
567
|
+
return { error: `Failed to write plugin config: ${err?.message || err}` };
|
|
568
|
+
}
|
|
569
|
+
return { plugins: normalized };
|
|
570
|
+
}
|
|
571
|
+
|
|
503
572
|
// ─── MCP server config (mcpServers array in config.json) ──
|
|
504
573
|
|
|
505
574
|
/**
|
|
@@ -579,24 +648,6 @@ function validateMcpServer(entry) {
|
|
|
579
648
|
return null;
|
|
580
649
|
}
|
|
581
650
|
|
|
582
|
-
/**
|
|
583
|
-
* Read existing config.json (silently start fresh on missing / corrupt).
|
|
584
|
-
* Internal helper used by the MCP CRUD trio to share one parse path.
|
|
585
|
-
*
|
|
586
|
-
* @param {string} configPath
|
|
587
|
-
* @returns {object}
|
|
588
|
-
*/
|
|
589
|
-
function readConfigJson(configPath) {
|
|
590
|
-
if (!existsSync(configPath)) return {};
|
|
591
|
-
try {
|
|
592
|
-
const raw = readFileSync(configPath, 'utf8');
|
|
593
|
-
const json = JSON.parse(raw);
|
|
594
|
-
return (json && typeof json === 'object') ? json : {};
|
|
595
|
-
} catch {
|
|
596
|
-
return {};
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
651
|
/**
|
|
601
652
|
* List MCP servers currently saved in config.json. Returns an array — empty
|
|
602
653
|
* when none configured. Each entry is the normalised on-disk shape, NOT
|
|
@@ -609,12 +660,12 @@ export function listMcpServers(dir) {
|
|
|
609
660
|
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
610
661
|
const configPath = join(root, 'config.json');
|
|
611
662
|
try {
|
|
612
|
-
const json =
|
|
663
|
+
const json = readConfigForWrite(configPath);
|
|
613
664
|
const raw = Array.isArray(json.mcpServers) ? json.mcpServers : [];
|
|
614
665
|
const servers = raw.map(normaliseMcpServer).filter(Boolean);
|
|
615
666
|
return { servers };
|
|
616
|
-
} catch (
|
|
617
|
-
return { error: `Failed to read config.json: ${
|
|
667
|
+
} catch (err) {
|
|
668
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
618
669
|
}
|
|
619
670
|
}
|
|
620
671
|
|
|
@@ -634,7 +685,12 @@ export function upsertMcpServer(server, dir) {
|
|
|
634
685
|
|
|
635
686
|
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
636
687
|
const configPath = join(root, 'config.json');
|
|
637
|
-
|
|
688
|
+
let existing;
|
|
689
|
+
try {
|
|
690
|
+
existing = readConfigForWrite(configPath);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
693
|
+
}
|
|
638
694
|
const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
|
|
639
695
|
|
|
640
696
|
const normalised = normaliseMcpServer(server);
|
|
@@ -677,7 +733,12 @@ export function removeMcpServer(name, dir) {
|
|
|
677
733
|
const target = name.trim();
|
|
678
734
|
const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
679
735
|
const configPath = join(root, 'config.json');
|
|
680
|
-
|
|
736
|
+
let existing;
|
|
737
|
+
try {
|
|
738
|
+
existing = readConfigForWrite(configPath);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
741
|
+
}
|
|
681
742
|
const list = Array.isArray(existing.mcpServers) ? existing.mcpServers.slice() : [];
|
|
682
743
|
const next = list.filter(s => !(s && typeof s === 'object' && s.name === target));
|
|
683
744
|
const removed = next.length !== list.length;
|