confluence_web/agent/local-tools.ts
Artur Mukhamadiev 38a8ca67f7 agent: pi runtime track handoff (contract revision 1)
Pinned pi SDK 0.85.1 bridge, Python supervisor, artifact exporter,
scripted backend peer, image checks and boundary checks under agent/**.
Review findings F1-F3 are recorded in docs/implementation/PI_AGENT_REVIEW.md.
2026-09-14 21:57:54 +03:00

57 lines
3.5 KiB
TypeScript

import fs from 'node:fs/promises';
import type { AgentTool } from '@earendil-works/pi-agent-core';
import { constants } from 'node:fs';
import { spawn } from 'node:child_process';
import { createCodingTools, type BashOperations } from '@earendil-works/pi-coding-agent';
const maxFile = 16 * 1024 * 1024;
async function readFile(name: string): Promise<Buffer> {
const file = await fs.open(name, constants.O_RDONLY | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > maxFile) throw new Error('Local read requires a regular file of at most 16 MiB; use shell ranges for large files.');
// Bound reads even if another local process grows the file concurrently.
const buffer = Buffer.alloc(stat.size + 1);
let offset = 0;
while (offset < buffer.length) { const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset); if (!bytesRead) break; offset += bytesRead; }
if (offset > stat.size) throw new Error('Local file changed while reading.');
return buffer.subarray(0, offset);
} finally { await file.close(); }
}
const operations: BashOperations = {
exec: (command, cwd, options) => new Promise((resolve, reject) => {
const child = spawn('/bin/bash', ['--noprofile', '--norc', '-c', command], { cwd, detached: true, env: { PATH: process.env.PATH, HOME: process.env.HOME, LANG: 'C.UTF-8' }, stdio: ['ignore', 'pipe', 'pipe'] });
let bytes = 0, capped = false, timedOut = false;
const kill = () => { if (child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } };
const output = (data: Buffer) => {
if (capped) return;
const remaining = 1024 * 1024 - bytes;
options.onData(data.subarray(0, remaining)); bytes += Math.min(data.length, remaining);
if (data.length > remaining) { capped = true; options.onData(Buffer.from('\n[Local shell output capped at 1 MiB.]\n')); kill(); }
};
child.stdout.on('data', output); child.stderr.on('data', output);
options.signal?.addEventListener('abort', kill, { once: true });
if (options.signal?.aborted) kill();
const timer = setTimeout(() => { timedOut = true; kill(); }, Math.min(30000, (options.timeout ?? 30) * 1000));
child.on('error', () => reject(new Error('Local shell could not start.')));
child.on('close', code => {
clearTimeout(timer); options.signal?.removeEventListener('abort', kill);
if (options.signal?.aborted) reject(new Error('aborted'));
else if (timedOut) reject(new Error(`timeout:${Math.min(30, options.timeout ?? 30)}`));
else resolve({ exitCode: capped ? 1 : code });
});
}),
};
export function localTools(work: string): AgentTool<any>[] {
const tools = createCodingTools(work, {
bash: { operations, exposeSessionEnvironment: false, shellPath: '/bin/bash' },
read: { operations: { readFile, access: p => fs.access(p, constants.R_OK), detectImageMimeType: async () => null } },
edit: { operations: { readFile, access: p => fs.access(p, constants.R_OK | constants.W_OK), writeFile: async (p, content) => {
if (Buffer.byteLength(content) > maxFile) throw new Error('Local edit result exceeds 16 MiB.');
await fs.writeFile(p, content);
} } },
});
const read = tools.find(t => t.name === 'read')!;
read.description = 'Read regular text files up to 16 MiB. Output is truncated to 2000 lines or 50 KiB; use offset/limit for continuation. Use shell ranges for larger files. Image attachments are not supported by this text/tool model adapter.';
return tools;
}