first commit
This commit is contained in:
176
async-agent-lab/live/tools.ts
Normal file
176
async-agent-lab/live/tools.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { link, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { ToolSpec } from '../shared/types.ts';
|
||||
import { aiwork, childEnvironment, within } from './evidence.ts';
|
||||
|
||||
const evidenceScript = fileURLToPath(new URL('./evidence.ts', import.meta.url));
|
||||
const allowedFile = /\.(?:md|txt|html|ts|tsx|js|mjs|css|py|rs|go|java|kt|swift|vue)$/i;
|
||||
const excludedFile = /(?:^|[/_.-])(?:secret[s]?|token[s]?|credential[s]?|private[_-]?key|id_rsa|id_ed25519)(?:$|[/_.-])/i;
|
||||
const bufferLimit = 160_000;
|
||||
|
||||
export type Args = Record<string, unknown>;
|
||||
export type ProcessResult = { exit_code: number | null; stdout: string; stderr: string };
|
||||
export type Evidence = {
|
||||
window: { from: string; through: string };
|
||||
sources: { source: string; status: string; sessions: number }[];
|
||||
repositories: {
|
||||
path: string; worktrees: string[];
|
||||
commits: { sha: string; subject: string }[];
|
||||
artifacts: { cwd: string; path: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export function spec(name: string, description: string, properties: Record<string, unknown>, required: string[]): ToolSpec {
|
||||
return { type: 'function', function: { name, description, parameters: {
|
||||
type: 'object', properties, required, additionalProperties: false,
|
||||
} } };
|
||||
}
|
||||
|
||||
const string = { type: 'string' };
|
||||
export const readSpec = spec('read', '读取证据仓库或本次运行目录中的文本行。', {
|
||||
path: string, start_line: { type: 'integer' }, end_line: { type: 'integer' },
|
||||
}, ['path']);
|
||||
|
||||
export function baseTools(background = false) {
|
||||
return [
|
||||
spec('grep', '在已定位的证据仓库中搜索固定字符串,返回路径和行号。', { pattern: string, path: string }, ['pattern', 'path']),
|
||||
readSpec,
|
||||
spec('write', '在本次运行目录生成 weekly-report.md。', { path: string, content: string }, ['path', 'content']),
|
||||
spec('bash', '执行只读取证命令 weekly-evidence、git log 或 git show <sha>。git 命令通过 cwd 指定仓库绝对路径。', {
|
||||
command: string,
|
||||
cwd: string,
|
||||
...(background ? { run_in_background: { type: 'boolean', description: '在后台执行 weekly-evidence。' } } : {}),
|
||||
}, ['command']),
|
||||
];
|
||||
}
|
||||
|
||||
export function runCommand(file: string, args: string[], cwd: string, signal: AbortSignal): Promise<ProcessResult> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
// Shell receives argv verbatim. Evidence text and model arguments cannot expand as Bash source.
|
||||
const child = spawn('/bin/bash', ['-c', 'exec "$@"', 'bash', file, ...args], {
|
||||
cwd, env: childEnvironment(), signal, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => child.kill('SIGTERM'), 20_000);
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
if (stdout.length < bufferLimit) stdout += chunk;
|
||||
});
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
if (stderr.length < bufferLimit) stderr += chunk;
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('close', (exit_code, reason) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({
|
||||
exit_code,
|
||||
stdout: redact(stdout.slice(0, bufferLimit)).trimEnd(),
|
||||
stderr: reason ? `进程结束:${reason}` : redact(stderr.slice(0, bufferLimit)).trimEnd(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function redact(text: string) {
|
||||
const key = process.env.EASTMONEY_API_KEY;
|
||||
return key ? text.replaceAll(key, '[已隐藏]') : text;
|
||||
}
|
||||
|
||||
export class Tools {
|
||||
readonly workspace: string;
|
||||
readonly signal: AbortSignal;
|
||||
private roots = new Set<string>();
|
||||
evidence?: Evidence;
|
||||
|
||||
constructor(workspace: string, signal: AbortSignal) {
|
||||
this.workspace = workspace;
|
||||
this.signal = signal;
|
||||
}
|
||||
|
||||
async bash(args: Args) {
|
||||
const command = requireString(args, 'command').trim();
|
||||
if (command === 'weekly-evidence') {
|
||||
const result = await runCommand(process.execPath, [evidenceScript], this.workspace, this.signal);
|
||||
if (result.exit_code === 0) {
|
||||
const evidence = JSON.parse(result.stdout) as Evidence;
|
||||
this.evidence = evidence;
|
||||
for (const repo of evidence.repositories) {
|
||||
for (const root of repo.worktrees) this.roots.add(root);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const show = /^git show ([0-9a-f]{7,64})$/.exec(command);
|
||||
if (command !== 'git log' && !show) throw new Error('支持 weekly-evidence、git log、git show <sha>。');
|
||||
const cwd = await realpath(typeof args.cwd === 'string' ? args.cwd : aiwork);
|
||||
const configuredAiwork = await realpath(aiwork).catch(() => aiwork);
|
||||
if (cwd !== configuredAiwork && !this.roots.has(cwd)) throw new Error('请使用 AiWork 或取证发现的仓库绝对路径。');
|
||||
if (show) {
|
||||
return runCommand('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', cwd,
|
||||
'show', '--no-ext-diff', '--no-textconv', '--format=fuller', '--stat', show[1], '--'], this.workspace, this.signal);
|
||||
}
|
||||
return runCommand(process.execPath, [evidenceScript, cwd], this.workspace, this.signal);
|
||||
}
|
||||
|
||||
async read(args: Args) {
|
||||
const path = await this.evidencePath(requireString(args, 'path'), true);
|
||||
const info = await stat(path);
|
||||
if (!info.isFile() || info.size > 350_000) throw new Error('请选择 350 KB 以内的文本文件。');
|
||||
const start = Math.max(1, Math.floor(Number(args.start_line) || 1));
|
||||
const end = Math.max(start, Math.min(Math.floor(Number(args.end_line) || start + 23), start + 49));
|
||||
const lines = redact(await readFile(path, 'utf8')).split(/\r?\n/);
|
||||
return lines.slice(start - 1, end).map((line, offset) => `${start + offset} ${line.slice(0, 1000)}`).join('\n');
|
||||
}
|
||||
|
||||
async grep(args: Args) {
|
||||
const path = await this.evidencePath(requireString(args, 'path'), false);
|
||||
const pattern = requireString(args, 'pattern');
|
||||
if (!pattern.trim() || pattern.length > 240 || pattern.includes('\n')) throw new Error('请输入一行搜索词。');
|
||||
const result = await runCommand('rg', ['--no-config', '--color=never', '-n', '-F', '--max-count', '8',
|
||||
'--max-columns', '360', '--max-filesize', '350K',
|
||||
'-g', '*.{md,txt,html,ts,tsx,js,mjs,css,py,rs,go,java,kt,swift,vue}',
|
||||
'-g', '!**/{node_modules,dist,vendor}/**', '-g', '!**/*{secret,token,credential,private-key}*',
|
||||
'--', pattern, path], this.workspace, this.signal);
|
||||
if (result.exit_code === 1) return '没有匹配。';
|
||||
if (result.exit_code !== 0) throw new Error(result.stderr || 'grep 执行失败。');
|
||||
return result.stdout.split('\n').slice(0, 35).join('\n');
|
||||
}
|
||||
|
||||
async write(args: Args, expected?: string) {
|
||||
const path = requireString(args, 'path');
|
||||
const content = requireString(args, 'content');
|
||||
if (path !== 'weekly-report.md') throw new Error('请写入 weekly-report.md。');
|
||||
if (expected === undefined || content.trim() !== expected.trim()) throw new Error('请逐字写入已确认的周报正文。');
|
||||
const target = join(this.workspace, path);
|
||||
const draft = join(this.workspace, '.weekly-report.tmp');
|
||||
try {
|
||||
await writeFile(draft, expected, { flag: 'wx', mode: 0o600, signal: this.signal });
|
||||
this.signal.throwIfAborted();
|
||||
await link(draft, target);
|
||||
} finally {
|
||||
await rm(draft, { force: true });
|
||||
}
|
||||
return { path: target, markdown: expected };
|
||||
}
|
||||
|
||||
private async evidencePath(value: string, file: boolean) {
|
||||
const given = isAbsolute(value) ? value : resolve(this.workspace, value);
|
||||
const path = await realpath(given);
|
||||
const root = [...this.roots, this.workspace].find((candidate) => within(candidate, path) && within(candidate, given));
|
||||
if (!root) throw new Error('请使用本次运行目录或已发现的证据仓库路径。');
|
||||
for (const candidate of [given, path]) {
|
||||
const rel = relative(root, candidate);
|
||||
if (rel.split('/').some((part) => part.startsWith('.') || ['node_modules', 'vendor', 'dist'].includes(part)) ||
|
||||
excludedFile.test(rel)) throw new Error('该路径不在取证范围内。');
|
||||
}
|
||||
if ((file || (await stat(path)).isFile()) && !allowedFile.test(path)) throw new Error('请指定可读文本文件。');
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireString(args: Args, key: string) {
|
||||
if (typeof args[key] !== 'string') throw new Error(`${key} 必须是字符串。`);
|
||||
return args[key];
|
||||
}
|
||||
Reference in New Issue
Block a user