@yeaft/webchat-agent 1.0.77 → 1.0.78

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.
@@ -1,461 +0,0 @@
1
- /**
2
- * Crew — 角色输出处理
3
- * processRoleOutput(核心流式输出处理循环)
4
- */
5
- import { sendCrewMessage, sendCrewOutput, sendStatusUpdate, endRoleStreaming } from './ui-messages.js';
6
- import { saveRoleSessionId, clearRoleSessionId, classifyRoleError, createRoleQuery } from './role-query.js';
7
- import { parseRoutes, executeRoute, dispatchToRole } from './routing.js';
8
- import { parseCompletedTasks, updateFeatureIndex, appendChangelog, saveRoleWorkSummary, updateKanban } from './task-files.js';
9
- import { debouncedSaveSessionMeta, saveSessionMeta } from './persistence.js';
10
- import { recordRoutingEvent } from './routing-metrics.js';
11
- import { resolveFallbackTarget } from './routing-fallback.js';
12
- import ctx from '../context.js';
13
-
14
- // Context 使用率常量(运行时从 ctx.CONFIG 读取)
15
- const getMaxContext = () => ctx.CONFIG?.maxContextTokens || 128000;
16
-
17
- /**
18
- * Detect routing intent in text that lacks a proper ROUTE block.
19
- * Returns true if keywords suggest the role intended to route to someone.
20
- * @param {string} text
21
- * @returns {boolean}
22
- */
23
- export function _detectRouteIntent(text) {
24
- if (!text || text.length < 10) return false;
25
- // Check only the last 1000 chars (routing intent is usually at the end)
26
- const tail = text.slice(-1000);
27
- // Chinese patterns: 提交给/交给/请.*审查/转给/发给/route to
28
- // English patterns: route to/submit to/forward to/hand off to/pass to
29
- const intentPatterns = [
30
- /提交给\s*\S+/,
31
- /交给\s*\S+/,
32
- /请\s*\S+\s*审查/,
33
- /转给\s*\S+/,
34
- /发给\s*\S+/,
35
- /请\s*(?:rev|test)-\d+/i, // 请 rev-1/test-2 (explicit role mention with request)
36
- /PR\s*#\d+.*(?:请|review|审查)/i, // PR #123 + review request
37
- /(?:rev|test)-\d+\s*(?:审查|review|check|测试)/i, // rev-1 审查 / test-2 测试
38
- /route\s+to\s+\S+/i,
39
- /submit\s+to\s+\S+/i,
40
- /forward\s+to\s+\S+/i,
41
- /hand\s*off\s+to\s+\S+/i,
42
- /pass\s+to\s+\S+/i,
43
- /ROUTE[→:]\s*\S+/, // shorthand that parseRoutes might have already caught, but as safety net
44
- ];
45
- return intentPatterns.some(p => p.test(tail));
46
- }
47
-
48
- /**
49
- * 处理角色的流式输出
50
- */
51
- export async function processRoleOutput(session, roleName, roleQuery, roleState) {
52
- // 辅助函数:将 lastSeenUsage 结算到 session(用于 abort/error 场景,避免丢失 token)
53
- function settleLastSeenUsage() {
54
- if (!roleState.lastSeenUsage) return;
55
- const { totalCostUsd, inputTokens, outputTokens } = roleState.lastSeenUsage;
56
- if (totalCostUsd != null) {
57
- const costDelta = totalCostUsd - roleState.lastCostUsd;
58
- if (costDelta > 0) session.costUsd += costDelta;
59
- roleState.lastCostUsd = totalCostUsd;
60
- }
61
- if (inputTokens != null || outputTokens != null) {
62
- const inputDelta = (inputTokens || 0) - (roleState.lastInputTokens || 0);
63
- const outputDelta = (outputTokens || 0) - (roleState.lastOutputTokens || 0);
64
- if (inputDelta > 0) session.totalInputTokens += inputDelta;
65
- if (outputDelta > 0) session.totalOutputTokens += outputDelta;
66
- roleState.lastInputTokens = inputTokens || 0;
67
- roleState.lastOutputTokens = outputTokens || 0;
68
- }
69
- roleState.lastSeenUsage = null;
70
- }
71
-
72
- try {
73
- for await (const message of roleQuery) {
74
- // 检查 session 是否已停止或暂停
75
- if (session.status === 'stopped' || session.status === 'paused') break;
76
-
77
- // 每次收到带 usage/cost 的消息,暂存到 lastSeenUsage(供 abort/error 结算)
78
- if (message.total_cost_usd != null || message.usage) {
79
- roleState.lastSeenUsage = {
80
- totalCostUsd: message.total_cost_usd,
81
- inputTokens: message.usage?.input_tokens,
82
- outputTokens: message.usage?.output_tokens
83
- };
84
- }
85
-
86
- if (message.type === 'system' && message.subtype === 'init') {
87
- roleState.claudeSessionId = message.session_id;
88
- console.log(`[Crew] ${roleName} session: ${message.session_id}`);
89
-
90
- // Decision maker 的 system init 中捕获 slash_commands,发给前端用于 autocomplete
91
- const roleConfig = session.roles.get(roleName);
92
- if (roleConfig?.isDecisionMaker && message.slash_commands?.length > 0) {
93
- console.log(`[Crew] ${roleName} slash commands: ${message.slash_commands.join(', ')}`);
94
- sendCrewMessage({
95
- type: 'slash_commands_update',
96
- conversationId: session.id,
97
- slashCommands: message.slash_commands
98
- });
99
- }
100
- continue;
101
- }
102
-
103
- if (message.type === 'assistant') {
104
- const content = message.message?.content;
105
- if (content) {
106
- if (typeof content === 'string') {
107
- roleState.accumulatedText += content;
108
- sendCrewOutput(session, roleName, 'text', message);
109
- } else if (Array.isArray(content)) {
110
- let hasText = false;
111
- for (const block of content) {
112
- if (block.type === 'text') {
113
- roleState.accumulatedText += block.text;
114
- hasText = true;
115
- } else if (block.type === 'tool_use') {
116
- endRoleStreaming(session, roleName);
117
- roleState.currentTool = block.name;
118
- sendCrewOutput(session, roleName, 'tool_use', message);
119
- }
120
- }
121
- if (hasText) {
122
- sendCrewOutput(session, roleName, 'text', message);
123
- }
124
- }
125
- }
126
- } else if (message.type === 'user') {
127
- roleState.currentTool = null;
128
- sendCrewOutput(session, roleName, 'tool_result', message);
129
- } else if (message.type === 'result') {
130
- // Turn 完成
131
- console.log(`[Crew] ${roleName} turn completed`);
132
- roleState.consecutiveErrors = 0;
133
-
134
- endRoleStreaming(session, roleName);
135
-
136
- // 更新费用(通过 settleLastSeenUsage 统一处理,避免重复逻辑)
137
- settleLastSeenUsage();
138
-
139
- // 持久化 sessionId
140
- if (roleState.claudeSessionId) {
141
- saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId)
142
- .catch(e => console.warn(`[Crew] Failed to save sessionId for ${roleName}:`, e.message));
143
- }
144
-
145
- // context 使用率监控
146
- const inputTokens = message.usage?.input_tokens || 0;
147
- if (inputTokens > 0) {
148
- sendCrewMessage({
149
- type: 'crew_context_usage',
150
- sessionId: session.id,
151
- role: roleName,
152
- inputTokens,
153
- maxTokens: getMaxContext(),
154
- percentage: Math.min(100, Math.round((inputTokens / getMaxContext()) * 100))
155
- });
156
- }
157
-
158
- // 解析路由 — task-328: parseRoutes returns a decorated Array
159
- // (`.routes`/`.displayBody`/`.strippedRanges`). We keep treating it as
160
- // an Array for routing iteration, but use `.displayBody` whenever we
161
- // need the role's prose with ROUTE blocks accurately removed.
162
- const parseResult = parseRoutes(roleState.accumulatedText);
163
- const routes = parseResult;
164
- const displayBody = parseResult.displayBody || roleState.accumulatedText;
165
- // task-330b §B item 1: detect "parse-fail" — text mentions a ROUTE
166
- // opener but nothing parseable came out. Counts as a separate metric
167
- // from plain missing-route so dashboards can split malformed-block
168
- // (likely template/prompt drift) from forgot-to-write-block.
169
- if (routes.length === 0 && /---\s*ROUTE\s*---/i.test(roleState.accumulatedText || '')) {
170
- recordRoutingEvent(session, 'parse-fail', {
171
- fromRole: roleName,
172
- taskId: roleState.currentTask?.taskId || null,
173
- note: 'ROUTE opener present but no parseable block',
174
- });
175
- }
176
- // Fallback: 如果 route summary 仍为空占位符,用 displayBody 末尾 500 字符
177
- // task-330c: source switched from `accumulatedText.slice(-500)` to
178
- // `displayBody.slice(-500)` — accumulatedText still contains
179
- // ---ROUTE--- markers; using it would re-strip already-stripped text
180
- // and risk truncating mid-marker. displayBody is the parser's
181
- // post-strip prose and is the single source of truth for "what the
182
- // role said outside its routing blocks".
183
- // ⚠️ DO NOT add any further `.replace(/---ROUTE---.../g, '')` on
184
- // `displayBody` here or downstream — it is already strip-clean.
185
- for (const route of routes) {
186
- if (route.summary === '[该角色未提供消息摘要]' && displayBody) {
187
- const tail = displayBody.slice(-500).trim();
188
- if (tail) route.summary = `[auto-extracted]\n${tail}`;
189
- }
190
- }
191
-
192
- // 决策者 turn 完成:检测 TASKS block 中新完成的任务
193
- const roleConfig = session.roles.get(roleName);
194
- if (roleConfig?.isDecisionMaker) {
195
- const knownTaskIds = Array.from(session.features.keys());
196
- const nowCompleted = parseCompletedTasks(roleState.accumulatedText, knownTaskIds);
197
- if (nowCompleted.size > 0) {
198
- const prev = session._completedTaskIds || new Set();
199
- const newlyDone = [];
200
- for (const tid of nowCompleted) {
201
- if (!prev.has(tid)) {
202
- prev.add(tid);
203
- newlyDone.push(tid);
204
- }
205
- }
206
- session._completedTaskIds = prev;
207
- if (newlyDone.length > 0) {
208
- updateFeatureIndex(session).catch(e => console.warn('[Crew] Failed to update feature index:', e.message));
209
- for (const tid of newlyDone) {
210
- const feature = session.features.get(tid);
211
- const title = feature?.taskTitle || tid;
212
- appendChangelog(session, tid, title).catch(e => console.warn(`[Crew] Failed to append changelog for ${tid}:`, e.message));
213
- updateKanban(session, { taskId: tid, completed: true }).catch(e => console.warn(`[Crew] Failed to update kanban for ${tid}:`, e.message));
214
- }
215
- }
216
- }
217
- }
218
-
219
- // 保存本 turn 文本(供 routing.js 预检时 saveRoleWorkSummary 使用)
220
- // task-328: lastTurnText keeps the raw transcript (consumers may need
221
- // ROUTE blocks for analysis); lastTurnDisplayBody is the parser-clean
222
- // body used by auto-forward/UI logic to avoid sending broken ROUTE
223
- // residue downstream.
224
- roleState.lastTurnText = roleState.accumulatedText;
225
- roleState.lastTurnDisplayBody = displayBody;
226
- roleState.accumulatedText = '';
227
- roleState.turnActive = false;
228
-
229
- // Mark pending tool messages as completed before notifying frontend
230
- for (const m of session.uiMessages) {
231
- if (m.role === roleName && m.type === 'tool' && !m.hasResult) {
232
- m.hasResult = true;
233
- }
234
- }
235
-
236
- sendCrewMessage({
237
- type: 'crew_turn_completed',
238
- sessionId: session.id,
239
- role: roleName
240
- });
241
-
242
- sendStatusUpdate(session);
243
- // Cost/tokens/messages updated — debounced persist (coalesces rapid turn-ends)
244
- debouncedSaveSessionMeta(session);
245
-
246
- // 执行路由
247
- if (routes.length > 0) {
248
- session.round++;
249
-
250
- // ★ Collect turn images for auto-attach (last 3, then clear)
251
- const turnImages = roleState.turnImages || [];
252
- roleState.turnImages = [];
253
-
254
- const currentTask = roleState.currentTask;
255
- for (const route of routes) {
256
- if (!route.taskId && currentTask) {
257
- route.taskId = currentTask.taskId;
258
- route.taskTitle = currentTask.taskTitle;
259
- }
260
- }
261
-
262
- // 通知前端进入 routing 状态
263
- sendCrewMessage({
264
- type: 'crew_routing',
265
- sessionId: session.id,
266
- fromRole: roleName,
267
- routes: routes.map(r => ({ to: r.to, taskId: r.taskId, taskTitle: r.taskTitle })),
268
- status: 'routing'
269
- });
270
-
271
- const results = await Promise.allSettled(routes.map(route =>
272
- executeRoute(session, roleName, route, turnImages)
273
- ));
274
- for (const r of results) {
275
- if (r.status === 'rejected') {
276
- console.warn(`[Crew] Route execution failed:`, r.reason);
277
- }
278
- }
279
-
280
- // routing 完成,通知前端恢复正常状态
281
- sendCrewMessage({
282
- type: 'crew_routing',
283
- sessionId: session.id,
284
- fromRole: roleName,
285
- status: 'done'
286
- });
287
- sendStatusUpdate(session);
288
- } else {
289
- // ★ No ROUTE found — resolve via the single fallback policy
290
- // (task-330b §B item 3). The resolver enforces:
291
- // - PM never auto-forwards to itself (parks pending instead)
292
- // - non-PM with active task / routing intent → PM (auto-forward)
293
- // - everyone else → null (process human queue)
294
- const hasActiveTask = !!roleState.currentTask;
295
- const hasRouteIntent = _detectRouteIntent(roleState.lastTurnText);
296
- const fallbackTo = resolveFallbackTarget(session, roleName, 'missing-route', {
297
- hasActiveTask,
298
- hasRouteIntent,
299
- });
300
-
301
- if (fallbackTo) {
302
- // §B item 1 — record the metric on every fallback dispatch.
303
- const reason = hasActiveTask ? 'has active task' : 'has routing intent';
304
- recordRoutingEvent(session, 'fallback-forward', {
305
- fromRole: roleName,
306
- toRole: fallbackTo,
307
- taskId: roleState.currentTask?.taskId || null,
308
- note: reason,
309
- });
310
- // Also record the upstream cause so dashboards can split
311
- // "missing ROUTE" events from the auto-forward dispatches.
312
- recordRoutingEvent(session, 'missing-route', {
313
- fromRole: roleName,
314
- taskId: roleState.currentTask?.taskId || null,
315
- note: reason,
316
- });
317
- // task-328: forward parser-clean displayBody (ROUTE residue removed)
318
- // so PM sees the actual prose, not stray END markers.
319
- console.log(`[Crew] ${roleName} turn ended without ROUTE (${reason}) — auto-forwarding to ${fallbackTo}`);
320
- const forwardSource = roleState.lastTurnDisplayBody || roleState.lastTurnText || '';
321
- const autoSummary = `[auto-forward: ${roleName} turn 结束但未输出 ROUTE 块 (${reason})]\n${forwardSource.slice(-800).trim()}`;
322
- await executeRoute(session, roleName, {
323
- to: fallbackTo,
324
- summary: autoSummary,
325
- taskId: roleState.currentTask?.taskId || null,
326
- taskTitle: roleState.currentTask?.taskTitle || null,
327
- });
328
- } else {
329
- // §B item 2 — PM no-auto-forward: when PM ends a turn without
330
- // ROUTE we DO NOT self-route. Instead we record the metric and
331
- // park the session (effectively pending) so the user's next
332
- // input drives the next step. Same applies to non-PM roles
333
- // with no active task / no routing intent.
334
- if (roleName === session.decisionMaker && (hasActiveTask || hasRouteIntent)) {
335
- recordRoutingEvent(session, 'missing-route', {
336
- fromRole: roleName,
337
- taskId: roleState.currentTask?.taskId || null,
338
- note: 'pm-pending (no auto-forward)',
339
- });
340
- console.log(`[Crew] ${roleName} (PM) turn ended without ROUTE — staying pending (no self-forward)`);
341
- }
342
- const { processHumanQueue } = await import('./human-interaction.js');
343
- await processHumanQueue(session);
344
- }
345
- }
346
- }
347
- }
348
- } catch (error) {
349
- if (error.name === 'AbortError') {
350
- console.log(`[Crew] ${roleName} aborted`);
351
- // 结算 abort 前累积的 usage,避免丢失 token
352
- settleLastSeenUsage();
353
- if (session.status === 'paused' && roleState.accumulatedText) {
354
- const routes = parseRoutes(roleState.accumulatedText);
355
- if (routes.length > 0 && session.pendingRoutes.length === 0) {
356
- // Fill missing taskId from roleState.currentTask (same as normal path L170-175)
357
- const currentTask = roleState.currentTask;
358
- for (const route of routes) {
359
- if (!route.taskId && currentTask) {
360
- route.taskId = currentTask.taskId;
361
- route.taskTitle = currentTask.taskTitle;
362
- }
363
- }
364
- session.pendingRoutes = routes.map(route => ({ fromRole: roleName, route }));
365
- console.log(`[Crew] Saved ${routes.length} pending route(s) from aborted ${roleName}`);
366
- }
367
- roleState.accumulatedText = '';
368
- }
369
- } else {
370
- console.error(`[Crew] ${roleName} error:`, error.message);
371
-
372
- // 结算 error 前累积的 usage,避免丢失 token
373
- settleLastSeenUsage();
374
-
375
- // Step 1: 清理 roleState
376
- endRoleStreaming(session, roleName);
377
- const errorTurnText = roleState.accumulatedText;
378
- roleState.query = null;
379
- roleState.inputStream = null;
380
- roleState.turnActive = false;
381
- roleState.accumulatedText = '';
382
-
383
- // Step 2: 错误分类
384
- const classification = classifyRoleError(error);
385
- roleState.consecutiveErrors++;
386
-
387
- // Step 3: 通知前端
388
- sendCrewMessage({
389
- type: 'crew_role_error',
390
- sessionId: session.id,
391
- role: roleName,
392
- error: error.message.substring(0, 500),
393
- reason: classification.reason,
394
- recoverable: classification.recoverable,
395
- retryCount: roleState.consecutiveErrors
396
- });
397
- sendStatusUpdate(session);
398
-
399
- // Step 4: 判断是否重试
400
- const MAX_RETRIES = 3;
401
- if (!classification.recoverable || roleState.consecutiveErrors > MAX_RETRIES) {
402
- const exhausted = roleState.consecutiveErrors > MAX_RETRIES;
403
- const errDetail = exhausted
404
- ? `角色 ${roleName} 连续 ${MAX_RETRIES} 次错误后停止重试。最后错误: ${error.message}`
405
- : `角色 ${roleName} 不可恢复错误: ${error.message}`;
406
- if (roleName !== session.decisionMaker) {
407
- await dispatchToRole(session, session.decisionMaker, errDetail, 'system');
408
- } else {
409
- session.status = 'waiting_human';
410
- sendCrewMessage({
411
- type: 'crew_human_needed',
412
- sessionId: session.id,
413
- fromRole: roleName,
414
- reason: 'error',
415
- message: errDetail
416
- });
417
- sendStatusUpdate(session);
418
- // Status changed to waiting_human — persist
419
- saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save after error→human:', e.message));
420
- }
421
- return;
422
- }
423
-
424
- // Step 5: 可恢复 → 保存摘要后 clear + 重建重试
425
- console.log(`[Crew] ${roleName} attempting recovery (${classification.reason}), retry ${roleState.consecutiveErrors}/${MAX_RETRIES}`);
426
-
427
- sendCrewOutput(session, 'system', 'system', {
428
- type: 'assistant',
429
- message: { role: 'assistant', content: [{
430
- type: 'text',
431
- text: `${roleName} 遇到 ${classification.reason},正在自动恢复 (${roleState.consecutiveErrors}/${MAX_RETRIES})...`
432
- }] }
433
- });
434
-
435
- if (roleState.lastDispatchContent) {
436
- // 保存工作摘要
437
- await saveRoleWorkSummary(session, roleName, errorTurnText).catch(e =>
438
- console.warn(`[Crew] Failed to save work summary for ${roleName}:`, e.message));
439
-
440
- // 所有可恢复错误统一 clear + rebuild
441
- await clearRoleSessionId(session.sharedDir, roleName);
442
- const consecutiveErrors = roleState.consecutiveErrors;
443
- await dispatchToRole(
444
- session, roleName,
445
- roleState.lastDispatchContent,
446
- roleState.lastDispatchFrom || 'system',
447
- roleState.lastDispatchTaskId,
448
- roleState.lastDispatchTaskTitle
449
- );
450
- // 保持错误计数
451
- const newState = session.roleStates.get(roleName);
452
- if (newState) newState.consecutiveErrors = consecutiveErrors;
453
- } else {
454
- const msg = `角色 ${roleName} 已恢复(${classification.reason}),但无待重试消息。`;
455
- if (roleName !== session.decisionMaker) {
456
- await dispatchToRole(session, session.decisionMaker, msg, 'system');
457
- }
458
- }
459
- }
460
- }
461
- }