@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.13
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/CHANGELOG.md +38 -0
- package/dist/cli.js +2982 -2825
- package/dist/types/cli/bench-cli.d.ts +0 -5
- package/dist/types/cli/dry-balance-cli.d.ts +0 -5
- package/dist/types/cli/models-cli.d.ts +1 -1
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
- package/dist/types/config/model-registry.d.ts +1 -23
- package/dist/types/config/model-resolver.d.ts +10 -15
- package/dist/types/config/models-config-schema.d.ts +12 -6
- package/dist/types/config/models-config.d.ts +9 -6
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +15 -0
- package/dist/types/lsp/client.d.ts +9 -3
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
- package/dist/types/task/spawn-policy.d.ts +17 -0
- package/dist/types/task/spawn-policy.test.d.ts +1 -0
- package/dist/types/task/types.d.ts +8 -2
- package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
- package/dist/types/tools/browser/registry.d.ts +2 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
- package/dist/types/tools/eval.d.ts +3 -6
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/utils/image-vision-fallback.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/auto-thinking/classifier.ts +1 -1
- package/src/cli/bench-cli.ts +3 -12
- package/src/cli/dry-balance-cli.ts +1 -6
- package/src/cli/models-cli.ts +3 -81
- package/src/commands/models.ts +1 -2
- package/src/commit/model-selection.ts +4 -4
- package/src/config/inline-tool-descriptors-mode.ts +1 -2
- package/src/config/model-discovery.ts +32 -6
- package/src/config/model-registry.ts +11 -225
- package/src/config/model-resolver.ts +23 -135
- package/src/config/models-config-schema.ts +13 -22
- package/src/config/prompt-templates.ts +20 -0
- package/src/config/settings-schema.ts +35 -1
- package/src/config/settings.ts +60 -24
- package/src/eval/__tests__/agent-bridge.test.ts +17 -3
- package/src/eval/agent-bridge.ts +7 -9
- package/src/eval/completion-bridge.ts +1 -1
- package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
- package/src/eval/py/runner.py +169 -18
- package/src/extensibility/extensions/model-api.ts +1 -3
- package/src/extensibility/extensions/runner.ts +62 -5
- package/src/lsp/client.ts +162 -45
- package/src/lsp/index.ts +31 -21
- package/src/main.ts +0 -1
- package/src/mcp/transports/http.ts +19 -17
- package/src/mcp/transports/stdio.test.ts +51 -1
- package/src/mcp/transports/stdio.ts +19 -9
- package/src/memories/index.ts +0 -1
- package/src/mnemopi/backend.ts +1 -1
- package/src/modes/components/model-selector.ts +32 -186
- package/src/modes/controllers/event-controller.ts +8 -39
- package/src/modes/controllers/selector-controller.ts +0 -1
- package/src/modes/controllers/tool-args-reveal.ts +0 -12
- package/src/prompts/system/subagent-system-prompt.md +2 -2
- package/src/prompts/system/tool-call-loop-redirect.md +8 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/ssh.md +1 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/sdk.ts +2 -9
- package/src/session/agent-session.ts +89 -8
- package/src/session/session-context.ts +2 -1
- package/src/session/session-manager.ts +2 -1
- package/src/session/unexpected-stop-classifier.ts +1 -1
- package/src/task/index.ts +23 -27
- package/src/task/spawn-policy.test.ts +63 -0
- package/src/task/spawn-policy.ts +58 -0
- package/src/task/types.ts +77 -6
- package/src/tools/__tests__/eval-description.test.ts +19 -0
- package/src/tools/browser/cmux/cmux-tab.ts +66 -24
- package/src/tools/browser/registry.ts +25 -0
- package/src/tools/browser/run-cancellation.ts +47 -0
- package/src/tools/browser/tab-supervisor.ts +120 -7
- package/src/tools/browser/tab-worker.ts +22 -10
- package/src/tools/browser.ts +1 -0
- package/src/tools/eval.ts +15 -10
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/ssh.ts +8 -1
- package/src/tools/write.ts +49 -9
- package/src/utils/commit-message-generator.ts +0 -1
- package/src/utils/image-vision-fallback.ts +2 -3
- package/src/utils/title-generator.ts +1 -1
|
@@ -196,15 +196,29 @@ describe("runEvalAgent", () => {
|
|
|
196
196
|
await expect(runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "" }) })).rejects.toThrow(
|
|
197
197
|
"spawns disabled",
|
|
198
198
|
);
|
|
199
|
-
await expect(
|
|
200
|
-
"
|
|
201
|
-
);
|
|
199
|
+
await expect(
|
|
200
|
+
runEvalAgent({ prompt: "hello", agent: "task" }, { session: makeSession({ spawns: "reviewer" }) }),
|
|
201
|
+
).rejects.toThrow("Allowed: reviewer");
|
|
202
202
|
await expect(
|
|
203
203
|
runEvalAgent({ prompt: "hello" }, { session: makeSession({ depth: EVAL_AGENT_MAX_DEPTH }) }),
|
|
204
204
|
).rejects.toThrow("maximum depth");
|
|
205
205
|
expect(runSpy).not.toHaveBeenCalled();
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
it("defaults to the first allowed spawn under restricted eval policies", async () => {
|
|
209
|
+
mockAgents();
|
|
210
|
+
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options =>
|
|
211
|
+
singleResult(options, {
|
|
212
|
+
output: options.agent.name,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result = await runEvalAgent({ prompt: "hello" }, { session: makeSession({ spawns: "reviewer,task" }) });
|
|
217
|
+
|
|
218
|
+
expect(result.text).toBe("reviewer");
|
|
219
|
+
expect(runSpy.mock.calls[0]?.[0].agent.name).toBe("reviewer");
|
|
220
|
+
});
|
|
221
|
+
|
|
208
222
|
it("throws instead of spawning from plan mode", async () => {
|
|
209
223
|
mockAgents();
|
|
210
224
|
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
|
package/src/eval/agent-bridge.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
runIsolatedSubprocess,
|
|
25
25
|
} from "../task/isolation-runner";
|
|
26
26
|
import { AgentOutputManager } from "../task/output-manager";
|
|
27
|
+
import { resolveSpawnPolicy } from "../task/spawn-policy";
|
|
27
28
|
import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types";
|
|
28
29
|
import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
|
|
29
30
|
import type { ToolSession } from "../tools";
|
|
@@ -39,7 +40,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
|
|
|
39
40
|
/** Hard recursion limit for eval-driven subagents. */
|
|
40
41
|
export const EVAL_AGENT_MAX_DEPTH = 3;
|
|
41
42
|
|
|
42
|
-
const DEFAULT_AGENT_TYPE = "task";
|
|
43
43
|
const DEFAULT_AGENT_LABEL = "EvalAgent";
|
|
44
44
|
|
|
45
45
|
const agentArgsSchema = type({
|
|
@@ -139,14 +139,12 @@ function assertDepthAllowed(session: ToolSession): void {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
function assertSpawnAllowed(session: ToolSession, agentName: string): void {
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: none (spawns disabled for this agent)`);
|
|
142
|
+
const spawnPolicy = resolveSpawnPolicy(session.getSessionSpawns());
|
|
143
|
+
if (!spawnPolicy.enabled) {
|
|
144
|
+
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
|
|
146
145
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${parentSpawns}`);
|
|
146
|
+
if (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName)) {
|
|
147
|
+
throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
|
|
150
148
|
}
|
|
151
149
|
}
|
|
152
150
|
|
|
@@ -283,7 +281,7 @@ function buildSubagentFailureMessage(agentName: string, result: SingleResult): s
|
|
|
283
281
|
*/
|
|
284
282
|
export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
|
|
285
283
|
const parsed = parseAgentArgs(args);
|
|
286
|
-
const agentName = parsed.agent ??
|
|
284
|
+
const agentName = parsed.agent ?? resolveSpawnPolicy(options.session.getSessionSpawns()).defaultAgent;
|
|
287
285
|
const structured = Object.hasOwn(parsed, "schema");
|
|
288
286
|
|
|
289
287
|
assertNotPlanMode(options.session);
|
|
@@ -75,7 +75,7 @@ function resolveTierModel(tier: CompletionTier, session: ToolSession): Model<Api
|
|
|
75
75
|
const resolve = (pattern: string | undefined): Model<Api> | undefined => {
|
|
76
76
|
if (!pattern) return undefined;
|
|
77
77
|
const expanded = expandRoleAlias(pattern, session.settings);
|
|
78
|
-
return resolveModelFromString(expanded, available, matchPreferences
|
|
78
|
+
return resolveModelFromString(expanded, available, matchPreferences);
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
if (tier === "default") {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
interface RunnerFrame {
|
|
5
|
+
type?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
data?: string;
|
|
8
|
+
status?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const pythonPath = Bun.env.PYTHON ?? "python3";
|
|
12
|
+
const runnerPath = path.resolve(import.meta.dir, "..", "runner.py");
|
|
13
|
+
const repoRoot = path.resolve(import.meta.dir, "../../../../../..");
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
|
|
16
|
+
function shellQuote(value: string): string {
|
|
17
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function runCell(code: string): Promise<RunnerFrame[]> {
|
|
21
|
+
const proc = Bun.spawn([pythonPath, "-u", runnerPath], {
|
|
22
|
+
cwd: repoRoot,
|
|
23
|
+
stdin: "pipe",
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
PYTHONUNBUFFERED: "1",
|
|
29
|
+
PYTHONIOENCODING: "utf-8",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const stderr = new Response(proc.stderr).text();
|
|
33
|
+
const reader = proc.stdout.getReader();
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let pending = "";
|
|
36
|
+
const frames: RunnerFrame[] = [];
|
|
37
|
+
|
|
38
|
+
async function readFrame(): Promise<RunnerFrame> {
|
|
39
|
+
while (true) {
|
|
40
|
+
const newline = pending.indexOf("\n");
|
|
41
|
+
if (newline >= 0) {
|
|
42
|
+
const line = pending.slice(0, newline);
|
|
43
|
+
pending = pending.slice(newline + 1);
|
|
44
|
+
return JSON.parse(line) as RunnerFrame;
|
|
45
|
+
}
|
|
46
|
+
const { value, done } = await reader.read();
|
|
47
|
+
if (done) {
|
|
48
|
+
throw new Error(`Python runner exited before done frame: ${await stderr}`);
|
|
49
|
+
}
|
|
50
|
+
pending += decoder.decode(value, { stream: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
proc.stdin.write(encoder.encode(`${JSON.stringify({ id: "r1", code })}\n`));
|
|
56
|
+
proc.stdin.flush();
|
|
57
|
+
while (true) {
|
|
58
|
+
const frame = await readFrame();
|
|
59
|
+
frames.push(frame);
|
|
60
|
+
if (frame.type === "done") break;
|
|
61
|
+
}
|
|
62
|
+
proc.stdin.write(encoder.encode(`${JSON.stringify({ type: "exit" })}\n`));
|
|
63
|
+
proc.stdin.end();
|
|
64
|
+
const exitCode = await proc.exited;
|
|
65
|
+
if (exitCode !== 0) {
|
|
66
|
+
throw new Error(`Python runner exited ${exitCode}: ${await stderr}`);
|
|
67
|
+
}
|
|
68
|
+
return frames;
|
|
69
|
+
} finally {
|
|
70
|
+
try {
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
} catch {
|
|
73
|
+
// Reader may already be released by stream closure.
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
proc.kill("SIGKILL");
|
|
77
|
+
} catch {
|
|
78
|
+
// Process already exited.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("Python runner shell output streaming", () => {
|
|
84
|
+
it("streams !cmd output chunks before the child process exits", async () => {
|
|
85
|
+
const child = [
|
|
86
|
+
"import sys,time",
|
|
87
|
+
"sys.stdout.write('first\\n')",
|
|
88
|
+
"sys.stdout.flush()",
|
|
89
|
+
"time.sleep(0.2)",
|
|
90
|
+
"sys.stdout.write('second\\n')",
|
|
91
|
+
"sys.stdout.flush()",
|
|
92
|
+
].join(";");
|
|
93
|
+
const frames = await runCell(
|
|
94
|
+
[
|
|
95
|
+
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
96
|
+
"print('return=' + str(result.returncode) + ' lines=' + repr(list(result)))",
|
|
97
|
+
].join("\n"),
|
|
98
|
+
);
|
|
99
|
+
const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
|
|
100
|
+
|
|
101
|
+
expect(stdout[0]).toBe("first\n");
|
|
102
|
+
expect(stdout.join("")).toContain("second\n");
|
|
103
|
+
expect(stdout.join("")).toContain("return=0 lines=['first', 'second']");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("caps !cmd output and captured result by line count with a truncation notice", async () => {
|
|
107
|
+
const child = ["import sys", "sys.stdout.write(('x' + chr(10)) * 3100)", "sys.stdout.flush()"].join(";");
|
|
108
|
+
const frames = await runCell(
|
|
109
|
+
[
|
|
110
|
+
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
111
|
+
"print('captured=' + str(len(result)) + ' return=' + str(result.returncode))",
|
|
112
|
+
].join("\n"),
|
|
113
|
+
);
|
|
114
|
+
const stdout = frames
|
|
115
|
+
.filter(frame => frame.type === "stdout")
|
|
116
|
+
.map(frame => frame.data)
|
|
117
|
+
.join("");
|
|
118
|
+
|
|
119
|
+
expect(stdout).toContain("[output truncated: shell helper exceeded");
|
|
120
|
+
expect(stdout).toContain("captured=3000 return=0");
|
|
121
|
+
expect(stdout).not.toContain("captured=3100");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("caps newline-free !cmd output by bytes with a truncation notice", async () => {
|
|
125
|
+
const child = ["import sys", "sys.stdout.write('z' * (1024 * 1024 + 17))", "sys.stdout.flush()"].join(";");
|
|
126
|
+
const frames = await runCell(
|
|
127
|
+
[
|
|
128
|
+
`result = !${pythonPath} -c ${shellQuote(child)}`,
|
|
129
|
+
"print('capturedChars=' + str(len(result.n)) + ' return=' + str(result.returncode))",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
);
|
|
132
|
+
const stdout = frames
|
|
133
|
+
.filter(frame => frame.type === "stdout")
|
|
134
|
+
.map(frame => frame.data)
|
|
135
|
+
.join("");
|
|
136
|
+
|
|
137
|
+
expect(stdout).toContain("[output truncated: shell helper exceeded");
|
|
138
|
+
expect(stdout).toContain("capturedChars=1048576 return=0");
|
|
139
|
+
expect(stdout).not.toContain("capturedChars=1048593");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("streams newline-free %%bash output without waiting for EOF", async () => {
|
|
143
|
+
const child = [
|
|
144
|
+
"import sys,time",
|
|
145
|
+
"sys.stdout.write('first')",
|
|
146
|
+
"sys.stdout.flush()",
|
|
147
|
+
"time.sleep(0.2)",
|
|
148
|
+
"sys.stdout.write('second')",
|
|
149
|
+
"sys.stdout.flush()",
|
|
150
|
+
].join(";");
|
|
151
|
+
const frames = await runCell(`%%bash\n${pythonPath} -c ${shellQuote(child)}`);
|
|
152
|
+
const stdout = frames.filter(frame => frame.type === "stdout").map(frame => frame.data);
|
|
153
|
+
|
|
154
|
+
expect(stdout[0]).toBe("first");
|
|
155
|
+
expect(stdout.join("")).toBe("firstsecond");
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/eval/py/runner.py
CHANGED
|
@@ -26,14 +26,16 @@ when installed.
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
-
import asyncio
|
|
30
29
|
import ast
|
|
31
|
-
import
|
|
30
|
+
import asyncio
|
|
32
31
|
import base64
|
|
33
32
|
import builtins
|
|
33
|
+
import codecs
|
|
34
|
+
import contextvars
|
|
34
35
|
import inspect
|
|
35
36
|
import io
|
|
36
37
|
import json
|
|
38
|
+
import locale
|
|
37
39
|
import os
|
|
38
40
|
import re
|
|
39
41
|
import runpy
|
|
@@ -45,7 +47,7 @@ import threading
|
|
|
45
47
|
import time
|
|
46
48
|
import traceback
|
|
47
49
|
from pathlib import Path
|
|
48
|
-
from typing import Any
|
|
50
|
+
from typing import Any, Callable
|
|
49
51
|
|
|
50
52
|
# ---------------------------------------------------------------------------
|
|
51
53
|
# Frame writer
|
|
@@ -396,6 +398,158 @@ def _emit_status(op: str, **data: Any) -> None:
|
|
|
396
398
|
return
|
|
397
399
|
_emit({"type": "display", "id": rid, "bundle": bundle})
|
|
398
400
|
|
|
401
|
+
_SHELL_READ_CHUNK_BYTES = 8192
|
|
402
|
+
_SHELL_OUTPUT_MAX_BYTES = 1024 * 1024
|
|
403
|
+
_SHELL_OUTPUT_MAX_LINES = 3000
|
|
404
|
+
_SHELL_RESULT_CAPTURE_BYTES = _SHELL_OUTPUT_MAX_BYTES
|
|
405
|
+
_PIP_LINE_SCAN_CHARS = 64 * 1024
|
|
406
|
+
_SHELL_TRUNCATION_NOTICE = (
|
|
407
|
+
f"[output truncated: shell helper exceeded {_SHELL_OUTPUT_MAX_BYTES} bytes "
|
|
408
|
+
f"or {_SHELL_OUTPUT_MAX_LINES} lines; remaining output discarded]\n"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _process_output_encoding() -> str:
|
|
413
|
+
return locale.getpreferredencoding(False) or "utf-8"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _process_output_decoder(encoding: str) -> codecs.IncrementalDecoder:
|
|
417
|
+
return codecs.getincrementaldecoder(encoding)(errors="strict")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _take_prefix_by_lines(text: str, max_lines: int) -> str:
|
|
421
|
+
if max_lines <= 0:
|
|
422
|
+
return ""
|
|
423
|
+
cursor = 0
|
|
424
|
+
for _ in range(max_lines):
|
|
425
|
+
newline = text.find("\n", cursor)
|
|
426
|
+
if newline < 0:
|
|
427
|
+
return text
|
|
428
|
+
cursor = newline + 1
|
|
429
|
+
return text[:cursor]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _take_prefix_by_encoded_bytes(text: str, max_bytes: int, encoding: str) -> str:
|
|
433
|
+
if max_bytes <= 0:
|
|
434
|
+
return ""
|
|
435
|
+
if len(text.encode(encoding, errors="strict")) <= max_bytes:
|
|
436
|
+
return text
|
|
437
|
+
lo = 0
|
|
438
|
+
hi = len(text)
|
|
439
|
+
while lo < hi:
|
|
440
|
+
mid = (lo + hi + 1) // 2
|
|
441
|
+
if len(text[:mid].encode(encoding, errors="strict")) <= max_bytes:
|
|
442
|
+
lo = mid
|
|
443
|
+
else:
|
|
444
|
+
hi = mid - 1
|
|
445
|
+
return text[:lo]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class _ShellOutputLimiter:
|
|
449
|
+
def __init__(self, *, max_bytes: int, max_lines: int, encoding: str) -> None:
|
|
450
|
+
self._remaining_bytes = max_bytes
|
|
451
|
+
self._remaining_lines = max_lines
|
|
452
|
+
self._encoding = encoding
|
|
453
|
+
self._truncated = False
|
|
454
|
+
self._at_line_start = True
|
|
455
|
+
|
|
456
|
+
def write(self, text: str) -> None:
|
|
457
|
+
if not text or self._truncated:
|
|
458
|
+
return
|
|
459
|
+
limited = _take_prefix_by_lines(text, self._remaining_lines)
|
|
460
|
+
truncated = limited != text
|
|
461
|
+
byte_limited = _take_prefix_by_encoded_bytes(limited, self._remaining_bytes, self._encoding)
|
|
462
|
+
truncated = truncated or byte_limited != limited
|
|
463
|
+
if byte_limited:
|
|
464
|
+
sys.stdout.write(byte_limited)
|
|
465
|
+
sys.stdout.flush()
|
|
466
|
+
self._remaining_bytes -= len(byte_limited.encode(self._encoding, errors="strict"))
|
|
467
|
+
self._remaining_lines -= byte_limited.count("\n")
|
|
468
|
+
self._at_line_start = byte_limited.endswith("\n")
|
|
469
|
+
if truncated:
|
|
470
|
+
self._emit_truncation_notice()
|
|
471
|
+
|
|
472
|
+
def _emit_truncation_notice(self) -> None:
|
|
473
|
+
if self._truncated:
|
|
474
|
+
return
|
|
475
|
+
prefix = "" if self._at_line_start else "\n"
|
|
476
|
+
sys.stdout.write(prefix + _SHELL_TRUNCATION_NOTICE)
|
|
477
|
+
sys.stdout.flush()
|
|
478
|
+
self._truncated = True
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _stream_process_output(proc: subprocess.Popen, on_text: Callable[[str], None] | None = None) -> None:
|
|
482
|
+
assert proc.stdout is not None
|
|
483
|
+
encoding = _process_output_encoding()
|
|
484
|
+
decoder = _process_output_decoder(encoding)
|
|
485
|
+
limiter = _ShellOutputLimiter(
|
|
486
|
+
max_bytes=_SHELL_OUTPUT_MAX_BYTES,
|
|
487
|
+
max_lines=_SHELL_OUTPUT_MAX_LINES,
|
|
488
|
+
encoding=encoding,
|
|
489
|
+
)
|
|
490
|
+
while True:
|
|
491
|
+
chunk = os.read(proc.stdout.fileno(), _SHELL_READ_CHUNK_BYTES)
|
|
492
|
+
if not chunk:
|
|
493
|
+
break
|
|
494
|
+
text = decoder.decode(chunk)
|
|
495
|
+
if text:
|
|
496
|
+
limiter.write(text)
|
|
497
|
+
if on_text is not None:
|
|
498
|
+
on_text(text)
|
|
499
|
+
tail = decoder.decode(b"", final=True)
|
|
500
|
+
if tail:
|
|
501
|
+
limiter.write(tail)
|
|
502
|
+
if on_text is not None:
|
|
503
|
+
on_text(tail)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class _BoundedTextCapture:
|
|
507
|
+
def __init__(self, max_bytes: int, max_lines: int, encoding: str) -> None:
|
|
508
|
+
self._remaining_bytes = max_bytes
|
|
509
|
+
self._remaining_lines = max_lines
|
|
510
|
+
self._encoding = encoding
|
|
511
|
+
self._parts: list[str] = []
|
|
512
|
+
|
|
513
|
+
def add(self, text: str) -> None:
|
|
514
|
+
if self._remaining_bytes <= 0 or self._remaining_lines <= 0:
|
|
515
|
+
return
|
|
516
|
+
line_limited = _take_prefix_by_lines(text, self._remaining_lines)
|
|
517
|
+
part = _take_prefix_by_encoded_bytes(line_limited, self._remaining_bytes, self._encoding)
|
|
518
|
+
if not part:
|
|
519
|
+
return
|
|
520
|
+
self._parts.append(part)
|
|
521
|
+
self._remaining_bytes -= len(part.encode(self._encoding, errors="strict"))
|
|
522
|
+
self._remaining_lines -= part.count("\n")
|
|
523
|
+
|
|
524
|
+
def text(self) -> str:
|
|
525
|
+
return "".join(self._parts)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class _BoundedLineScanner:
|
|
529
|
+
def __init__(self, max_chars: int, on_line: Callable[[str], None]) -> None:
|
|
530
|
+
self._max_chars = max_chars
|
|
531
|
+
self._on_line = on_line
|
|
532
|
+
self._partial = ""
|
|
533
|
+
|
|
534
|
+
def add(self, text: str) -> None:
|
|
535
|
+
data = self._partial + text
|
|
536
|
+
lines = data.splitlines(keepends=True)
|
|
537
|
+
if not lines:
|
|
538
|
+
return
|
|
539
|
+
if lines[-1].endswith(("\n", "\r")):
|
|
540
|
+
self._partial = ""
|
|
541
|
+
else:
|
|
542
|
+
self._partial = lines.pop()
|
|
543
|
+
for line in lines:
|
|
544
|
+
self._on_line(line)
|
|
545
|
+
if len(self._partial) > self._max_chars:
|
|
546
|
+
self._partial = self._partial[-self._max_chars :]
|
|
547
|
+
|
|
548
|
+
def finish(self) -> None:
|
|
549
|
+
if self._partial:
|
|
550
|
+
self._on_line(self._partial)
|
|
551
|
+
self._partial = ""
|
|
552
|
+
|
|
399
553
|
|
|
400
554
|
@line_magic("pip")
|
|
401
555
|
def _magic_pip(args: str) -> None:
|
|
@@ -405,19 +559,20 @@ def _magic_pip(args: str) -> None:
|
|
|
405
559
|
cmd,
|
|
406
560
|
stdout=subprocess.PIPE,
|
|
407
561
|
stderr=subprocess.STDOUT,
|
|
408
|
-
text=True,
|
|
409
|
-
bufsize=1,
|
|
410
562
|
)
|
|
411
563
|
installed_packages: list[str] = []
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
sys.stdout.write(raw_line)
|
|
564
|
+
|
|
565
|
+
def scan_pip_line(raw_line: str) -> None:
|
|
415
566
|
m = re.search(r"Successfully installed\s+(.+)$", raw_line)
|
|
416
567
|
if m:
|
|
417
568
|
for token in m.group(1).split():
|
|
418
569
|
# Token is name-version; drop the version suffix.
|
|
419
570
|
pkg = token.rsplit("-", 1)[0]
|
|
420
571
|
installed_packages.append(pkg.replace("_", "-"))
|
|
572
|
+
|
|
573
|
+
scanner = _BoundedLineScanner(_PIP_LINE_SCAN_CHARS, scan_pip_line)
|
|
574
|
+
_stream_process_output(proc, scanner.add)
|
|
575
|
+
scanner.finish()
|
|
421
576
|
proc.wait()
|
|
422
577
|
if installed_packages:
|
|
423
578
|
import importlib
|
|
@@ -599,12 +754,8 @@ def _run_shell_body(body: str, *, shell_arg: str) -> int:
|
|
|
599
754
|
[shell_arg, "-c", body],
|
|
600
755
|
stdout=subprocess.PIPE,
|
|
601
756
|
stderr=subprocess.STDOUT,
|
|
602
|
-
text=True,
|
|
603
|
-
bufsize=1,
|
|
604
757
|
)
|
|
605
|
-
|
|
606
|
-
for raw_line in proc.stdout:
|
|
607
|
-
sys.stdout.write(raw_line)
|
|
758
|
+
_stream_process_output(proc)
|
|
608
759
|
proc.wait()
|
|
609
760
|
return proc.returncode
|
|
610
761
|
|
|
@@ -640,16 +791,16 @@ class _ShellResult(list):
|
|
|
640
791
|
|
|
641
792
|
|
|
642
793
|
def __omp_shell(cmd: str) -> _ShellResult:
|
|
643
|
-
proc = subprocess.
|
|
794
|
+
proc = subprocess.Popen(
|
|
644
795
|
cmd,
|
|
645
796
|
shell=True,
|
|
646
797
|
stdout=subprocess.PIPE,
|
|
647
798
|
stderr=subprocess.STDOUT,
|
|
648
|
-
text=True,
|
|
649
799
|
)
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
800
|
+
capture = _BoundedTextCapture(_SHELL_RESULT_CAPTURE_BYTES, _SHELL_OUTPUT_MAX_LINES, _process_output_encoding())
|
|
801
|
+
_stream_process_output(proc, capture.add)
|
|
802
|
+
proc.wait()
|
|
803
|
+
lines = [line for line in capture.text().splitlines()]
|
|
653
804
|
return _ShellResult(lines, proc.returncode)
|
|
654
805
|
|
|
655
806
|
|
|
@@ -33,9 +33,7 @@ export function createExtensionModelQuery(
|
|
|
33
33
|
resolveModelRoleValue(spec, modelRegistry.getAvailable(), {
|
|
34
34
|
settings,
|
|
35
35
|
matchPreferences: getModelMatchPreferences(settings),
|
|
36
|
-
modelRegistry,
|
|
37
36
|
}).model,
|
|
38
|
-
family: (model: Model<Api>): string =>
|
|
39
|
-
modelFamilyToken(modelRegistry.getCanonicalId(model) ?? model.id) || model.provider.toLowerCase(),
|
|
37
|
+
family: (model: Model<Api>): string => modelFamilyToken(model.id) || model.provider.toLowerCase(),
|
|
40
38
|
};
|
|
41
39
|
}
|
|
@@ -98,6 +98,32 @@ function handlerTimeoutForEvent(eventType: string): number {
|
|
|
98
98
|
|
|
99
99
|
const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Race `work` against a `timeoutMs` budget, clearing the pending timer the
|
|
103
|
+
* instant the work settles.
|
|
104
|
+
*
|
|
105
|
+
* We deliberately avoid `Bun.sleep(timeoutMs).then(...)` here: that leaves an
|
|
106
|
+
* uncancellable timer registered with the event loop, so every successful
|
|
107
|
+
* handler race leaks a timer that keeps the process alive until the deadline
|
|
108
|
+
* fires — up to the default 30s cap, which stalls non-interactive CLI exit
|
|
109
|
+
* after any subscribed `tool_call`/`tool_result` handler runs (issue #3948
|
|
110
|
+
* review, `chatgpt-codex-connector[bot]`). `setTimeout` returns a handle we
|
|
111
|
+
* can `clearTimeout` on the winning branch.
|
|
112
|
+
*/
|
|
113
|
+
async function raceHandlerWithTimeout<T>(
|
|
114
|
+
work: Promise<T>,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT> {
|
|
117
|
+
const { promise: timeoutPromise, resolve: resolveTimeout } =
|
|
118
|
+
Promise.withResolvers<typeof EXTENSION_HANDLER_TIMEOUT>();
|
|
119
|
+
const timer = setTimeout(() => resolveTimeout(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
|
|
120
|
+
try {
|
|
121
|
+
return await Promise.race([work, timeoutPromise]);
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
const MAX_PENDING_CREDENTIAL_DISABLED = 32;
|
|
102
128
|
|
|
103
129
|
/**
|
|
@@ -555,10 +581,7 @@ export class ExtensionRunner {
|
|
|
555
581
|
timeoutMs: number,
|
|
556
582
|
): Promise<TResult | undefined> {
|
|
557
583
|
try {
|
|
558
|
-
const handlerResult = await Promise.
|
|
559
|
-
Promise.resolve(handler(event, ctx)),
|
|
560
|
-
Bun.sleep(timeoutMs).then(() => EXTENSION_HANDLER_TIMEOUT),
|
|
561
|
-
]);
|
|
584
|
+
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
|
|
562
585
|
if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
|
|
563
586
|
const error = `handler timed out after ${timeoutMs}ms`;
|
|
564
587
|
logger.warn("Extension handler timed out", {
|
|
@@ -693,8 +716,24 @@ export class ExtensionRunner {
|
|
|
693
716
|
};
|
|
694
717
|
}
|
|
695
718
|
|
|
719
|
+
/**
|
|
720
|
+
* Emit a `tool_call` event to every subscribed extension before the tool executes.
|
|
721
|
+
*
|
|
722
|
+
* Each handler is bounded by `extensionHandlerTimeoutMs` (default 30s). This
|
|
723
|
+
* matches the timeout policy already applied to `emitToolResult` and every
|
|
724
|
+
* other handler routed through `#runHandlerWithTimeout`; without it a single
|
|
725
|
+
* hung extension (unresolved `await`, network call with no timeout) would
|
|
726
|
+
* park `ExtensionToolWrapper.execute` indefinitely and freeze tool
|
|
727
|
+
* dispatch — see issue #3948.
|
|
728
|
+
*
|
|
729
|
+
* On-timeout policy: **fail-closed** (return `{ block: true }`). This is
|
|
730
|
+
* symmetric with the existing error path below and safer for a
|
|
731
|
+
* pre-execution gate — an unresponsive extension MUST NOT be treated as
|
|
732
|
+
* silent consent to run the tool.
|
|
733
|
+
*/
|
|
696
734
|
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
|
|
697
735
|
const ctx = this.createContext();
|
|
736
|
+
const timeoutMs = extensionHandlerTimeoutMs;
|
|
698
737
|
let result: ToolCallEventResult | undefined;
|
|
699
738
|
|
|
700
739
|
for (const ext of this.extensions) {
|
|
@@ -703,7 +742,25 @@ export class ExtensionRunner {
|
|
|
703
742
|
|
|
704
743
|
for (const handler of handlers) {
|
|
705
744
|
try {
|
|
706
|
-
const handlerResult = await handler(event, ctx);
|
|
745
|
+
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
|
|
746
|
+
|
|
747
|
+
if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
|
|
748
|
+
const error = `handler timed out after ${timeoutMs}ms`;
|
|
749
|
+
logger.warn("Extension handler timed out", {
|
|
750
|
+
extensionPath: ext.path,
|
|
751
|
+
event: "tool_call",
|
|
752
|
+
timeoutMs,
|
|
753
|
+
});
|
|
754
|
+
this.emitError({
|
|
755
|
+
extensionPath: ext.path,
|
|
756
|
+
event: "tool_call",
|
|
757
|
+
error,
|
|
758
|
+
});
|
|
759
|
+
return {
|
|
760
|
+
block: true,
|
|
761
|
+
reason: `Extension ${ext.path} timed out after ${timeoutMs}ms`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
707
764
|
|
|
708
765
|
if (handlerResult) {
|
|
709
766
|
result = handlerResult as ToolCallEventResult;
|