@steerable/agent-shell 0.6.26 → 0.6.28
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/dist/local-backend/ai-suggestions.d.ts +11 -29
- package/dist/local-backend/ai-suggestions.js +73 -277
- package/dist/local-backend/router.d.ts +2 -4
- package/dist/local-backend/router.js +23 -40
- package/dist/local-backend/skills/00-identity/SKILL.md +11 -0
- package/dist/local-backend/turn-timeline.d.ts +12 -2
- package/dist/local-backend/turn-timeline.js +31 -3
- package/dist/sidecar/supervisor.d.ts +8 -0
- package/dist/sidecar/supervisor.js +88 -30
- package/dist/sidecar/types.d.ts +3 -1
- package/package.json +4 -4
|
@@ -1,52 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 回合结束后生成「下一轮用户输入」建议。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
|
|
5
|
+
* `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
|
|
6
|
+
* 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
|
|
7
7
|
*
|
|
8
|
-
* 调用方应该 fire-and-forget
|
|
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
|
-
*
|
|
18
|
-
* 兼容用户自建技能:标题不固定,脚本调用行只保留标题。
|
|
13
|
+
* 建议芯片的判断来源:有 `[next_steps]` 用最后一段标签正文,否则用回复最后一段。
|
|
19
14
|
*/
|
|
20
|
-
export declare function
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* 回合结束后生成「下一轮用户输入」建议。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
|
|
5
|
+
* `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
|
|
6
|
+
* 不做规则抽取、不按 PPT/计划/代码套模板。永不抛错。
|
|
7
7
|
*
|
|
8
|
-
* 调用方应该 fire-and-forget
|
|
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
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
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.
|
|
22
|
-
2.
|
|
23
|
-
3.
|
|
24
|
-
4.
|
|
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
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
64
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
72
|
+
return LIST_ITEM_RE.test(line.trim());
|
|
86
73
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
91
|
+
function uniqueSuggestions(candidates) {
|
|
120
92
|
const out = [];
|
|
121
93
|
const seen = new Set();
|
|
122
|
-
for (const raw of
|
|
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
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
147
|
+
const source = extractNextStepsSource(assistantText);
|
|
342
148
|
const user = clip(userText, USER_TEXT_LIMIT);
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
160
|
+
content: `用户请求:\n${user || '(空)'}\n\n下一步来源:\n${sourceClip}`,
|
|
362
161
|
},
|
|
363
162
|
],
|
|
364
|
-
temperature: 0.
|
|
163
|
+
temperature: 0.4,
|
|
365
164
|
}), perAttemptTimeoutMs, 'ai-suggestions');
|
|
366
|
-
const
|
|
165
|
+
const raw = result.content ?? '';
|
|
166
|
+
const parsed = parseSuggestedReplies(raw);
|
|
367
167
|
if (parsed.length > 0) {
|
|
368
|
-
|
|
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
|
|
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
|
}
|
|
@@ -174,13 +174,11 @@ export declare class LocalBackendRouter {
|
|
|
174
174
|
*/
|
|
175
175
|
private handleCoreLoopTurn;
|
|
176
176
|
/**
|
|
177
|
-
*
|
|
178
|
-
|
|
177
|
+
* 回合结束后生成下一轮用户输入建议。只走一次 LLM 判断
|
|
178
|
+
*(来源:`[next_steps]` 或最后一段),只广播一次最终结果。
|
|
179
179
|
* 走 broadcast 而不是 SSE:`[DONE]` 之后渲染端已经不再监听这条流。
|
|
180
180
|
*/
|
|
181
181
|
private runSuggestedRepliesInBackground;
|
|
182
|
-
/** 用户/工作区技能正文,供追问建议抽出「下一步」。内置技能不参与。 */
|
|
183
|
-
private loadUserSkillSuggestionTexts;
|
|
184
182
|
private publishSuggestedReplies;
|
|
185
183
|
/**
|
|
186
184
|
* 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
|
|
@@ -9,7 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
9
9
|
import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
|
|
10
10
|
import { driveWithAutoContinue, resolveAutoContinueMax, } from './auto-continue-helper.js';
|
|
11
11
|
import { builtinSubagentParam } from './subagent-profiles.js';
|
|
12
|
-
import { appendTimelineDelta, syncTimelineTools, } from './turn-timeline.js';
|
|
12
|
+
import { appendTimelineDelta, freezeTimelineReasoning, sealLastTimelineBlock, syncTimelineTools, } from './turn-timeline.js';
|
|
13
13
|
import { turnDurationMs } from './turn-duration.js';
|
|
14
14
|
import { DEFAULT_SYSTEM_PROMPT, telemetryEnabled } from '../storage/index.js';
|
|
15
15
|
import { isSkillPinned, isToolAllowed, mergeAgentCapabilities, normalizeToolPolicy, resolveSkillExcludes, } from './agent-capability.js';
|
|
@@ -27,7 +27,7 @@ import { parseUserMessageTriggers } from './message-triggers.js';
|
|
|
27
27
|
import { findSkill, loadSkills, classifySkillOrigin, listSkillRoots } from './skill-loader.js';
|
|
28
28
|
import { installSkillFromDirectory } from './skill-install.js';
|
|
29
29
|
import { generateChatTitle } from './ai-title.js';
|
|
30
|
-
import {
|
|
30
|
+
import { generateSuggestedReplies } from './ai-suggestions.js';
|
|
31
31
|
import { detectDeferredExecution } from './deferred-detector.js';
|
|
32
32
|
import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js';
|
|
33
33
|
import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
|
|
@@ -124,6 +124,11 @@ function buildLocalApiUser() {
|
|
|
124
124
|
}
|
|
125
125
|
const LOCAL_USER = LOCAL_USER_BASE;
|
|
126
126
|
const LOCAL_AGENT_ID = 'local-agent';
|
|
127
|
+
function isRoundBoundaryHook(notice) {
|
|
128
|
+
if (notice?.kind !== 'hook_action')
|
|
129
|
+
return false;
|
|
130
|
+
return notice.action === 'retry' || notice.action === 'narrate';
|
|
131
|
+
}
|
|
127
132
|
function buildLocalAgent() {
|
|
128
133
|
const now = new Date().toISOString();
|
|
129
134
|
return {
|
|
@@ -3003,6 +3008,17 @@ export class LocalBackendRouter {
|
|
|
3003
3008
|
budget: { kind: budgetKind },
|
|
3004
3009
|
message: budgetKind ? `budget_exhausted: ${budgetKind}` : 'budget_exhausted',
|
|
3005
3010
|
}));
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
// 轮次边界:封住当前思考/文本段,避免下一轮 reasoning delta
|
|
3014
|
+
// 拼进上一段,把中间的工具结果挤没。
|
|
3015
|
+
if (kind === 'round_end' || isRoundBoundaryHook(notice)) {
|
|
3016
|
+
sealLastTimelineBlock(timeline);
|
|
3017
|
+
emit(this.sseData({
|
|
3018
|
+
type: 'completion',
|
|
3019
|
+
status: 'executing',
|
|
3020
|
+
...(typeof notice?.round === 'number' ? { round: notice.round } : {}),
|
|
3021
|
+
}));
|
|
3006
3022
|
}
|
|
3007
3023
|
},
|
|
3008
3024
|
onChildEvent: (event) => {
|
|
@@ -3053,6 +3069,8 @@ export class LocalBackendRouter {
|
|
|
3053
3069
|
}
|
|
3054
3070
|
},
|
|
3055
3071
|
onContinuation: (pass, max) => {
|
|
3072
|
+
sealLastTimelineBlock(timeline);
|
|
3073
|
+
emit(this.sseData({ type: 'completion', status: 'executing' }));
|
|
3056
3074
|
console.log('[local-backend] coreloop budget exhausted — auto-continuing', {
|
|
3057
3075
|
chatId,
|
|
3058
3076
|
pass,
|
|
@@ -3104,6 +3122,7 @@ export class LocalBackendRouter {
|
|
|
3104
3122
|
catch (turnFilesErr) {
|
|
3105
3123
|
console.warn('[local-backend] collect turn files failed', turnFilesErr);
|
|
3106
3124
|
}
|
|
3125
|
+
freezeTimelineReasoning(timeline);
|
|
3107
3126
|
const assistant = await this.store.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
|
|
3108
3127
|
completionStatus,
|
|
3109
3128
|
completionReason,
|
|
@@ -3205,8 +3224,8 @@ export class LocalBackendRouter {
|
|
|
3205
3224
|
return { status: 200 };
|
|
3206
3225
|
}
|
|
3207
3226
|
/**
|
|
3208
|
-
*
|
|
3209
|
-
|
|
3227
|
+
* 回合结束后生成下一轮用户输入建议。只走一次 LLM 判断
|
|
3228
|
+
*(来源:`[next_steps]` 或最后一段),只广播一次最终结果。
|
|
3210
3229
|
* 走 broadcast 而不是 SSE:`[DONE]` 之后渲染端已经不再监听这条流。
|
|
3211
3230
|
*/
|
|
3212
3231
|
runSuggestedRepliesInBackground(args) {
|
|
@@ -3215,27 +3234,11 @@ export class LocalBackendRouter {
|
|
|
3215
3234
|
return;
|
|
3216
3235
|
if (!assistantText.trim())
|
|
3217
3236
|
return;
|
|
3218
|
-
const fallback = fallbackSuggestedReplies(userText, assistantText);
|
|
3219
|
-
this.publishSuggestedReplies(chatId, messageId, fallback);
|
|
3220
3237
|
void (async () => {
|
|
3221
3238
|
try {
|
|
3222
|
-
const skillContents = await this.loadUserSkillSuggestionTexts();
|
|
3223
|
-
const enriched = fallbackSuggestedReplies(userText, assistantText, { skillContents });
|
|
3224
|
-
if (enriched.length !== fallback.length ||
|
|
3225
|
-
enriched.some((item, i) => item !== fallback[i])) {
|
|
3226
|
-
this.publishSuggestedReplies(chatId, messageId, enriched);
|
|
3227
|
-
}
|
|
3228
3239
|
const result = await generateSuggestedReplies(userText, assistantText, {
|
|
3229
3240
|
perAttemptTimeoutMs: 60_000,
|
|
3230
|
-
skillContents,
|
|
3231
3241
|
});
|
|
3232
|
-
if (result.usedFallback)
|
|
3233
|
-
return;
|
|
3234
|
-
const baseline = enriched.length > 0 ? enriched : fallback;
|
|
3235
|
-
const same = result.suggestions.length === baseline.length &&
|
|
3236
|
-
result.suggestions.every((item, i) => item === baseline[i]);
|
|
3237
|
-
if (same)
|
|
3238
|
-
return;
|
|
3239
3242
|
this.publishSuggestedReplies(chatId, messageId, result.suggestions);
|
|
3240
3243
|
}
|
|
3241
3244
|
catch (err) {
|
|
@@ -3243,26 +3246,6 @@ export class LocalBackendRouter {
|
|
|
3243
3246
|
}
|
|
3244
3247
|
})();
|
|
3245
3248
|
}
|
|
3246
|
-
/** 用户/工作区技能正文,供追问建议抽出「下一步」。内置技能不参与。 */
|
|
3247
|
-
async loadUserSkillSuggestionTexts() {
|
|
3248
|
-
try {
|
|
3249
|
-
const modules = await loadSkills({ ignoreConditions: true });
|
|
3250
|
-
return modules
|
|
3251
|
-
.filter((module) => classifySkillOrigin(module.skillsDir) !== 'builtin')
|
|
3252
|
-
.map((module) => {
|
|
3253
|
-
const title = (module.displayName || module.name || module.dirName || '').trim();
|
|
3254
|
-
const body = (module.content ?? '').trim();
|
|
3255
|
-
if (!body)
|
|
3256
|
-
return '';
|
|
3257
|
-
return title ? `# ${title}\n${body}` : body;
|
|
3258
|
-
})
|
|
3259
|
-
.filter(Boolean);
|
|
3260
|
-
}
|
|
3261
|
-
catch (err) {
|
|
3262
|
-
console.warn('[local-backend] suggested-replies skill load failed', err);
|
|
3263
|
-
return [];
|
|
3264
|
-
}
|
|
3265
|
-
}
|
|
3266
3249
|
async publishSuggestedReplies(chatId, messageId, suggestions) {
|
|
3267
3250
|
if (suggestions.length === 0)
|
|
3268
3251
|
return;
|
|
@@ -22,6 +22,17 @@ tags: [identity, base]
|
|
|
22
22
|
- 工具失败或无权限时,把错误如实告诉用户,并建议下一步动作。
|
|
23
23
|
- 回复保持简洁;超过 6-8 行的长内容用 markdown 列表或代码块组织。
|
|
24
24
|
|
|
25
|
+
## 下一步建议
|
|
26
|
+
|
|
27
|
+
回合结束且用户还能接着做时,把建议写在回复最后一段,并用标签包起来(标签本身不会显示,只显示里面的内容):
|
|
28
|
+
|
|
29
|
+
[next_steps]
|
|
30
|
+
- 调整封面配色
|
|
31
|
+
- 把第 2 页写具体
|
|
32
|
+
[/next_steps]
|
|
33
|
+
|
|
34
|
+
只写真实的下一步。本轮已完成的汇报(文件位置、页数、内容结构等)不要放进标签。没有下一步就不要写这个标签。
|
|
35
|
+
|
|
25
36
|
## 结构化提问(ask_user)使用规范
|
|
26
37
|
|
|
27
38
|
当你需要向用户收集结构化信息(提供选项让用户选择)时,使用 `ask_user` 工具,并遵守:
|
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
export type PersistedTurnBlock = {
|
|
6
6
|
type: 'reasoning';
|
|
7
7
|
content: string;
|
|
8
|
+
sealed?: boolean;
|
|
9
|
+
startedAtMs?: number;
|
|
10
|
+
durationMs?: number;
|
|
8
11
|
} | {
|
|
9
12
|
type: 'text';
|
|
10
13
|
content: string;
|
|
14
|
+
sealed?: boolean;
|
|
11
15
|
} | {
|
|
12
16
|
type: 'tools';
|
|
13
17
|
actions: Array<Record<string, unknown>>;
|
|
14
18
|
};
|
|
15
|
-
export declare function appendTimelineDelta(blocks: PersistedTurnBlock[], type: 'text' | 'reasoning', delta: string): void;
|
|
16
|
-
|
|
19
|
+
export declare function appendTimelineDelta(blocks: PersistedTurnBlock[], type: 'text' | 'reasoning', delta: string, now?: number): void;
|
|
20
|
+
/**
|
|
21
|
+
* Close the current reasoning/text segment so the next same-kind delta
|
|
22
|
+
* starts a new block. Mirrors apps/web `sealLastBlock`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sealLastTimelineBlock(blocks: PersistedTurnBlock[], now?: number): void;
|
|
25
|
+
export declare function freezeTimelineReasoning(blocks: PersistedTurnBlock[], now?: number): void;
|
|
26
|
+
export declare function syncTimelineTools(blocks: PersistedTurnBlock[], actions: Array<Record<string, unknown>>, now?: number): void;
|
|
@@ -2,17 +2,44 @@
|
|
|
2
2
|
* Call-order display blocks persisted on assistant message metadata.
|
|
3
3
|
* Keep this JSON compatible with apps/web `turn-timeline.ts`.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
function freezeOpenReasoning(blocks, now = Date.now()) {
|
|
6
|
+
const last = blocks[blocks.length - 1];
|
|
7
|
+
if (!last || last.type !== 'reasoning' || last.durationMs != null)
|
|
8
|
+
return;
|
|
9
|
+
if (last.startedAtMs == null)
|
|
10
|
+
return;
|
|
11
|
+
last.durationMs = Math.max(0, now - last.startedAtMs);
|
|
12
|
+
}
|
|
13
|
+
export function appendTimelineDelta(blocks, type, delta, now = Date.now()) {
|
|
6
14
|
if (!delta)
|
|
7
15
|
return;
|
|
8
16
|
const last = blocks[blocks.length - 1];
|
|
9
|
-
if (last && last.type === type) {
|
|
17
|
+
if (last && last.type === type && !last.sealed) {
|
|
10
18
|
last.content += delta;
|
|
11
19
|
return;
|
|
12
20
|
}
|
|
21
|
+
freezeOpenReasoning(blocks, now);
|
|
22
|
+
if (type === 'reasoning') {
|
|
23
|
+
blocks.push({ type, content: delta, startedAtMs: now });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
13
26
|
blocks.push({ type, content: delta });
|
|
14
27
|
}
|
|
15
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Close the current reasoning/text segment so the next same-kind delta
|
|
30
|
+
* starts a new block. Mirrors apps/web `sealLastBlock`.
|
|
31
|
+
*/
|
|
32
|
+
export function sealLastTimelineBlock(blocks, now = Date.now()) {
|
|
33
|
+
const last = blocks[blocks.length - 1];
|
|
34
|
+
if (!last || last.type === 'tools' || last.sealed)
|
|
35
|
+
return;
|
|
36
|
+
freezeOpenReasoning(blocks, now);
|
|
37
|
+
last.sealed = true;
|
|
38
|
+
}
|
|
39
|
+
export function freezeTimelineReasoning(blocks, now = Date.now()) {
|
|
40
|
+
freezeOpenReasoning(blocks, now);
|
|
41
|
+
}
|
|
42
|
+
export function syncTimelineTools(blocks, actions, now = Date.now()) {
|
|
16
43
|
let placed = 0;
|
|
17
44
|
for (const block of blocks) {
|
|
18
45
|
if (block.type === 'tools')
|
|
@@ -37,6 +64,7 @@ export function syncTimelineTools(blocks, actions) {
|
|
|
37
64
|
last.actions.push(...added);
|
|
38
65
|
}
|
|
39
66
|
else {
|
|
67
|
+
freezeOpenReasoning(blocks, now);
|
|
40
68
|
blocks.push({ type: 'tools', actions: added });
|
|
41
69
|
}
|
|
42
70
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { EventEmitter } from 'node:events';
|
|
13
13
|
import type { SidecarApplyEditsResult, SidecarChatStreamHandlers, SidecarChatStreamRequest, SidecarHealthSnapshot, SidecarMethodOptions, SidecarModelCatalog, SidecarReverseHandler, SidecarSandboxPosture, SidecarSessionBranches, SidecarSessionForkOutcome, SidecarSessionMessages, SidecarSessionTree, SidecarSkillModule, SidecarStartOptions, SidecarToolResult } from './types.js';
|
|
14
|
+
export declare const RUST_SIDECAR_ENV = "STEERABLE_RUST_SIDECAR";
|
|
15
|
+
export declare const RUST_SIDECAR_BIN_ENV = "STEERABLE_RUST_SIDECAR_BIN";
|
|
14
16
|
/**
|
|
15
17
|
* Tool names carried by a `tool.list` reply.
|
|
16
18
|
*
|
|
@@ -216,3 +218,9 @@ export declare class SidecarSupervisor extends EventEmitter {
|
|
|
216
218
|
* launcher (W1.3.3) spawns the same interpreter the sidecar uses.
|
|
217
219
|
*/
|
|
218
220
|
export declare function resolveSidecarPython(pythonExecutable?: string): string;
|
|
221
|
+
export declare function rustSidecarEnabled(): boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the optional Rust sidecar binary. Missing binary with the flag
|
|
224
|
+
* on is a silent fallback to Python (`python -m steerable_sidecar`).
|
|
225
|
+
*/
|
|
226
|
+
export declare function resolveRustSidecarBin(explicit?: string): string | undefined;
|
|
@@ -25,6 +25,8 @@ const READY_PREFIX = '__SIDECAR_READY__:';
|
|
|
25
25
|
const DEFAULT_BOOT_TIMEOUT_MS = 15_000;
|
|
26
26
|
const DEFAULT_HEALTH_INTERVAL_MS = 5_000;
|
|
27
27
|
const DEFAULT_RESTART_AFTER_FAILED_PINGS = 3;
|
|
28
|
+
export const RUST_SIDECAR_ENV = 'STEERABLE_RUST_SIDECAR';
|
|
29
|
+
export const RUST_SIDECAR_BIN_ENV = 'STEERABLE_RUST_SIDECAR_BIN';
|
|
28
30
|
// Only /usr/bin/sandbox-exec is trusted — a PATH-relative lookup could
|
|
29
31
|
// resolve to an attacker-planted binary (codex's rule).
|
|
30
32
|
const SEATBELT_EXECUTABLE = '/usr/bin/sandbox-exec';
|
|
@@ -381,10 +383,19 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
381
383
|
// Internal: boot
|
|
382
384
|
// ------------------------------------------------------------------
|
|
383
385
|
async boot() {
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
386
|
+
const rustBin = rustSidecarEnabled()
|
|
387
|
+
? resolveRustSidecarBin(this.options.rustSidecarBin)
|
|
388
|
+
: undefined;
|
|
389
|
+
let spawnPlan;
|
|
390
|
+
if (rustBin) {
|
|
391
|
+
spawnPlan = await this.resolveSandboxedSpawn(rustBin, this.options.args ?? []);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const py = this.resolvePythonBinary();
|
|
395
|
+
const entry = this.options.entryModule ?? 'steerable_sidecar';
|
|
396
|
+
const args = ['-m', entry, ...(this.options.args ?? [])];
|
|
397
|
+
spawnPlan = await this.resolveSandboxedSpawn(py, args);
|
|
398
|
+
}
|
|
388
399
|
const child = spawn(spawnPlan.command, spawnPlan.args, {
|
|
389
400
|
cwd: this.options.cwd,
|
|
390
401
|
env: { ...process.env, ...this.options.env, ...spawnPlan.env },
|
|
@@ -416,8 +427,8 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
416
427
|
* (`sandbox: false` / `STEERABLE_SIDECAR_SANDBOX=0`) is the only
|
|
417
428
|
* unconfined path. Every exit records `sandboxPosture`.
|
|
418
429
|
*/
|
|
419
|
-
async resolveSandboxedSpawn(
|
|
420
|
-
const plain = { command
|
|
430
|
+
async resolveSandboxedSpawn(command, args) {
|
|
431
|
+
const plain = { command, args };
|
|
421
432
|
const enabled = this.options.sandbox ?? process.env.STEERABLE_SIDECAR_SANDBOX !== '0';
|
|
422
433
|
if (!enabled) {
|
|
423
434
|
this.sandboxPosture = {
|
|
@@ -446,16 +457,16 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
446
457
|
.filter(Boolean);
|
|
447
458
|
const webEgress = Boolean(this.options.sandboxWebEgress) && allowedHosts.length > 0;
|
|
448
459
|
// 3.1b: egress-proxy 模式下 web_fetch 的 SSRF 预检在沙箱内解析 DNS,
|
|
449
|
-
//
|
|
460
|
+
// 但真正的出口走代理。Seatbelt 只放行 resolver socket,不放行 *:80/443。
|
|
450
461
|
const allowResolver = Boolean(this.options.sandboxAllowResolver) && allowedHosts.length > 0 && !webEgress;
|
|
451
462
|
if (process.platform === 'darwin') {
|
|
452
|
-
return this.wrapSeatbelt(
|
|
463
|
+
return this.wrapSeatbelt(command, args, writableRoots, allowedHosts, webEgress, allowResolver);
|
|
453
464
|
}
|
|
454
465
|
if (process.platform === 'linux') {
|
|
455
|
-
return this.wrapLinux(
|
|
466
|
+
return this.wrapLinux(command, args, writableRoots);
|
|
456
467
|
}
|
|
457
468
|
if (process.platform === 'win32') {
|
|
458
|
-
return this.wrapWindows(
|
|
469
|
+
return this.wrapWindows(command, args, writableRoots);
|
|
459
470
|
}
|
|
460
471
|
const posture = {
|
|
461
472
|
backend: 'none',
|
|
@@ -470,21 +481,23 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
470
481
|
this.options.onLogLine?.(message);
|
|
471
482
|
throw new SidecarSandboxUnavailableError(message, posture, cause);
|
|
472
483
|
}
|
|
473
|
-
async wrapSeatbelt(
|
|
484
|
+
async wrapSeatbelt(command, args, writableRoots, allowedHosts, webEgress, allowResolver) {
|
|
474
485
|
if (!existsSync(SEATBELT_EXECUTABLE)) {
|
|
475
486
|
this.refuse({ backend: 'none', enforcement: 'none', reason: 'seatbelt_missing' }, 'sandbox: /usr/bin/sandbox-exec missing; refusing unsandboxed spawn');
|
|
476
487
|
}
|
|
477
488
|
try {
|
|
478
|
-
const
|
|
479
|
-
'-m',
|
|
480
|
-
'steerable_sidecar.sandbox',
|
|
481
|
-
'profile',
|
|
489
|
+
const flags = [
|
|
482
490
|
...writableRoots.flatMap((root) => ['--writable-root', root]),
|
|
483
491
|
...allowedHosts.flatMap((h) => ['--allow-host', h]),
|
|
484
492
|
...(webEgress ? ['--allow-web-egress'] : []),
|
|
485
493
|
...(allowResolver ? ['--allow-resolver'] : []),
|
|
486
494
|
];
|
|
487
|
-
const
|
|
495
|
+
const rustBin = rustSidecarEnabled()
|
|
496
|
+
? resolveRustSidecarBin(this.options.rustSidecarBin)
|
|
497
|
+
: undefined;
|
|
498
|
+
const { stdout } = rustBin
|
|
499
|
+
? await execFileAsync(rustBin, ['sandbox', 'profile', ...flags], { timeout: 10_000 })
|
|
500
|
+
: await execFileAsync(command, ['-m', 'steerable_sidecar.sandbox', 'profile', ...flags], { timeout: 10_000 });
|
|
488
501
|
const profile = stdout.trim();
|
|
489
502
|
if (!profile.includes('(deny default)')) {
|
|
490
503
|
throw new Error('generated profile is not a Seatbelt policy');
|
|
@@ -497,7 +510,7 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
497
510
|
this.sandboxPosture = { backend: 'seatbelt', enforcement: 'partial', reason: 'active' };
|
|
498
511
|
return {
|
|
499
512
|
command: SEATBELT_EXECUTABLE,
|
|
500
|
-
args: ['-p', profile,
|
|
513
|
+
args: ['-p', profile, command, ...args],
|
|
501
514
|
env: {
|
|
502
515
|
PYTHONDONTWRITEBYTECODE: '1',
|
|
503
516
|
// macOS denies a nested sandbox_apply once the outer profile allows
|
|
@@ -516,7 +529,7 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
516
529
|
this.refuse({ backend: 'none', enforcement: 'none', reason: 'profile_failed' }, `sandbox: profile generation failed; refusing unsandboxed spawn: ${String(err)}`, err);
|
|
517
530
|
}
|
|
518
531
|
}
|
|
519
|
-
async wrapLinux(
|
|
532
|
+
async wrapLinux(command, args, writableRoots) {
|
|
520
533
|
// 3.1c:框架的 linux-wrap 只接受 --writable-root / --no-network——
|
|
521
534
|
// bwrap 的 allowed_hosts 仅是接口兼容(不强制),Landlock 根本没有
|
|
522
535
|
// per-host egress。所以 Linux 的按主机管控不在 layer-1:egress-proxy
|
|
@@ -524,17 +537,29 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
524
537
|
// 都透传环境)+ sidecar 应用层域名名单强制,网络命名空间保持共享。
|
|
525
538
|
// 这里如实记录,不假装接上了实际不强制的参数。
|
|
526
539
|
const egressViaProxy = this.options.env?.STEERABLE_EGRESS_CONFINED === '1';
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
540
|
+
const rustBin = rustSidecarEnabled()
|
|
541
|
+
? resolveRustSidecarBin(this.options.rustSidecarBin)
|
|
542
|
+
: undefined;
|
|
543
|
+
const wrapArgs = rustBin
|
|
544
|
+
? [
|
|
545
|
+
'sandbox',
|
|
546
|
+
'linux-wrap',
|
|
547
|
+
...writableRoots.flatMap((root) => ['--writable-root', root]),
|
|
548
|
+
'--',
|
|
549
|
+
command,
|
|
550
|
+
...args,
|
|
551
|
+
]
|
|
552
|
+
: [
|
|
553
|
+
'-m',
|
|
554
|
+
'steerable_sidecar.sandbox',
|
|
555
|
+
'linux-wrap',
|
|
556
|
+
...writableRoots.flatMap((root) => ['--writable-root', root]),
|
|
557
|
+
'--',
|
|
558
|
+
command,
|
|
559
|
+
...args,
|
|
560
|
+
];
|
|
536
561
|
try {
|
|
537
|
-
const { stdout } = await execFileAsync(
|
|
562
|
+
const { stdout } = await execFileAsync(rustBin ?? command, wrapArgs, { timeout: 15_000 });
|
|
538
563
|
const plan = JSON.parse(stdout.trim());
|
|
539
564
|
if (!Array.isArray(plan.argv) || plan.argv.length < 1 || typeof plan.argv[0] !== 'string') {
|
|
540
565
|
throw new Error('linux-wrap did not return an argv');
|
|
@@ -562,7 +587,7 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
562
587
|
this.refuse({ backend: 'none', enforcement: 'none', reason: 'wrap_failed' }, `sandbox: Linux process wrap failed; refusing unsandboxed spawn: ${String(err)}`, err);
|
|
563
588
|
}
|
|
564
589
|
}
|
|
565
|
-
wrapWindows(
|
|
590
|
+
wrapWindows(command, args, writableRoots) {
|
|
566
591
|
const helper = resolveWinSpawnHelperPath();
|
|
567
592
|
if (!helper) {
|
|
568
593
|
this.refuse({ backend: 'none', enforcement: 'none', reason: 'helper_missing' }, 'sandbox: win-spawn-helper.exe not found; refusing unsandboxed sidecar spawn');
|
|
@@ -590,7 +615,7 @@ export class SidecarSupervisor extends EventEmitter {
|
|
|
590
615
|
steerableDir,
|
|
591
616
|
...extraRoots.flatMap((root) => ['--writable-root', root]),
|
|
592
617
|
'--',
|
|
593
|
-
|
|
618
|
+
command,
|
|
594
619
|
...args,
|
|
595
620
|
],
|
|
596
621
|
env: {
|
|
@@ -930,3 +955,36 @@ export function resolveSidecarPython(pythonExecutable) {
|
|
|
930
955
|
// Fallback to system python (developer machines).
|
|
931
956
|
return process.platform === 'win32' ? 'python' : 'python3';
|
|
932
957
|
}
|
|
958
|
+
function envFlag(name) {
|
|
959
|
+
const raw = (process.env[name] ?? '').trim().toLowerCase();
|
|
960
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
961
|
+
}
|
|
962
|
+
export function rustSidecarEnabled() {
|
|
963
|
+
return envFlag(RUST_SIDECAR_ENV);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Resolve the optional Rust sidecar binary. Missing binary with the flag
|
|
967
|
+
* on is a silent fallback to Python (`python -m steerable_sidecar`).
|
|
968
|
+
*/
|
|
969
|
+
export function resolveRustSidecarBin(explicit) {
|
|
970
|
+
if (explicit) {
|
|
971
|
+
return existsSync(explicit) ? explicit : undefined;
|
|
972
|
+
}
|
|
973
|
+
const fromEnv = process.env[RUST_SIDECAR_BIN_ENV];
|
|
974
|
+
if (fromEnv) {
|
|
975
|
+
return existsSync(fromEnv) ? fromEnv : undefined;
|
|
976
|
+
}
|
|
977
|
+
const exe = process.platform === 'win32' ? 'steerable-sidecar.exe' : 'steerable-sidecar';
|
|
978
|
+
const relatives = [
|
|
979
|
+
join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
|
|
980
|
+
join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'debug', exe),
|
|
981
|
+
join('..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
|
|
982
|
+
join('..', '..', '..', '..', '..', 'sidecar', 'rs', 'target', 'release', exe),
|
|
983
|
+
];
|
|
984
|
+
for (const rel of relatives) {
|
|
985
|
+
const candidate = join(__dirname, rel);
|
|
986
|
+
if (existsSync(candidate))
|
|
987
|
+
return candidate;
|
|
988
|
+
}
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface SidecarSandboxPosture {
|
|
|
21
21
|
export interface SidecarStartOptions {
|
|
22
22
|
/** Override the python binary; defaults to the bundled portable runtime. */
|
|
23
23
|
pythonExecutable?: string;
|
|
24
|
+
/** Optional Rust sidecar binary; used when STEERABLE_RUST_SIDECAR is on. */
|
|
25
|
+
rustSidecarBin?: string;
|
|
24
26
|
/** Override the entrypoint module; defaults to ``steerable_sidecar``. */
|
|
25
27
|
entryModule?: string;
|
|
26
28
|
/** Extra arguments appended after ``-m <entryModule>``. */
|
|
@@ -495,7 +497,7 @@ export interface SidecarStreamChunk {
|
|
|
495
497
|
enforcement: string;
|
|
496
498
|
};
|
|
497
499
|
};
|
|
498
|
-
/** CoreLoop notices: soft_timeout / budget_exhausted. */
|
|
500
|
+
/** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
|
|
499
501
|
notice?: {
|
|
500
502
|
kind: string;
|
|
501
503
|
[key: string]: unknown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.28",
|
|
4
4
|
"description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"electron-store": "^8.1.0",
|
|
50
50
|
"he": "^1.2.0",
|
|
51
51
|
"node-pty": "^1.1.0",
|
|
52
|
-
"@steerable/agent-harness": "0.6.
|
|
53
|
-
"@steerable/
|
|
54
|
-
"@steerable/
|
|
52
|
+
"@steerable/agent-harness": "0.6.28",
|
|
53
|
+
"@steerable/pack-sdk": "0.6.28",
|
|
54
|
+
"@steerable/agent-protocol": "0.6.28"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/better-sqlite3": "^7.6.13",
|