@trim21/personal-pi-extensions 0.1.20261012010044 → 0.1.20261012031237
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 +14 -6
- package/package.json +1 -1
- package/src/bwrap/runtime.ts +15 -0
- package/src/claude-code/shell.ts +2 -1
- package/src/codemode/declarations.ts +1 -0
- package/src/codemode/fs.ts +3 -0
- package/src/codemode/prelude.ts +28 -3
- package/src/codemode/protocol.ts +4 -38
- package/src/codemode/sandbox.ts +32 -3
- package/src/codemode/source.ts +21 -2
- package/src/codemode/tool.ts +21 -4
- package/src/codemode/worker.js +86 -683
- package/src/codemode/worker.ts +61 -13
- package/src/lib/tool-bus.ts +25 -1
- package/src/lib/write-guard.ts +9 -0
- package/src/opencode/bash.ts +2 -1
package/README.md
CHANGED
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
|
|
32
32
|
`codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
|
|
33
33
|
进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
|
|
34
|
-
或模块加载,脚本的出口只有 `call(name, args)
|
|
34
|
+
或模块加载,脚本的出口只有 `call(name, args)`、`fs.read` / `fs.write` 两个文件原语与内建的
|
|
35
|
+
`sleep`。
|
|
35
36
|
|
|
36
37
|
- **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
|
|
37
|
-
`execute
|
|
38
|
-
|
|
38
|
+
`execute`。**脚本里不弹审批**:需要人工授权的调用(写工作区外、Bash 申请沙箱外执行)
|
|
39
|
+
在 codemode 内直接以 `CallFailedError` 失败,错误信息提示改用直调;模型直调同一个工具时
|
|
40
|
+
审批照常出现。此外 codemode 不额外加确认层:不需要审批的调用不会多出任何确认框。
|
|
39
41
|
- **可调用集合 = 声明了结构化输出的工具**(有 `structuredSchema` 的才进),执行时再与当前
|
|
40
42
|
active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
|
|
41
43
|
`--tools`、子代理的工具白名单都同样约束脚本。脚本拿到的返回值必须有确定的形状,因此没给
|
|
@@ -60,9 +62,15 @@
|
|
|
60
62
|
(脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。没有声明输出
|
|
61
63
|
结构的工具不进集合,`call()` 会报「not available in codemode」。
|
|
62
64
|
- **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `fs.read` / `fs.write` /
|
|
63
|
-
`text` / `image` / `exit` / `console.*` / `store.set` / `store.get` / `store.list`
|
|
64
|
-
(会话内持久的键值表);首行可选
|
|
65
|
-
|
|
65
|
+
`text` / `image` / `exit` / `sleep` / `console.*` / `store.set` / `store.get` / `store.list`
|
|
66
|
+
(会话内持久的键值表);首行可选
|
|
67
|
+
`// @options: {"max_output_tokens": 10000, "timeout_ms": 600000}`——选项留在脚本里而不是工具参数,
|
|
68
|
+
因为 pi 的 grammar 约束(`constrainedSampling`)只支持单个自由文本参数,多出来的参数在支持
|
|
69
|
+
grammar 的模型上会被静默忽略。不声明 `timeout_ms` 时脚本没有时间上限:死循环由调用方中止(Esc)
|
|
70
|
+
结束,等嵌套调用返回(含慢命令)多久都不算超时。声明了 `timeout_ms` 就有墙钟预算(含等待嵌套
|
|
71
|
+
调用的时间),到点时正在跑的调用被中止、结果以超时结束,已产生的输出与调用记录仍然返回。
|
|
72
|
+
`await sleep(seconds)`(秒,可为小数)是脚本唯一的等待手段,由 worker 线程自己的定时器驱动
|
|
73
|
+
(不经主线程、不产生进度帧),它自己也没有上限——但会被 `timeout_ms` 结束。
|
|
66
74
|
- **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
|
|
67
75
|
参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
|
|
68
76
|
- **store** 记在每次成功调用工具结果的 `details.store` 上(与 `src/lib/file-reads.ts` 的
|
package/package.json
CHANGED
package/src/bwrap/runtime.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface BwrapExecutionRequest {
|
|
|
86
86
|
signal?: AbortSignal;
|
|
87
87
|
onUpdate?: AgentToolUpdateCallback;
|
|
88
88
|
ctx: ExtensionContext;
|
|
89
|
+
/** 本次调用不可请求用户审批(codemode 的嵌套调用):提权请求直接失败,不弹框。 */
|
|
90
|
+
unapprovable?: boolean;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
/**
|
|
@@ -333,6 +335,13 @@ function describeLimits(resolved: ResolvedBwrap): string {
|
|
|
333
335
|
*/
|
|
334
336
|
const UNSANDBOXED_DENIED = "User denied unsandboxed execution.";
|
|
335
337
|
|
|
338
|
+
/**
|
|
339
|
+
* codemode 内的提权请求:脚本里弹不了审批框(那次工具调用只经 codemode 暴露),
|
|
340
|
+
* 因此直接拒绝,并让模型知道改用直调就能走正常审批。
|
|
341
|
+
*/
|
|
342
|
+
const UNSANDBOXED_DENIED_IN_CODEMODE =
|
|
343
|
+
"Unsandboxed execution needs user approval, which codemode cannot ask for. Call Bash directly instead of from a script.";
|
|
344
|
+
|
|
336
345
|
/** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
|
|
337
346
|
const SANDBOX_ESCAPE_HATCH =
|
|
338
347
|
"If the command needs more than that, use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.";
|
|
@@ -571,6 +580,7 @@ export class BwrapRuntime {
|
|
|
571
580
|
request.description,
|
|
572
581
|
execCwd,
|
|
573
582
|
ruleSet,
|
|
583
|
+
request.unapprovable === true,
|
|
574
584
|
)) === "sandbox";
|
|
575
585
|
}
|
|
576
586
|
}
|
|
@@ -706,11 +716,16 @@ export class BwrapRuntime {
|
|
|
706
716
|
reason: string | undefined,
|
|
707
717
|
execCwd: string,
|
|
708
718
|
ruleSet: ApprovalRuleSet,
|
|
719
|
+
unapprovable: boolean,
|
|
709
720
|
): Promise<FullAccessGrant> {
|
|
710
721
|
// hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
|
|
711
722
|
if (!ctx.hasUI) {
|
|
712
723
|
throw new Error(UNSANDBOXED_DENIED);
|
|
713
724
|
}
|
|
725
|
+
// codemode 内一律不弹审批:提权请求直接失败,并提示改用直调
|
|
726
|
+
if (unapprovable) {
|
|
727
|
+
throw new Error(UNSANDBOXED_DENIED_IN_CODEMODE);
|
|
728
|
+
}
|
|
714
729
|
const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd, ruleSet);
|
|
715
730
|
// 关闭对话框 = 中断并拒绝,不循环重问
|
|
716
731
|
if (decision === undefined) {
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -127,7 +127,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
|
|
|
127
127
|
},
|
|
128
128
|
{ additionalProperties: false },
|
|
129
129
|
),
|
|
130
|
-
async execute(id, params, signal, onUpdate, ctx) {
|
|
130
|
+
async execute(id, params, signal, onUpdate, ctx, callOptions) {
|
|
131
131
|
const timeout = params.timeout ?? BASH_DEFAULT_TIMEOUT_MS;
|
|
132
132
|
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > BASH_MAX_TIMEOUT_MS) {
|
|
133
133
|
throw new Error(`timeout must be between 1 and ${BASH_MAX_TIMEOUT_MS} milliseconds`);
|
|
@@ -147,6 +147,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
|
|
|
147
147
|
description: params.description,
|
|
148
148
|
signal,
|
|
149
149
|
onUpdate,
|
|
150
|
+
unapprovable: callOptions?.unapprovable,
|
|
150
151
|
});
|
|
151
152
|
} catch (error) {
|
|
152
153
|
if (!(error instanceof Error)) {
|
|
@@ -152,6 +152,7 @@ export function renderDeclarations(tools: readonly ToolLike[]): string {
|
|
|
152
152
|
"declare function text(value: unknown): void;",
|
|
153
153
|
"declare function image(value: unknown): void;",
|
|
154
154
|
"declare function exit(): void;",
|
|
155
|
+
"declare function sleep(seconds: number): Promise<void>;",
|
|
155
156
|
"declare const store: { set(key: string, value: unknown): void; get(key: string): unknown; list(): string[] };",
|
|
156
157
|
].join("\n");
|
|
157
158
|
}
|
package/src/codemode/fs.ts
CHANGED
|
@@ -36,6 +36,8 @@ const FS_WRITE = "fs.write";
|
|
|
36
36
|
export interface FsCallContext {
|
|
37
37
|
ctx: ExtensionContext;
|
|
38
38
|
signal?: AbortSignal;
|
|
39
|
+
/** 本次调用不可请求用户审批(脚本里弹不了框):区外写入直接失败。 */
|
|
40
|
+
unapprovable?: boolean;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/** 一次 fs 原语的结果:`value` 回给脚本,`reads` 是本次新增的已读记账(进 details.reads)。 */
|
|
@@ -114,6 +116,7 @@ export function createCodemodeFs(options: CodemodeFsOptions): CodemodeFs {
|
|
|
114
116
|
},
|
|
115
117
|
policy,
|
|
116
118
|
signal: call.signal,
|
|
119
|
+
unapprovable: call.unapprovable,
|
|
117
120
|
});
|
|
118
121
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
119
122
|
await writeFile(absolutePath, content, { encoding: "utf8", signal: call.signal });
|
package/src/codemode/prelude.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
|
|
3
3
|
* 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
|
|
4
|
-
* `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
|
|
4
|
+
* `sleep` / `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
|
|
5
5
|
*
|
|
6
6
|
* 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
|
|
7
7
|
* 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
|
|
8
8
|
* 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
|
|
9
9
|
* instanceof 区分「调用失败」与自己的运行期错误)。
|
|
10
10
|
*
|
|
11
|
-
* 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
|
|
11
|
+
* 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, timerDone, run, stalled }`。
|
|
12
12
|
* `bridge(kind, a, b, c)`:
|
|
13
13
|
* - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
|
|
14
|
+
* - `"sleep"`(id, ms):等待,由 worker 线程自己的定时器在 ms 后调 `timerDone(id)`
|
|
14
15
|
* - `"output"`("text", text)/("image", data, mimeType)
|
|
15
16
|
* - `"done"`(ok, valueJsonOrErrorJson, writesJson)
|
|
16
17
|
*/
|
|
@@ -259,6 +260,22 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
259
260
|
throw EXIT;
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
// 脚本唯一的等待手段:worker 线程在 ms 后调 timerDone(id) 解开它。沙箱里没有别的
|
|
264
|
+
// timer,也没有上限——睡多久由脚本决定,结束只能靠调用方中止。
|
|
265
|
+
function sleep(seconds) {
|
|
266
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
|
267
|
+
throw new TypeErrorCtor(
|
|
268
|
+
"sleep(seconds) expects a non-negative number of seconds, got " + format(seconds),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const id = nextId++;
|
|
273
|
+
// sleep 只会被 timerDone 成功解开,reject 用不到
|
|
274
|
+
pending.set(id, { resolve, reject: () => {} });
|
|
275
|
+
bridge("sleep", id, Math.round(seconds * 1000));
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
262
279
|
const console = {};
|
|
263
280
|
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
264
281
|
console[level] = (...args) => {
|
|
@@ -275,6 +292,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
275
292
|
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
|
|
276
293
|
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
|
|
277
294
|
Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
|
|
295
|
+
Object.defineProperty(globalThis, "sleep", { value: sleep, enumerable: true });
|
|
278
296
|
|
|
279
297
|
return {
|
|
280
298
|
settle(id, ok, payload) {
|
|
@@ -294,6 +312,13 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
294
312
|
}
|
|
295
313
|
entry.resolve(value);
|
|
296
314
|
},
|
|
315
|
+
// worker 线程的定时器到点后解开一次 sleep(成功路径,不带值)
|
|
316
|
+
timerDone(id) {
|
|
317
|
+
const entry = pending.get(id);
|
|
318
|
+
if (!entry) return;
|
|
319
|
+
pending.delete(id);
|
|
320
|
+
entry.resolve();
|
|
321
|
+
},
|
|
297
322
|
run(fn) {
|
|
298
323
|
let promise;
|
|
299
324
|
try {
|
|
@@ -326,7 +351,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
|
|
|
326
351
|
stringify({
|
|
327
352
|
name: "Error",
|
|
328
353
|
message:
|
|
329
|
-
"The script is waiting on a promise that can never settle: no tool call
|
|
354
|
+
"The script is waiting on a promise that can never settle: no tool call and no sleep is pending.",
|
|
330
355
|
}),
|
|
331
356
|
);
|
|
332
357
|
return true;
|
package/src/codemode/protocol.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* 主线程执行工具后把结果发回去。脚本没有超时,只有调用方的中止:中止时主线程直接
|
|
7
7
|
* `worker.terminate()`,因此不需要取消消息。
|
|
8
8
|
*
|
|
9
|
-
* worker
|
|
10
|
-
*
|
|
9
|
+
* 校验只做一个方向:worker 里跑的是模型写的代码,它回传的消息由主线程用 typebox
|
|
10
|
+
* 当外部输入复核;反向的消息是主线程自己构造的,形状由 `HostMessage` 类型约束,
|
|
11
|
+
* worker 只按 `t` 分派。
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { type TSchema, Type } from "typebox";
|
|
@@ -32,35 +33,6 @@ const outputItemSchema = Type.Union([
|
|
|
32
33
|
Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() }),
|
|
33
34
|
]);
|
|
34
35
|
|
|
35
|
-
const toolDeclSchema = Type.Object({
|
|
36
|
-
name: Type.String(),
|
|
37
|
-
description: Type.Optional(Type.String()),
|
|
38
|
-
/** 工具的 structuredSchema(TypeBox schema):脚本侧据此渲染 call() 的返回类型。 */
|
|
39
|
-
structuredSchema: Type.Optional(Type.Unknown()),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const startSchema = Type.Object({
|
|
43
|
-
t: Type.Literal("start"),
|
|
44
|
-
code: Type.String(),
|
|
45
|
-
tools: Type.Array(toolDeclSchema),
|
|
46
|
-
store: Type.Record(Type.String(), Type.Unknown()),
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const resultSchema = Type.Union([
|
|
50
|
-
Type.Object({
|
|
51
|
-
t: Type.Literal("result"),
|
|
52
|
-
id: Type.Number(),
|
|
53
|
-
ok: Type.Literal(true),
|
|
54
|
-
value: Type.Unknown(),
|
|
55
|
-
}),
|
|
56
|
-
Type.Object({
|
|
57
|
-
t: Type.Literal("result"),
|
|
58
|
-
id: Type.Number(),
|
|
59
|
-
ok: Type.Literal(false),
|
|
60
|
-
error: Type.String(),
|
|
61
|
-
}),
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
36
|
const callSchema = Type.Object({
|
|
65
37
|
t: Type.Literal("call"),
|
|
66
38
|
id: Type.Number(),
|
|
@@ -100,7 +72,6 @@ const doneSchema = Type.Union([
|
|
|
100
72
|
}),
|
|
101
73
|
]);
|
|
102
74
|
|
|
103
|
-
const hostMessageSchema = Type.Union([startSchema, resultSchema]);
|
|
104
75
|
const workerMessageSchema = Type.Union([callSchema, outputFrameSchema, doneSchema]);
|
|
105
76
|
|
|
106
77
|
/** worker 启动数据:注册时编译好的 wasm 模块(结构化克隆可以带它跨线程)。 */
|
|
@@ -113,7 +84,7 @@ export interface WorkerBootstrap {
|
|
|
113
84
|
export type CodemodeOutputItem =
|
|
114
85
|
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
|
|
115
86
|
|
|
116
|
-
export type ScriptErrorKind = "script" | "aborted" | "sandbox";
|
|
87
|
+
export type ScriptErrorKind = "script" | "aborted" | "timeout" | "sandbox";
|
|
117
88
|
|
|
118
89
|
export interface ScriptError {
|
|
119
90
|
kind: ScriptErrorKind;
|
|
@@ -155,11 +126,6 @@ function decode<T>(schema: TSchema, value: unknown): DecodeResult<T> {
|
|
|
155
126
|
return { ok: true, frame: value as T };
|
|
156
127
|
}
|
|
157
128
|
|
|
158
|
-
/** 校验主线程发给 worker 的消息。 */
|
|
159
|
-
export function decodeHostMessage(value: unknown): DecodeResult<HostMessage> {
|
|
160
|
-
return decode<HostMessage>(hostMessageSchema, value);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
129
|
/** 校验 worker 发回主线程的消息。 */
|
|
164
130
|
export function decodeWorkerMessage(value: unknown): DecodeResult<WorkerMessage> {
|
|
165
131
|
return decode<WorkerMessage>(workerMessageSchema, value);
|
package/src/codemode/sandbox.ts
CHANGED
|
@@ -52,8 +52,18 @@ export interface SandboxRunOptions {
|
|
|
52
52
|
toolCallId: string;
|
|
53
53
|
/** 调用方中止本次执行;脚本没有自己的超时,死循环只能靠它结束。 */
|
|
54
54
|
signal?: AbortSignal;
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 本次执行的墙钟预算(毫秒),含等待嵌套调用的时间;缺省不设上限。
|
|
57
|
+
* 到点后执行以 `kind: "timeout"` 结束,正在跑的嵌套调用收到中止信号。
|
|
58
|
+
*/
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
/** 每次嵌套调用:由调用方执行工具并把结果回给脚本。`signal` 含调用方中止与本次预算。 */
|
|
61
|
+
onCall(request: {
|
|
62
|
+
id: number;
|
|
63
|
+
name: string;
|
|
64
|
+
args: unknown;
|
|
65
|
+
signal: AbortSignal;
|
|
66
|
+
}): Promise<SandboxCallOutcome>;
|
|
57
67
|
/** 脚本流式产生的输出项,用于 toolcall 进度。 */
|
|
58
68
|
onOutput?(items: readonly CodemodeOutputItem[]): void;
|
|
59
69
|
/** 嵌套调用的开始与结束,用于 toolcall 进度;事件里带截至此刻的调用记录快照。 */
|
|
@@ -108,6 +118,15 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
|
|
|
108
118
|
const started = new Map<number, { name: string; at: number }>();
|
|
109
119
|
let callSequence = 0;
|
|
110
120
|
let settled = false;
|
|
121
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
122
|
+
|
|
123
|
+
// 超时与调用方中止合成一条信号,交给嵌套调用:预算到点时正在跑的工具也要停,
|
|
124
|
+
// 否则 terminate 只杀 worker,工具会在主线程继续跑完并留下副作用
|
|
125
|
+
const aborter = new AbortController();
|
|
126
|
+
const signal =
|
|
127
|
+
options.signal === undefined
|
|
128
|
+
? aborter.signal
|
|
129
|
+
: AbortSignal.any([options.signal, aborter.signal]);
|
|
111
130
|
|
|
112
131
|
// 进度帧发出去的是拷贝:记录随后会被改成终态,已经发出去的帧不能被它改掉
|
|
113
132
|
const snapshotCalls = (): ScriptCall[] => calls.map((call) => ({ ...call }));
|
|
@@ -121,6 +140,7 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
|
|
|
121
140
|
return;
|
|
122
141
|
}
|
|
123
142
|
settled = true;
|
|
143
|
+
clearTimeout(timeoutTimer);
|
|
124
144
|
void worker.terminate();
|
|
125
145
|
resolve({ ...body, output, calls });
|
|
126
146
|
};
|
|
@@ -143,6 +163,15 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
|
|
|
143
163
|
);
|
|
144
164
|
}
|
|
145
165
|
|
|
166
|
+
if (options.timeoutMs !== undefined) {
|
|
167
|
+
const timeoutMs = options.timeoutMs;
|
|
168
|
+
timeoutTimer = setTimeout(() => {
|
|
169
|
+
// 先中止正在跑的嵌套调用,再定结果:terminate 不会自动停主线程里的工具
|
|
170
|
+
aborter.abort();
|
|
171
|
+
fail(`Execution timed out after ${timeoutMs} ms`, "timeout");
|
|
172
|
+
}, timeoutMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
146
175
|
const handleCall = async (id: number, name: string, args: unknown): Promise<void> => {
|
|
147
176
|
callSequence += 1;
|
|
148
177
|
const record: ScriptCall = {
|
|
@@ -157,7 +186,7 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
|
|
|
157
186
|
options.onCallProgress?.({ phase: "start", name, args, calls: snapshotCalls() });
|
|
158
187
|
let outcome: SandboxCallOutcome;
|
|
159
188
|
try {
|
|
160
|
-
outcome = await options.onCall({ id, name, args });
|
|
189
|
+
outcome = await options.onCall({ id, name, args, signal });
|
|
161
190
|
} catch (error) {
|
|
162
191
|
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
163
192
|
}
|
package/src/codemode/source.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* codemode 脚本源码的解析:首行可选的 `// @options: {"max_output_tokens":…}`。
|
|
2
|
+
* codemode 脚本源码的解析:首行可选的 `// @options: {"max_output_tokens":…, "timeout_ms":…}`。
|
|
3
3
|
*
|
|
4
4
|
* 选项行与脚本首行共用一行,解析后原样保留为空行,脚本里报错的行号因此与用户写的
|
|
5
5
|
* 一致。解析失败(空输入、JSON 非法、未知字段、只有选项行没有代码)直接报错。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const OPTIONS_PREFIX = "// @options:";
|
|
9
|
-
const SUPPORTED_FIELDS = ["max_output_tokens"] as const;
|
|
9
|
+
const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
|
|
10
|
+
|
|
11
|
+
/** `timeout_ms` 的上限:`setTimeout` 能接受的最大延时(毫秒)。 */
|
|
12
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
10
13
|
|
|
11
14
|
export const DEFAULT_OUTPUT_TOKENS = 10_000;
|
|
12
15
|
|
|
@@ -19,6 +22,8 @@ export class CodemodeSourceError extends Error {
|
|
|
19
22
|
|
|
20
23
|
export interface CodemodeSourceOptions {
|
|
21
24
|
maxOutputTokens: number;
|
|
25
|
+
/** 本次执行的墙钟预算(毫秒)。不声明时脚本没有时间上限。 */
|
|
26
|
+
timeoutMs: number;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface ParsedCodemodeSource {
|
|
@@ -45,6 +50,20 @@ function parseOptions(json: string): Partial<CodemodeSourceOptions> {
|
|
|
45
50
|
`${OPTIONS_PREFIX} does not support ${JSON.stringify(key)}; supported fields are ${SUPPORTED_FIELDS.map((field) => `\`${field}\``).join(" and ")}`,
|
|
46
51
|
);
|
|
47
52
|
}
|
|
53
|
+
if (key === "timeout_ms") {
|
|
54
|
+
if (
|
|
55
|
+
typeof value !== "number" ||
|
|
56
|
+
!Number.isSafeInteger(value) ||
|
|
57
|
+
value <= 0 ||
|
|
58
|
+
value > MAX_TIMEOUT_MS
|
|
59
|
+
) {
|
|
60
|
+
throw new CodemodeSourceError(
|
|
61
|
+
`${OPTIONS_PREFIX} \`timeout_ms\` must be a positive integer up to ${MAX_TIMEOUT_MS}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
options.timeoutMs = value;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
48
67
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
49
68
|
throw new CodemodeSourceError(`${OPTIONS_PREFIX} ${key} must be a positive number`);
|
|
50
69
|
}
|
package/src/codemode/tool.ts
CHANGED
|
@@ -113,6 +113,12 @@ function buildDescription(tools: readonly CallableTool[]): string {
|
|
|
113
113
|
" fields you need into `text(value)` / `return` — e.g. `text(result.items.map((item) => item.title))`.",
|
|
114
114
|
"- Do not stringify whole results (`JSON.stringify(result)`) or print every field the tool happened to",
|
|
115
115
|
" return: urls, ids and metadata you will not use cost context and buy nothing.",
|
|
116
|
+
"- `await sleep(seconds)` waits that many seconds (fractions allowed). It is the only way to",
|
|
117
|
+
' wait: the sandbox has no timers, and it beats `call("Bash", { command: "sleep 1" })`.',
|
|
118
|
+
"- Nested calls cannot ask for user approval: a call that would need it (writing outside the",
|
|
119
|
+
" workspace, unsandboxed Bash) fails. Make those calls directly instead of from a script.",
|
|
120
|
+
'- `// @options: {"timeout_ms": N}` bounds the whole run in milliseconds; without it the script',
|
|
121
|
+
" has no time limit beyond the user aborting it.",
|
|
116
122
|
"- `store.set(key, value)`, `store.get(key)` and `store.list()` are a small key/value store that",
|
|
117
123
|
" persists across codemode calls in this session; `store.set(key, undefined)` removes a key.",
|
|
118
124
|
"- The sandbox has no file system, network, timers or Node APIs: calling tools is its only",
|
|
@@ -292,9 +298,12 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
292
298
|
parameters: Type.Object({
|
|
293
299
|
code: Type.String({
|
|
294
300
|
description:
|
|
295
|
-
'Raw JavaScript source. Top-level await and return work. May start with a `// @options: {"max_output_tokens": 10000}` line.',
|
|
301
|
+
'Raw JavaScript source. Top-level await and return work. May start with a `// @options: {"max_output_tokens": 10000, "timeout_ms": 600000}` line.',
|
|
296
302
|
}),
|
|
297
303
|
}),
|
|
304
|
+
// 采样约束与 pi 内置 codemode 同形:grammar 只能约束一个自由文本参数,所以
|
|
305
|
+
// options 只能待在脚本里(多参数会让这个工具在支持 grammar 的模型上退化成
|
|
306
|
+
// 单字段 custom tool,额外参数被静默忽略)
|
|
298
307
|
constrainedSampling: {
|
|
299
308
|
type: "grammar",
|
|
300
309
|
variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR },
|
|
@@ -304,10 +313,12 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
304
313
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
305
314
|
let code: string;
|
|
306
315
|
let maxOutputTokens = DEFAULT_OUTPUT_TOKENS;
|
|
316
|
+
let timeoutMs: number | undefined;
|
|
307
317
|
try {
|
|
308
318
|
const parsed = parseCodemodeSource(params.code);
|
|
309
319
|
code = parsed.code;
|
|
310
320
|
maxOutputTokens = parsed.options.maxOutputTokens ?? maxOutputTokens;
|
|
321
|
+
timeoutMs = parsed.options.timeoutMs;
|
|
311
322
|
} catch (error) {
|
|
312
323
|
const message = error instanceof Error ? error.message : String(error);
|
|
313
324
|
return {
|
|
@@ -333,6 +344,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
333
344
|
store: readStore(ctx),
|
|
334
345
|
toolCallId,
|
|
335
346
|
signal,
|
|
347
|
+
timeoutMs,
|
|
336
348
|
onOutput: (items) => {
|
|
337
349
|
const text = textOf(items);
|
|
338
350
|
if (text === "") {
|
|
@@ -362,10 +374,15 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
362
374
|
},
|
|
363
375
|
});
|
|
364
376
|
},
|
|
365
|
-
onCall: async ({ name, args }) => {
|
|
377
|
+
onCall: async ({ name, args, signal: callSignal }) => {
|
|
366
378
|
if (fs.handles(name)) {
|
|
367
379
|
try {
|
|
368
|
-
|
|
380
|
+
// 脚本里弹不了审批:需要授权的操作(工作区外写入)直接失败
|
|
381
|
+
const result = await fs.execute(name, args, {
|
|
382
|
+
ctx,
|
|
383
|
+
signal: callSignal,
|
|
384
|
+
unapprovable: true,
|
|
385
|
+
});
|
|
369
386
|
if (result.reads !== undefined) {
|
|
370
387
|
Object.assign(recordedReads, result.reads);
|
|
371
388
|
}
|
|
@@ -380,7 +397,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
|
|
|
380
397
|
if (!callable.has(name)) {
|
|
381
398
|
return { ok: false, error: `Tool "${name}" is not available in codemode.` };
|
|
382
399
|
}
|
|
383
|
-
return bus.executeCodemodeTool(name, args, { ctx, signal });
|
|
400
|
+
return bus.executeCodemodeTool(name, args, { ctx, signal: callSignal });
|
|
384
401
|
},
|
|
385
402
|
});
|
|
386
403
|
|