@steerable/agent-shell 0.6.41 → 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 CHANGED
@@ -1,6 +1,21 @@
1
1
  # @steerable/agent-shell
2
2
 
3
- Product-neutral Electron and headless HTTP host for Steerable Framework.
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": 2,
315
+ "version": 3,
298
316
  "dualImplementations": {
299
317
  "todo_write": ["python", "rust"],
300
318
  "web_fetch": ["python", "rust"],
@@ -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: number;
71
+ timeoutMs?: number;
72
72
  storePath: string;
73
73
  } | undefined;
@@ -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 ?? 120_000,
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`.
@@ -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
  }
@@ -56,7 +56,7 @@ function tryResolveBundled(config) {
56
56
  return {
57
57
  command: process.execPath,
58
58
  args: [entryPath, ...extraArgs],
59
- env: { ...config.env, ELECTRON_RUN_AS_NODE: '1' },
59
+ env: { ...config.env },
60
60
  cwd: config.cwd,
61
61
  };
62
62
  }
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 {};
@@ -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
+ }
@@ -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; in the desktop that worker is the bundled
308
- // runtime — process.execPath is the Electron binary, which runs as
309
- // plain Node under ELECTRON_RUN_AS_NODE (and already *is* plain
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. The sidecar additionally bounds the wait (`timeoutMs` on the
9
- * approval config), so a wedged renderer degrades to `timed_out` there.
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. The sidecar additionally bounds the wait (`timeoutMs` on the
9
- * approval config), so a wedged renderer degrades to `timed_out` there.
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: OpenSteerable
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: OpenSteerable
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;
@@ -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` fails closed as `timed_out` (a denial) when the UI does
449
- * not answer in time. Absent → no approval layer (legacy behavior).
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';
@@ -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
- * 工具曝光分层(OpenSteerable Wave 2 同构):`direct` 进模型可见列表;
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
- * 文案都与 OpenSteerable `tool_search.py` 对齐——同名工具在两侧给
213
+ * 文案都与 Steerable `tool_search.py` 对齐——同名工具在两侧给
213
214
  * 出同样的名次;hidden 层不进搜索。
214
215
  *
215
216
  * 智能体工具策略在这里同样生效:否则每轮列表藏起来的工具会从这条发现缝
@@ -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
- * 工具曝光分层(OpenSteerable Wave 2 同构):`direct` 进模型可见列表;
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
- * 文案都与 OpenSteerable `tool_search.py` 对齐——同名工具在两侧给
972
+ * 文案都与 Steerable `tool_search.py` 对齐——同名工具在两侧给
885
973
  * 出同样的名次;hidden 层不进搜索。
886
974
  *
887
975
  * 智能体工具策略在这里同样生效:否则每轮列表藏起来的工具会从这条发现缝
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `tool_search` 的 BM25 排序,与 OpenSteerable
2
+ * `tool_search` 的 BM25 排序,与 Steerable
3
3
  * `packages/agent-runtime/py/src/steerable_agent_runtime/tool_search.py`
4
4
  * 同一算法与同一常量:同名工具在两侧必须给出同样的名次与同样的默认上限,
5
5
  * 否则「桌面的 tool_search」和「框架的 tool_search」是两个契约不同的工具。
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `tool_search` 的 BM25 排序,与 OpenSteerable
2
+ * `tool_search` 的 BM25 排序,与 Steerable
3
3
  * `packages/agent-runtime/py/src/steerable_agent_runtime/tool_search.py`
4
4
  * 同一算法与同一常量:同名工具在两侧必须给出同样的名次与同样的默认上限,
5
5
  * 否则「桌面的 tool_search」和「框架的 tool_search」是两个契约不同的工具。
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.6.41",
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.",
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/opensteerable.git",
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/opensteerable/issues"
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-harness": "0.6.41",
59
- "@steerable/agent-protocol": "0.6.41",
60
- "@steerable/pack-sdk": "0.6.41"
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": "ELECTRON_RUN_AS_NODE=1 DEEPPATH_WEB_DIST=${DEEPPATH_WEB_DIST:-../web/app/dist} node node_modules/electron/cli.js dist/server/index.js",
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
  }