aitable-workflow-cli 0.1.22 → 0.1.23
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/dist/{chunk-VYYLEYUZ.js → chunk-IGB6WDLR.js} +9 -9
- package/dist/cli.js +24 -24
- package/dist/{config-ui-server-GSFSGBRK.js → config-ui-server-CORDAOYI.js} +1 -1
- package/package.json +3 -3
- package/templates/support-qa/.env.example +7 -1
- package/templates/support-qa/coding-agents.config.json +2 -1
- package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +382 -0
- package/templates/support-qa/lib/sandbox-agent.ts +653 -0
- package/templates/support-qa/package.json +11 -1
- package/templates/support-qa/tsconfig.json +16 -0
- package/templates/support-qa/vitest.config.ts +9 -0
- package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +19 -6
- package/templates/support-qa/workspaces/support/AGENTS.md +1 -1
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { zipSync } from "fflate";
|
|
5
|
+
import { getAgentConfig, loadAgentConfig, workspaceAgentConfigPath } from "aitable-workflow-core";
|
|
6
|
+
import { SandboxManager } from "@ali/aone-sandbox";
|
|
7
|
+
import {
|
|
8
|
+
CodeInterpreter,
|
|
9
|
+
QoderRemoteAgent,
|
|
10
|
+
Sandbox,
|
|
11
|
+
type AcpClient,
|
|
12
|
+
type AcpSessionUpdate,
|
|
13
|
+
type QoderConfig,
|
|
14
|
+
type SandboxClient,
|
|
15
|
+
} from "@ali/aone-remote-agent";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* support-qa 沙箱 Agent 执行器
|
|
19
|
+
*
|
|
20
|
+
* 解决 4G 部署机只能并发 1 个本地 qodercli 的问题:把 Agent 执行搬到 Aone Sandbox,
|
|
21
|
+
* 维护 3 个独立 Sandbox,每个 Sandbox 只运行 1 个 QoderRemoteAgent。进程启动后的首次请求
|
|
22
|
+
* 会清理同 workspace 的遗留 Sandbox,再创建新池;Sandbox 生命周期 24h,到期前自动重建。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const CONCURRENT_AGENTS = 3;
|
|
26
|
+
|
|
27
|
+
/** Sandbox 创建 TTL(秒):24h,接近 Aone 单创建最大 24h 上限 */
|
|
28
|
+
const SANDBOX_TTL_SECONDS = 24 * 60 * 60;
|
|
29
|
+
|
|
30
|
+
/** 在 TTL 前多久重建整个池(秒):23h,留 1h 缓冲处理进行中的请求 */
|
|
31
|
+
const POOL_REBUILD_BEFORE_TTL_SECONDS = 60 * 60;
|
|
32
|
+
|
|
33
|
+
const NPM_REGISTRY = "https://registry.anpm.alibaba-inc.com/";
|
|
34
|
+
const WORKSPACE_DIR = "/workspace";
|
|
35
|
+
const SANDBOX_KB_ROOT = `${WORKSPACE_DIR}/.kb/faq-support`;
|
|
36
|
+
const SANDBOX_KB_LINK = `${WORKSPACE_DIR}/wiki/faq-support`;
|
|
37
|
+
const SANDBOX_MEDIA_ROOT = `${WORKSPACE_DIR}/.media`;
|
|
38
|
+
const SANDBOX_METADATA = { application: "aitable-agent", workload: "support-qa" };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* code-interpreter 镜像将 Node 22 安装在 /opt/taobao/install/nvm 下,却没有把 npm 放进 PATH,
|
|
42
|
+
* 因此必须显式找到同版本 npm 执行安装。
|
|
43
|
+
*/
|
|
44
|
+
function npmInstallCommand(args: string): string {
|
|
45
|
+
return [
|
|
46
|
+
"set -eu",
|
|
47
|
+
"for node_exec in /opt/taobao/install/nvm/versions/node/*/bin/node \"$HOME\"/.nvm/versions/node/*/bin/node /root/.nvm/versions/node/*/bin/node /home/admin/.nvm/versions/node/*/bin/node /usr/local/bin/node /usr/bin/node; do",
|
|
48
|
+
" [ -x \"$node_exec\" ] || continue",
|
|
49
|
+
" node_major=$(\"$node_exec\" -p 'process.versions.node.split(\".\")[0]' 2>/dev/null || true)",
|
|
50
|
+
" [ \"$node_major\" -ge 22 ] 2>/dev/null || continue",
|
|
51
|
+
" npm_exec=$(dirname \"$node_exec\")/npm",
|
|
52
|
+
" [ -x \"$npm_exec\" ] || continue",
|
|
53
|
+
" export PATH=$(dirname \"$node_exec\"):$PATH",
|
|
54
|
+
" [ -x \"$(dirname \"$node_exec\")/qodercli\" ] && exit 0",
|
|
55
|
+
` exec \"$npm_exec\" ${args} --registry=${NPM_REGISTRY}`,
|
|
56
|
+
"done",
|
|
57
|
+
"echo 'npm for Node.js >= 22: command not found' >&2",
|
|
58
|
+
"exit 127",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SandboxLogFn = (msg: string) => void;
|
|
63
|
+
|
|
64
|
+
interface PooledAgent {
|
|
65
|
+
agent: QoderRemoteAgent;
|
|
66
|
+
sandbox: Sandbox;
|
|
67
|
+
client: SandboxClient;
|
|
68
|
+
busy: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PoolGeneration {
|
|
72
|
+
agents: PooledAgent[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireEnv(name: string): string {
|
|
76
|
+
const value = process.env[name]?.trim();
|
|
77
|
+
if (!value) {
|
|
78
|
+
throw new Error(`缺少环境变量 ${name},support-qa 沙箱模式无法启动`);
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function logDefault(msg: string): void {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.log(`[sandbox-agent] ${msg}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 从更新事件中提取文本片段。
|
|
90
|
+
*
|
|
91
|
+
* ACP 协议的具体 update shape 取决于远端 Agent 实现,这里做防御性抽取:
|
|
92
|
+
* 先匹配最常见的 Anthropic 风格 text_delta,再回退到若干常见字段。
|
|
93
|
+
*/
|
|
94
|
+
function extractTextFromUpdate(update: AcpSessionUpdate): string {
|
|
95
|
+
const u = update.update;
|
|
96
|
+
if (!u || typeof u !== "object") return "";
|
|
97
|
+
|
|
98
|
+
const anyU = u as Record<string, unknown>;
|
|
99
|
+
|
|
100
|
+
// Anthropic / claude-agent-sdk 风格
|
|
101
|
+
const delta = anyU.delta;
|
|
102
|
+
if (delta && typeof delta === "object") {
|
|
103
|
+
const d = delta as Record<string, unknown>;
|
|
104
|
+
if (d.type === "text_delta" && typeof d.text === "string") {
|
|
105
|
+
return d.text;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ACP agent_message_chunk 使用 content 对象承载文本
|
|
110
|
+
if (anyU.content && typeof anyU.content === "object" && !Array.isArray(anyU.content)) {
|
|
111
|
+
const content = anyU.content as Record<string, unknown>;
|
|
112
|
+
if (typeof content.text === "string") return content.text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 常见直接字段
|
|
116
|
+
if (typeof anyU.text === "string") return anyU.text;
|
|
117
|
+
if (typeof anyU.output === "string") return anyU.output;
|
|
118
|
+
if (typeof anyU.content === "string") return anyU.content;
|
|
119
|
+
|
|
120
|
+
// content 数组
|
|
121
|
+
if (Array.isArray(anyU.content)) {
|
|
122
|
+
return anyU.content
|
|
123
|
+
.map((c: unknown) => {
|
|
124
|
+
if (c && typeof c === "object") {
|
|
125
|
+
const cc = c as Record<string, unknown>;
|
|
126
|
+
return typeof cc.text === "string" ? cc.text : typeof cc.output === "string" ? cc.output : "";
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
})
|
|
130
|
+
.join("");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function uploadPromptImages(
|
|
137
|
+
sandbox: SandboxClient,
|
|
138
|
+
prompt: string,
|
|
139
|
+
localWorkDir: string,
|
|
140
|
+
log: SandboxLogFn,
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
if (!sandbox.files.writeFiles) {
|
|
143
|
+
throw new Error("当前 Sandbox 不支持 writeFiles,无法同步消息图片");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mediaRoot = path.resolve(localWorkDir, "..", "..", ".aitable", "media", "dingtalk");
|
|
147
|
+
if (!existsSync(mediaRoot)) return prompt;
|
|
148
|
+
|
|
149
|
+
const realMediaRoot = realpathSync(mediaRoot);
|
|
150
|
+
const replacements = new Map<string, string>();
|
|
151
|
+
const uploads = new Map<string, { path: string; data: Uint8Array }>();
|
|
152
|
+
const imagePattern = /!\[[^\n]*?\]\((?:<([^>\n]+)>|([^)\n]+))\)/g;
|
|
153
|
+
|
|
154
|
+
for (const match of prompt.matchAll(imagePattern)) {
|
|
155
|
+
const destination = (match[1] ?? match[2] ?? "").trim();
|
|
156
|
+
if (!path.isAbsolute(destination) || !existsSync(destination)) continue;
|
|
157
|
+
|
|
158
|
+
const realSource = realpathSync(destination);
|
|
159
|
+
const relative = path.relative(realMediaRoot, realSource);
|
|
160
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) continue;
|
|
161
|
+
|
|
162
|
+
const data = readFileSync(realSource);
|
|
163
|
+
const digest = createHash("sha256").update(data).digest("hex").slice(0, 20);
|
|
164
|
+
const extension = path.extname(realSource).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
165
|
+
const sandboxPath = `${SANDBOX_MEDIA_ROOT}/${digest}${extension}`;
|
|
166
|
+
replacements.set(destination, sandboxPath);
|
|
167
|
+
uploads.set(sandboxPath, { path: sandboxPath, data });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (uploads.size === 0) return prompt;
|
|
171
|
+
await sandbox.files.writeFiles([...uploads.values()]);
|
|
172
|
+
log(`沙箱:已同步消息图片 ${uploads.size} 张`);
|
|
173
|
+
let rewritten = prompt;
|
|
174
|
+
for (const [localPath, sandboxPath] of replacements) {
|
|
175
|
+
rewritten = rewritten.split(localPath).join(sandboxPath);
|
|
176
|
+
}
|
|
177
|
+
return rewritten;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createKbArchive(localWorkDir: string): { archive: Uint8Array; digest: string } {
|
|
181
|
+
const kbLink = path.join(path.resolve(localWorkDir), "wiki", "faq-support");
|
|
182
|
+
if (!existsSync(kbLink)) {
|
|
183
|
+
throw new Error(`本地知识库软链不存在: ${kbLink}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const compiledWikiDir = realpathSync(kbLink);
|
|
187
|
+
const kbDir = path.dirname(compiledWikiDir);
|
|
188
|
+
const indexPath = path.join(compiledWikiDir, "_index.md");
|
|
189
|
+
if (!existsSync(indexPath)) {
|
|
190
|
+
throw new Error(`本地知识库未编译或缺少索引: ${indexPath}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const files: Record<string, Uint8Array> = {};
|
|
194
|
+
const visit = (dir: string): void => {
|
|
195
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const absolutePath = path.join(dir, entry.name);
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
visit(absolutePath);
|
|
200
|
+
} else if (entry.isFile()) {
|
|
201
|
+
const archivePath = path.relative(kbDir, absolutePath).split(path.sep).join("/");
|
|
202
|
+
files[archivePath] = readFileSync(absolutePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
visit(kbDir);
|
|
207
|
+
|
|
208
|
+
const hash = createHash("sha256");
|
|
209
|
+
for (const archivePath of Object.keys(files).sort()) {
|
|
210
|
+
hash.update(archivePath).update("\0").update(files[archivePath]).update("\0");
|
|
211
|
+
}
|
|
212
|
+
const archive = zipSync(files, { level: 6, mtime: "1980-01-01T00:00:00.000Z" });
|
|
213
|
+
const digest = hash.digest("hex").slice(0, 16);
|
|
214
|
+
return { archive, digest };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function syncSandboxFilesOnce(
|
|
218
|
+
sandbox: SandboxClient,
|
|
219
|
+
localWorkDir: string,
|
|
220
|
+
log: SandboxLogFn,
|
|
221
|
+
kbDigests: WeakMap<SandboxClient, string>,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
if (!sandbox.files.writeFiles) {
|
|
224
|
+
throw new Error("当前 Sandbox 不支持 writeFiles,无法同步动态知识库");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const resolvedWorkDir = path.resolve(localWorkDir);
|
|
228
|
+
const entries: Array<{ path: string; data: string | Uint8Array }> = [];
|
|
229
|
+
const requestCommands = [`mkdir -p ${WORKSPACE_DIR}/.request`];
|
|
230
|
+
for (const fileName of ["AGENTS.md", "owner-map.json", ".long-term.md"]) {
|
|
231
|
+
const localPath = path.join(resolvedWorkDir, fileName);
|
|
232
|
+
const stagingPath = `${WORKSPACE_DIR}/.request/${fileName}`;
|
|
233
|
+
const targetPath = `${WORKSPACE_DIR}/${fileName}`;
|
|
234
|
+
if (existsSync(localPath)) {
|
|
235
|
+
entries.push({ path: stagingPath, data: readFileSync(localPath) });
|
|
236
|
+
requestCommands.push(`cp ${stagingPath} ${targetPath}`);
|
|
237
|
+
} else {
|
|
238
|
+
requestCommands.push(`rm -f ${targetPath}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const { archive, digest } = createKbArchive(resolvedWorkDir);
|
|
243
|
+
const kbChanged = kbDigests.get(sandbox) !== digest;
|
|
244
|
+
const commands = ["set -eu", ...requestCommands];
|
|
245
|
+
|
|
246
|
+
if (kbChanged) {
|
|
247
|
+
const archivePath = `${SANDBOX_KB_ROOT}/${digest}.zip`;
|
|
248
|
+
entries.push({ path: archivePath, data: archive });
|
|
249
|
+
|
|
250
|
+
log(`沙箱:同步动态知识库 ${digest}...`);
|
|
251
|
+
const targetDir = `${SANDBOX_KB_ROOT}/${digest}`;
|
|
252
|
+
const stagingDir = `${targetDir}.tmp`;
|
|
253
|
+
const stagingLink = `${WORKSPACE_DIR}/wiki/.faq-support-${digest}`;
|
|
254
|
+
commands.push(
|
|
255
|
+
`mkdir -p ${SANDBOX_KB_ROOT} ${WORKSPACE_DIR}/wiki`,
|
|
256
|
+
`rm -rf ${stagingDir}`,
|
|
257
|
+
`mkdir -p ${stagingDir}`,
|
|
258
|
+
`python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' ${archivePath} ${stagingDir}`,
|
|
259
|
+
`test -f ${stagingDir}/wiki/_index.md`,
|
|
260
|
+
`if [ -f ${targetDir}/wiki/_index.md ]; then rm -rf ${stagingDir}; else mv ${stagingDir} ${targetDir}; fi`,
|
|
261
|
+
`rm -f ${stagingLink}`,
|
|
262
|
+
`ln -s ${targetDir}/wiki ${stagingLink}`,
|
|
263
|
+
`if [ -L ${SANDBOX_KB_LINK} ]; then mv -Tf ${stagingLink} ${SANDBOX_KB_LINK}; else rm -rf ${SANDBOX_KB_LINK}; mv ${stagingLink} ${SANDBOX_KB_LINK}; fi`,
|
|
264
|
+
`rm -f ${archivePath}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await sandbox.files.writeFiles(entries);
|
|
269
|
+
const syncRes = await sandbox.commands.runCommand(commands.join("\n"));
|
|
270
|
+
if ((syncRes.exitCode ?? 1) !== 0) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`同步 Sandbox 工作区失败: exit=${syncRes.exitCode}, stderr=${JSON.stringify(syncRes.logs?.stderr)}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (kbChanged) {
|
|
277
|
+
kbDigests.set(sandbox, digest);
|
|
278
|
+
log("沙箱:动态知识库已就绪");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function syncSandboxFiles(
|
|
283
|
+
sandbox: SandboxClient,
|
|
284
|
+
localWorkDir: string,
|
|
285
|
+
log: SandboxLogFn,
|
|
286
|
+
kbDigests: WeakMap<SandboxClient, string>,
|
|
287
|
+
syncs: WeakMap<SandboxClient, Promise<void>>,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const previous = syncs.get(sandbox) ?? Promise.resolve();
|
|
290
|
+
const current = previous
|
|
291
|
+
.catch(() => undefined)
|
|
292
|
+
.then(() => syncSandboxFilesOnce(sandbox, localWorkDir, log, kbDigests));
|
|
293
|
+
syncs.set(sandbox, current);
|
|
294
|
+
try {
|
|
295
|
+
await current;
|
|
296
|
+
} finally {
|
|
297
|
+
if (syncs.get(sandbox) === current) syncs.delete(sandbox);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function buildQoderConfig(pat: string, localWorkDir: string): Promise<QoderConfig> {
|
|
302
|
+
const configDir = path.resolve(localWorkDir, "..", "..");
|
|
303
|
+
const agentConfig = getAgentConfig(
|
|
304
|
+
await loadAgentConfig(workspaceAgentConfigPath(configDir)),
|
|
305
|
+
"qoder",
|
|
306
|
+
);
|
|
307
|
+
const model = agentConfig.model?.trim();
|
|
308
|
+
return {
|
|
309
|
+
personalAccessToken: pat,
|
|
310
|
+
...(model ? { model } : {}),
|
|
311
|
+
permissionMode: "allow_always",
|
|
312
|
+
// qodercli 已在 prepareSandbox 中预装,避免 QoderRemoteAgent 默认的 sh -lc npm install 在
|
|
313
|
+
// code-interpreter 镜像里找不到 npm 而报 exit 127。
|
|
314
|
+
skipInstall: true,
|
|
315
|
+
acpRuntime: {
|
|
316
|
+
requestTimeoutMs: 420_000,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 把 @ali/aone-sandbox 的 Sandbox 包装成 QoderRemoteAgent 需要的 SandboxClient。 */
|
|
322
|
+
async function createSandboxClient(sandbox: Sandbox): Promise<SandboxClient> {
|
|
323
|
+
const interpreter = await CodeInterpreter.fromSandbox(sandbox);
|
|
324
|
+
type ExecutionResult = Awaited<ReturnType<SandboxClient["commands"]["runCommand"]>>;
|
|
325
|
+
const toExecutionResult = (execution: { error?: unknown; logs?: unknown; exitCode?: number | null }): ExecutionResult => ({
|
|
326
|
+
error: typeof execution.error === "string" ? execution.error : undefined,
|
|
327
|
+
logs: execution.logs as ExecutionResult["logs"],
|
|
328
|
+
exitCode: execution.exitCode,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
commands: {
|
|
333
|
+
runCommand: async (command) => toExecutionResult(await interpreter.commands.run(command)),
|
|
334
|
+
},
|
|
335
|
+
files: {
|
|
336
|
+
readFile: (path, options) => interpreter.files.readFile(path, options),
|
|
337
|
+
writeFiles: (entries) => interpreter.files.writeFiles(entries),
|
|
338
|
+
},
|
|
339
|
+
getEndpoint: async (port) => {
|
|
340
|
+
const endpoint = await sandbox.getEndpoint(port);
|
|
341
|
+
if (!endpoint?.endpoint) throw new Error("Sandbox endpoint is not available");
|
|
342
|
+
return { endpoint: endpoint.endpoint, headers: { ...endpoint.headers ?? {} } };
|
|
343
|
+
},
|
|
344
|
+
kill: () => sandbox.kill(),
|
|
345
|
+
id: sandbox.id,
|
|
346
|
+
connection: {
|
|
347
|
+
baseUrl: sandbox.connectionConfig.getBaseUrl(),
|
|
348
|
+
apiKey: sandbox.connectionConfig.apiKey,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface SupportQaSandboxAgentPoolStatus {
|
|
354
|
+
capacity: number;
|
|
355
|
+
busy: number;
|
|
356
|
+
idle: number;
|
|
357
|
+
initializing: boolean;
|
|
358
|
+
sandboxIds: string[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface SandboxAgentRunResult {
|
|
362
|
+
response: string;
|
|
363
|
+
sandboxId: string;
|
|
364
|
+
sessionId: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export class SupportQaSandboxAgentPool {
|
|
368
|
+
private generation: PoolGeneration | null = null;
|
|
369
|
+
private starting: Promise<PoolGeneration> | null = null;
|
|
370
|
+
private rebuilding: Promise<void> | null = null;
|
|
371
|
+
private lifecycleTimer: NodeJS.Timeout | null = null;
|
|
372
|
+
private workspaceDir: string | null = null;
|
|
373
|
+
private readonly kbDigests = new WeakMap<SandboxClient, string>();
|
|
374
|
+
private readonly syncs = new WeakMap<SandboxClient, Promise<void>>();
|
|
375
|
+
|
|
376
|
+
constructor(private readonly capacity = CONCURRENT_AGENTS) {}
|
|
377
|
+
|
|
378
|
+
status(): SupportQaSandboxAgentPoolStatus {
|
|
379
|
+
const agents = this.generation?.agents ?? [];
|
|
380
|
+
const busy = agents.filter((entry) => entry.busy).length;
|
|
381
|
+
return {
|
|
382
|
+
capacity: this.capacity,
|
|
383
|
+
busy,
|
|
384
|
+
idle: agents.length - busy,
|
|
385
|
+
initializing: this.starting !== null || this.rebuilding !== null,
|
|
386
|
+
sandboxIds: agents.map((entry) => entry.sandbox.id).filter((id): id is string => Boolean(id)),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async run(
|
|
391
|
+
prompt: string,
|
|
392
|
+
localWorkDir: string,
|
|
393
|
+
log: SandboxLogFn = logDefault,
|
|
394
|
+
): Promise<SandboxAgentRunResult> {
|
|
395
|
+
const pooled = await this.acquireAgent(localWorkDir, log);
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await syncSandboxFiles(pooled.client, localWorkDir, log, this.kbDigests, this.syncs);
|
|
399
|
+
const sandboxPrompt = await uploadPromptImages(pooled.client, prompt, localWorkDir, log);
|
|
400
|
+
|
|
401
|
+
const client: AcpClient = pooled.agent.client();
|
|
402
|
+
const sessionId = await client.newAcpSession(WORKSPACE_DIR);
|
|
403
|
+
const sandboxId = pooled.sandbox.id ?? "unknown";
|
|
404
|
+
log(`沙箱:ACP Session 已创建 sandboxId=${sandboxId} sessionId=${sessionId}`);
|
|
405
|
+
const chunks: string[] = [];
|
|
406
|
+
const unsubscribe = client.addUpdateListener((update) => {
|
|
407
|
+
if (update.sessionId === sessionId) {
|
|
408
|
+
const text = extractTextFromUpdate(update);
|
|
409
|
+
if (text) chunks.push(text);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
let result;
|
|
414
|
+
try {
|
|
415
|
+
result = await client.query(sandboxPrompt, sessionId);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
const partialResponse = chunks.join("");
|
|
418
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
419
|
+
throw new Error(
|
|
420
|
+
`sandbox agent 调用失败: sandboxId=${sandboxId}, sessionId=${sessionId}, ` +
|
|
421
|
+
`responseLength=${partialResponse.length}, response=${JSON.stringify(partialResponse)}, error=${reason}`,
|
|
422
|
+
);
|
|
423
|
+
} finally {
|
|
424
|
+
unsubscribe();
|
|
425
|
+
await client.closeAcpSession(sessionId).catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let responseText = chunks.join("");
|
|
429
|
+
if (!responseText && result?.raw) {
|
|
430
|
+
const raw = result.raw as Record<string, unknown>;
|
|
431
|
+
responseText =
|
|
432
|
+
typeof raw.text === "string" ? raw.text
|
|
433
|
+
: typeof raw.output === "string" ? raw.output
|
|
434
|
+
: typeof raw.content === "string" ? raw.content
|
|
435
|
+
: "";
|
|
436
|
+
}
|
|
437
|
+
if (!responseText.trim()) {
|
|
438
|
+
throw new Error(`sandbox agent 返回空响应: sandboxId=${sandboxId}, sessionId=${sessionId}`);
|
|
439
|
+
}
|
|
440
|
+
return { response: responseText, sandboxId, sessionId };
|
|
441
|
+
} finally {
|
|
442
|
+
pooled.busy = false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async stop(log: SandboxLogFn = logDefault): Promise<void> {
|
|
447
|
+
this.clearLifecycleTimer();
|
|
448
|
+
if (this.rebuilding) await this.rebuilding.catch(() => undefined);
|
|
449
|
+
|
|
450
|
+
const generation = this.generation ?? (this.starting ? await this.starting.catch(() => null) : null);
|
|
451
|
+
this.generation = null;
|
|
452
|
+
this.starting = null;
|
|
453
|
+
this.workspaceDir = null;
|
|
454
|
+
|
|
455
|
+
if (generation) {
|
|
456
|
+
log("沙箱:停止 Agent 池并释放全部 Sandbox");
|
|
457
|
+
await this.disposeGeneration(generation, log);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private bindWorkspace(localWorkDir: string): string {
|
|
462
|
+
const resolved = path.resolve(localWorkDir);
|
|
463
|
+
if (this.workspaceDir && this.workspaceDir !== resolved) {
|
|
464
|
+
throw new Error(`Sandbox Agent 池已绑定工作目录 ${this.workspaceDir},不能切换到 ${resolved}`);
|
|
465
|
+
}
|
|
466
|
+
this.workspaceDir = resolved;
|
|
467
|
+
return resolved;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private reuseKey(localWorkDir: string): string {
|
|
471
|
+
return createHash("sha256").update(localWorkDir).digest("hex").slice(0, 16);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async cleanupExistingSandboxes(apiKey: string, localWorkDir: string, log: SandboxLogFn): Promise<void> {
|
|
475
|
+
const manager = SandboxManager.create({ connectionConfig: { apiKey } });
|
|
476
|
+
try {
|
|
477
|
+
const response = await manager.listSandboxInfos({
|
|
478
|
+
states: ["Running"],
|
|
479
|
+
metadata: { ...SANDBOX_METADATA, reuseKey: this.reuseKey(localWorkDir) },
|
|
480
|
+
pageSize: 20,
|
|
481
|
+
});
|
|
482
|
+
await Promise.all(response.items.map(async (info) => {
|
|
483
|
+
await manager.killSandbox(info.id);
|
|
484
|
+
log(`沙箱:已清理上次进程遗留的 Sandbox ${info.id}`);
|
|
485
|
+
}));
|
|
486
|
+
} finally {
|
|
487
|
+
await manager.close().catch(() => undefined);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async createAgentSandbox(
|
|
492
|
+
apiKey: string,
|
|
493
|
+
qoderConfig: QoderConfig,
|
|
494
|
+
localWorkDir: string,
|
|
495
|
+
index: number,
|
|
496
|
+
log: SandboxLogFn,
|
|
497
|
+
): Promise<PooledAgent> {
|
|
498
|
+
let sandbox: Sandbox | null = null;
|
|
499
|
+
let agent: QoderRemoteAgent | null = null;
|
|
500
|
+
try {
|
|
501
|
+
log(`沙箱:创建 Agent ${index + 1}/${this.capacity}...`);
|
|
502
|
+
sandbox = await Sandbox.create({
|
|
503
|
+
connectionConfig: { apiKey },
|
|
504
|
+
template: "code-interpreter",
|
|
505
|
+
timeoutSeconds: SANDBOX_TTL_SECONDS,
|
|
506
|
+
readyTimeoutSeconds: 120,
|
|
507
|
+
env: {
|
|
508
|
+
PYTHON_VERSION: "3.12",
|
|
509
|
+
JAVA_VERSION: "21",
|
|
510
|
+
NODE_VERSION: "22",
|
|
511
|
+
NPM_CONFIG_REGISTRY: NPM_REGISTRY,
|
|
512
|
+
},
|
|
513
|
+
metadata: {
|
|
514
|
+
...SANDBOX_METADATA,
|
|
515
|
+
reuseKey: this.reuseKey(localWorkDir),
|
|
516
|
+
slot: String(index + 1),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const client = await createSandboxClient(sandbox);
|
|
521
|
+
const qoderInstall = await client.commands.runCommand(npmInstallCommand("install -g @qoder-ai/qodercli"));
|
|
522
|
+
if ((qoderInstall.exitCode ?? 1) !== 0) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`准备 qodercli 失败: exit=${qoderInstall.exitCode}, stderr=${JSON.stringify(qoderInstall.logs?.stderr)}`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
agent = new QoderRemoteAgent(client, qoderConfig, WORKSPACE_DIR, false);
|
|
529
|
+
await agent.start();
|
|
530
|
+
log(`沙箱:Agent ${index + 1}/${this.capacity} 已就绪 sandboxId=${sandbox.id ?? "unknown"}`);
|
|
531
|
+
return { agent, sandbox, client, busy: false };
|
|
532
|
+
} catch (error) {
|
|
533
|
+
await agent?.stop().catch(() => undefined);
|
|
534
|
+
await sandbox?.kill().catch(() => undefined);
|
|
535
|
+
await sandbox?.close().catch(() => undefined);
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async createGeneration(
|
|
541
|
+
localWorkDir: string,
|
|
542
|
+
log: SandboxLogFn,
|
|
543
|
+
cleanupExisting: boolean,
|
|
544
|
+
): Promise<PoolGeneration> {
|
|
545
|
+
const apiKey = requireEnv("SANDBOX_API_KEY");
|
|
546
|
+
const pat = requireEnv("QODER_PERSONAL_ACCESS_TOKEN");
|
|
547
|
+
const qoderConfig = await buildQoderConfig(pat, localWorkDir);
|
|
548
|
+
if (cleanupExisting) await this.cleanupExistingSandboxes(apiKey, localWorkDir, log);
|
|
549
|
+
|
|
550
|
+
const settled = await Promise.allSettled(
|
|
551
|
+
Array.from({ length: this.capacity }, (_, index) =>
|
|
552
|
+
this.createAgentSandbox(apiKey, qoderConfig, localWorkDir, index, log)),
|
|
553
|
+
);
|
|
554
|
+
const agents = settled
|
|
555
|
+
.filter((result): result is PromiseFulfilledResult<PooledAgent> => result.status === "fulfilled")
|
|
556
|
+
.map((result) => result.value);
|
|
557
|
+
const failed = settled.find((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
558
|
+
if (failed) {
|
|
559
|
+
await this.disposeGeneration({ agents }, log);
|
|
560
|
+
throw failed.reason;
|
|
561
|
+
}
|
|
562
|
+
return { agents };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private async ensureGeneration(localWorkDir: string, log: SandboxLogFn): Promise<PoolGeneration> {
|
|
566
|
+
const workspace = this.bindWorkspace(localWorkDir);
|
|
567
|
+
if (this.generation) return this.generation;
|
|
568
|
+
if (this.rebuilding) {
|
|
569
|
+
await this.rebuilding;
|
|
570
|
+
if (this.generation) return this.generation;
|
|
571
|
+
}
|
|
572
|
+
if (!this.starting) {
|
|
573
|
+
const starting = this.createGeneration(workspace, log, true)
|
|
574
|
+
.then((generation) => {
|
|
575
|
+
this.generation = generation;
|
|
576
|
+
this.scheduleRebuild(log);
|
|
577
|
+
return generation;
|
|
578
|
+
})
|
|
579
|
+
.finally(() => {
|
|
580
|
+
if (this.starting === starting) this.starting = null;
|
|
581
|
+
});
|
|
582
|
+
this.starting = starting;
|
|
583
|
+
}
|
|
584
|
+
return this.starting;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async acquireAgent(localWorkDir: string, log: SandboxLogFn): Promise<PooledAgent> {
|
|
588
|
+
while (true) {
|
|
589
|
+
const generation = await this.ensureGeneration(localWorkDir, log);
|
|
590
|
+
const idle = generation.agents.find((entry) => !entry.busy);
|
|
591
|
+
if (idle) {
|
|
592
|
+
idle.busy = true;
|
|
593
|
+
return idle;
|
|
594
|
+
}
|
|
595
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private scheduleRebuild(log: SandboxLogFn): void {
|
|
600
|
+
this.clearLifecycleTimer();
|
|
601
|
+
const delayMs = Math.max(60_000, (SANDBOX_TTL_SECONDS - POOL_REBUILD_BEFORE_TTL_SECONDS) * 1000);
|
|
602
|
+
this.lifecycleTimer = setTimeout(() => {
|
|
603
|
+
log("沙箱:接近生命周期上限,排空请求后重建 Agent 池");
|
|
604
|
+
void this.rebuild(log).catch((error) => log(`沙箱:Agent 池重建失败: ${error}`));
|
|
605
|
+
}, delayMs);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private async rebuild(log: SandboxLogFn): Promise<void> {
|
|
609
|
+
if (this.rebuilding || !this.generation || !this.workspaceDir) return this.rebuilding ?? Promise.resolve();
|
|
610
|
+
const oldGeneration = this.generation;
|
|
611
|
+
const rebuilding = (async () => {
|
|
612
|
+
this.generation = null;
|
|
613
|
+
while (oldGeneration.agents.some((entry) => entry.busy)) {
|
|
614
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
615
|
+
}
|
|
616
|
+
await this.disposeGeneration(oldGeneration, log);
|
|
617
|
+
const nextGeneration = await this.createGeneration(this.workspaceDir!, log, false);
|
|
618
|
+
this.generation = nextGeneration;
|
|
619
|
+
this.scheduleRebuild(log);
|
|
620
|
+
})().finally(() => {
|
|
621
|
+
if (this.rebuilding === rebuilding) this.rebuilding = null;
|
|
622
|
+
});
|
|
623
|
+
this.rebuilding = rebuilding;
|
|
624
|
+
return rebuilding;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private clearLifecycleTimer(): void {
|
|
628
|
+
if (this.lifecycleTimer) clearTimeout(this.lifecycleTimer);
|
|
629
|
+
this.lifecycleTimer = null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async disposeGeneration(generation: PoolGeneration, log: SandboxLogFn): Promise<void> {
|
|
633
|
+
await Promise.all(generation.agents.map(async ({ agent, sandbox }) => {
|
|
634
|
+
await agent.stop().catch((error) => log(`沙箱:停止 Agent 失败(忽略): ${error}`));
|
|
635
|
+
await sandbox.kill().catch((error) => log(`沙箱:释放 Sandbox 失败(忽略): ${error}`));
|
|
636
|
+
await sandbox.close().catch((error) => log(`沙箱:关闭 Sandbox 客户端失败(忽略): ${error}`));
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const defaultSandboxAgentPool = new SupportQaSandboxAgentPool();
|
|
642
|
+
|
|
643
|
+
export function runSandboxAgent(
|
|
644
|
+
prompt: string,
|
|
645
|
+
localWorkDir: string,
|
|
646
|
+
log: SandboxLogFn = logDefault,
|
|
647
|
+
): Promise<SandboxAgentRunResult> {
|
|
648
|
+
return defaultSandboxAgentPool.run(prompt, localWorkDir, log);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function stopSandboxAgentPool(log: SandboxLogFn = logDefault): Promise<void> {
|
|
652
|
+
return defaultSandboxAgentPool.stop(log);
|
|
653
|
+
}
|
|
@@ -6,12 +6,22 @@
|
|
|
6
6
|
"description": "通用群答疑助手(由 aitable-workflow init --template support-qa 生成)。业务差异全部在 profile/profile.yml。",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"run": "aitable-workflow run",
|
|
9
|
-
"setup": "aitable-workflow setup"
|
|
9
|
+
"setup": "aitable-workflow setup",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
10
12
|
},
|
|
11
13
|
"dependencies": {
|
|
12
14
|
"aitable-workflow-core": "^0.1.5",
|
|
15
|
+
"@ali/aone-remote-agent": "^4.1.0",
|
|
16
|
+
"@ali/aone-sandbox": "^1.0.10",
|
|
17
|
+
"fflate": "^0.8.2",
|
|
13
18
|
"tsx": "^4.20.6",
|
|
14
19
|
"yaml": "^2.8.1",
|
|
15
20
|
"zod": "^4.1.13"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.10.1",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^3.0.0"
|
|
16
26
|
}
|
|
17
27
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"rootDir": "."
|
|
13
|
+
},
|
|
14
|
+
"include": ["lib/**/*.ts", "workflows/**/*.ts"],
|
|
15
|
+
"exclude": ["**/__tests__/**", "**/node_modules/**", "**/dist/**"]
|
|
16
|
+
}
|