diff --git a/packages/workflow-agent-react/__tests__/tools.test.ts b/packages/workflow-agent-react/__tests__/tools.test.ts new file mode 100644 index 0000000..6be6661 --- /dev/null +++ b/packages/workflow-agent-react/__tests__/tools.test.ts @@ -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"); + }); +}); diff --git a/packages/workflow-agent-react/src/index.ts b/packages/workflow-agent-react/src/index.ts index 5f1961f..c5ce881 100644 --- a/packages/workflow-agent-react/src/index.ts +++ b/packages/workflow-agent-react/src/index.ts @@ -1,2 +1,4 @@ export { createReactAdapter } from "./create-react-adapter.js"; export type { ReactAdapterConfig, ReactToolHandler } from "./types.js"; +export { defaultTools, defaultToolHandler } from "./tools/index.js"; +export type { ToolEntry, ToolHandler } from "./tools/index.js"; diff --git a/packages/workflow-agent-react/src/tools/defaults.ts b/packages/workflow-agent-react/src/tools/defaults.ts new file mode 100644 index 0000000..906d610 --- /dev/null +++ b/packages/workflow-agent-react/src/tools/defaults.ts @@ -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 { + const entry = ALL_TOOLS.find((t) => t.definition.function.name === name); + if (!entry) return `Unknown tool: ${name}`; + return entry.handler(args); +} diff --git a/packages/workflow-agent-react/src/tools/index.ts b/packages/workflow-agent-react/src/tools/index.ts new file mode 100644 index 0000000..6c4dfe0 --- /dev/null +++ b/packages/workflow-agent-react/src/tools/index.ts @@ -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"; diff --git a/packages/workflow-agent-react/src/tools/patch-file.ts b/packages/workflow-agent-react/src/tools/patch-file.ts new file mode 100644 index 0000000..6402a34 --- /dev/null +++ b/packages/workflow-agent-react/src/tools/patch-file.ts @@ -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 => { + 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)}`; + } + }, +}; diff --git a/packages/workflow-agent-react/src/tools/read-file.ts b/packages/workflow-agent-react/src/tools/read-file.ts new file mode 100644 index 0000000..577dbac --- /dev/null +++ b/packages/workflow-agent-react/src/tools/read-file.ts @@ -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 => { + 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)}`; + } + }, +}; diff --git a/packages/workflow-agent-react/src/tools/shell-exec.ts b/packages/workflow-agent-react/src/tools/shell-exec.ts new file mode 100644 index 0000000..fa54461 --- /dev/null +++ b/packages/workflow-agent-react/src/tools/shell-exec.ts @@ -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 => { + 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)}`; + } + }, +}; diff --git a/packages/workflow-agent-react/src/tools/types.ts b/packages/workflow-agent-react/src/tools/types.ts new file mode 100644 index 0000000..255094a --- /dev/null +++ b/packages/workflow-agent-react/src/tools/types.ts @@ -0,0 +1,8 @@ +import type { ToolDefinition } from "@uncaged/workflow-reactor"; + +export type ToolHandler = (args: string) => Promise; + +export type ToolEntry = { + definition: ToolDefinition; + handler: ToolHandler; +}; diff --git a/packages/workflow-agent-react/src/tools/write-file.ts b/packages/workflow-agent-react/src/tools/write-file.ts new file mode 100644 index 0000000..f6a2e74 --- /dev/null +++ b/packages/workflow-agent-react/src/tools/write-file.ts @@ -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 => { + 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)}`; + } + }, +};