@steerable/agent-shell 0.6.40 → 0.6.42
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/README.md +16 -1
- package/contracts/tool-contract.json +19 -1
- package/dist/host-tools.d.ts +2 -2
- package/dist/host-tools.js +4 -1
- package/dist/image-attachment.d.ts +34 -0
- package/dist/image-attachment.js +111 -0
- package/dist/mcp-executor.js +1 -1
- package/dist/runtime.d.ts +8 -3
- package/dist/server/index.js +26 -0
- package/dist/server/ready.d.ts +12 -0
- package/dist/server/ready.js +10 -0
- package/dist/sidecar/boot.js +3 -6
- package/dist/sidecar/reverse-approval.d.ts +3 -2
- package/dist/sidecar/reverse-approval.js +3 -2
- package/dist/sidecar/reverse-ask-user.d.ts +2 -0
- package/dist/sidecar/reverse-ask-user.js +2 -0
- package/dist/sidecar/reverse-spawn.d.ts +1 -1
- package/dist/sidecar/reverse-spawn.js +1 -1
- package/dist/sidecar/reverse-tools.js +16 -0
- package/dist/sidecar/types.d.ts +3 -2
- package/dist/tool-router.d.ts +3 -2
- package/dist/tool-router.js +90 -2
- package/dist/tool-search-rank.d.ts +1 -1
- package/dist/tool-search-rank.js +1 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# @steerable/agent-shell
|
|
2
2
|
|
|
3
|
-
Product-neutral
|
|
3
|
+
Product-neutral Node HostRuntime and headless HTTP host for Steerable Framework.
|
|
4
|
+
|
|
5
|
+
## Desktop composition
|
|
6
|
+
|
|
7
|
+
Tauri desktop products supervise the same BS executable used by browser mode,
|
|
8
|
+
bind it to an ephemeral loopback port, and load its web UI in a WebView. The
|
|
9
|
+
renderer uses `HostBridge`: HTTP/SSE carries agent, storage, PTY, approval,
|
|
10
|
+
attachment, and pack traffic; narrow Tauri commands provide native dialogs,
|
|
11
|
+
menus, screenshots, and updates. `STEERABLE_HOST_READY` on stdout is the
|
|
12
|
+
machine-readable startup record. `STEERABLE_HOST_PARENT_PID` makes the Node
|
|
13
|
+
host shut down if its desktop supervisor disappears.
|
|
14
|
+
|
|
15
|
+
The reusable Rust host source is published as
|
|
16
|
+
`@steerable/agent-shell-tauri`. Product `src-tauri` crates depend on its
|
|
17
|
+
installed npm path and only supply product configuration plus
|
|
18
|
+
`tauri::generate_context!()`.
|
|
4
19
|
|
|
5
20
|
## BS composition
|
|
6
21
|
|
|
@@ -280,6 +280,24 @@
|
|
|
280
280
|
"version"
|
|
281
281
|
]
|
|
282
282
|
},
|
|
283
|
+
"view_image": {
|
|
284
|
+
"desktopName": "view_image",
|
|
285
|
+
"optionalInput": [
|
|
286
|
+
"region",
|
|
287
|
+
"maxEdge",
|
|
288
|
+
"format"
|
|
289
|
+
],
|
|
290
|
+
"requiredInput": [
|
|
291
|
+
"path"
|
|
292
|
+
],
|
|
293
|
+
"requiredResult": [
|
|
294
|
+
"width",
|
|
295
|
+
"height",
|
|
296
|
+
"sourcePath",
|
|
297
|
+
"mediaType",
|
|
298
|
+
"_image"
|
|
299
|
+
]
|
|
300
|
+
},
|
|
283
301
|
"write_file": {
|
|
284
302
|
"desktopName": "local_write_file",
|
|
285
303
|
"optionalInput": [
|
|
@@ -294,7 +312,7 @@
|
|
|
294
312
|
]
|
|
295
313
|
}
|
|
296
314
|
},
|
|
297
|
-
"version":
|
|
315
|
+
"version": 3,
|
|
298
316
|
"dualImplementations": {
|
|
299
317
|
"todo_write": ["python", "rust"],
|
|
300
318
|
"web_fetch": ["python", "rust"],
|
package/dist/host-tools.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type ResolvedSettingsChrome = Record<SettingsItemId, boolean>;
|
|
|
34
34
|
*/
|
|
35
35
|
export declare function resolveSettingsChrome(value?: unknown, tools?: ResolvedHostTools): ResolvedSettingsChrome;
|
|
36
36
|
export declare function hasGeneralSettingsChrome(chrome?: ResolvedSettingsChrome): boolean;
|
|
37
|
-
export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "local_write_file", "local_edit_file", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script"];
|
|
37
|
+
export declare const LOCAL_FS_TOOL_NAMES: readonly ["local_exec_shell", "local_read_file", "view_image", "local_write_file", "local_edit_file", "local_open_path", "local_run_snippet", "local_list_scripts", "local_run_script"];
|
|
38
38
|
export declare function resolveHostToolSurface(value: boolean | {
|
|
39
39
|
capability?: boolean;
|
|
40
40
|
chrome?: boolean;
|
|
@@ -68,6 +68,6 @@ export declare function buildHostApproval(input: {
|
|
|
68
68
|
timeoutMs?: number;
|
|
69
69
|
}): {
|
|
70
70
|
mode: 'host';
|
|
71
|
-
timeoutMs
|
|
71
|
+
timeoutMs?: number;
|
|
72
72
|
storePath: string;
|
|
73
73
|
} | undefined;
|
package/dist/host-tools.js
CHANGED
|
@@ -76,6 +76,7 @@ export function hasGeneralSettingsChrome(chrome = resolveSettingsChrome()) {
|
|
|
76
76
|
export const LOCAL_FS_TOOL_NAMES = [
|
|
77
77
|
'local_exec_shell',
|
|
78
78
|
'local_read_file',
|
|
79
|
+
'view_image',
|
|
79
80
|
'local_write_file',
|
|
80
81
|
'local_edit_file',
|
|
81
82
|
'local_open_path',
|
|
@@ -190,9 +191,11 @@ export function isHostIpcAllowed(channel, tools = resolveHostTools()) {
|
|
|
190
191
|
export function buildHostApproval(input) {
|
|
191
192
|
if (!isApprovalEnabled(input))
|
|
192
193
|
return undefined;
|
|
194
|
+
// No default timer: the card stays until the user allows or denies.
|
|
195
|
+
// An explicit timeoutMs still fails closed as timed_out.
|
|
193
196
|
return {
|
|
194
197
|
mode: 'host',
|
|
195
|
-
timeoutMs: input.timeoutMs
|
|
198
|
+
...(input.timeoutMs != null ? { timeoutMs: input.timeoutMs } : {}),
|
|
196
199
|
storePath: input.storePath,
|
|
197
200
|
};
|
|
198
201
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NativeImageLike } from './runtime.js';
|
|
1
2
|
import type { LlmImage } from './llm/types.js';
|
|
2
3
|
/** Refuse to read source files larger than this (10 MB). */
|
|
3
4
|
export declare const IMAGE_MAX_SOURCE_BYTES: number;
|
|
@@ -14,6 +15,33 @@ export interface ProcessedImageAttachments {
|
|
|
14
15
|
/** One line per input describing the outcome, for the model-visible note. */
|
|
15
16
|
notes: string[];
|
|
16
17
|
}
|
|
18
|
+
export interface ImageRegion {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ViewImageInput {
|
|
25
|
+
path: string;
|
|
26
|
+
region?: ImageRegion;
|
|
27
|
+
maxEdge?: number;
|
|
28
|
+
format?: 'png' | 'jpeg';
|
|
29
|
+
}
|
|
30
|
+
export interface ViewImageResult {
|
|
31
|
+
success: boolean;
|
|
32
|
+
data?: {
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
sourcePath: string;
|
|
36
|
+
mediaType: 'image/png' | 'image/jpeg';
|
|
37
|
+
_image: {
|
|
38
|
+
b64: string;
|
|
39
|
+
media_type: 'image/png' | 'image/jpeg';
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
error?: string;
|
|
43
|
+
needsFollowup?: boolean;
|
|
44
|
+
}
|
|
17
45
|
export declare function isImagePath(path: string): boolean;
|
|
18
46
|
/**
|
|
19
47
|
* Validate the wire value (`metadata.images` from the renderer) into a clean
|
|
@@ -32,6 +60,12 @@ export declare function computeTargetSize(width: number, height: number, maxDime
|
|
|
32
60
|
height: number;
|
|
33
61
|
resized: boolean;
|
|
34
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Decode, optionally crop, resize, and encode one image for a model-visible
|
|
65
|
+
* tool result. `_image` is consumed by the CoreLoop as an image content part;
|
|
66
|
+
* it is not a path or model-visible base64 string.
|
|
67
|
+
*/
|
|
68
|
+
export declare function processViewImage(input: ViewImageInput, nativeImage?: NativeImageLike | null): ViewImageResult;
|
|
35
69
|
/**
|
|
36
70
|
* Process a batch of image attachments. Synchronous: `nativeImage` decode /
|
|
37
71
|
* resize / encode are all synchronous, and the byte check uses `statSync`.
|
package/dist/image-attachment.js
CHANGED
|
@@ -69,6 +69,117 @@ export function computeTargetSize(width, height, maxDimension = IMAGE_MAX_DIMENS
|
|
|
69
69
|
resized: true,
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Decode, optionally crop, resize, and encode one image for a model-visible
|
|
74
|
+
* tool result. `_image` is consumed by the CoreLoop as an image content part;
|
|
75
|
+
* it is not a path or model-visible base64 string.
|
|
76
|
+
*/
|
|
77
|
+
export function processViewImage(input, nativeImage = getNativeImage()) {
|
|
78
|
+
if (!isImagePath(input.path)) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: 'view_image supports PNG, JPEG, and WebP files',
|
|
82
|
+
needsFollowup: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!nativeImage) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: '当前运行环境不支持图片解码',
|
|
89
|
+
needsFollowup: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
let sourceBytes;
|
|
93
|
+
try {
|
|
94
|
+
sourceBytes = statSync(input.path).size;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { success: false, error: '图片不存在或不可读', needsFollowup: true };
|
|
98
|
+
}
|
|
99
|
+
if (sourceBytes > IMAGE_MAX_SOURCE_BYTES) {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: `源文件 ${formatMb(sourceBytes)}MB 超过 ${formatMb(IMAGE_MAX_SOURCE_BYTES)}MB 上限`,
|
|
103
|
+
needsFollowup: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const maxEdge = input.maxEdge ?? IMAGE_MAX_DIMENSION;
|
|
107
|
+
if (!Number.isInteger(maxEdge) || maxEdge < 1 || maxEdge > 4096) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
error: 'maxEdge 必须是 1–4096 的整数',
|
|
111
|
+
needsFollowup: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
let rendered = nativeImage.createFromPath(input.path);
|
|
115
|
+
if (rendered.isEmpty()) {
|
|
116
|
+
return { success: false, error: '不是可识别的图片', needsFollowup: true };
|
|
117
|
+
}
|
|
118
|
+
if (input.region) {
|
|
119
|
+
const crop = resolveCrop(input.region, rendered.getSize());
|
|
120
|
+
if ('error' in crop) {
|
|
121
|
+
return { success: false, error: crop.error, needsFollowup: true };
|
|
122
|
+
}
|
|
123
|
+
rendered = rendered.crop(crop);
|
|
124
|
+
}
|
|
125
|
+
const croppedSize = rendered.getSize();
|
|
126
|
+
const target = computeTargetSize(croppedSize.width, croppedSize.height, maxEdge);
|
|
127
|
+
if (target.resized) {
|
|
128
|
+
rendered = rendered.resize({
|
|
129
|
+
width: target.width,
|
|
130
|
+
height: target.height,
|
|
131
|
+
quality: 'good',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const sourceExt = extname(input.path).toLowerCase();
|
|
135
|
+
const outputFormat = input.format ?? (['.jpg', '.jpeg'].includes(sourceExt) ? 'jpeg' : 'png');
|
|
136
|
+
let mediaType = outputFormat === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
137
|
+
let buffer = outputFormat === 'jpeg' ? rendered.toJPEG(85) : rendered.toPNG();
|
|
138
|
+
if (buffer.length > IMAGE_MAX_ENCODED_BYTES && input.format == null && outputFormat === 'png') {
|
|
139
|
+
mediaType = 'image/jpeg';
|
|
140
|
+
buffer = rendered.toJPEG(80);
|
|
141
|
+
}
|
|
142
|
+
if (buffer.length > IMAGE_MAX_ENCODED_BYTES) {
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
error: `图片编码后 ${formatMb(buffer.length)}MB 超过 ${formatMb(IMAGE_MAX_ENCODED_BYTES)}MB 上限;请减小 maxEdge 或使用 jpeg`,
|
|
146
|
+
needsFollowup: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const { width, height } = rendered.getSize();
|
|
150
|
+
const b64 = buffer.toString('base64');
|
|
151
|
+
return {
|
|
152
|
+
success: true,
|
|
153
|
+
data: {
|
|
154
|
+
width,
|
|
155
|
+
height,
|
|
156
|
+
sourcePath: input.path,
|
|
157
|
+
mediaType,
|
|
158
|
+
_image: { b64, media_type: mediaType },
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function resolveCrop(region, size) {
|
|
163
|
+
const values = [region.x, region.y, region.w, region.h];
|
|
164
|
+
if (!values.every(Number.isFinite))
|
|
165
|
+
return { error: 'region 的 x/y/w/h 必须是有限数字' };
|
|
166
|
+
const normalized = values.every((value) => value >= 0 && value <= 1);
|
|
167
|
+
const x = normalized ? Math.floor(region.x * size.width) : Math.round(region.x);
|
|
168
|
+
const y = normalized ? Math.floor(region.y * size.height) : Math.round(region.y);
|
|
169
|
+
const width = normalized ? Math.ceil(region.w * size.width) : Math.round(region.w);
|
|
170
|
+
const height = normalized ? Math.ceil(region.h * size.height) : Math.round(region.h);
|
|
171
|
+
if (x < 0 ||
|
|
172
|
+
y < 0 ||
|
|
173
|
+
width < 1 ||
|
|
174
|
+
height < 1 ||
|
|
175
|
+
x + width > size.width ||
|
|
176
|
+
y + height > size.height) {
|
|
177
|
+
return {
|
|
178
|
+
error: `region 超出图片范围 ${size.width}×${size.height}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { x, y, width, height };
|
|
182
|
+
}
|
|
72
183
|
function formatMb(bytes) {
|
|
73
184
|
return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '');
|
|
74
185
|
}
|
package/dist/mcp-executor.js
CHANGED
package/dist/runtime.d.ts
CHANGED
|
@@ -43,12 +43,18 @@ export declare function shellOpenExternal(url: string): Promise<void>;
|
|
|
43
43
|
*/
|
|
44
44
|
export declare function onAppWillQuit(listener: () => void): void;
|
|
45
45
|
export declare function offAppWillQuit(listener: () => void): void;
|
|
46
|
-
interface NativeImageInstance {
|
|
46
|
+
export interface NativeImageInstance {
|
|
47
47
|
isEmpty(): boolean;
|
|
48
48
|
getSize(): {
|
|
49
49
|
width: number;
|
|
50
50
|
height: number;
|
|
51
51
|
};
|
|
52
|
+
crop(rect: {
|
|
53
|
+
x: number;
|
|
54
|
+
y: number;
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
}): NativeImageInstance;
|
|
52
58
|
resize(o: {
|
|
53
59
|
width?: number;
|
|
54
60
|
height?: number;
|
|
@@ -57,7 +63,7 @@ interface NativeImageInstance {
|
|
|
57
63
|
toPNG(): Buffer;
|
|
58
64
|
toJPEG(quality: number): Buffer;
|
|
59
65
|
}
|
|
60
|
-
interface NativeImageLike {
|
|
66
|
+
export interface NativeImageLike {
|
|
61
67
|
createFromPath(p: string): NativeImageInstance;
|
|
62
68
|
}
|
|
63
69
|
/**
|
|
@@ -65,4 +71,3 @@ interface NativeImageLike {
|
|
|
65
71
|
* null——调用方按"不支持图片解码"降级(image-attachment 已有该分支)。
|
|
66
72
|
*/
|
|
67
73
|
export declare function getNativeImage(): NativeImageLike | null;
|
|
68
|
-
export {};
|
package/dist/server/index.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/** Executable BS entry; reusable assembly lives in `start.ts`. */
|
|
2
2
|
import { startBsHost } from './start.js';
|
|
3
|
+
import { formatHostReady } from './ready.js';
|
|
3
4
|
async function main() {
|
|
4
5
|
const handle = await startBsHost();
|
|
6
|
+
console.log(formatHostReady({ host: handle.host, port: handle.port }));
|
|
5
7
|
let shuttingDown = false;
|
|
8
|
+
let parentWatch;
|
|
6
9
|
const shutdown = () => {
|
|
7
10
|
if (shuttingDown)
|
|
8
11
|
return;
|
|
9
12
|
shuttingDown = true;
|
|
13
|
+
if (parentWatch)
|
|
14
|
+
clearInterval(parentWatch);
|
|
10
15
|
console.log('[bs] shutting down…');
|
|
11
16
|
void handle.shutdown().finally(() => process.exit(0));
|
|
12
17
|
// 兜底:sidecar 卡住也不拖住退出。
|
|
@@ -14,6 +19,27 @@ async function main() {
|
|
|
14
19
|
};
|
|
15
20
|
process.on('SIGINT', shutdown);
|
|
16
21
|
process.on('SIGTERM', shutdown);
|
|
22
|
+
const parentPidValue = process.env.STEERABLE_HOST_PARENT_PID?.trim();
|
|
23
|
+
if (parentPidValue) {
|
|
24
|
+
const parentPid = Number(parentPidValue);
|
|
25
|
+
if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || parentPid === process.pid) {
|
|
26
|
+
throw new Error(`invalid STEERABLE_HOST_PARENT_PID: ${parentPidValue}`);
|
|
27
|
+
}
|
|
28
|
+
parentWatch = setInterval(() => {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(parentPid, 0);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error &&
|
|
34
|
+
typeof error === 'object' &&
|
|
35
|
+
'code' in error &&
|
|
36
|
+
error.code === 'ESRCH') {
|
|
37
|
+
shutdown();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, 1_000);
|
|
41
|
+
parentWatch.unref();
|
|
42
|
+
}
|
|
17
43
|
}
|
|
18
44
|
main().catch((err) => {
|
|
19
45
|
console.error('[bs] failed to start:', err);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Prefix for the machine-readable host startup record on stdout. */
|
|
2
|
+
export declare const HOST_READY_PREFIX = "STEERABLE_HOST_READY ";
|
|
3
|
+
export interface HostReadyRecord {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Formats the startup record consumed by desktop host supervisors.
|
|
9
|
+
*
|
|
10
|
+
* Human-readable log lines are not part of this protocol.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatHostReady(record: HostReadyRecord): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Prefix for the machine-readable host startup record on stdout. */
|
|
2
|
+
export const HOST_READY_PREFIX = 'STEERABLE_HOST_READY ';
|
|
3
|
+
/**
|
|
4
|
+
* Formats the startup record consumed by desktop host supervisors.
|
|
5
|
+
*
|
|
6
|
+
* Human-readable log lines are not part of this protocol.
|
|
7
|
+
*/
|
|
8
|
+
export function formatHostReady(record) {
|
|
9
|
+
return `${HOST_READY_PREFIX}${JSON.stringify(record)}`;
|
|
10
|
+
}
|
package/dist/sidecar/boot.js
CHANGED
|
@@ -304,14 +304,11 @@ export async function startHostSidecar(deps) {
|
|
|
304
304
|
// tool-router forwards the call back over the reverse channel.
|
|
305
305
|
STEERABLE_RUN_CODE: '1',
|
|
306
306
|
// P3: conversational JS PTC (run_js/wait_js). The sidecar spawns a
|
|
307
|
-
// long-lived Node worker
|
|
308
|
-
// runtime
|
|
309
|
-
//
|
|
310
|
-
// Node in headless/BS mode, where the variable is ignored). The
|
|
311
|
-
// sidecar's env scrub passes both through to the worker child.
|
|
307
|
+
// long-lived Node worker. Browser and Tauri desktop hosts both run
|
|
308
|
+
// under the pinned Node runtime, so process.execPath is directly
|
|
309
|
+
// executable by the sidecar.
|
|
312
310
|
STEERABLE_PTC_JS: '1',
|
|
313
311
|
STEERABLE_PTC_NODE: process.execPath,
|
|
314
|
-
ELECTRON_RUN_AS_NODE: '1',
|
|
315
312
|
},
|
|
316
313
|
onLogLine: (line) => (deps.onLogLine ?? ((l) => log.info('[sidecar]', l)))(line),
|
|
317
314
|
});
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Fail-closed by construction: no window, no listener, a renderer error,
|
|
7
7
|
* or an invalid reply all become `deny_once` — never a hang, never an
|
|
8
|
-
* auto-allow.
|
|
9
|
-
*
|
|
8
|
+
* auto-allow. An explicit `timeoutMs` on the approval config still fails
|
|
9
|
+
* closed as `timed_out`; the desktop omits it, so the prompt waits until
|
|
10
|
+
* the user decides.
|
|
10
11
|
*
|
|
11
12
|
* The renderer answers via the `approval:decide` invoke with the requestId
|
|
12
13
|
* echoed back; decisions are validated against the algebra's kind set
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Fail-closed by construction: no window, no listener, a renderer error,
|
|
7
7
|
* or an invalid reply all become `deny_once` — never a hang, never an
|
|
8
|
-
* auto-allow.
|
|
9
|
-
*
|
|
8
|
+
* auto-allow. An explicit `timeoutMs` on the approval config still fails
|
|
9
|
+
* closed as `timed_out`; the desktop omits it, so the prompt waits until
|
|
10
|
+
* the user decides.
|
|
10
11
|
*
|
|
11
12
|
* The renderer answers via the `approval:decide` invoke with the requestId
|
|
12
13
|
* echoed back; decisions are validated against the algebra's kind set
|
|
@@ -16,6 +16,8 @@ export interface AskUserPromptRequest {
|
|
|
16
16
|
requestId: string;
|
|
17
17
|
intro: string;
|
|
18
18
|
questions: Array<Record<string, unknown>>;
|
|
19
|
+
/** 发起提问的对话。renderer 只在这条会话的输入框展示卡片。 */
|
|
20
|
+
chatId?: string;
|
|
19
21
|
}
|
|
20
22
|
export interface AskUserBridgeDeps {
|
|
21
23
|
broadcast: (channel: 'ask-user:request', payload: AskUserPromptRequest) => void;
|
|
@@ -17,6 +17,7 @@ export function createAskUserBridge(deps) {
|
|
|
17
17
|
handler: (params) => {
|
|
18
18
|
const p = (params ?? {});
|
|
19
19
|
const questions = Array.isArray(p.questions) ? p.questions : [];
|
|
20
|
+
const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
|
|
20
21
|
if (questions.length === 0) {
|
|
21
22
|
deps.onLog?.('ask_user: malformed request (no questions); answering empty');
|
|
22
23
|
return Promise.resolve({ ...EMPTY_REPLY });
|
|
@@ -30,6 +31,7 @@ export function createAskUserBridge(deps) {
|
|
|
30
31
|
requestId,
|
|
31
32
|
intro: typeof p.intro === 'string' ? p.intro : '',
|
|
32
33
|
questions: questions,
|
|
34
|
+
...(chatId ? { chatId } : {}),
|
|
33
35
|
};
|
|
34
36
|
return new Promise((resolve) => {
|
|
35
37
|
pending.set(requestId, { prompt, resolve });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* W4.1.1 reverse channel: serve the sidecar's `host.process.spawn` by
|
|
3
3
|
* spawning the command confined on Windows via the win-spawn-helper Rust
|
|
4
|
-
* binary (restricted token + Job Object; contract:
|
|
4
|
+
* binary (restricted token + Job Object; contract: Steerable
|
|
5
5
|
* docs/spec/safety.md "Host capability surface").
|
|
6
6
|
*
|
|
7
7
|
* Fail closed by construction: on non-Windows platforms (which have local
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* W4.1.1 reverse channel: serve the sidecar's `host.process.spawn` by
|
|
3
3
|
* spawning the command confined on Windows via the win-spawn-helper Rust
|
|
4
|
-
* binary (restricted token + Job Object; contract:
|
|
4
|
+
* binary (restricted token + Job Object; contract: Steerable
|
|
5
5
|
* docs/spec/safety.md "Host capability surface").
|
|
6
6
|
*
|
|
7
7
|
* Fail closed by construction: on non-Windows platforms (which have local
|
|
@@ -18,6 +18,22 @@ import { normalizeToolPolicy } from '../local-backend/agent-capability.js';
|
|
|
18
18
|
* 2000 单字段,最坏情况纯 CJK 约 4.8k token)。
|
|
19
19
|
*/
|
|
20
20
|
function capResultForModelContext(out) {
|
|
21
|
+
if (out && typeof out === 'object') {
|
|
22
|
+
const record = out;
|
|
23
|
+
const data = record.data && typeof record.data === 'object'
|
|
24
|
+
? record.data
|
|
25
|
+
: null;
|
|
26
|
+
const image = data?._image;
|
|
27
|
+
if (image && typeof image === 'object') {
|
|
28
|
+
const textOnly = { ...record, data: { ...data } };
|
|
29
|
+
delete textOnly.data._image;
|
|
30
|
+
const capped = capResultForModelContext(textOnly);
|
|
31
|
+
const cappedData = capped.data && typeof capped.data === 'object'
|
|
32
|
+
? capped.data
|
|
33
|
+
: {};
|
|
34
|
+
return { ...capped, data: { ...cappedData, _image: image } };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
const json = JSON.stringify(out);
|
|
22
38
|
if (json.length <= 8000)
|
|
23
39
|
return out;
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -445,8 +445,9 @@ export interface SidecarChatStreamRequest {
|
|
|
445
445
|
* `mode: 'host'` asks the host UI over the reverse channel
|
|
446
446
|
* (`approval.request`); `storePath` enables the durable scope
|
|
447
447
|
* (`allow_always` / `deny_always` persisted per category);
|
|
448
|
-
* `timeoutMs
|
|
449
|
-
* not answer in time.
|
|
448
|
+
* `timeoutMs`, when set, fails closed as `timed_out` (a denial) when the
|
|
449
|
+
* UI does not answer in time. Omitted, the prompt waits until the user
|
|
450
|
+
* decides. Absent `approval` → no approval layer (legacy behavior).
|
|
450
451
|
*/
|
|
451
452
|
approval?: {
|
|
452
453
|
mode: 'host' | 'auto';
|
package/dist/tool-router.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { type AgentToolPolicy } from './local-backend/agent-capability.js';
|
|
|
10
10
|
/** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
|
|
11
11
|
export declare const MCP_DYNAMIC_TOOL_PREFIX = "mcp__";
|
|
12
12
|
/**
|
|
13
|
-
* 工具曝光分层(
|
|
13
|
+
* 工具曝光分层(Steerable Wave 2 同构):`direct` 进模型可见列表;
|
|
14
14
|
* `deferred` 可分发、可经 `tool_search` 发现,但不占每轮工具列表的 token;
|
|
15
15
|
* `hidden` 仅可分发。缺省 `direct`。
|
|
16
16
|
*
|
|
@@ -205,11 +205,12 @@ export declare class ToolRouter {
|
|
|
205
205
|
*/
|
|
206
206
|
private applyProjectCwdSandbox;
|
|
207
207
|
private executeShell;
|
|
208
|
+
private resolveReadablePath;
|
|
208
209
|
private resolveMcpConfig;
|
|
209
210
|
/**
|
|
210
211
|
* `tool_search` 处理器:BM25 排序 deferred 名录(name 分词计两次),返回
|
|
211
212
|
* 完整 schema,结果有界(默认 8,封顶 20)。算法、常量、默认上限与返回
|
|
212
|
-
* 文案都与
|
|
213
|
+
* 文案都与 Steerable `tool_search.py` 对齐——同名工具在两侧给
|
|
213
214
|
* 出同样的名次;hidden 层不进搜索。
|
|
214
215
|
*
|
|
215
216
|
* 智能体工具策略在这里同样生效:否则每轮列表藏起来的工具会从这条发现缝
|
package/dist/tool-router.js
CHANGED
|
@@ -5,12 +5,13 @@ import { mcpExecutor } from './mcp-executor.js';
|
|
|
5
5
|
import { rankTools, resolveMaxResults, TOOL_SEARCH_DEFAULT_MAX_RESULTS, } from './tool-search-rank.js';
|
|
6
6
|
import { maybeAutoInstallWrittenSkill } from './local-backend/skill-install.js';
|
|
7
7
|
import { filterToolsByPolicy, isToolAllowed, } from './local-backend/agent-capability.js';
|
|
8
|
+
import { processViewImage } from './image-attachment.js';
|
|
8
9
|
import { isHostToolCapabilityEnabled } from './host-tools.js';
|
|
9
10
|
import { getResolvedHostTools } from './host-tools-runtime.js';
|
|
10
11
|
/** 已注册 MCP 服务的动态工具名前缀:`mcp__<serverKey>__<toolName>`。 */
|
|
11
12
|
export const MCP_DYNAMIC_TOOL_PREFIX = 'mcp__';
|
|
12
13
|
/**
|
|
13
|
-
* 工具曝光分层(
|
|
14
|
+
* 工具曝光分层(Steerable Wave 2 同构):`direct` 进模型可见列表;
|
|
14
15
|
* `deferred` 可分发、可经 `tool_search` 发现,但不占每轮工具列表的 token;
|
|
15
16
|
* `hidden` 仅可分发。缺省 `direct`。
|
|
16
17
|
*
|
|
@@ -148,6 +149,43 @@ export class ToolRouter {
|
|
|
148
149
|
required: ['path'],
|
|
149
150
|
},
|
|
150
151
|
},
|
|
152
|
+
{
|
|
153
|
+
name: 'view_image',
|
|
154
|
+
description: 'Look at a local PNG, JPEG, or WebP image. The result includes an actual image content part the model can see, not merely a path or base64 text. ' +
|
|
155
|
+
'Use region for a pixel or normalized 0–1 crop, maxEdge to bound the longest output edge, and jpeg to reduce payload size.',
|
|
156
|
+
mode: 'read',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
path: { type: 'string', description: 'Local PNG, JPEG, or WebP path.' },
|
|
161
|
+
region: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
description: 'Optional crop {x,y,w,h}; use either pixel values or all-normalized 0–1 values.',
|
|
164
|
+
properties: {
|
|
165
|
+
x: { type: 'number' },
|
|
166
|
+
y: { type: 'number' },
|
|
167
|
+
w: { type: 'number' },
|
|
168
|
+
h: { type: 'number' },
|
|
169
|
+
},
|
|
170
|
+
required: ['x', 'y', 'w', 'h'],
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
},
|
|
173
|
+
maxEdge: {
|
|
174
|
+
type: 'integer',
|
|
175
|
+
minimum: 1,
|
|
176
|
+
maximum: 4096,
|
|
177
|
+
description: 'Resize the longest output edge to at most this many pixels (default 1568).',
|
|
178
|
+
},
|
|
179
|
+
format: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
enum: ['png', 'jpeg'],
|
|
182
|
+
description: 'Output encoding. JPEG usually uses fewer bytes/tokens.',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
required: ['path'],
|
|
186
|
+
additionalProperties: false,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
151
189
|
{
|
|
152
190
|
name: 'local_write_file',
|
|
153
191
|
description: 'Write content to local file path',
|
|
@@ -655,6 +693,33 @@ export class ToolRouter {
|
|
|
655
693
|
return await this.localExecutor.readLocalFile({
|
|
656
694
|
path: String(args.path || ''),
|
|
657
695
|
}, projectRoot, context?.additionalReadRoots ?? null);
|
|
696
|
+
case 'view_image': {
|
|
697
|
+
if (args.format !== undefined && args.format !== 'png' && args.format !== 'jpeg') {
|
|
698
|
+
return {
|
|
699
|
+
success: false,
|
|
700
|
+
error: 'format 必须是 png 或 jpeg',
|
|
701
|
+
needsFollowup: true,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const sourcePath = this.resolveReadablePath(String(args.path || ''), projectRoot, context?.additionalReadRoots ?? null);
|
|
705
|
+
if ('error' in sourcePath) {
|
|
706
|
+
return { success: false, error: sourcePath.error, needsFollowup: true };
|
|
707
|
+
}
|
|
708
|
+
const region = args.region && typeof args.region === 'object'
|
|
709
|
+
? {
|
|
710
|
+
x: Number(args.region.x),
|
|
711
|
+
y: Number(args.region.y),
|
|
712
|
+
w: Number(args.region.w),
|
|
713
|
+
h: Number(args.region.h),
|
|
714
|
+
}
|
|
715
|
+
: undefined;
|
|
716
|
+
return processViewImage({
|
|
717
|
+
path: sourcePath.path,
|
|
718
|
+
region,
|
|
719
|
+
maxEdge: typeof args.maxEdge === 'number' ? args.maxEdge : undefined,
|
|
720
|
+
format: args.format === 'png' || args.format === 'jpeg' ? args.format : undefined,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
658
723
|
case 'local_write_file': {
|
|
659
724
|
const written = await this.localExecutor.writeLocalFile({
|
|
660
725
|
path: String(args.path || ''),
|
|
@@ -871,6 +936,29 @@ export class ToolRouter {
|
|
|
871
936
|
}
|
|
872
937
|
return this.localExecutor.executeShell(sandboxed.request);
|
|
873
938
|
}
|
|
939
|
+
resolveReadablePath(inputPath, projectRoot, additionalReadRoots) {
|
|
940
|
+
if (!inputPath.trim())
|
|
941
|
+
return { error: 'path 不能为空' };
|
|
942
|
+
const expanded = inputPath.startsWith('~')
|
|
943
|
+
? path.join(os.homedir(), inputPath.slice(1))
|
|
944
|
+
: inputPath;
|
|
945
|
+
const resolved = path.resolve(expanded);
|
|
946
|
+
if (!projectRoot)
|
|
947
|
+
return { path: resolved };
|
|
948
|
+
const root = path.resolve(projectRoot.startsWith('~')
|
|
949
|
+
? path.join(os.homedir(), projectRoot.slice(1))
|
|
950
|
+
: projectRoot);
|
|
951
|
+
const inAdditionalRoot = (additionalReadRoots ?? []).some((candidate) => {
|
|
952
|
+
const expandedCandidate = candidate.startsWith('~')
|
|
953
|
+
? path.join(os.homedir(), candidate.slice(1))
|
|
954
|
+
: candidate;
|
|
955
|
+
return isPathWithinRoot(resolved, path.resolve(expandedCandidate));
|
|
956
|
+
});
|
|
957
|
+
if (!isPathWithinRoot(resolved, root) && !inAdditionalRoot) {
|
|
958
|
+
return { error: buildProjectRootViolation(resolved, root) };
|
|
959
|
+
}
|
|
960
|
+
return { path: resolved };
|
|
961
|
+
}
|
|
874
962
|
resolveMcpConfig(args) {
|
|
875
963
|
return {
|
|
876
964
|
command: String(args.command || ''),
|
|
@@ -881,7 +969,7 @@ export class ToolRouter {
|
|
|
881
969
|
/**
|
|
882
970
|
* `tool_search` 处理器:BM25 排序 deferred 名录(name 分词计两次),返回
|
|
883
971
|
* 完整 schema,结果有界(默认 8,封顶 20)。算法、常量、默认上限与返回
|
|
884
|
-
* 文案都与
|
|
972
|
+
* 文案都与 Steerable `tool_search.py` 对齐——同名工具在两侧给
|
|
885
973
|
* 出同样的名次;hidden 层不进搜索。
|
|
886
974
|
*
|
|
887
975
|
* 智能体工具策略在这里同样生效:否则每轮列表藏起来的工具会从这条发现缝
|
package/dist/tool-search-rank.js
CHANGED
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Steerable framework — product-neutral
|
|
3
|
+
"version": "0.6.42",
|
|
4
|
+
"description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/pathlyapp/
|
|
9
|
+
"url": "git+https://github.com/pathlyapp/steerable.git",
|
|
10
10
|
"directory": "packages/agent-shell/ts"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/pathlyapp/
|
|
13
|
+
"url": "https://github.com/pathlyapp/steerable/issues"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
16
|
"main": "dist/runtime.js",
|
|
@@ -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/pack-sdk": "0.6.
|
|
60
|
-
"@steerable/agent-
|
|
58
|
+
"@steerable/agent-protocol": "0.6.42",
|
|
59
|
+
"@steerable/pack-sdk": "0.6.42",
|
|
60
|
+
"@steerable/agent-harness": "0.6.42"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/better-sqlite3": "^7.6.13",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"check:drift": "echo ok",
|
|
78
78
|
"shell:neutral": "node scripts/check-shell-neutral.mjs",
|
|
79
79
|
"storage:boundaries": "node scripts/check-storage-boundaries.mjs",
|
|
80
|
-
"web": "
|
|
80
|
+
"web": "DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node dist/server/index.js",
|
|
81
81
|
"client": "DEEPPATH_FORCE_BUNDLED_WEB=1 DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node node_modules/electron/cli.js dist/main.js"
|
|
82
82
|
}
|
|
83
83
|
}
|