From ec555b43d14ae586243d5499fc43fd668d8b0916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 13 May 2026 02:57:47 +0000 Subject: [PATCH] feat: add minimal tool set (read/write/patch/shell) to workflow-agent-react (#222) --- packages/workflow-agent-react/src/index.ts | 2 + .../src/tools/defaults.ts | 16 +++++++ .../workflow-agent-react/src/tools/index.ts | 6 +++ .../src/tools/patch-file.ts | 40 +++++++++++++++++ .../src/tools/read-file.ts | 35 +++++++++++++++ .../src/tools/shell-exec.ts | 45 +++++++++++++++++++ .../workflow-agent-react/src/tools/types.ts | 8 ++++ .../src/tools/write-file.ts | 32 +++++++++++++ 8 files changed, 184 insertions(+) create mode 100644 packages/workflow-agent-react/src/tools/defaults.ts create mode 100644 packages/workflow-agent-react/src/tools/index.ts create mode 100644 packages/workflow-agent-react/src/tools/patch-file.ts create mode 100644 packages/workflow-agent-react/src/tools/read-file.ts create mode 100644 packages/workflow-agent-react/src/tools/shell-exec.ts create mode 100644 packages/workflow-agent-react/src/tools/types.ts create mode 100644 packages/workflow-agent-react/src/tools/write-file.ts 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)}`; + } + }, +};