Merge pull request 'feat: minimal tool set for workflow-agent-react (#222 Phase 3)' (#229) from feat/222-tools-smoke-test-phase3 into main

This commit is contained in:
2026-05-13 03:16:37 +00:00
9 changed files with 285 additions and 0 deletions
@@ -0,0 +1,101 @@
import { describe, test, expect, afterAll } from "bun:test";
import { readFileTool, writeFileTool, patchFileTool, shellExecTool } from "../src/tools/index.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
import { randomBytes } from "node:crypto";
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
mkdirSync(TMP_DIR, { recursive: true });
const tmpFile = (name: string) => join(TMP_DIR, name);
const cleanupFiles: string[] = [];
afterAll(() => {
for (const f of cleanupFiles) {
try { unlinkSync(f); } catch { /* ignore */ }
}
try { unlinkSync(TMP_DIR); } catch { /* ignore */ }
});
describe("read_file", () => {
test("reads file with line numbers", async () => {
const p = tmpFile("read-test.txt");
cleanupFiles.push(p);
const content = "line1\nline2\nline3\n";
require("node:fs").writeFileSync(p, content);
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: null, limit: null }));
expect(result).toContain("1|line1");
expect(result).toContain("2|line2");
expect(result).toContain("3|line3");
});
test("reads with offset and limit", async () => {
const p = tmpFile("read-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "a\nb\nc\nd\ne\n");
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: 2, limit: 2 }));
expect(result).toBe("2|b\n3|c");
});
test("returns error for missing file", async () => {
const result = await readFileTool.handler(JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }));
expect(result).toContain("Error:");
});
});
describe("write_file", () => {
test("writes file and creates dirs", async () => {
const p = tmpFile("sub/write-test.txt");
cleanupFiles.push(p);
const result = await writeFileTool.handler(JSON.stringify({ path: p, content: "hello world" }));
expect(result).toContain("11 bytes");
expect(readFileSync(p, "utf-8")).toBe("hello world");
});
});
describe("patch_file", () => {
test("patches file content", async () => {
const p = tmpFile("patch-test.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo bar baz");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }));
expect(result).toContain("Successfully");
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
});
test("errors on not found", async () => {
const p = tmpFile("patch-test2.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }));
expect(result).toContain("not found");
});
test("errors on non-unique match", async () => {
const p = tmpFile("patch-test3.txt");
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "aaa bbb aaa");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }));
expect(result).toContain("not unique");
});
});
describe("shell_exec", () => {
test("runs echo", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "echo hello", timeout: null }));
expect(result.trim()).toBe("hello");
});
test("handles timeout", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "sleep 10", timeout: 1 }));
expect(result).toContain("timed out");
});
});
@@ -1,2 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js"; export { createReactAdapter } from "./create-react-adapter.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js"; export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
export { defaultTools, defaultToolHandler } from "./tools/index.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
@@ -0,0 +1,16 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
import type { ToolEntry } from "./types.js";
import { readFileTool } from "./read-file.js";
import { writeFileTool } from "./write-file.js";
import { patchFileTool } from "./patch-file.js";
import { shellExecTool } from "./shell-exec.js";
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
export const defaultTools: readonly ToolDefinition[] = ALL_TOOLS.map((t) => t.definition);
export async function defaultToolHandler(name: string, args: string): Promise<string> {
const entry = ALL_TOOLS.find((t) => t.definition.function.name === name);
if (!entry) return `Unknown tool: ${name}`;
return entry.handler(args);
}
@@ -0,0 +1,6 @@
export { readFileTool } from "./read-file.js";
export { writeFileTool } from "./write-file.js";
export { patchFileTool } from "./patch-file.js";
export { shellExecTool } from "./shell-exec.js";
export { defaultTools, defaultToolHandler } from "./defaults.js";
export type { ToolEntry, ToolHandler } from "./types.js";
@@ -0,0 +1,40 @@
import { readFile, writeFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const patchFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "patch_file",
description: "Find and replace a string in a file (first occurrence only).",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
old_string: { type: "string", description: "Text to find" },
new_string: { type: "string", description: "Replacement text" },
},
required: ["path", "old_string", "new_string"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; old_string: string; new_string: string };
const content = await readFile(parsed.path, "utf-8");
const firstIdx = content.indexOf(parsed.old_string);
if (firstIdx === -1) {
return `Error: old_string not found in ${parsed.path}`;
}
const secondIdx = content.indexOf(parsed.old_string, firstIdx + 1);
if (secondIdx !== -1) {
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
}
const updated = content.slice(0, firstIdx) + parsed.new_string + content.slice(firstIdx + parsed.old_string.length);
await writeFile(parsed.path, updated);
return `Successfully patched ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import type { ToolEntry } from "./types.js";
export const readFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "read_file",
description: "Read a text file and return lines with line numbers.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file to read" },
offset: { type: ["number", "null"], description: "Start line number (1-indexed, default: 1)" },
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
},
required: ["path"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; offset: number | null; limit: number | null };
const content = await readFile(parsed.path, "utf-8");
const allLines = content.split("\n");
const offset = parsed.offset ?? 1;
const start = Math.max(0, offset - 1);
const end = parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
const lines = allLines.slice(start, end);
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,45 @@
import { execSync } from "node:child_process";
import type { ToolEntry } from "./types.js";
const MAX_OUTPUT = 10000;
export const shellExecTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "shell_exec",
description: "Execute a shell command and return stdout + stderr.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "Shell command to run" },
timeout: { type: ["number", "null"], description: "Timeout in seconds (default: 30)" },
},
required: ["command"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { command: string; timeout: number | null };
const timeoutMs = (parsed.timeout ?? 30) * 1000;
const output = execSync(parsed.command, {
encoding: "utf-8",
timeout: timeoutMs,
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: MAX_OUTPUT * 2,
});
return output.length > MAX_OUTPUT ? `${output.slice(0, MAX_OUTPUT)}\n...(truncated)` : output;
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: unknown }).status === null) {
return "Error: command timed out";
}
if (err && typeof err === "object" && "stderr" in err) {
const e = err as { stderr: string; stdout: string; status: number };
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
return combined.length > MAX_OUTPUT ? `${combined.slice(0, MAX_OUTPUT)}\n...(truncated)` : combined || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};
@@ -0,0 +1,8 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
export type ToolHandler = (args: string) => Promise<string>;
export type ToolEntry = {
definition: ToolDefinition;
handler: ToolHandler;
};
@@ -0,0 +1,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { ToolEntry } from "./types.js";
export const writeFileTool: ToolEntry = {
definition: {
type: "function",
function: {
name: "write_file",
description: "Write content to a file, creating parent directories as needed.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path to write" },
content: { type: "string", description: "File content" },
},
required: ["path", "content"],
},
},
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; content: string };
await mkdir(dirname(parsed.path), { recursive: true });
const buf = Buffer.from(parsed.content, "utf-8");
await writeFile(parsed.path, buf);
return `Successfully wrote ${buf.length} bytes to ${parsed.path}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
},
};