@steerable/agent-shell 0.6.27 → 0.6.29

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.
@@ -295,6 +295,14 @@
295
295
  }
296
296
  },
297
297
  "version": 2,
298
+ "dualImplementations": {
299
+ "todo_write": ["python", "rust"],
300
+ "web_fetch": ["python", "rust"],
301
+ "web_search": ["python", "rust"],
302
+ "run_code": ["python", "rust"],
303
+ "run_js": ["python", "rust"],
304
+ "wait_js": ["python", "rust"]
305
+ },
298
306
  "versionAlgorithm": "sha256-utf8-hex",
299
307
  "versionToken": {
300
308
  "inputField": "expectedVersion",
@@ -14,9 +14,8 @@
14
14
  * 2. sidecar 技能目录 —— `SkillTurnContext.exclude`
15
15
  * 3. 工具面 —— 每轮 `turnTools`、`tool_search` 结果、反向通道分发复检
16
16
  *
17
- * `@提及` 多个智能体的回合按「最宽松」合并:提及额外专家是为了叠加能力,
18
- * 不应该反而让对话失去工具或技能(与 personaPreamble 的「同时具备以下 N 个
19
- * 角色的能力」一致)。
17
+ * 多个智能体配置按「最宽松」合并(父代理自身、或显式传入的列表)。
18
+ * `@` 提及不再合并进父的能力面——被点名的智能体走子代理画像。
20
19
  */
21
20
  export type AgentToolPolicyMode = 'all' | 'allowlist' | 'denylist';
22
21
  export interface AgentToolPolicy {
@@ -30,7 +29,7 @@ export interface AgentCapabilityInput {
30
29
  loadAllSkills: boolean;
31
30
  toolPolicy: AgentToolPolicy;
32
31
  }
33
- /** 本轮生效的能力面(可能是多个被提及智能体的合并结果)。 */
32
+ /** 本轮生效的能力面(可能是多个智能体配置的合并结果)。 */
34
33
  export interface AgentCapability {
35
34
  /** 正文无条件注入的技能别名(dirName / name / displayName 皆可)。 */
36
35
  pinnedSkills: string[];
@@ -74,7 +73,7 @@ export declare function filterToolsByPolicy<T extends {
74
73
  /**
75
74
  * 合并本轮涉及的全部智能体的能力面,取最宽松。
76
75
  *
77
- * @param agents 按提及顺序排列的智能体配置;空数组表示无绑定。
76
+ * @param agents 按顺序排列的智能体配置;空数组表示无绑定。
78
77
  * @returns 本轮生效的能力面;空数组时为 {@link UNRESTRICTED_CAPABILITY}。
79
78
  */
80
79
  export declare function mergeAgentCapabilities(agents: readonly AgentCapabilityInput[]): AgentCapability;
@@ -14,9 +14,8 @@
14
14
  * 2. sidecar 技能目录 —— `SkillTurnContext.exclude`
15
15
  * 3. 工具面 —— 每轮 `turnTools`、`tool_search` 结果、反向通道分发复检
16
16
  *
17
- * `@提及` 多个智能体的回合按「最宽松」合并:提及额外专家是为了叠加能力,
18
- * 不应该反而让对话失去工具或技能(与 personaPreamble 的「同时具备以下 N 个
19
- * 角色的能力」一致)。
17
+ * 多个智能体配置按「最宽松」合并(父代理自身、或显式传入的列表)。
18
+ * `@` 提及不再合并进父的能力面——被点名的智能体走子代理画像。
20
19
  */
21
20
  /** 无智能体绑定(或未配置)的回合:不钉技能、不限制。 */
22
21
  export const UNRESTRICTED_CAPABILITY = {
@@ -102,7 +101,7 @@ function mergeToolPolicies(a, b) {
102
101
  /**
103
102
  * 合并本轮涉及的全部智能体的能力面,取最宽松。
104
103
  *
105
- * @param agents 按提及顺序排列的智能体配置;空数组表示无绑定。
104
+ * @param agents 按顺序排列的智能体配置;空数组表示无绑定。
106
105
  * @returns 本轮生效的能力面;空数组时为 {@link UNRESTRICTED_CAPABILITY}。
107
106
  */
108
107
  export function mergeAgentCapabilities(agents) {
@@ -1,52 +1,34 @@
1
1
  /**
2
- * 回合结束后生成「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
2
+ * 回合结束后生成「下一轮用户输入」建议。
3
3
  *
4
- * 主路径走一次短 LLM 调用。优先消化用户/工作区技能和助手回复里已经写明的
5
- * 下一步(编号列表、脚本调用、推荐后续),条数跟真实下一步走,不凑 3 条。
6
- * 没有可执行下一步时,才用启发式兜底(PPT / 计划 / 代码 / 通用)。永不抛错。
4
+ * 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
5
+ * `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
6
+ * 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
7
7
  *
8
- * 调用方应该 fire-and-forget:先把兜底推上 UI,LLM 成功后再替换。
8
+ * 调用方应该 fire-and-forget,且只广播一次最终结果。
9
9
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
10
10
  */
11
11
  export declare const SUGGESTED_REPLY_MAX = 8;
12
- /** 去掉脚本调用、绝对路径,留下用户能点的短标题。 */
13
- export declare function stripSkillInvocation(raw: string): string;
14
- /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
15
- export declare function cleanSuggestedReply(raw: string): string;
16
12
  /**
17
- * 从技能正文抽出「下一步 / 后续 / Next steps」节里的列表项。
18
- * 兼容用户自建技能:标题不固定,脚本调用行只保留标题。
13
+ * 建议芯片的判断来源:有 `[next_steps]` 用最后一段标签正文,否则用回复最后一段。
19
14
  */
20
- export declare function extractSkillNextSteps(content: string): string[];
21
- /**
22
- * 从助手回复抽出已列出的下一步。先看「下一步」类标题,再看末尾带脚本调用的编号列表。
23
- */
24
- export declare function extractListedNextSteps(text: string): string[];
15
+ export declare function extractNextStepsSource(assistantText: string): string;
16
+ /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
17
+ export declare function cleanSuggestedReply(raw: string): string;
25
18
  /**
26
19
  * 从模型原文抽出建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
27
20
  * 最多 {@link SUGGESTED_REPLY_MAX} 条。
28
21
  */
29
22
  export declare function parseSuggestedReplies(raw: string): string[];
30
- export interface FallbackSuggestedRepliesOptions {
31
- /** 用户/工作区技能正文(可多份)。会从中抽出「下一步」节。 */
32
- skillContents?: string[];
33
- }
34
- /**
35
- * 不调模型的建议。技能或回复里已有下一步时原样采用(条数不固定),
36
- * 否则回落到启发式句子。
37
- */
38
- export declare function fallbackSuggestedReplies(userText: string, assistantText: string, opts?: FallbackSuggestedRepliesOptions): string[];
39
23
  export interface GenerateSuggestedRepliesResult {
40
24
  suggestions: string[];
41
- /** true = LLM 失败或输出不合格,且没有从技能/回复抽出下一步,suggestions 来自启发式兜底。 */
25
+ /** true = LLM 失败、超时或输出不合格;suggestions 为空。空数组本身算有效判断。 */
42
26
  usedFallback: boolean;
43
27
  }
44
28
  export interface GenerateSuggestedRepliesOptions {
45
29
  perAttemptTimeoutMs?: number;
46
- /** 用户/工作区技能正文。生成器只抽「下一步」节,不依赖内置技能。 */
47
- skillContents?: string[];
48
30
  }
49
31
  /**
50
- * 生成本轮追问建议。永不抛错;有技能/回复下一步时按实际条数返回。
32
+ * 生成本轮追问建议。永不抛错;没有下一步来源或模型判定没有下一步时返回空数组。
51
33
  */
52
34
  export declare function generateSuggestedReplies(userText: string, assistantText: string, opts?: GenerateSuggestedRepliesOptions): Promise<GenerateSuggestedRepliesResult>;
@@ -1,39 +1,32 @@
1
1
  /**
2
- * 回合结束后生成「下一轮用户输入」建议(WorkBuddy 式快捷追问)。
2
+ * 回合结束后生成「下一轮用户输入」建议。
3
3
  *
4
- * 主路径走一次短 LLM 调用。优先消化用户/工作区技能和助手回复里已经写明的
5
- * 下一步(编号列表、脚本调用、推荐后续),条数跟真实下一步走,不凑 3 条。
6
- * 没有可执行下一步时,才用启发式兜底(PPT / 计划 / 代码 / 通用)。永不抛错。
4
+ * 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
5
+ * `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
6
+ * 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
7
7
  *
8
- * 调用方应该 fire-and-forget:先把兜底推上 UI,LLM 成功后再替换。
8
+ * 调用方应该 fire-and-forget,且只广播一次最终结果。
9
9
  * 不要挂在 SSE 主流程里阻塞 `[DONE]`。
10
10
  */
11
11
  import { llmService } from '../llm/index.js';
12
12
  export const SUGGESTED_REPLY_MAX = 8;
13
13
  const MAX_SUGGESTION_CHARS = 48;
14
14
  const USER_TEXT_LIMIT = 500;
15
- const ASSISTANT_HEAD_LIMIT = 800;
16
- const ASSISTANT_TAIL_LIMIT = 2000;
17
- const SKILL_EXCERPT_LIMIT = 4000;
18
- const SYSTEM_PROMPT = `你是对话追问建议助手。根据用户上一轮请求、助手刚刚完成的回复,以及用户技能里写明的下一步,生成用户点一下就能发出去的下一轮输入。
15
+ const SOURCE_LIMIT = 2000;
16
+ const NEXT_STEPS_BLOCK_RE = /\[next_steps\]([\s\S]*?)\[\/next_steps\]/gi;
17
+ const SYSTEM_PROMPT = `你是对话追问建议助手。根据「下一步来源」判断用户点一下就能发出去的下一轮输入。
18
+
19
+ 下一步来源只有两种:
20
+ - [next_steps]...[/next_steps] 标签里的内容
21
+ - 否则是助手回复的最后一段
19
22
 
20
23
  要求:
21
- 1. 条数不固定:有几条真实的下一步就给几条,最少 1 条,最多 8 条。不要为了凑数编造,也不要无故压成 3 条。
22
- 2. 优先使用「用户技能中的下一步」和助手回复末尾已经列出的后续动作(编号列表、脚本调用、推荐后续)。把它们改写成用户口吻的短指令,保留层位、对象、参数等关键信息,不要丢掉技能里的具体动作。
23
- 3. 助手本轮已经做完的步骤不要再建议;技能里写了但还没做的后续必须出现。
24
- 4. 每条 6-40 个字,像用户会打的话。不要空泛的「再详细说说」「告诉我下一步怎么做」。
25
- 5. 不要编号、不要引号、不要解释、不要附带命令行或绝对路径。
26
- 6. 只输出 JSON 字符串数组,例如 ["画接底层的井密度交会图","统计这口井的有效厚度","补一张气层厚度等值线"]`;
27
- const NEXT_STEP_WORDS = '下一步(?:动作|操作|工作)?|后续(?:步骤|工作|动作|操作|可做)?|建议(?:的)?(?:下一步|继续|操作)?|可以继续|推荐(?:的)?(?:后续|操作|下一步)?|next\\s*steps?';
28
- /** 整行即「下一步」类标题:可带 #/** 前缀、括号补充说明(如「后续动作(可点选)」)、结尾冒号。 */
29
- const NEXT_STEP_HEADING_RE = new RegExp(`^(?:#{1,6}\\s*)?(?:\\*\\*)?(?:${NEXT_STEP_WORDS})(?:\\s*[((][^))]*[))])?\\s*(?:\\*\\*)?\\s*[::]?$`, 'i');
30
- /**
31
- * 短引导行,如「完成后可以继续:」。必须以关键词加冒号收尾且整行够短——
32
- * 否则「以上是我的建议」这类普通句尾会被当成标题,把后面无关的列表抽成建议。
33
- */
34
- const NEXT_STEP_LEAD_IN_RE = new RegExp(`(?:${NEXT_STEP_WORDS})\\s*[::]$`, 'i');
35
- const NEXT_STEP_LEAD_IN_MAX_CHARS = 16;
36
- // 列表标记后必须有空白,否则 PowerShell 参数(-Action)会被当成列表项。
24
+ 1. 只把「用户可以接着做的下一步」改写成用户口吻的短指令。
25
+ 2. 本轮已完成的汇报(文件位置、页数、设计风格、内容结构、摘要、目录、生平/作品列表)不是下一步。来源不是给用户的下一步时,输出空数组 []。
26
+ 3. 不要编造,不要用「继续完善这份结果」「告诉我下一步怎么做」这类套话凑数。
27
+ 4. 有几条真实下一步就给几条,最少 0 条,最多 8 条。
28
+ 5. 每条 6-40 个字。不要编号、不要引号、不要解释、不要附带命令行或绝对路径。
29
+ 6. 只输出 JSON 字符串数组,例如 ["把封面改成深蓝商务风","第2页个人简介写具体"]`;
37
30
  const LIST_ITEM_RE = /^(?:\d+[.)、]|[-*•])\s+(\S.*)$/;
38
31
  function withTimeout(promise, timeoutMs, label) {
39
32
  if (timeoutMs <= 0)
@@ -57,54 +50,33 @@ function clip(text, limit) {
57
50
  return chars.join('');
58
51
  return `${chars.slice(0, limit).join('')}…`;
59
52
  }
60
- function clipHeadTail(text, headLimit, tailLimit) {
61
- const chars = Array.from((text ?? '').trim());
62
- if (chars.length <= headLimit + tailLimit)
63
- return chars.join('');
64
- return `${chars.slice(0, headLimit).join('')}…\n…${chars.slice(-tailLimit).join('')}`;
65
- }
66
- function isNextStepHeading(line) {
67
- return NEXT_STEP_HEADING_RE.test(line.trim());
68
- }
69
- function isNextStepLeadIn(line) {
70
- const trimmed = line.trim();
71
- if (isNextStepHeading(trimmed))
72
- return true;
73
- return (Array.from(trimmed).length <= NEXT_STEP_LEAD_IN_MAX_CHARS &&
74
- NEXT_STEP_LEAD_IN_RE.test(trimmed));
75
- }
76
- function isMarkdownHeading(line) {
77
- return /^\s*#{1,6}\s+\S/.test(line);
53
+ function lastParagraph(text) {
54
+ const trimmed = (text ?? '').trim();
55
+ if (!trimmed)
56
+ return '';
57
+ const parts = trimmed.split(/\n\s*\n/);
58
+ return (parts[parts.length - 1] ?? '').trim();
78
59
  }
79
- /** 列表项正文;不是列表项返回 null。 */
80
- function listItemBody(line) {
81
- const matched = line.trim().match(LIST_ITEM_RE);
82
- return matched ? matched[1].trim() : null;
60
+ /**
61
+ * 建议芯片的判断来源:有 `[next_steps]` 用最后一段标签正文,否则用回复最后一段。
62
+ */
63
+ export function extractNextStepsSource(assistantText) {
64
+ const text = assistantText ?? '';
65
+ const matches = [...text.matchAll(new RegExp(NEXT_STEPS_BLOCK_RE.source, 'gi'))];
66
+ if (matches.length > 0) {
67
+ return (matches[matches.length - 1][1] ?? '').trim();
68
+ }
69
+ return lastParagraph(text);
83
70
  }
84
71
  function isListItem(line) {
85
- return listItemBody(line) !== null;
72
+ return LIST_ITEM_RE.test(line.trim());
86
73
  }
87
- function hasScriptInvocation(text) {
88
- return (/(?:^|\s)[&|]\s+/.test(text) ||
89
- /\.(?:ps1|py|js|mjs|sh|bat|cmd)\b/i.test(text) ||
90
- /\{scripts\}/i.test(text) ||
91
- /(?:^|[\s`])(?:powershell|pwsh)\b/i.test(text) ||
92
- /[A-Za-z]:\\/.test(text) ||
93
- /[/\\]skills[/\\]/.test(text));
94
- }
95
- /** 去掉脚本调用、绝对路径,留下用户能点的短标题。 */
96
- export function stripSkillInvocation(raw) {
74
+ /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
75
+ export function cleanSuggestedReply(raw) {
97
76
  let text = (raw ?? '').trim();
98
77
  text = text.replace(/`([^`]+)`/g, '$1');
99
78
  text = text.replace(/\s+[&|]\s+.+$/s, '');
100
79
  text = text.replace(/\s+[A-Za-z]:\\[^\s].*$/, '');
101
- text = text.replace(/\s+(?:powershell(?:\.exe)?|pwsh)\b.*$/i, '');
102
- text = text.replace(/\s+\{scripts\}.*$/i, '');
103
- return text.trim();
104
- }
105
- /** 单条建议清洗:去编号/引号、压空白、截断。不合格返回空串。 */
106
- export function cleanSuggestedReply(raw) {
107
- let text = stripSkillInvocation(raw ?? '');
108
80
  text = text.replace(/^[\s"'“”‘’「」『』《》【】]+|[\s"'“”‘’「」『』《》【】。.!!??,,;;::]+$/g, '');
109
81
  text = text.replace(/^(?:\d+[\.\)、]|[-*•])\s*/, '');
110
82
  text = text.replace(/\s+/g, ' ').trim();
@@ -116,10 +88,10 @@ export function cleanSuggestedReply(raw) {
116
88
  }
117
89
  return text;
118
90
  }
119
- function uniqueSuggestions(candidates, extras = []) {
91
+ function uniqueSuggestions(candidates) {
120
92
  const out = [];
121
93
  const seen = new Set();
122
- for (const raw of [...candidates, ...extras]) {
94
+ for (const raw of candidates) {
123
95
  const cleaned = cleanSuggestedReply(raw);
124
96
  if (!cleaned || seen.has(cleaned))
125
97
  continue;
@@ -130,147 +102,37 @@ function uniqueSuggestions(candidates, extras = []) {
130
102
  }
131
103
  return out;
132
104
  }
133
- function collectListAfter(lines, start) {
134
- const items = [];
135
- for (let i = start; i < lines.length; i += 1) {
136
- const trimmed = lines[i].trim();
137
- // 列表项之间常有空行,空行不终止收集。
138
- if (!trimmed)
139
- continue;
140
- if (isMarkdownHeading(trimmed) || isNextStepHeading(trimmed))
141
- break;
142
- const body = listItemBody(trimmed);
143
- if (body === null) {
144
- if (items.length > 0)
145
- break;
146
- continue;
147
- }
148
- const title = stripSkillInvocation(body);
149
- if (title)
150
- items.push(title);
151
- }
152
- return items;
153
- }
154
- /**
155
- * 从技能正文抽出「下一步 / 后续 / Next steps」节里的列表项。
156
- * 兼容用户自建技能:标题不固定,脚本调用行只保留标题。
157
- */
158
- export function extractSkillNextSteps(content) {
159
- const lines = (content ?? '').split(/\r?\n/);
160
- const collected = [];
161
- for (let i = 0; i < lines.length; i += 1) {
162
- if (!isNextStepLeadIn(lines[i]))
163
- continue;
164
- collected.push(...collectListAfter(lines, i + 1));
165
- }
166
- return uniqueSuggestions(collected);
167
- }
168
- function extractHeadingNextSteps(text) {
169
- const lines = (text ?? '').split(/\r?\n/);
170
- let lastLeadIn = -1;
171
- for (let i = 0; i < lines.length; i += 1) {
172
- if (isNextStepLeadIn(lines[i]))
173
- lastLeadIn = i;
174
- }
175
- if (lastLeadIn < 0)
176
- return [];
177
- return uniqueSuggestions(collectListAfter(lines, lastLeadIn + 1));
178
- }
179
- function extractTrailingScriptedList(text) {
180
- const lines = (text ?? '').split(/\r?\n/);
181
- let best = [];
182
- let i = 0;
183
- while (i < lines.length) {
184
- if (!isListItem(lines[i])) {
185
- i += 1;
186
- continue;
187
- }
188
- const rawItems = [];
189
- const titles = [];
190
- while (i < lines.length) {
191
- const trimmed = lines[i].trim();
192
- if (!trimmed) {
193
- i += 1;
194
- continue;
195
- }
196
- const body = listItemBody(trimmed);
197
- if (body === null)
198
- break;
199
- rawItems.push(body);
200
- const title = stripSkillInvocation(body);
201
- if (title)
202
- titles.push(title);
203
- i += 1;
204
- }
205
- if (titles.length >= 2 && rawItems.some((item) => hasScriptInvocation(item))) {
206
- best = titles;
207
- }
105
+ function sliceJsonArray(raw) {
106
+ const trimmed = (raw ?? '').trim();
107
+ if (!trimmed)
108
+ return undefined;
109
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
110
+ const candidate = (fenced?.[1] ?? trimmed).trim();
111
+ const start = candidate.indexOf('[');
112
+ const end = candidate.lastIndexOf(']');
113
+ if (start < 0 || end <= start)
114
+ return undefined;
115
+ try {
116
+ return JSON.parse(candidate.slice(start, end + 1));
208
117
  }
209
- return uniqueSuggestions(best);
210
- }
211
- /**
212
- * 从助手回复抽出已列出的下一步。先看「下一步」类标题,再看末尾带脚本调用的编号列表。
213
- */
214
- export function extractListedNextSteps(text) {
215
- const fromHeading = extractHeadingNextSteps(text);
216
- if (fromHeading.length > 0)
217
- return fromHeading;
218
- return extractTrailingScriptedList(text);
219
- }
220
- function skillTitle(content) {
221
- const first = (content.split(/\r?\n/, 1)[0] ?? '').trim();
222
- const matched = first.match(/^#{1,6}\s+(.*)$/);
223
- return matched ? matched[1].trim() : '';
224
- }
225
- /**
226
- * 各技能「下一步」节的合并结果,本轮点到名的技能排在前面。用户可能装了很多
227
- * 技能,与本轮无关的后续不该把相关的那几条挤出 {@link SUGGESTED_REPLY_MAX}。
228
- */
229
- function extractFromSkillContents(skillContents, turnText = '') {
230
- if (!skillContents || skillContents.length === 0)
231
- return [];
232
- const ranked = skillContents
233
- .map((content, index) => {
234
- const title = skillTitle(content);
235
- return { content, index, mentioned: title.length >= 2 && turnText.includes(title) };
236
- })
237
- .sort((a, b) => Number(b.mentioned) - Number(a.mentioned) || a.index - b.index);
238
- const collected = [];
239
- for (const entry of ranked) {
240
- collected.push(...extractSkillNextSteps(entry.content));
118
+ catch {
119
+ return undefined;
241
120
  }
242
- return uniqueSuggestions(collected);
243
- }
244
- /** 本轮可用的下一步:助手回复里已列出的,加上用户技能写明的。 */
245
- function collectNextSteps(userText, assistantText, skillContents) {
246
- return uniqueSuggestions([
247
- ...extractListedNextSteps(assistantText),
248
- ...extractFromSkillContents(skillContents, `${userText}\n${assistantText}`),
249
- ]);
250
121
  }
251
122
  /**
252
123
  * 从模型原文抽出建议。接受 JSON 数组、markdown 代码块、或编号/项目列表。
253
124
  * 最多 {@link SUGGESTED_REPLY_MAX} 条。
254
125
  */
255
126
  export function parseSuggestedReplies(raw) {
127
+ const parsed = sliceJsonArray(raw);
128
+ if (Array.isArray(parsed)) {
129
+ return uniqueSuggestions(parsed.filter((item) => typeof item === 'string'));
130
+ }
256
131
  const trimmed = (raw ?? '').trim();
257
132
  if (!trimmed)
258
133
  return [];
259
134
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
260
135
  const candidate = (fenced?.[1] ?? trimmed).trim();
261
- const start = candidate.indexOf('[');
262
- const end = candidate.lastIndexOf(']');
263
- if (start >= 0 && end > start) {
264
- try {
265
- const parsed = JSON.parse(candidate.slice(start, end + 1));
266
- if (Array.isArray(parsed)) {
267
- return uniqueSuggestions(parsed.filter((item) => typeof item === 'string'));
268
- }
269
- }
270
- catch {
271
- // 落到分行解析。
272
- }
273
- }
274
136
  const lines = candidate
275
137
  .split(/\r?\n/)
276
138
  .map((line) => line.trim())
@@ -278,106 +140,40 @@ export function parseSuggestedReplies(raw) {
278
140
  const listLines = lines.filter((line) => isListItem(line));
279
141
  return uniqueSuggestions(listLines.length > 0 ? listLines : lines);
280
142
  }
281
- const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
282
- const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
283
- const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
284
- const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
285
- const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
286
- /** 没有下一步可用时的句子:PPT / 计划 / 代码走专用三条,其它走通用三条。 */
287
- function heuristicSuggestions(userText, assistantText) {
288
- const blob = `${userText}\n${assistantText}`;
289
- if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
290
- if (/修改内容|调整样式/.test(assistantText)) {
291
- return uniqueSuggestions(PPT_OFFER_FALLBACK);
292
- }
293
- return uniqueSuggestions(PPT_FALLBACK);
294
- }
295
- if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
296
- return uniqueSuggestions(PLAN_FALLBACK);
297
- }
298
- if (/```|单元测试|函数实现|补测试/.test(blob)) {
299
- return uniqueSuggestions(CODE_FALLBACK);
300
- }
301
- return uniqueSuggestions(GENERIC_FALLBACK);
302
- }
303
- /**
304
- * 不调模型的建议。技能或回复里已有下一步时原样采用(条数不固定),
305
- * 否则回落到启发式句子。
306
- */
307
- export function fallbackSuggestedReplies(userText, assistantText, opts = {}) {
308
- const extracted = collectNextSteps(userText, assistantText, opts.skillContents);
309
- if (extracted.length > 0)
310
- return extracted;
311
- return heuristicSuggestions(userText, assistantText);
312
- }
313
143
  /**
314
- * 提示词里「用户技能中的下一步」一节。只收真的写了下一步的技能——
315
- * 把其余技能的正文片段塞进来只会稀释上下文,且与小节标题不符。
316
- */
317
- function buildSkillExcerpt(skillContents) {
318
- if (!skillContents || skillContents.length === 0)
319
- return '';
320
- const parts = [];
321
- let used = 0;
322
- for (const content of skillContents) {
323
- const steps = extractSkillNextSteps(content);
324
- if (steps.length === 0)
325
- continue;
326
- const title = skillTitle(content);
327
- const body = steps.map((step, index) => `${index + 1}. ${step}`).join('\n');
328
- const chunk = title ? `【${title}】\n${body}` : body;
329
- const next = used + chunk.length;
330
- if (next > SKILL_EXCERPT_LIMIT && parts.length > 0)
331
- break;
332
- parts.push(chunk);
333
- used = next;
334
- }
335
- return parts.join('\n\n');
336
- }
337
- /**
338
- * 生成本轮追问建议。永不抛错;有技能/回复下一步时按实际条数返回。
144
+ * 生成本轮追问建议。永不抛错;没有下一步来源或模型判定没有下一步时返回空数组。
339
145
  */
340
146
  export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
341
- const extracted = collectNextSteps(userText, assistantText, opts.skillContents);
147
+ const source = extractNextStepsSource(assistantText);
342
148
  const user = clip(userText, USER_TEXT_LIMIT);
343
- const assistant = clipHeadTail(assistantText, ASSISTANT_HEAD_LIMIT, ASSISTANT_TAIL_LIMIT);
344
- const skillExcerpt = buildSkillExcerpt(opts.skillContents);
345
- if (!user && !assistant && extracted.length === 0) {
346
- return { suggestions: heuristicSuggestions(userText, assistantText), usedFallback: true };
149
+ const sourceClip = clip(source, SOURCE_LIMIT);
150
+ if (!sourceClip) {
151
+ return { suggestions: [], usedFallback: false };
347
152
  }
348
153
  const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
349
154
  try {
350
- const skillBlock = skillExcerpt
351
- ? `\n\n用户技能中的下一步:\n${skillExcerpt}`
352
- : '';
353
- const extractedBlock = extracted.length > 0
354
- ? `\n\n已从回复或技能抽出的候选(请改写成用户口吻,保留这些动作,不要压成 3 条):\n${extracted.map((item, index) => `${index + 1}. ${item}`).join('\n')}`
355
- : '';
356
155
  const result = await withTimeout(llmService.generate({
357
156
  messages: [
358
157
  { role: 'system', content: SYSTEM_PROMPT },
359
158
  {
360
159
  role: 'user',
361
- content: `用户请求:\n${user || '(空)'}\n\n助手回复:\n${assistant || '(空)'}${skillBlock}${extractedBlock}`,
160
+ content: `用户请求:\n${user || '(空)'}\n\n下一步来源:\n${sourceClip}`,
362
161
  },
363
162
  ],
364
- temperature: 0.6,
163
+ temperature: 0.4,
365
164
  }), perAttemptTimeoutMs, 'ai-suggestions');
366
- const parsed = parseSuggestedReplies(result.content ?? '');
165
+ const raw = result.content ?? '';
166
+ const parsed = parseSuggestedReplies(raw);
367
167
  if (parsed.length > 0) {
368
- const suggestions = extracted.length > parsed.length
369
- ? uniqueSuggestions(parsed, extracted)
370
- : uniqueSuggestions(parsed);
371
- if (suggestions.length > 0) {
372
- return { suggestions, usedFallback: false };
373
- }
168
+ return { suggestions: parsed, usedFallback: false };
374
169
  }
170
+ if (Array.isArray(sliceJsonArray(raw))) {
171
+ return { suggestions: [], usedFallback: false };
172
+ }
173
+ return { suggestions: [], usedFallback: true };
375
174
  }
376
175
  catch (err) {
377
- console.warn('[ai-suggestions] LLM 追问建议失败,用启发式兜底', err);
378
- }
379
- if (extracted.length > 0) {
380
- return { suggestions: extracted, usedFallback: false };
176
+ console.warn('[ai-suggestions] LLM 追问建议失败,不展示建议', err);
177
+ return { suggestions: [], usedFallback: true };
381
178
  }
382
- return { suggestions: heuristicSuggestions(userText, assistantText), usedFallback: true };
383
179
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `@提及` → 被点名的智能体 id。
3
+ *
4
+ * 渲染层从 `@` 菜单点选时会把 id 放进 `metadata.mentionedAgentIds`;手打
5
+ * 的 `@名字` 不带 id。只认 id 的话,手打提及在后端等于没提及:不下发子代理
6
+ * 画像、不注入强制派发指令,而 Web 顶栏又按正文扫 `@词` 画徽章——界面显示
7
+ * 点了三个人,实际一个都没派活。这里把正文解析补在后端,两侧同一口径。
8
+ *
9
+ * 中文没有词边界,所以按「最长画像名/slug 前缀」匹配,而不是按空白切词:
10
+ * `@日程规划,帮我…` 里的标点不会被吃进名字。
11
+ */
12
+ /** 匹配用的智能体子集。 */
13
+ export interface MentionAgentRef {
14
+ id: string;
15
+ slug: string | null;
16
+ name: string;
17
+ }
18
+ /**
19
+ * 请求体里显式带来的提及 id(`@` 菜单点选的结果)。
20
+ *
21
+ * @param payload 发送请求体。
22
+ * @returns 去重后的 id 列表,保持 `mentionedAgentId` 优先的原有顺序。
23
+ */
24
+ export declare function collectExplicitMentionIds(payload: Record<string, unknown>): string[];
25
+ /**
26
+ * 从消息正文里解析 `@名字` / `@slug`。
27
+ *
28
+ * @param text 用户消息正文。
29
+ * @param agents 本会话可用的智能体。
30
+ * @returns 命中的智能体 id,按正文出现顺序去重。
31
+ */
32
+ export declare function mentionedAgentIdsFromText(text: string, agents: readonly MentionAgentRef[]): string[];
33
+ /**
34
+ * 本轮生效的提及 id:显式 id 优先,正文解析补齐。
35
+ *
36
+ * @param payload 发送请求体。
37
+ * @param text 用户消息正文。
38
+ * @param agents 本会话可用的智能体。
39
+ * @returns 去重后的 id 列表。
40
+ */
41
+ export declare function resolveMentionedAgentIds(payload: Record<string, unknown>, text: string, agents: readonly MentionAgentRef[]): string[];