fix(agent): stabilize large tool results
This commit is contained in:
@@ -10,6 +10,7 @@ export type CliExecutionResult = {
|
||||
signal: NodeJS.Signals | null;
|
||||
status: number;
|
||||
stderr: string;
|
||||
stderrTruncated: boolean;
|
||||
stdout: string;
|
||||
exceededStream?: OutputStream;
|
||||
};
|
||||
@@ -17,7 +18,8 @@ export type CliExecutionResult = {
|
||||
type ExecuteCliCommandOptions = {
|
||||
apiBaseUrl: string;
|
||||
cliPath: string;
|
||||
maxOutputBytes: number;
|
||||
maxStderrBytes: number;
|
||||
maxStdoutBytes: number;
|
||||
terminationGraceMs?: number;
|
||||
};
|
||||
|
||||
@@ -46,9 +48,11 @@ export const executeCliCommand = async (
|
||||
timeoutSec: number,
|
||||
options: ExecuteCliCommandOptions,
|
||||
): Promise<CliExecutionResult> => {
|
||||
const maxOutputBytes = options.maxOutputBytes;
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
|
||||
throw new Error("maxOutputBytes must be a positive safe integer");
|
||||
if (!Number.isSafeInteger(options.maxStdoutBytes) || options.maxStdoutBytes <= 0) {
|
||||
throw new Error("maxStdoutBytes must be a positive safe integer");
|
||||
}
|
||||
if (!Number.isSafeInteger(options.maxStderrBytes) || options.maxStderrBytes <= 0) {
|
||||
throw new Error("maxStderrBytes must be a positive safe integer");
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
@@ -60,6 +64,7 @@ export const executeCliCommand = async (
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let stderrTruncated = false;
|
||||
let terminationReason:
|
||||
| "timeout"
|
||||
| "output_limit"
|
||||
@@ -98,23 +103,34 @@ export const executeCliCommand = async (
|
||||
}, options.terminationGraceMs ?? 1500);
|
||||
};
|
||||
|
||||
const capture = (stream: OutputStream, data: Buffer) => {
|
||||
const captureStdout = (data: Buffer) => {
|
||||
if (terminationReason) {
|
||||
return;
|
||||
}
|
||||
const chunks = stream === "stdout" ? stdoutChunks : stderrChunks;
|
||||
const bytes = stream === "stdout" ? stdoutBytes : stderrBytes;
|
||||
if (bytes + data.length > maxOutputBytes) {
|
||||
exceededStream = stream;
|
||||
if (stdoutBytes + data.length > options.maxStdoutBytes) {
|
||||
exceededStream = "stdout";
|
||||
terminate("output_limit");
|
||||
return;
|
||||
}
|
||||
chunks.push(data);
|
||||
if (stream === "stdout") {
|
||||
stdoutBytes += data.length;
|
||||
} else {
|
||||
stderrBytes += data.length;
|
||||
stdoutChunks.push(data);
|
||||
stdoutBytes += data.length;
|
||||
};
|
||||
|
||||
const captureStderr = (data: Buffer) => {
|
||||
if (terminationReason || stderrTruncated) {
|
||||
return;
|
||||
}
|
||||
const remainingBytes = options.maxStderrBytes - stderrBytes;
|
||||
if (data.length > remainingBytes) {
|
||||
if (remainingBytes > 0) {
|
||||
stderrChunks.push(data.subarray(0, remainingBytes));
|
||||
stderrBytes += remainingBytes;
|
||||
}
|
||||
stderrTruncated = true;
|
||||
return;
|
||||
}
|
||||
stderrChunks.push(data);
|
||||
stderrBytes += data.length;
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
@@ -123,8 +139,8 @@ export const executeCliCommand = async (
|
||||
}
|
||||
}, timeoutSec * 1000);
|
||||
|
||||
child.stdout.on("data", (data: Buffer) => capture("stdout", data));
|
||||
child.stderr.on("data", (data: Buffer) => capture("stderr", data));
|
||||
child.stdout.on("data", captureStdout);
|
||||
child.stderr.on("data", captureStderr);
|
||||
child.stdin.on("error", (error) => {
|
||||
if (terminationReason === null) {
|
||||
executionError = error;
|
||||
@@ -150,6 +166,7 @@ export const executeCliCommand = async (
|
||||
signal,
|
||||
status: 504,
|
||||
stderr: "",
|
||||
stderrTruncated,
|
||||
stdout: "",
|
||||
});
|
||||
return;
|
||||
@@ -166,6 +183,7 @@ export const executeCliCommand = async (
|
||||
signal,
|
||||
status: 502,
|
||||
stderr: "",
|
||||
stderrTruncated,
|
||||
stdout: "",
|
||||
});
|
||||
return;
|
||||
@@ -179,6 +197,7 @@ export const executeCliCommand = async (
|
||||
signal,
|
||||
status: getCompletedStatus(exitCode, stdout),
|
||||
stderr,
|
||||
stderrTruncated,
|
||||
stdout,
|
||||
});
|
||||
});
|
||||
|
||||
+14
-2
@@ -61,8 +61,20 @@ const envSchema = z
|
||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
||||
// OpenCode 工具结果以内联形式返回给模型的阈值;更大的结果由 OpenCode 落盘。
|
||||
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
|
||||
// 单次 tjwater-cli stdout 的硬上限;超过后终止子进程。
|
||||
MAX_CLI_OUTPUT_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(128 * 1024 * 1024),
|
||||
// 单次 tjwater-cli stderr 最多保留的字节数;超过后截断但不终止进程。
|
||||
MAX_CLI_STDERR_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(256 * 1024),
|
||||
// 生成结果 preview 时最多抽样的条目数。
|
||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||
// memory 持久化存储目录。
|
||||
@@ -110,7 +122,7 @@ const envSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(64 * 1024 * 1024),
|
||||
.default(128 * 1024 * 1024),
|
||||
// result_ref 保留时长(小时)。
|
||||
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
||||
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type ApprovalMode = "request" | "auto" | "always";
|
||||
|
||||
export type PermissionApprovalContext = {
|
||||
metadata?: Record<string, unknown>;
|
||||
patterns?: readonly string[];
|
||||
workspaceRoot?: string;
|
||||
};
|
||||
|
||||
const lowRiskToolPermissions = new Set([
|
||||
"apply_layer_style",
|
||||
"geocode",
|
||||
@@ -12,10 +21,31 @@ const lowRiskToolPermissions = new Set([
|
||||
"zoom_to_map",
|
||||
]);
|
||||
|
||||
const lowRiskSearchRootNames = new Set([
|
||||
".opencode",
|
||||
"cli",
|
||||
"contracts",
|
||||
"node-tests",
|
||||
"scripts",
|
||||
"src",
|
||||
"tests",
|
||||
]);
|
||||
|
||||
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
|
||||
|
||||
export const canAutoApprovePermission = (permission: string): boolean => {
|
||||
export const canAutoApprovePermission = (
|
||||
permission: string,
|
||||
context: PermissionApprovalContext = {},
|
||||
): boolean => {
|
||||
const normalized = normalizePermission(permission);
|
||||
if (normalized === "skill") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "glob" || normalized === "grep") {
|
||||
return isSafeWorkspaceSearch(normalized, context);
|
||||
}
|
||||
|
||||
if (lowRiskToolPermissions.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
@@ -30,6 +60,7 @@ export const canAutoApprovePermission = (permission: string): boolean => {
|
||||
export const resolvePermissionApproval = (
|
||||
approvalMode: ApprovalMode,
|
||||
permission: string,
|
||||
context: PermissionApprovalContext = {},
|
||||
) => {
|
||||
if (approvalMode === "always") {
|
||||
return {
|
||||
@@ -40,7 +71,7 @@ export const resolvePermissionApproval = (
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (approvalMode === "auto" && canAutoApprovePermission(permission)) {
|
||||
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
|
||||
return {
|
||||
autoApprove: true,
|
||||
title: "已自动批准低风险权限",
|
||||
@@ -54,3 +85,138 @@ export const resolvePermissionApproval = (
|
||||
detail: undefined,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const isSafeWorkspaceSearch = (
|
||||
permission: "glob" | "grep",
|
||||
context: PermissionApprovalContext,
|
||||
): boolean => {
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestedPath =
|
||||
typeof context.metadata?.path === "string" && context.metadata.path.trim()
|
||||
? context.metadata.path
|
||||
: workspaceRoot;
|
||||
let root: string;
|
||||
let searchRoot: string;
|
||||
try {
|
||||
root = realpathSync.native(resolve(workspaceRoot));
|
||||
searchRoot = realpathSync.native(resolve(root, requestedPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let relativePath = relative(root, searchRoot);
|
||||
if (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expressions: string[] = [];
|
||||
if (permission === "glob" && typeof context.metadata?.pattern === "string") {
|
||||
expressions.push(context.metadata.pattern);
|
||||
}
|
||||
if (permission === "glob") {
|
||||
expressions.push(...(context.patterns ?? []));
|
||||
}
|
||||
if (typeof context.metadata?.include === "string") {
|
||||
expressions.push(context.metadata.include);
|
||||
}
|
||||
if (
|
||||
expressions.some(
|
||||
(expression) =>
|
||||
isAbsolute(expression) ||
|
||||
containsParentTraversal(expression) ||
|
||||
containsAmbiguousGlobSyntax(expression) ||
|
||||
containsProtectedPath(expression),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
if (permission !== "glob") {
|
||||
return false;
|
||||
}
|
||||
const literalPrefix = getLiteralGlobPrefix(expressions[0]);
|
||||
if (!literalPrefix) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
searchRoot = realpathSync.native(resolve(root, literalPrefix));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
relativePath = relative(root, searchRoot);
|
||||
}
|
||||
|
||||
return (
|
||||
relativePath !== "" &&
|
||||
relativePath !== ".." &&
|
||||
!relativePath.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(relativePath) &&
|
||||
!containsProtectedPath(relativePath) &&
|
||||
isSafeSearchTarget(searchRoot, relativePath)
|
||||
);
|
||||
};
|
||||
|
||||
const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => {
|
||||
try {
|
||||
const target = lstatSync(searchRoot);
|
||||
if (target.isFile()) {
|
||||
return true;
|
||||
}
|
||||
if (!target.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
const topLevelName = relativePath.split(sep)[0];
|
||||
if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pending = [searchRoot];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()!;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) {
|
||||
return false;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(resolve(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getLiteralGlobPrefix = (expression: string | undefined): string | null => {
|
||||
const firstSegment = expression
|
||||
?.replaceAll("\\", "/")
|
||||
.replace(/^\.\//, "")
|
||||
.split("/")[0];
|
||||
return firstSegment && !/[*?[\]{}()!+@]/.test(firstSegment)
|
||||
? firstSegment
|
||||
: null;
|
||||
};
|
||||
|
||||
const containsParentTraversal = (value: string): boolean =>
|
||||
value.replaceAll("\\", "/").split("/").includes("..");
|
||||
|
||||
const containsAmbiguousGlobSyntax = (value: string): boolean =>
|
||||
/[?[\]{}()!+@\\]/.test(value);
|
||||
|
||||
const containsProtectedPath = (value: string): boolean => {
|
||||
const normalized = value.replaceAll("\\", "/").toLowerCase();
|
||||
return (
|
||||
normalized.includes(".env") ||
|
||||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -394,6 +394,11 @@ export const streamPromptResponse = async ({
|
||||
const permissionApproval = resolvePermissionApproval(
|
||||
approvalMode,
|
||||
event.properties.permission,
|
||||
{
|
||||
metadata: event.properties.metadata,
|
||||
patterns: event.properties.patterns,
|
||||
workspaceRoot: process.cwd(),
|
||||
},
|
||||
);
|
||||
logDevelopmentDebug("permission request received", {
|
||||
...debugContext,
|
||||
@@ -440,6 +445,11 @@ export const streamPromptResponse = async ({
|
||||
const permissionApproval = resolvePermissionApproval(
|
||||
approvalMode,
|
||||
event.properties.action,
|
||||
{
|
||||
metadata: event.properties.metadata,
|
||||
patterns: event.properties.resources,
|
||||
workspaceRoot: process.cwd(),
|
||||
},
|
||||
);
|
||||
logDevelopmentDebug("permission v2 request received", {
|
||||
...debugContext,
|
||||
|
||||
@@ -7,6 +7,10 @@ import { resolve } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
cleanupExpiredToolOutputs,
|
||||
resolveOpencodeToolOutputDirectory,
|
||||
} from "./opencodeToolOutputCleanup.js";
|
||||
|
||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
@@ -46,6 +50,7 @@ const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
|
||||
export class OpencodeRuntimeAdapter {
|
||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||
private closeServer: (() => void) | null = null;
|
||||
private toolOutputCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async ensureClient(): Promise<OpencodeClient> {
|
||||
if (!this.clientPromise) {
|
||||
@@ -380,12 +385,18 @@ export class OpencodeRuntimeAdapter {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.toolOutputCleanupTimer) {
|
||||
clearInterval(this.toolOutputCleanupTimer);
|
||||
this.toolOutputCleanupTimer = null;
|
||||
}
|
||||
this.closeServer?.();
|
||||
this.closeServer = null;
|
||||
this.clientPromise = null;
|
||||
}
|
||||
|
||||
private async bootstrapClient(): Promise<OpencodeClient> {
|
||||
await this.cleanupToolOutputs();
|
||||
|
||||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||||
@@ -436,9 +447,39 @@ export class OpencodeRuntimeAdapter {
|
||||
this.closeServer = () => {
|
||||
runtime.server.close();
|
||||
};
|
||||
this.startToolOutputCleanupLoop();
|
||||
|
||||
return runtime.client;
|
||||
}
|
||||
|
||||
private async cleanupToolOutputs(): Promise<void> {
|
||||
const directory = resolveOpencodeToolOutputDirectory();
|
||||
const ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000;
|
||||
try {
|
||||
const result = await cleanupExpiredToolOutputs(directory, ttlMs);
|
||||
if (result.removed > 0) {
|
||||
logger.info(
|
||||
{ directory, ...result },
|
||||
"removed expired opencode tool output files",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, directory },
|
||||
"failed to clean expired opencode tool output files",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private startToolOutputCleanupLoop(): void {
|
||||
if (this.toolOutputCleanupTimer) {
|
||||
return;
|
||||
}
|
||||
this.toolOutputCleanupTimer = setInterval(() => {
|
||||
void this.cleanupToolOutputs();
|
||||
}, config.RESULT_REF_CLEANUP_INTERVAL_MS);
|
||||
this.toolOutputCleanupTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||
@@ -448,6 +489,10 @@ function buildOpencodeConfig(): Record<string, unknown> {
|
||||
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||||
{
|
||||
model: config.OPENCODE_MODEL,
|
||||
tool_output: {
|
||||
max_bytes: config.MAX_INLINE_RESULT_BYTES,
|
||||
max_lines: 2000,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readdir, rm, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type ToolOutputCleanupResult = {
|
||||
removed: number;
|
||||
scanned: number;
|
||||
};
|
||||
|
||||
export const resolveOpencodeToolOutputDirectory = (): string => {
|
||||
const dataRoot = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
|
||||
return join(dataRoot, "opencode", "tool-output");
|
||||
};
|
||||
|
||||
export const cleanupExpiredToolOutputs = async (
|
||||
directory: string,
|
||||
ttlMs: number,
|
||||
now = Date.now(),
|
||||
): Promise<ToolOutputCleanupResult> => {
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
||||
throw new Error("tool output cleanup ttlMs must be a positive number");
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isNodeError(error, "ENOENT")) {
|
||||
return { removed: 0, scanned: 0 };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.startsWith("tool_")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = join(directory, entry.name);
|
||||
try {
|
||||
const file = await stat(path);
|
||||
if (now - file.mtimeMs <= ttlMs) {
|
||||
continue;
|
||||
}
|
||||
await rm(path);
|
||||
removed += 1;
|
||||
} catch (error) {
|
||||
if (!isNodeError(error, "ENOENT")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, scanned: entries.length };
|
||||
};
|
||||
|
||||
const isNodeError = (error: unknown, code: string): error is NodeJS.ErrnoException =>
|
||||
error instanceof Error && "code" in error && error.code === code;
|
||||
+13
-11
@@ -233,7 +233,8 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
executeCliCommand(activeContext, command, timeoutSec, {
|
||||
apiBaseUrl: config.TJWATER_API_BASE_URL,
|
||||
cliPath: config.TJWATER_CLI_PATH,
|
||||
maxOutputBytes: config.MAX_INLINE_RESULT_BYTES,
|
||||
maxStderrBytes: config.MAX_CLI_STDERR_BYTES,
|
||||
maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -278,7 +279,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
summary: "CLI 输出超过安全限制",
|
||||
error: {
|
||||
code: "OUTPUT_LIMIT_EXCEEDED",
|
||||
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_INLINE_RESULT_BYTES} bytes`,
|
||||
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`,
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
@@ -307,16 +308,17 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(JSON.parse(result.stdout));
|
||||
} catch {
|
||||
res.json({
|
||||
ok: true,
|
||||
schema_version: "tjwater-cli/v1",
|
||||
raw: result.stdout,
|
||||
stderr: result.stderr || undefined,
|
||||
});
|
||||
if (result.stdout.trim()) {
|
||||
res.status(200).type("application/json").send(result.stdout);
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
schema_version: "tjwater-cli/v1",
|
||||
raw: "",
|
||||
stderr: result.stderr || undefined,
|
||||
stderr_truncated: result.stderrTruncated || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user