@yeaft/webchat-agent 0.1.448 → 0.1.450
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/crew/role-output.js +43 -2
- package/crew/routing.js +86 -26
- package/package.json +1 -1
package/crew/role-output.js
CHANGED
|
@@ -12,6 +12,34 @@ import ctx from '../context.js';
|
|
|
12
12
|
// Context 使用率常量(运行时从 ctx.CONFIG 读取)
|
|
13
13
|
const getMaxContext = () => ctx.CONFIG?.maxContextTokens || 128000;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Detect routing intent in text that lacks a proper ROUTE block.
|
|
17
|
+
* Returns true if keywords suggest the role intended to route to someone.
|
|
18
|
+
* @param {string} text
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function _detectRouteIntent(text) {
|
|
22
|
+
if (!text || text.length < 10) return false;
|
|
23
|
+
// Check only the last 1000 chars (routing intent is usually at the end)
|
|
24
|
+
const tail = text.slice(-1000);
|
|
25
|
+
// Chinese patterns: 提交给/交给/请.*审查/转给/发给/route to
|
|
26
|
+
// English patterns: route to/submit to/forward to/hand off to/pass to
|
|
27
|
+
const intentPatterns = [
|
|
28
|
+
/提交给\s*\S+/,
|
|
29
|
+
/交给\s*\S+/,
|
|
30
|
+
/请\s*\S+\s*审查/,
|
|
31
|
+
/转给\s*\S+/,
|
|
32
|
+
/发给\s*\S+/,
|
|
33
|
+
/route\s+to\s+\S+/i,
|
|
34
|
+
/submit\s+to\s+\S+/i,
|
|
35
|
+
/forward\s+to\s+\S+/i,
|
|
36
|
+
/hand\s*off\s+to\s+\S+/i,
|
|
37
|
+
/pass\s+to\s+\S+/i,
|
|
38
|
+
/ROUTE[→:]\s*\S+/, // shorthand that parseRoutes might have already caught, but as safety net
|
|
39
|
+
];
|
|
40
|
+
return intentPatterns.some(p => p.test(tail));
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
/**
|
|
16
44
|
* 处理角色的流式输出
|
|
17
45
|
*/
|
|
@@ -224,8 +252,21 @@ export async function processRoleOutput(session, roleName, roleQuery, roleState)
|
|
|
224
252
|
});
|
|
225
253
|
sendStatusUpdate(session);
|
|
226
254
|
} else {
|
|
227
|
-
|
|
228
|
-
|
|
255
|
+
// ★ Route intent detection: if no ROUTE block but text suggests routing intent,
|
|
256
|
+
// auto-forward to PM so the message doesn't get lost
|
|
257
|
+
if (_detectRouteIntent(roleState.lastTurnText) && roleName !== session.decisionMaker) {
|
|
258
|
+
console.log(`[Crew] ${roleName} turn ended without ROUTE but has routing intent — auto-forwarding to PM`);
|
|
259
|
+
const autoSummary = `[auto-forward: ${roleName} 的输出包含路由意图但缺少 ROUTE 块]\n${(roleState.lastTurnText || '').slice(-500).trim()}`;
|
|
260
|
+
await executeRoute(session, roleName, {
|
|
261
|
+
to: session.decisionMaker,
|
|
262
|
+
summary: autoSummary,
|
|
263
|
+
taskId: roleState.currentTask?.taskId || null,
|
|
264
|
+
taskTitle: roleState.currentTask?.taskTitle || null,
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
const { processHumanQueue } = await import('./human-interaction.js');
|
|
268
|
+
await processHumanQueue(session);
|
|
269
|
+
}
|
|
229
270
|
}
|
|
230
271
|
}
|
|
231
272
|
}
|
package/crew/routing.js
CHANGED
|
@@ -38,42 +38,102 @@ function _appendTextToContent(content, text) {
|
|
|
38
38
|
*/
|
|
39
39
|
export function parseRoutes(text) {
|
|
40
40
|
const routes = [];
|
|
41
|
+
|
|
42
|
+
// ─── Pre-pass: Strip fenced code blocks to avoid parsing quoted ROUTE examples ──
|
|
43
|
+
// Replaces ```...``` content with whitespace of same length to preserve positions
|
|
44
|
+
text = text.replace(/```[\s\S]*?```/g, m => ' '.repeat(m.length));
|
|
45
|
+
|
|
46
|
+
// ─── Phase 1: Standard ROUTE blocks (with END_ROUTE) ──────────
|
|
41
47
|
// ★ Tolerate both underscore and space variants: ---END_ROUTE--- or ---END ROUTE---
|
|
42
|
-
|
|
48
|
+
// ★ Use negative lookahead to not cross another ---ROUTE--- boundary
|
|
49
|
+
const regex = /---ROUTE---\s*\n((?:(?!---ROUTE---)[\s\S])*?)---END[_ ]ROUTE---/g;
|
|
43
50
|
let match;
|
|
51
|
+
const matchedRanges = []; // track matched ranges to avoid double-parsing
|
|
44
52
|
|
|
45
53
|
while ((match = regex.exec(text)) !== null) {
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
// ★ Clean `to` value: take only the first word (strip parenthetical notes, extra text)
|
|
51
|
-
// e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
|
|
52
|
-
const toRaw = toMatch[1].trim().toLowerCase();
|
|
53
|
-
// Strip trailing punctuation (commas, semicolons, colons, etc.)
|
|
54
|
-
const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
55
|
-
|
|
56
|
-
// ★ summary: match until next known field (task:/taskTitle:) or end of block
|
|
57
|
-
const summaryMatch = block.match(/summary:\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*:|$)/i);
|
|
58
|
-
const taskMatch = block.match(/^task:\s*(.+)/im);
|
|
59
|
-
const taskTitleMatch = block.match(/^taskTitle:\s*(.+)/im);
|
|
60
|
-
|
|
61
|
-
let summary = summaryMatch ? summaryMatch[1].trim() : '';
|
|
62
|
-
if (!summary) {
|
|
63
|
-
summary = '[该角色未提供消息摘要]';
|
|
64
|
-
}
|
|
54
|
+
matchedRanges.push({ start: match.index, end: match.index + match[0].length });
|
|
55
|
+
const parsed = _parseRouteBlock(match[1]);
|
|
56
|
+
if (parsed) routes.push(parsed);
|
|
57
|
+
}
|
|
65
58
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
// ─── Phase 2: Fallback — ROUTE block missing END_ROUTE ────────
|
|
60
|
+
// Match ---ROUTE--- without a closing ---END_ROUTE---
|
|
61
|
+
// Take content until next ---ROUTE--- or EOF
|
|
62
|
+
const openRegex = /---ROUTE---\s*\n/g;
|
|
63
|
+
while ((match = openRegex.exec(text)) !== null) {
|
|
64
|
+
// Skip if this range was already captured by Phase 1
|
|
65
|
+
const pos = match.index;
|
|
66
|
+
if (matchedRanges.some(r => pos >= r.start && pos < r.end)) continue;
|
|
67
|
+
|
|
68
|
+
const blockStart = pos + match[0].length;
|
|
69
|
+
// End at next ---ROUTE--- or EOF
|
|
70
|
+
const nextRoute = text.indexOf('---ROUTE---', blockStart);
|
|
71
|
+
const blockEnd = nextRoute !== -1 ? nextRoute : text.length;
|
|
72
|
+
const block = text.slice(blockStart, blockEnd);
|
|
73
|
+
|
|
74
|
+
const parsed = _parseRouteBlock(block);
|
|
75
|
+
if (parsed) routes.push(parsed);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
|
|
79
|
+
// Matches single-line shorthands like: ROUTE → dev-1: summary here
|
|
80
|
+
// or: ROUTE: dev-1, summary here
|
|
81
|
+
const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
|
|
82
|
+
while ((match = shorthandRegex.exec(text)) !== null) {
|
|
83
|
+
// Skip if inside an already-matched ROUTE block range
|
|
84
|
+
const pos = match.index;
|
|
85
|
+
if (matchedRanges.some(r => pos >= r.start && pos < r.end)) continue;
|
|
86
|
+
// Also skip if the line is inside a ---ROUTE--- block (even unclosed)
|
|
87
|
+
const precedingText = text.slice(0, pos);
|
|
88
|
+
const lastRouteOpen = precedingText.lastIndexOf('---ROUTE---');
|
|
89
|
+
const lastRouteClose = Math.max(
|
|
90
|
+
precedingText.lastIndexOf('---END_ROUTE---'),
|
|
91
|
+
precedingText.lastIndexOf('---END ROUTE---')
|
|
92
|
+
);
|
|
93
|
+
if (lastRouteOpen > lastRouteClose) continue; // inside an open block
|
|
94
|
+
|
|
95
|
+
const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
|
|
96
|
+
const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
|
|
97
|
+
|
|
98
|
+
routes.push({ to: toRaw, summary, taskId: null, taskTitle: null });
|
|
72
99
|
}
|
|
73
100
|
|
|
74
101
|
return routes;
|
|
75
102
|
}
|
|
76
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Parse fields from a ROUTE block body (the content between ---ROUTE--- and ---END_ROUTE---).
|
|
106
|
+
* @param {string} block — raw block content
|
|
107
|
+
* @returns {{ to: string, summary: string, taskId: string|null, taskTitle: string|null } | null}
|
|
108
|
+
*/
|
|
109
|
+
function _parseRouteBlock(block) {
|
|
110
|
+
const toMatch = block.match(/to:\s*(.+)/i);
|
|
111
|
+
if (!toMatch) return null;
|
|
112
|
+
|
|
113
|
+
// ★ Clean `to` value: take only the first word (strip parenthetical notes, extra text)
|
|
114
|
+
// e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
|
|
115
|
+
const toRaw = toMatch[1].trim().toLowerCase();
|
|
116
|
+
// Strip trailing punctuation (commas, semicolons, colons, etc.)
|
|
117
|
+
const toClean = toRaw.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
|
|
118
|
+
|
|
119
|
+
// ★ summary: match until next known field (task:/taskTitle:) or end of block
|
|
120
|
+
const summaryMatch = block.match(/summary:\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*:|$)/i);
|
|
121
|
+
const taskMatch = block.match(/^task:\s*(.+)/im);
|
|
122
|
+
const taskTitleMatch = block.match(/^taskTitle:\s*(.+)/im);
|
|
123
|
+
|
|
124
|
+
let summary = summaryMatch ? summaryMatch[1].trim() : '';
|
|
125
|
+
if (!summary) {
|
|
126
|
+
summary = '[该角色未提供消息摘要]';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
to: toClean,
|
|
131
|
+
summary,
|
|
132
|
+
taskId: taskMatch ? taskMatch[1].trim() : null,
|
|
133
|
+
taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
77
137
|
/**
|
|
78
138
|
* Resolve a ROUTE `to` value to an actual role name in the session.
|
|
79
139
|
*
|