chore: reorganize repo — legacy packages to legacy-packages/, templates to examples/
- Move 15 old workflow-* packages to legacy-packages/ (inactive, preserved for reference)
- Rename templates/ → examples/ for clarity
- Rewrite docs/architecture.md to reflect current uwf architecture
- Active packages remain in packages/: cli-uwf, uwf-agent-hermes, uwf-agent-kit, uwf-moderator, uwf-protocol, workflow-util
小橘 🍊(NEKO Team)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# @uncaged/workflow-agent-react
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ok, START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
|
||||
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createReactAdapter } from "../src/create-react-adapter.js";
|
||||
import type { ReactAdapterConfig } from "../src/types.js";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeThread(prompt: string): ThreadContext {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const STUB_RUNTIME: WorkflowRuntime = {
|
||||
cas: {
|
||||
put: async (_content: string) => "STUBHASH",
|
||||
get: async (_hash: string) => null,
|
||||
delete: async (_hash: string) => {},
|
||||
list: async () => [],
|
||||
},
|
||||
extract: async (_schema, _contentHash) => ({
|
||||
meta: {},
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const TEST_SCHEMA = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
score: z.number(),
|
||||
})
|
||||
.meta({ title: "resolve", description: "Submit the final result." });
|
||||
|
||||
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
|
||||
const message: Record<string, unknown> = { role: "assistant" };
|
||||
if (content !== null) {
|
||||
message.content = content;
|
||||
}
|
||||
if (toolCalls !== null) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
return JSON.stringify({ choices: [{ message }] });
|
||||
}
|
||||
|
||||
function makeToolCallResponse(name: string, args: Record<string, unknown>, id: string): string {
|
||||
return makeChatResponse(null, [
|
||||
{
|
||||
id,
|
||||
type: "function",
|
||||
function: { name, arguments: JSON.stringify(args) },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("createReactAdapter", () => {
|
||||
test("direct resolve: LLM immediately calls resolve tool with valid args", async () => {
|
||||
const llm: LlmFn = async (_input) => {
|
||||
return ok(makeToolCallResponse("resolve", { summary: "done", score: 42 }, "call_1"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "done", score: 42 });
|
||||
expect(result.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("tool call then resolve: LLM calls user tool first, then resolves", async () => {
|
||||
let callCount = 0;
|
||||
const llm: LlmFn = async (_input) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return ok(makeToolCallResponse("search", { query: "test" }, "call_1"));
|
||||
}
|
||||
return ok(makeToolCallResponse("resolve", { summary: "found it", score: 99 }, "call_2"));
|
||||
};
|
||||
|
||||
const searchTool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
description: "Search for information",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const toolResults: string[] = [];
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [searchTool],
|
||||
toolHandler: async (name, args) => {
|
||||
toolResults.push(`${name}:${args}`);
|
||||
return "search result: found the answer";
|
||||
},
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "found it", score: 99 });
|
||||
expect(toolResults).toHaveLength(1);
|
||||
expect(toolResults[0]).toContain("search:");
|
||||
});
|
||||
|
||||
test("plain JSON response accepted", async () => {
|
||||
const llm: LlmFn = async (_input) => {
|
||||
return ok(makeChatResponse(JSON.stringify({ summary: "plain", score: 7 }), null));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "plain", score: 7 });
|
||||
});
|
||||
|
||||
test("schema validation failure + retry: invalid args then valid args", async () => {
|
||||
let callCount = 0;
|
||||
const llm: LlmFn = async (_input) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
// Invalid: score should be number, not string
|
||||
return ok(
|
||||
makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"),
|
||||
);
|
||||
}
|
||||
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "fixed", score: 10 });
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
test("max rounds exceeded: throws error", async () => {
|
||||
const searchTool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const llm: LlmFn = async (_input) => {
|
||||
// Always call search, never resolve
|
||||
return ok(makeToolCallResponse("search", {}, "call_n"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [searchTool],
|
||||
toolHandler: async () => "still searching...",
|
||||
maxRounds: 3,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
|
||||
await expect(roleFn(makeThread("test task"), STUB_RUNTIME)).rejects.toThrow(
|
||||
"max_react_rounds_exceeded",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { patchFileTool, readFileTool, shellExecTool, writeFileTool } from "../src/tools/index.js";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-react",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import { buildThreadInput } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { ReactAdapterConfig } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function readToolName(parametersSchema: Record<string, unknown>): string {
|
||||
const title = parametersSchema.title;
|
||||
if (typeof title === "string" && title.trim().length > 0) {
|
||||
return title.trim();
|
||||
}
|
||||
return "resolve";
|
||||
}
|
||||
|
||||
function readToolDescription(parametersSchema: Record<string, unknown>): string {
|
||||
const d = parametersSchema.description;
|
||||
if (typeof d === "string" && d.trim().length > 0) {
|
||||
return d.trim();
|
||||
}
|
||||
return "Submit the final structured result.";
|
||||
}
|
||||
|
||||
export function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: config.llm,
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => {
|
||||
const rawJsonSchema = z.toJSONSchema(s) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
const name = readToolName(parameters);
|
||||
return {
|
||||
name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name,
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (_name) => prompt,
|
||||
toolHandler: async (call, _thread) => {
|
||||
return config.toolHandler(call.function.name, call.function.arguments);
|
||||
},
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const input = await buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return { meta: result.value, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import { patchFileTool } from "./patch-file.js";
|
||||
import { readFileTool } from "./read-file.js";
|
||||
import { shellExecTool } from "./shell-exec.js";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
import { writeFileTool } from "./write-file.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 { defaultToolHandler, defaultTools } from "./defaults.js";
|
||||
export { patchFileTool } from "./patch-file.js";
|
||||
export { readFileTool } from "./read-file.js";
|
||||
export { shellExecTool } from "./shell-exec.js";
|
||||
export type { ToolEntry, ToolHandler } from "./types.js";
|
||||
export { writeFileTool } from "./write-file.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
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,43 @@
|
||||
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,58 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
const MAX_OUTPUT = 10000;
|
||||
|
||||
function truncate(text: string): string {
|
||||
return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n...(truncated)` : text;
|
||||
}
|
||||
|
||||
function classifyExecError(err: unknown): string {
|
||||
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 truncate(combined) || `Error: command exited with status ${e.status}`;
|
||||
}
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
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 truncate(output);
|
||||
} catch (err: unknown) {
|
||||
return classifyExecError(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)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
export type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
export type ReactAdapterConfig = {
|
||||
llm: LlmFn;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user