@steerable/agent-shell 0.6.36 → 0.6.37
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/attachments.d.ts +6 -0
- package/dist/attachments.js +9 -1
- package/dist/local-backend/ai-suggestions.d.ts +24 -3
- package/dist/local-backend/ai-suggestions.js +109 -8
- package/dist/local-backend/coreloop-stream.d.ts +1 -1
- package/dist/local-backend/coreloop-stream.js +1 -1
- package/dist/local-backend/router.js +6 -1
- package/dist/main.js +1 -1
- package/dist/server/http-server.js +28 -2
- package/dist/sidecar/boot.js +14 -4
- package/dist/sidecar/egress-proxy.d.ts +24 -2
- package/dist/sidecar/egress-proxy.js +136 -6
- package/dist/sidecar/types.d.ts +1 -1
- package/dist/storage/llm-settings.d.ts +4 -3
- package/package.json +4 -4
package/dist/attachments.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface StoredAttachment {
|
|
|
20
20
|
error?: string;
|
|
21
21
|
}
|
|
22
22
|
export declare function isValidChatAttachmentsKey(chatId: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* 当前会话的附件目录路径,**不创建目录**(供拼系统提示等只读场景使用)。
|
|
25
|
+
* chatId 非法时仍然返回路径但由调用方在写入前用
|
|
26
|
+
* {@link isValidChatAttachmentsKey} 校验。
|
|
27
|
+
*/
|
|
28
|
+
export declare function chatAttachmentsDirPath(chatId: string): string;
|
|
23
29
|
/**
|
|
24
30
|
* 当前会话的附件目录(会确保目录存在)。chatId 非法时仍然返回路径但由
|
|
25
31
|
* 调用方在写入前用 {@link isValidChatAttachmentsKey} 校验。
|
package/dist/attachments.js
CHANGED
|
@@ -31,12 +31,20 @@ const SAFE_CHAT_ID = /^[0-9a-zA-Z_-]{1,64}$/;
|
|
|
31
31
|
export function isValidChatAttachmentsKey(chatId) {
|
|
32
32
|
return SAFE_CHAT_ID.test(chatId);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* 当前会话的附件目录路径,**不创建目录**(供拼系统提示等只读场景使用)。
|
|
36
|
+
* chatId 非法时仍然返回路径但由调用方在写入前用
|
|
37
|
+
* {@link isValidChatAttachmentsKey} 校验。
|
|
38
|
+
*/
|
|
39
|
+
export function chatAttachmentsDirPath(chatId) {
|
|
40
|
+
return path.join(getUserDataDir(), ATTACHMENTS_DIR_NAME, chatId);
|
|
41
|
+
}
|
|
34
42
|
/**
|
|
35
43
|
* 当前会话的附件目录(会确保目录存在)。chatId 非法时仍然返回路径但由
|
|
36
44
|
* 调用方在写入前用 {@link isValidChatAttachmentsKey} 校验。
|
|
37
45
|
*/
|
|
38
46
|
export function getChatAttachmentsDir(chatId) {
|
|
39
|
-
const dir =
|
|
47
|
+
const dir = chatAttachmentsDirPath(chatId);
|
|
40
48
|
mkdirSync(dir, { recursive: true });
|
|
41
49
|
return dir;
|
|
42
50
|
}
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 回合结束后生成「下一轮用户输入」建议。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 主路径只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
|
|
5
5
|
* `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
|
|
6
|
-
*
|
|
6
|
+
* 永不抛错;没有真实下一步时不展示建议。
|
|
7
|
+
*
|
|
8
|
+
* 项目分支额外保留两类保障:
|
|
9
|
+
* - 正文里显式承诺的下一步指令(如「请回复:确认内容」)始终排在最前,
|
|
10
|
+
* 不依赖模型是否稳定产出;
|
|
11
|
+
* - 长回复按头 + 尾截断,避免只看头部时丢掉结尾处的邀请。
|
|
7
12
|
*
|
|
8
13
|
* 调用方应该 fire-and-forget,且只广播一次最终结果。
|
|
9
14
|
* 不要挂在 SSE 主流程里阻塞 `[DONE]`。
|
|
10
15
|
*/
|
|
11
16
|
export declare const SUGGESTED_REPLY_MAX = 8;
|
|
17
|
+
/** 项目分支历史上建议芯片固定最多 3 条,生成入口继续沿用该上限。 */
|
|
18
|
+
export declare const SUGGESTED_REPLY_COUNT = 3;
|
|
12
19
|
/**
|
|
13
20
|
* 建议芯片的判断来源:有 `[next_steps]` 用最后一段标签正文,否则用回复最后一段。
|
|
14
21
|
*/
|
|
@@ -20,9 +27,23 @@ export declare function cleanSuggestedReply(raw: string): string;
|
|
|
20
27
|
* 最多 {@link SUGGESTED_REPLY_MAX} 条。
|
|
21
28
|
*/
|
|
22
29
|
export declare function parseSuggestedReplies(raw: string): string[];
|
|
30
|
+
/**
|
|
31
|
+
* 助手正文里显式写出的下一步指令(「请回复:确认内容」/「回复「确认内容」」)。
|
|
32
|
+
*
|
|
33
|
+
* 这是角色提示词对下一轮用户输入的**明确承诺**,对应的按钮必须原样出现:
|
|
34
|
+
* 不能交给追问建议模型自由改写(长回复被截断、模型跑偏都会让按钮消失)。
|
|
35
|
+
* 只认「请回复:X」「回复「X」」这两种窄格式,X 为 2-12 字的短标签,
|
|
36
|
+
* 避免把正文里的普通引用误当成按钮。
|
|
37
|
+
*/
|
|
38
|
+
export declare function explicitReplyHints(assistantText: string): string[];
|
|
39
|
+
/**
|
|
40
|
+
* 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
|
|
41
|
+
* 该函数作为项目分支的兼容能力保留;新的生成主路径不再依赖它兜底。
|
|
42
|
+
*/
|
|
43
|
+
export declare function fallbackSuggestedReplies(userText: string, assistantText: string): string[];
|
|
23
44
|
export interface GenerateSuggestedRepliesResult {
|
|
24
45
|
suggestions: string[];
|
|
25
|
-
/** true = LLM 失败、超时或输出不合格;suggestions
|
|
46
|
+
/** true = LLM 失败、超时或输出不合格;suggestions 为空或仅剩显式回复提示。 */
|
|
26
47
|
usedFallback: boolean;
|
|
27
48
|
}
|
|
28
49
|
export interface GenerateSuggestedRepliesOptions {
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 回合结束后生成「下一轮用户输入」建议。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 主路径只走一次短 LLM 调用。判断来源只有助手给用户的下一步:
|
|
5
5
|
* `[next_steps]...[/next_steps]` 标签,或回复最后一段里的建议。
|
|
6
|
-
*
|
|
6
|
+
* 永不抛错;没有真实下一步时不展示建议。
|
|
7
|
+
*
|
|
8
|
+
* 项目分支额外保留两类保障:
|
|
9
|
+
* - 正文里显式承诺的下一步指令(如「请回复:确认内容」)始终排在最前,
|
|
10
|
+
* 不依赖模型是否稳定产出;
|
|
11
|
+
* - 长回复按头 + 尾截断,避免只看头部时丢掉结尾处的邀请。
|
|
7
12
|
*
|
|
8
13
|
* 调用方应该 fire-and-forget,且只广播一次最终结果。
|
|
9
14
|
* 不要挂在 SSE 主流程里阻塞 `[DONE]`。
|
|
10
15
|
*/
|
|
11
16
|
import { llmService } from '../llm/index.js';
|
|
12
17
|
export const SUGGESTED_REPLY_MAX = 8;
|
|
18
|
+
/** 项目分支历史上建议芯片固定最多 3 条,生成入口继续沿用该上限。 */
|
|
19
|
+
export const SUGGESTED_REPLY_COUNT = 3;
|
|
13
20
|
const MAX_SUGGESTION_CHARS = 48;
|
|
14
21
|
const USER_TEXT_LIMIT = 500;
|
|
15
22
|
const SOURCE_LIMIT = 2000;
|
|
@@ -26,7 +33,8 @@ const SYSTEM_PROMPT = `你是对话追问建议助手。根据「下一步来源
|
|
|
26
33
|
3. 不要编造,不要用「继续完善这份结果」「告诉我下一步怎么做」这类套话凑数。
|
|
27
34
|
4. 有几条真实下一步就给几条,最少 0 条,最多 8 条。
|
|
28
35
|
5. 每条 6-40 个字。不要编号、不要引号、不要解释、不要附带命令行或绝对路径。
|
|
29
|
-
6. 只输出 JSON 字符串数组,例如 ["把封面改成深蓝商务风","第2页个人简介写具体"]
|
|
36
|
+
6. 只输出 JSON 字符串数组,例如 ["把封面改成深蓝商务风","第2页个人简介写具体"]
|
|
37
|
+
7. 如果下一步来源显示任务尚未完成、校验/验证未通过、或助手在问「是否继续」,第一条建议必须是继续完成任务的短指令。`;
|
|
30
38
|
const LIST_ITEM_RE = /^(?:\d+[.)、]|[-*•])\s+(\S.*)$/;
|
|
31
39
|
function withTimeout(promise, timeoutMs, label) {
|
|
32
40
|
if (timeoutMs <= 0)
|
|
@@ -50,6 +58,18 @@ function clip(text, limit) {
|
|
|
50
58
|
return chars.join('');
|
|
51
59
|
return `${chars.slice(0, limit).join('')}…`;
|
|
52
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* 长文本截断(头 + 尾)。助手回复的**结尾**才是结论与下一步邀请所在
|
|
63
|
+
* (例如「以上初稿已生成完毕。如需继续,请回复:确认内容」);只取头部会让
|
|
64
|
+
* 追问建议模型看不到邀请。头部保留话题上下文,尾部保留收尾邀请,两头各取一半。
|
|
65
|
+
*/
|
|
66
|
+
function clipHeadTail(text, limit) {
|
|
67
|
+
const chars = Array.from((text ?? '').trim());
|
|
68
|
+
if (chars.length <= limit)
|
|
69
|
+
return chars.join('');
|
|
70
|
+
const head = Math.floor(limit / 2);
|
|
71
|
+
return `${chars.slice(0, head).join('')}…${chars.slice(-(limit - head)).join('')}`;
|
|
72
|
+
}
|
|
53
73
|
function lastParagraph(text) {
|
|
54
74
|
const trimmed = (text ?? '').trim();
|
|
55
75
|
if (!trimmed)
|
|
@@ -140,15 +160,92 @@ export function parseSuggestedReplies(raw) {
|
|
|
140
160
|
const listLines = lines.filter((line) => isListItem(line));
|
|
141
161
|
return uniqueSuggestions(listLines.length > 0 ? listLines : lines);
|
|
142
162
|
}
|
|
163
|
+
function uniqueThree(candidates, extras = []) {
|
|
164
|
+
const out = [];
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
for (const raw of [...candidates, ...extras]) {
|
|
167
|
+
const cleaned = cleanSuggestedReply(raw);
|
|
168
|
+
if (!cleaned || seen.has(cleaned))
|
|
169
|
+
continue;
|
|
170
|
+
seen.add(cleaned);
|
|
171
|
+
out.push(cleaned);
|
|
172
|
+
if (out.length === SUGGESTED_REPLY_COUNT)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 助手正文里显式写出的下一步指令(「请回复:确认内容」/「回复「确认内容」」)。
|
|
179
|
+
*
|
|
180
|
+
* 这是角色提示词对下一轮用户输入的**明确承诺**,对应的按钮必须原样出现:
|
|
181
|
+
* 不能交给追问建议模型自由改写(长回复被截断、模型跑偏都会让按钮消失)。
|
|
182
|
+
* 只认「请回复:X」「回复「X」」这两种窄格式,X 为 2-12 字的短标签,
|
|
183
|
+
* 避免把正文里的普通引用误当成按钮。
|
|
184
|
+
*/
|
|
185
|
+
export function explicitReplyHints(assistantText) {
|
|
186
|
+
const out = [];
|
|
187
|
+
const text = assistantText ?? '';
|
|
188
|
+
const push = (raw) => {
|
|
189
|
+
const cleaned = cleanSuggestedReply(raw);
|
|
190
|
+
if (cleaned && Array.from(cleaned).length <= 12 && !out.includes(cleaned)) {
|
|
191
|
+
out.push(cleaned);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
for (const match of text.matchAll(/请回复[::][ \t]*([^\n,。;、,.;!?!?]{2,12})/g)) {
|
|
195
|
+
push(match[1]);
|
|
196
|
+
}
|
|
197
|
+
for (const match of text.matchAll(/回复[「『“"]([^」』”"\n]{2,12})[」』”"]/g)) {
|
|
198
|
+
push(match[1]);
|
|
199
|
+
}
|
|
200
|
+
return out.slice(0, SUGGESTED_REPLY_COUNT);
|
|
201
|
+
}
|
|
202
|
+
const PPT_FALLBACK = ['调整封面标题和配色', '把某一页内容写得更具体', '再加一页项目案例'];
|
|
203
|
+
const PLAN_FALLBACK = ['按这个计划开始执行', '先改第 2 步再执行', '把计划写得更细一点'];
|
|
204
|
+
const CODE_FALLBACK = ['解释这段实现的思路', '帮我补上测试', '再优化一下可读性'];
|
|
205
|
+
const GENERIC_FALLBACK = ['继续完善这份结果', '换一种呈现方式', '告诉我下一步怎么做'];
|
|
206
|
+
const PPT_OFFER_FALLBACK = ['调整幻灯片的内容和文案', '调整配色和版式', '再加一页补充材料'];
|
|
207
|
+
const PPT_INCOMPLETE_FALLBACK = [
|
|
208
|
+
'继续修复并生成 PPT 预览稿',
|
|
209
|
+
'按当前方案继续生成 PPT 预览',
|
|
210
|
+
'先修正配置再继续生成预览稿',
|
|
211
|
+
];
|
|
212
|
+
/**
|
|
213
|
+
* 不调模型的启发式三条。PPT / 计划 / 代码有专用句子,其它走通用。
|
|
214
|
+
* 该函数作为项目分支的兼容能力保留;新的生成主路径不再依赖它兜底。
|
|
215
|
+
*/
|
|
216
|
+
export function fallbackSuggestedReplies(userText, assistantText) {
|
|
217
|
+
const blob = `${userText}\n${assistantText}`;
|
|
218
|
+
if (/\.pptx\b|幻灯片|演示文稿|\bppt\b/i.test(blob)) { // shell-neutral:allow — Office 扩展名 'ppt'/'.pptx',不是产品品牌
|
|
219
|
+
// PPT 任务未完成:校验未通过、预览稿未生成、助手在问是否继续时,
|
|
220
|
+
// 第一条建议必须是可以直接点发、继续把 PPT 预览稿生成出来的指令。
|
|
221
|
+
if (/svg_output|SVG\s*预览|预览.{0,12}(?:未|没有|为空|尚未)|验证.{0,12}(?:未通过|失败|错误)|校验.{0,12}(?:未通过|失败|错误)|是否继续|请确认是否继续/.test(assistantText)) {
|
|
222
|
+
return uniqueThree(PPT_INCOMPLETE_FALLBACK, PPT_FALLBACK);
|
|
223
|
+
}
|
|
224
|
+
if (/修改内容|调整样式/.test(assistantText)) {
|
|
225
|
+
return uniqueThree(PPT_OFFER_FALLBACK, PPT_FALLBACK);
|
|
226
|
+
}
|
|
227
|
+
return uniqueThree(PPT_FALLBACK, GENERIC_FALLBACK);
|
|
228
|
+
}
|
|
229
|
+
if (/标准工作流程|待办清单|\bTODO\b|先制定计划/.test(blob) || /^\s*计划已/.test(assistantText)) {
|
|
230
|
+
return uniqueThree(PLAN_FALLBACK, GENERIC_FALLBACK);
|
|
231
|
+
}
|
|
232
|
+
if (/```|单元测试|函数实现|补测试/.test(blob)) {
|
|
233
|
+
return uniqueThree(CODE_FALLBACK, GENERIC_FALLBACK);
|
|
234
|
+
}
|
|
235
|
+
return uniqueThree(GENERIC_FALLBACK);
|
|
236
|
+
}
|
|
143
237
|
/**
|
|
144
238
|
* 生成本轮追问建议。永不抛错;没有下一步来源或模型判定没有下一步时返回空数组。
|
|
145
239
|
*/
|
|
146
240
|
export async function generateSuggestedReplies(userText, assistantText, opts = {}) {
|
|
241
|
+
// 正文里显式承诺的下一步指令(如「确认内容」)始终排在最前,优先级高于
|
|
242
|
+
// LLM 自由生成的建议——按钮不能时有时无。
|
|
243
|
+
const hints = explicitReplyHints(assistantText);
|
|
147
244
|
const source = extractNextStepsSource(assistantText);
|
|
148
245
|
const user = clip(userText, USER_TEXT_LIMIT);
|
|
149
|
-
const sourceClip =
|
|
246
|
+
const sourceClip = clipHeadTail(source, SOURCE_LIMIT);
|
|
150
247
|
if (!sourceClip) {
|
|
151
|
-
return { suggestions:
|
|
248
|
+
return { suggestions: uniqueThree(hints), usedFallback: false };
|
|
152
249
|
}
|
|
153
250
|
const perAttemptTimeoutMs = opts.perAttemptTimeoutMs ?? 12_000;
|
|
154
251
|
try {
|
|
@@ -164,8 +261,12 @@ export async function generateSuggestedReplies(userText, assistantText, opts = {
|
|
|
164
261
|
}), perAttemptTimeoutMs, 'ai-suggestions');
|
|
165
262
|
const raw = result.content ?? '';
|
|
166
263
|
const parsed = parseSuggestedReplies(raw);
|
|
167
|
-
|
|
168
|
-
|
|
264
|
+
// 显式承诺的那条已经在 hints 里了;模型经常再产出一条「确认内容,按这版继续」
|
|
265
|
+
// 这类包含同一指令的变体,去掉,给真正的修改建议腾位置。
|
|
266
|
+
const withoutHintEchoes = parsed.filter((item) => !hints.some((hint) => item.includes(hint)));
|
|
267
|
+
const suggestions = uniqueThree([...hints, ...withoutHintEchoes]);
|
|
268
|
+
if (suggestions.length > 0) {
|
|
269
|
+
return { suggestions, usedFallback: false };
|
|
169
270
|
}
|
|
170
271
|
if (Array.isArray(sliceJsonArray(raw))) {
|
|
171
272
|
return { suggestions: [], usedFallback: false };
|
|
@@ -174,6 +275,6 @@ export async function generateSuggestedReplies(userText, assistantText, opts = {
|
|
|
174
275
|
}
|
|
175
276
|
catch (err) {
|
|
176
277
|
console.warn('[ai-suggestions] LLM 追问建议失败,不展示建议', err);
|
|
177
|
-
return { suggestions:
|
|
278
|
+
return { suggestions: uniqueThree(hints), usedFallback: true };
|
|
178
279
|
}
|
|
179
280
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A4: drive one chat turn through the sidecar-hosted
|
|
2
|
+
* A4: drive one chat turn through the sidecar-hosted Rust CoreLoop.
|
|
3
3
|
*
|
|
4
4
|
* This is the default chat path (since 2026-08-26): the sidecar owns
|
|
5
5
|
* think→act→observe, and tool calls come back over the reverse channel
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A4: drive one chat turn through the sidecar-hosted
|
|
2
|
+
* A4: drive one chat turn through the sidecar-hosted Rust CoreLoop.
|
|
3
3
|
*
|
|
4
4
|
* This is the default chat path (since 2026-08-26): the sidecar owns
|
|
5
5
|
* think→act→observe, and tool calls come back over the reverse channel
|
|
@@ -39,6 +39,7 @@ import { resolveBranchActivation } from './branch-helper.js';
|
|
|
39
39
|
import { detectInterruptedTurn } from './interrupted-helper.js';
|
|
40
40
|
import { dropCurrentUserMessage } from './history-helper.js';
|
|
41
41
|
import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
|
|
42
|
+
import { chatAttachmentsDirPath } from '../attachments.js';
|
|
42
43
|
import { loadProjectRuleFiles } from '../project-rules.js';
|
|
43
44
|
import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUserPath, } from '../project-home.js';
|
|
44
45
|
import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
|
|
@@ -2435,7 +2436,11 @@ export class LocalBackendRouter {
|
|
|
2435
2436
|
? `另有源文件夹(只读):${chatProject.sourceFolders.join('、')}。`
|
|
2436
2437
|
: '') +
|
|
2437
2438
|
`请一律使用项目目录内的路径(相对路径按家目录解析)。` +
|
|
2438
|
-
|
|
2439
|
+
`如确需读取项目外的文件,可请用户在项目里附加为源文件夹。\n` +
|
|
2440
|
+
`例外:本会话的用户上传附件目录 ${chatAttachmentsDirPath(chatId)} 也在项目家目录外,` +
|
|
2441
|
+
`但它已作为**只读放行根**开放给 local_read_file(用绝对路径可直接读取,不会被项目围栏拒绝)。` +
|
|
2442
|
+
`用户附到本条消息的文件都放在那里;需要转换(docx / pdf / xlsx / pptx 等)时,` +
|
|
2443
|
+
`把该绝对路径交给 local_exec_shell 的转换脚本处理即可,**不要**因为它在项目家目录外就拒绝读取或要求用户重新拷贝。`;
|
|
2439
2444
|
// W6-5 + W6-7a:项目级规则文件(AGENTS.md / CLAUDE.md)是不可信输入,
|
|
2440
2445
|
// 仅在用户显式信任该项目后才注入模型上下文——未信任一律不读取、不注入
|
|
2441
2446
|
// (打开恶意仓库时,一段构造的规则文件不能劫持 agent)。信任状态持久化、
|
package/dist/main.js
CHANGED
|
@@ -208,7 +208,7 @@ function applyStrictCsp() {
|
|
|
208
208
|
"img-src 'self' data: blob:",
|
|
209
209
|
"style-src 'self' 'unsafe-inline'",
|
|
210
210
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
211
|
-
"font-src 'self' data:",
|
|
211
|
+
"font-src 'self' data: blob:",
|
|
212
212
|
].join('; ');
|
|
213
213
|
callback({
|
|
214
214
|
responseHeaders: {
|
|
@@ -43,7 +43,23 @@ const STREAM_PATH_PATTERNS = [
|
|
|
43
43
|
/^\/api\/v2\/chats\/[^/]+\/(send|run|agent)$/,
|
|
44
44
|
/^\/api\/v2\/chats\/[^/]+\/messages\/[^/]+\/regenerate$/,
|
|
45
45
|
];
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 请求体上限。会话附件(`/host/attachments/save`)在浏览器模式走 base64,
|
|
48
|
+
* 相对原始字节膨胀约 4/3,所以这里必须比 `ATTACHMENT_MAX_BYTES`(25MB,
|
|
49
|
+
* 按**解码后**字节校验)留出足够 headroom,否则一个合法的 20MB 文件也会
|
|
50
|
+
* 在进到 saveAttachmentFiles 之前就被 body 上限挡掉。批量多个文件共享同一
|
|
51
|
+
* 上限(超出返回 413,前端据此提示用户分开上传)。
|
|
52
|
+
*/
|
|
53
|
+
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
54
|
+
/** body 超限专用错误:外层据此回 413 + 可读原因,而不是笼统的 500。 */
|
|
55
|
+
class BodyTooLargeError extends Error {
|
|
56
|
+
limit;
|
|
57
|
+
constructor(limit) {
|
|
58
|
+
super(`request body too large (max ${Math.round(limit / 1024 / 1024)}MB)`);
|
|
59
|
+
this.limit = limit;
|
|
60
|
+
this.name = 'BodyTooLargeError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
47
63
|
const MIME = {
|
|
48
64
|
'.html': 'text/html; charset=utf-8',
|
|
49
65
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -79,7 +95,7 @@ async function readJsonBody(req) {
|
|
|
79
95
|
for await (const chunk of req) {
|
|
80
96
|
size += chunk.length;
|
|
81
97
|
if (size > MAX_BODY_BYTES)
|
|
82
|
-
throw new
|
|
98
|
+
throw new BodyTooLargeError(MAX_BODY_BYTES);
|
|
83
99
|
chunks.push(chunk);
|
|
84
100
|
}
|
|
85
101
|
if (chunks.length === 0)
|
|
@@ -432,6 +448,16 @@ export function createBsServer(deps) {
|
|
|
432
448
|
}
|
|
433
449
|
sendJson(res, 404, { detail: 'not found' });
|
|
434
450
|
})().catch((err) => {
|
|
451
|
+
// body 超限是可预期的客户端问题(附件太大/一次传太多),回 413 +
|
|
452
|
+
// 可读原因,让前端把它显示给用户,而不是笼统的 500 "internal error"。
|
|
453
|
+
if (err instanceof BodyTooLargeError) {
|
|
454
|
+
log.warn('[bs] request body too large', { url: req.url, limit: err.limit });
|
|
455
|
+
if (!res.headersSent)
|
|
456
|
+
sendJson(res, 413, { detail: err.message });
|
|
457
|
+
else
|
|
458
|
+
res.end();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
435
461
|
log.error('[bs] unhandled request error', err);
|
|
436
462
|
if (!res.headersSent)
|
|
437
463
|
sendJson(res, 500, { detail: 'internal error' });
|
package/dist/sidecar/boot.js
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
* 由宿主的退出钩子调用(Electron before-quit / BS SIGTERM)。
|
|
8
8
|
*/
|
|
9
9
|
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import log from 'electron-log';
|
|
11
|
-
import { SidecarSupervisor
|
|
12
|
+
import { SidecarSupervisor } from './supervisor.js';
|
|
12
13
|
import { setSidecarSupervisor, setSidecarSupervisorPending, llmService } from '../llm/index.js';
|
|
13
14
|
import { resolveSidecarStoragePath } from './storage-path.js';
|
|
14
15
|
import { deriveEgressAllowListFromBaseUrl, probeExecSandboxCapability, } from './exec-sandbox.js';
|
|
15
16
|
import { createToolInvokeHandler } from './reverse-tools.js';
|
|
16
17
|
import { createProcessSpawnHandler } from './reverse-spawn.js';
|
|
17
18
|
import { collectAmbientProxyEndpoints } from './proxy-detect.js';
|
|
18
|
-
import { buildEgressProxyPlan, decideEgressProxy, deriveWebEgressHosts, pickFreePort, recordEgressPosture, startEgressProxy, } from './egress-proxy.js';
|
|
19
|
+
import { buildEgressProxyPlan, decideEgressProxy, ensureEgressProxyExecutable, deriveWebEgressHosts, pickFreePort, recordEgressPosture, startEgressProxy, } from './egress-proxy.js';
|
|
19
20
|
import { WEB_TOOL_RPC_TIMEOUT_MS, webToolsEnabled } from '../tool-router.js';
|
|
20
21
|
import { sidecarWebSearchEnv } from '../storage/web-search-settings.js';
|
|
21
22
|
import { executeHostedWebSearch } from '../hosted-web-search.js';
|
|
@@ -48,7 +49,7 @@ async function deriveSidecarEgressAllowList() {
|
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
50
51
|
* W1.3.3(3.1a 起默认开):把 sidecar 出网收敛到本机
|
|
51
|
-
*
|
|
52
|
+
* steerable-egress-proxy 二进制。显式 `STEERABLE_EGRESS_PROXY=0` 退出。
|
|
52
53
|
*
|
|
53
54
|
* 两类自动回退到旧的 Seatbelt 端口级派生路径(加固永远不能弄断 LLM
|
|
54
55
|
* 通路):
|
|
@@ -78,8 +79,17 @@ async function startEgressProxyIfEnabled(store) {
|
|
|
78
79
|
storedProvider: searchSettings?.provider,
|
|
79
80
|
llmBaseUrl: settings.baseUrl,
|
|
80
81
|
});
|
|
82
|
+
const executable = await ensureEgressProxyExecutable(path.dirname(fileURLToPath(import.meta.url)));
|
|
83
|
+
if (!executable) {
|
|
84
|
+
log.warn('[egress-proxy] steerable-egress-proxy binary was not found; staying on port-level enforcement');
|
|
85
|
+
recordEgressPosture({
|
|
86
|
+
mode: 'port-only-fallback',
|
|
87
|
+
reason: '未找到 steerable-egress-proxy 二进制,按主机管控未启用',
|
|
88
|
+
});
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
81
91
|
const plan = buildEgressProxyPlan({
|
|
82
|
-
|
|
92
|
+
executable,
|
|
83
93
|
port: await pickFreePort(),
|
|
84
94
|
providerBaseUrl: settings.baseUrl,
|
|
85
95
|
providerApiKey: settings.apiKey || undefined,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 桌面宿主启动闭源 `steerable-egress-proxy` 二进制的启动器。
|
|
3
3
|
*
|
|
4
4
|
* 默认态势(3.1a,2026-09-08 起默认开):sidecar 出网收敛到本机代理,
|
|
5
5
|
* Seatbelt 列表只放行代理端口,真正的主机名单由代理进程持有(代理跑在
|
|
@@ -71,8 +71,30 @@ export declare function egressAllowEntry(baseUrl: string | undefined): {
|
|
|
71
71
|
* null(调用方回退到旧的 Seatbelt 派生路径——空名单的代理没有意义,
|
|
72
72
|
* 框架侧也会 fail loud)。
|
|
73
73
|
*/
|
|
74
|
+
export declare function resolveEgressProxyExecutable(): string | null;
|
|
75
|
+
/** Walk up from a compiled file until the repo that owns the fetch script. */
|
|
76
|
+
export declare function findFrameworkRoot(startDir: string): string | null;
|
|
77
|
+
/**
|
|
78
|
+
* 下载缓存目录。egress 代理跑在沙箱外,所以这里绝不能落在 sidecar 沙箱
|
|
79
|
+
* 可写的根(`~/.steerable`、storage path、scratch)之下——否则沙箱内
|
|
80
|
+
* 代码可以替换这个二进制,下次启动即在沙箱外执行。
|
|
81
|
+
*/
|
|
82
|
+
export declare function egressBinaryCacheDir(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, home?: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* 缓存命中且与同目录 `.sha256` 一致时返回路径。每次启动都重新计算哈希,
|
|
85
|
+
* 被截断或替换的文件不会被执行。
|
|
86
|
+
*/
|
|
87
|
+
export declare function verifiedCachedEgress(cacheDir: string, version: string, target: string): string | null;
|
|
88
|
+
/** Download the lockstep egress binary. Returns the printed path, or null. */
|
|
89
|
+
export declare function downloadPublishedEgress(repoRoot: string, cacheDir: string): Promise<string | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Env override, then a locally built private binary, then a cached copy
|
|
92
|
+
* whose SHA-256 still matches, then the verified public Release asset.
|
|
93
|
+
* A missing binary stays null so boot can fall back to port-level rules.
|
|
94
|
+
*/
|
|
95
|
+
export declare function ensureEgressProxyExecutable(startDir: string, download?: (repoRoot: string, cacheDir: string) => Promise<string | null>, cacheDir?: string): Promise<string | null>;
|
|
74
96
|
export declare function buildEgressProxyPlan(options: {
|
|
75
|
-
|
|
97
|
+
executable: string;
|
|
76
98
|
port: number;
|
|
77
99
|
providerBaseUrl?: string;
|
|
78
100
|
/** 存在且 provider 为 https 时启用凭证代理模式(W2.2.2)。 */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 桌面宿主启动闭源 `steerable-egress-proxy` 二进制的启动器。
|
|
3
3
|
*
|
|
4
4
|
* 默认态势(3.1a,2026-09-08 起默认开):sidecar 出网收敛到本机代理,
|
|
5
5
|
* Seatbelt 列表只放行代理端口,真正的主机名单由代理进程持有(代理跑在
|
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
* - 代理直连目标主机,无上游代理链。
|
|
19
19
|
*/
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
|
-
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
23
|
import { connect, createServer } from 'node:net';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
23
26
|
import { defaultSearchBaseUrl } from '../storage/web-search-settings.js';
|
|
27
|
+
import { resolveSidecarPython } from './supervisor.js';
|
|
24
28
|
/**
|
|
25
29
|
* `baseUrl` 对应的允许条目形态:裸主机(框架侧隐含 443/80)或 `host:port`。
|
|
26
30
|
*
|
|
@@ -53,8 +57,136 @@ export function egressAllowEntry(baseUrl) {
|
|
|
53
57
|
* null(调用方回退到旧的 Seatbelt 派生路径——空名单的代理没有意义,
|
|
54
58
|
* 框架侧也会 fail loud)。
|
|
55
59
|
*/
|
|
60
|
+
export function resolveEgressProxyExecutable() {
|
|
61
|
+
const fromEnv = process.env.STEERABLE_EGRESS_PROXY_BIN?.trim();
|
|
62
|
+
return fromEnv && existsSync(fromEnv) ? fromEnv : null;
|
|
63
|
+
}
|
|
64
|
+
/** Walk up from a compiled file until the repo that owns the fetch script. */
|
|
65
|
+
export function findFrameworkRoot(startDir) {
|
|
66
|
+
let dir = path.resolve(startDir);
|
|
67
|
+
for (let i = 0; i < 8; i += 1) {
|
|
68
|
+
if (existsSync(path.join(dir, 'scripts', 'fetch_verified_artifacts.py')))
|
|
69
|
+
return dir;
|
|
70
|
+
const parent = path.dirname(dir);
|
|
71
|
+
if (parent === dir)
|
|
72
|
+
return null;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function localBuiltEgress(repoRoot) {
|
|
78
|
+
const exe = process.platform === 'win32' ? 'steerable-egress-proxy.exe' : 'steerable-egress-proxy';
|
|
79
|
+
for (const kind of ['release', 'debug']) {
|
|
80
|
+
const candidate = path.join(repoRoot, 'packages', 'egress-proxy', 'rs', 'target', kind, exe);
|
|
81
|
+
if (existsSync(candidate))
|
|
82
|
+
return candidate;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 下载缓存目录。egress 代理跑在沙箱外,所以这里绝不能落在 sidecar 沙箱
|
|
88
|
+
* 可写的根(`~/.steerable`、storage path、scratch)之下——否则沙箱内
|
|
89
|
+
* 代码可以替换这个二进制,下次启动即在沙箱外执行。
|
|
90
|
+
*/
|
|
91
|
+
export function egressBinaryCacheDir(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
92
|
+
if (platform === 'darwin')
|
|
93
|
+
return path.join(home, 'Library', 'Caches', 'steerable', 'egress-proxy');
|
|
94
|
+
if (platform === 'win32') {
|
|
95
|
+
return path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'steerable', 'egress-proxy');
|
|
96
|
+
}
|
|
97
|
+
return path.join(env.XDG_CACHE_HOME || path.join(home, '.cache'), 'steerable', 'egress-proxy');
|
|
98
|
+
}
|
|
99
|
+
function hostEgressTarget() {
|
|
100
|
+
if (process.platform === 'darwin')
|
|
101
|
+
return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
|
102
|
+
if (process.platform === 'win32')
|
|
103
|
+
return process.arch === 'x64' ? 'win32-x64' : null;
|
|
104
|
+
if (process.platform === 'linux')
|
|
105
|
+
return process.arch === 'x64' ? 'linux-x64' : null;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function lockstepVersion(repoRoot) {
|
|
109
|
+
try {
|
|
110
|
+
const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
111
|
+
return typeof pkg.version === 'string' ? pkg.version : null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// 读不到根 package.json 就无法定位缓存文件,交给下载步骤处理。
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 缓存命中且与同目录 `.sha256` 一致时返回路径。每次启动都重新计算哈希,
|
|
120
|
+
* 被截断或替换的文件不会被执行。
|
|
121
|
+
*/
|
|
122
|
+
export function verifiedCachedEgress(cacheDir, version, target) {
|
|
123
|
+
const name = `steerable-egress-proxy-bin-${version}-${target}${target === 'win32-x64' ? '.exe' : ''}`;
|
|
124
|
+
const binary = path.join(cacheDir, name);
|
|
125
|
+
const digestFile = `${binary}.sha256`;
|
|
126
|
+
if (!existsSync(binary) || !existsSync(digestFile))
|
|
127
|
+
return null;
|
|
128
|
+
const expected = readFileSync(digestFile, 'utf8').trim().split(/\s+/)[0]?.toLowerCase();
|
|
129
|
+
const actual = createHash('sha256').update(readFileSync(binary)).digest('hex');
|
|
130
|
+
return expected && expected === actual ? binary : null;
|
|
131
|
+
}
|
|
132
|
+
const EGRESS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
133
|
+
/** Download the lockstep egress binary. Returns the printed path, or null. */
|
|
134
|
+
export function downloadPublishedEgress(repoRoot, cacheDir) {
|
|
135
|
+
const script = path.join(repoRoot, 'scripts', 'fetch_verified_artifacts.py');
|
|
136
|
+
// 与 sidecar 同一解释器:系统 python3 可能不带 SSL,下载不了 https。
|
|
137
|
+
const python = resolveSidecarPython();
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const child = spawn(python, [script, 'egress', '--lockstep', '--target', 'host', '--out', cacheDir], {
|
|
140
|
+
cwd: repoRoot,
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
const timer = setTimeout(() => child.kill(), EGRESS_DOWNLOAD_TIMEOUT_MS);
|
|
144
|
+
let stdout = '';
|
|
145
|
+
child.stdout.setEncoding('utf8');
|
|
146
|
+
child.stdout.on('data', (chunk) => {
|
|
147
|
+
stdout += chunk;
|
|
148
|
+
});
|
|
149
|
+
child.on('error', () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(null);
|
|
152
|
+
});
|
|
153
|
+
child.on('close', (code) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
const printed = stdout.trim().split('\n').pop()?.trim();
|
|
156
|
+
if (code === 0 && printed && existsSync(printed))
|
|
157
|
+
resolve(printed);
|
|
158
|
+
else
|
|
159
|
+
resolve(null);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Env override, then a locally built private binary, then a cached copy
|
|
165
|
+
* whose SHA-256 still matches, then the verified public Release asset.
|
|
166
|
+
* A missing binary stays null so boot can fall back to port-level rules.
|
|
167
|
+
*/
|
|
168
|
+
export async function ensureEgressProxyExecutable(startDir, download = downloadPublishedEgress, cacheDir = egressBinaryCacheDir()) {
|
|
169
|
+
const fromEnv = resolveEgressProxyExecutable();
|
|
170
|
+
if (fromEnv)
|
|
171
|
+
return fromEnv;
|
|
172
|
+
const repoRoot = findFrameworkRoot(startDir);
|
|
173
|
+
if (!repoRoot)
|
|
174
|
+
return null;
|
|
175
|
+
const built = localBuiltEgress(repoRoot);
|
|
176
|
+
if (built)
|
|
177
|
+
return built;
|
|
178
|
+
const version = lockstepVersion(repoRoot);
|
|
179
|
+
const target = hostEgressTarget();
|
|
180
|
+
if (!version || !target)
|
|
181
|
+
return null;
|
|
182
|
+
const cached = verifiedCachedEgress(cacheDir, version, target);
|
|
183
|
+
if (cached)
|
|
184
|
+
return cached;
|
|
185
|
+
const downloaded = await download(repoRoot, cacheDir);
|
|
186
|
+
return downloaded && verifiedCachedEgress(cacheDir, version, target);
|
|
187
|
+
}
|
|
56
188
|
export function buildEgressProxyPlan(options) {
|
|
57
|
-
const {
|
|
189
|
+
const { executable, port, providerBaseUrl, providerApiKey } = options;
|
|
58
190
|
const proxiedHosts = [];
|
|
59
191
|
const directHosts = [];
|
|
60
192
|
let brokerHost = null;
|
|
@@ -91,10 +223,8 @@ export function buildEgressProxyPlan(options) {
|
|
|
91
223
|
tokenValue: randomBytes(24).toString('base64url'),
|
|
92
224
|
};
|
|
93
225
|
return {
|
|
94
|
-
command:
|
|
226
|
+
command: executable,
|
|
95
227
|
args: [
|
|
96
|
-
'-m',
|
|
97
|
-
'steerable_egress_proxy',
|
|
98
228
|
'--bind',
|
|
99
229
|
proxyEndpoint,
|
|
100
230
|
...proxiedHosts.flatMap((host) => ['--allow', host]),
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -292,7 +292,7 @@ export interface SidecarChatStreamRequest {
|
|
|
292
292
|
providerOptions?: Record<string, unknown>;
|
|
293
293
|
/** Per-start RPC timeout (NOT per-chunk). Default 30_000ms. */
|
|
294
294
|
startTimeoutMs?: number;
|
|
295
|
-
/** Route the stream through the sidecar-hosted
|
|
295
|
+
/** Route the stream through the sidecar-hosted Rust CoreLoop (A4). */
|
|
296
296
|
useCoreLoop?: boolean;
|
|
297
297
|
/**
|
|
298
298
|
* W7-1: continue the durable record's interrupted turn instead of opening
|
|
@@ -21,9 +21,10 @@ export interface OpenAICompatOverrides {
|
|
|
21
21
|
reasoningEchoField?: string;
|
|
22
22
|
supportsForcedToolChoice?: boolean;
|
|
23
23
|
/**
|
|
24
|
-
* thinking
|
|
25
|
-
*
|
|
26
|
-
* 自动为 api.deepseek.com
|
|
24
|
+
* thinking 模式下**所有** assistant 消息即使没产出思考,也必须回传
|
|
25
|
+
* reasoning 字段(空串)——DeepSeek 缺键即 400,不只是带 tool_calls 的
|
|
26
|
+
* 轮次;框架按 baseUrl 自动为 api.deepseek.com 打开,这里只是允许设置页
|
|
27
|
+
* 显式覆盖。
|
|
27
28
|
*/
|
|
28
29
|
echoEmptyReasoningForToolCalls?: boolean;
|
|
29
30
|
cachedTokensFields?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.37",
|
|
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/",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"he": "^1.2.0",
|
|
56
56
|
"node-pty": "^1.1.0",
|
|
57
57
|
"semver": "^7.8.0",
|
|
58
|
-
"@steerable/agent-
|
|
59
|
-
"@steerable/
|
|
60
|
-
"@steerable/
|
|
58
|
+
"@steerable/agent-harness": "0.6.37",
|
|
59
|
+
"@steerable/agent-protocol": "0.6.37",
|
|
60
|
+
"@steerable/pack-sdk": "0.6.37"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/better-sqlite3": "^7.6.13",
|