feat: add @uncaged/workflow-agent-builtin package

Built-in role agent that uses workflow config models directly,
with its own tool-calling run loop. No external agent dependency.

- OpenAI-compatible chat completion client with tool_calls support
- P0 toolkit: read_file, write_file, run_command
- Integrates via createAgent factory from workflow-agent-kit
- CAS detail recording for each turn
- Path sandboxing and shell opt-in (UWF_BUILTIN_ALLOW_SHELL)
This commit is contained in:
2026-05-23 15:28:40 +08:00
parent 10642fdc45
commit deac2336b6
25 changed files with 1842 additions and 0 deletions
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import type { LlmToolCall } from "../src/llm/types.js";
/** Mirror OpenAI response shape for parser coverage via chatCompletionWithTools integration later. */
describe("LlmToolCall shape", () => {
test("tool call record fields", () => {
const call: LlmToolCall = {
id: "call_1",
name: "read_file",
arguments: '{"path":"README.md"}',
};
expect(call.name).toBe("read_file");
expect(JSON.parse(call.arguments)).toEqual({ path: "README.md" });
});
});
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { resolvePathInWorkspace } from "../src/tools/path.js";
describe("resolvePathInWorkspace", () => {
const root = join("/tmp", "uwf-workspace");
test("resolves relative paths inside root", () => {
const resolved = resolvePathInWorkspace(root, "src/foo.ts");
expect(resolved).toBe(join(root, "src/foo.ts"));
});
test("rejects parent traversal", () => {
expect(resolvePathInWorkspace(root, "../etc/passwd")).toBeNull();
});
});
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import { buildBuiltinPrompt } from "../src/prompt.js";
function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "00000000000000000000000000" as AgentContext["threadId"],
role: "developer",
store: {} as AgentContext["store"],
workflow: {
name: "test",
roles: {
developer: {
goal: "Ship the fix",
capabilities: ["file-edit"],
procedure: ["Edit files"],
output: "A patch",
frontmatter: "schema-hash",
},
},
conditions: {},
graph: {},
},
start: { workflow: "wf-hash", prompt: "Fix the bug" },
steps: [],
outputFormatInstruction: "---\nstatus: done\n---",
...overrides,
};
}
describe("buildBuiltinPrompt", () => {
test("includes output format, task, and role goal", () => {
const prompt = buildBuiltinPrompt(minimalContext());
expect(prompt).toContain("status: done");
expect(prompt).toContain("## Goal");
expect(prompt).toContain("Ship the fix");
expect(prompt).toContain("## Task");
expect(prompt).toContain("Fix the bug");
});
test("includes history when steps exist", () => {
const prompt = buildBuiltinPrompt(
minimalContext({
steps: [
{
role: "planner",
output: { plan: "step 1" },
agent: "uwf-builtin",
detail: "detail-hash",
},
],
}),
);
expect(prompt).toContain("## Previous Steps");
expect(prompt).toContain("planner");
});
});
@@ -0,0 +1,34 @@
{
"name": "@uncaged/workflow-agent-builtin",
"version": "0.5.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-builtin": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,123 @@
import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
createAgent,
loadWorkflowConfig,
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-agent-kit";
import { generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
import { buildBuiltinPrompt } from "./prompt.js";
import type { BuiltinSessionState } from "./types.js";
const sessions = new Map<string, BuiltinSessionState>();
function getSession(sessionId: string): BuiltinSessionState {
const session = sessions.get(sessionId);
if (session === undefined) {
throw new Error(`builtin session not found: ${sessionId}`);
}
return session;
}
function buildToolContext(storageRoot: string): { cwd: string; storageRoot: string } {
return {
cwd: process.cwd(),
storageRoot,
};
}
async function runBuiltinWithMessages(
storageRoot: string,
provider: ReturnType<typeof resolveModel>,
messages: ChatMessage[],
session: BuiltinSessionState,
store: Store,
maxTurns: number,
): Promise<AgentRunResult> {
const loopResult = await runBuiltinLoop({
provider,
messages,
toolCtx: buildToolContext(storageRoot),
maxTurns,
existingTurns: session.turns,
});
session.messages = loopResult.messages;
session.turns = loopResult.turns;
const { detailHash, output } = await storeBuiltinDetail(
store,
session.sessionId,
session.model,
session.startedAtMs,
session.turns,
);
const finalOutput = output !== "" ? output : loopResult.finalText;
return { output: finalOutput, detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
const storageRoot = resolveStorageRoot();
const config = await loadWorkflowConfig(storageRoot);
const provider = resolveModel(config, config.defaultModel);
const sessionId = generateUlid(Date.now());
const systemPrompt = buildBuiltinPrompt(ctx);
const messages: ChatMessage[] = [{ role: "system", content: systemPrompt }];
const session: BuiltinSessionState = {
sessionId,
model: provider.model,
startedAtMs: Date.now(),
messages,
turns: [],
};
sessions.set(sessionId, session);
return runBuiltinWithMessages(
storageRoot,
provider,
messages,
session,
ctx.store,
BUILTIN_MAX_TURNS,
);
}
async function continueBuiltin(
sessionId: string,
message: string,
store: Store,
): Promise<AgentRunResult> {
const session = getSession(sessionId);
const storageRoot = resolveStorageRoot();
const config = await loadWorkflowConfig(storageRoot);
const provider = resolveModel(config, config.defaultModel);
const messages: ChatMessage[] = [...session.messages, { role: "user", content: message }];
return runBuiltinWithMessages(
storageRoot,
provider,
messages,
session,
store,
BUILTIN_CONTINUE_MAX_TURNS,
);
}
/** Agent CLI factory: built-in LLM loop with file/shell tools. */
export function createBuiltinAgent(): () => Promise<void> {
return createAgent({
name: "builtin",
run: runBuiltin,
continue: continueBuiltin,
});
}
@@ -0,0 +1,6 @@
#!/usr/bin/env bun
import { createBuiltinAgent } from "./agent.js";
const main = createBuiltinAgent();
void main();
@@ -0,0 +1,115 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
import type {
BuiltinDetailPayload,
BuiltinLoopTurn,
BuiltinToolCall,
BuiltinTurnPayload,
BuiltinTurnRole,
} from "./types.js";
function mapToolCalls(calls: NonNullable<BuiltinLoopTurn["toolCalls"]>): BuiltinToolCall[] {
return calls.map((call) => ({
name: call.name,
args: call.args,
}));
}
function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload {
return {
index,
role: "assistant",
content: turn.assistantContent ?? "",
toolCalls:
turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null,
reasoning: null,
};
}
function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] {
if (turn.toolResults === null || turn.toolResults.length === 0) {
return [];
}
const payloads: BuiltinTurnPayload[] = [];
let index = startIndex;
for (const result of turn.toolResults) {
payloads.push({
index,
role: "tool" as BuiltinTurnRole,
content: result.content,
toolCalls: null,
reasoning: null,
});
index += 1;
}
return payloads;
}
/** Last assistant message with non-empty text. */
export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string {
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i];
if (turn === undefined) {
continue;
}
const text = turn.assistantContent;
if (text !== null && text.trim() !== "") {
return text;
}
}
return "";
}
type BuiltinSchemaHashes = {
turn: string;
detail: string;
};
async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, BUILTIN_TURN_SCHEMA),
putSchema(store, BUILTIN_DETAIL_SCHEMA),
]);
return { turn, detail };
}
export async function storeBuiltinDetail(
store: Store,
sessionId: string,
model: string,
startedAtMs: number,
turns: BuiltinLoopTurn[],
nowMs: number = Date.now(),
): Promise<{ detailHash: string; output: string }> {
const schemas = await registerBuiltinSchemas(store);
const turnHashes: string[] = [];
let turnIndex = 0;
for (const loopTurn of turns) {
const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex);
const assistantHash = await store.put(schemas.turn, assistant);
turnHashes.push(assistantHash);
turnIndex += 1;
const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex);
for (const toolPayload of toolPayloads) {
const toolHash = await store.put(schemas.turn, toolPayload);
turnHashes.push(toolHash);
turnIndex += 1;
}
}
const duration = Math.max(0, nowMs - startedAtMs);
const detail: BuiltinDetailPayload = {
sessionId,
model,
duration,
turnCount: turnHashes.length,
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
const output = extractFinalAssistantText(turns);
return { detailHash, output };
}
@@ -0,0 +1,14 @@
export { createBuiltinAgent } from "./agent.js";
export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js";
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
export { chatCompletionWithTools } from "./llm/index.js";
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
export { buildBuiltinPrompt } from "./prompt.js";
export type { BuiltinTool, ToolContext } from "./tools/index.js";
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
export type {
BuiltinDetailPayload,
BuiltinLoopTurn,
BuiltinSessionState,
BuiltinTurnPayload,
} from "./types.js";
@@ -0,0 +1,7 @@
export { chatCompletionWithTools } from "./llm.js";
export type {
ChatMessage,
LlmAssistantResponse,
LlmToolCall,
OpenAiToolDefinition,
} from "./types.js";
@@ -0,0 +1,135 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import type {
ChatMessage,
LlmAssistantResponse,
LlmToolCall,
OpenAiToolDefinition,
} from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function parseToolCalls(raw: unknown): LlmToolCall[] | null {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: LlmToolCall[] = [];
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
const id = entry.id;
const fn = entry.function;
if (typeof id !== "string" || !isRecord(fn)) {
continue;
}
const name = fn.name;
const args = fn.arguments;
if (typeof name !== "string" || typeof args !== "string") {
continue;
}
calls.push({ id, name, arguments: args });
}
return calls.length > 0 ? calls : null;
}
function parseAssistantMessage(parsed: unknown): LlmAssistantResponse {
if (!isRecord(parsed)) {
throw new Error("LLM response is not an object");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new Error("LLM response has no choices");
}
const c0 = choices[0];
if (!isRecord(c0)) {
throw new Error("LLM choice is not an object");
}
const messageObj = c0.message;
if (!isRecord(messageObj)) {
throw new Error("LLM message is not an object");
}
const contentRaw = messageObj.content;
const content =
typeof contentRaw === "string"
? contentRaw
: contentRaw === null || contentRaw === undefined
? null
: null;
const toolCalls = parseToolCalls(messageObj.tool_calls);
return { content, toolCalls };
}
function serializeMessage(message: ChatMessage): Record<string, unknown> {
if (message.role === "tool") {
return {
role: "tool",
tool_call_id: message.tool_call_id,
content: message.content,
};
}
if (message.role === "assistant") {
const base: Record<string, unknown> = {
role: "assistant",
content: message.content,
};
if (message.tool_calls !== null && message.tool_calls.length > 0) {
base.tool_calls = message.tool_calls.map((call) => ({
id: call.id,
type: "function",
function: { name: call.name, arguments: call.arguments },
}));
}
return base;
}
return { role: message.role, content: message.content };
}
/** OpenAI-compatible chat completion with tool calling (non-streaming). */
export async function chatCompletionWithTools(
provider: ResolvedLlmProvider,
messages: ChatMessage[],
tools: OpenAiToolDefinition[],
): Promise<LlmAssistantResponse> {
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: messages.map(serializeMessage),
tools,
tool_choice: "auto",
}),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM network error: ${message}`);
}
const responseText = await response.text();
if (!response.ok) {
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM invalid JSON response: ${message}`);
}
return parseAssistantMessage(parsed);
}
@@ -0,0 +1,29 @@
export type LlmToolCall = {
id: string;
name: string;
arguments: string;
};
export type LlmAssistantResponse = {
content: string | null;
toolCalls: LlmToolCall[] | null;
};
export type ChatMessage =
| { role: "system"; content: string }
| { role: "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls: LlmToolCall[] | null;
}
| { role: "tool"; tool_call_id: string; content: string };
export type OpenAiToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
+110
View File
@@ -0,0 +1,110 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
import {
builtinToolsToOpenAi,
executeBuiltinTool,
getBuiltinTools,
type ToolContext,
} from "./tools/index.js";
import type { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
export const BUILTIN_MAX_TURNS = 30;
export const BUILTIN_CONTINUE_MAX_TURNS = 5;
export type RunBuiltinLoopOptions = {
provider: ResolvedLlmProvider;
messages: ChatMessage[];
toolCtx: ToolContext;
maxTurns: number;
existingTurns: BuiltinLoopTurn[];
};
export type RunBuiltinLoopResult = {
finalText: string;
messages: ChatMessage[];
turns: BuiltinLoopTurn[];
};
function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] {
return calls.map((call) => ({
id: call.id,
name: call.name,
args: call.arguments,
}));
}
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
export async function runBuiltinLoop(
options: RunBuiltinLoopOptions,
): Promise<RunBuiltinLoopResult> {
const messages = [...options.messages];
const turns = [...options.existingTurns];
const openAiTools = builtinToolsToOpenAi(getBuiltinTools());
let finalText = "";
for (let turn = 0; turn < options.maxTurns; turn++) {
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
const response = await chatCompletionWithTools(options.provider, messages, openAiTools);
const assistantMessage: ChatMessage = {
role: "assistant",
content: response.content,
tool_calls: response.toolCalls,
};
messages.push(assistantMessage);
if (response.toolCalls === null || response.toolCalls.length === 0) {
finalText = response.content ?? "";
turns.push({
assistantContent: response.content,
toolCalls: null,
toolResults: null,
});
break;
}
const toolCallRecords = mapToolCalls(response.toolCalls);
const toolResults: BuiltinToolResultRecord[] = [];
for (const call of response.toolCalls) {
const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx);
toolResults.push({
toolCallId: call.id,
name: call.name,
content: result,
});
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
turns.push({
assistantContent: response.content,
toolCalls: toolCallRecords,
toolResults,
});
}
if (finalText === "" && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (
msg !== undefined &&
msg.role === "assistant" &&
msg.content !== null &&
msg.content.trim() !== ""
) {
finalText = msg.content;
break;
}
}
}
return { finalText, messages, turns };
}
@@ -0,0 +1,36 @@
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
}
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
/** Assemble output format, role prompt, task, and history (aligned with buildHermesPrompt). */
export function buildBuiltinPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
@@ -0,0 +1,46 @@
import type { JSONSchema } from "@uncaged/json-cas";
const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
};
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
title: "builtin-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [{ type: "array", items: BUILTIN_TOOL_CALL_SCHEMA }, { type: "null" }],
},
reasoning: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
export const BUILTIN_DETAIL_SCHEMA: JSONSchema = {
title: "builtin-detail",
type: "object",
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
duration: { type: "integer" },
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
@@ -0,0 +1,44 @@
import type { OpenAiToolDefinition } from "../llm/index.js";
import { readFileTool } from "./read-file.js";
import { runCommandTool } from "./run-command.js";
import type { BuiltinTool, ToolContext } from "./types.js";
import { writeFileTool } from "./write-file.js";
export { resolvePathInWorkspace } from "./path.js";
export type { BuiltinTool, ToolContext } from "./types.js";
const BUILTIN_TOOLS: BuiltinTool[] = [readFileTool, writeFileTool, runCommandTool];
export function getBuiltinTools(): readonly BuiltinTool[] {
return BUILTIN_TOOLS;
}
export function builtinToolsToOpenAi(tools: readonly BuiltinTool[]): OpenAiToolDefinition[] {
return tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters as Record<string, unknown>,
},
}));
}
export async function executeBuiltinTool(
name: string,
argsJson: string,
ctx: ToolContext,
): Promise<string> {
const tool = BUILTIN_TOOLS.find((t) => t.name === name);
if (tool === undefined) {
return `Error: unknown tool ${name}`;
}
let args: unknown;
try {
args = JSON.parse(argsJson) as unknown;
} catch {
return "Error: tool arguments must be valid JSON";
}
return tool.execute(args, ctx);
}
@@ -0,0 +1,12 @@
import { isAbsolute, relative, resolve } from "node:path";
/** Reject paths that escape the workspace root via `..` segments. */
export function resolvePathInWorkspace(cwd: string, inputPath: string): string | null {
const root = resolve(cwd);
const target = resolve(root, inputPath);
const rel = relative(root, target);
if (rel.startsWith("..") || isAbsolute(rel)) {
return null;
}
return target;
}
@@ -0,0 +1,44 @@
import { readFile, stat } from "node:fs/promises";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
const MAX_READ_BYTES = 512 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const readFileTool: BuiltinTool = {
name: "read_file",
description: "Read a UTF-8 text file from the workspace.",
parameters: {
type: "object",
required: ["path"],
properties: {
path: { type: "string", description: "Relative or absolute path within the workspace." },
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (!isRecord(args) || typeof args.path !== "string") {
return "Error: path must be a string";
}
const resolved = resolvePathInWorkspace(ctx.cwd, args.path);
if (resolved === null) {
return "Error: path escapes workspace root";
}
try {
const info = await stat(resolved);
if (!info.isFile()) {
return "Error: not a file";
}
if (info.size > MAX_READ_BYTES) {
return `Error: file exceeds ${MAX_READ_BYTES} byte limit`;
}
return await readFile(resolved, "utf8");
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -0,0 +1,103 @@
import { spawn } from "node:child_process";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
const COMMAND_TIMEOUT_MS = 60_000;
const MAX_OUTPUT_CHARS = 32_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${text.slice(0, maxChars)}\n...(truncated)`;
}
function runShell(
command: string,
cwd: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
const child = spawn(command, {
cwd,
env: process.env,
shell: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
}, COMMAND_TIMEOUT_MS);
child.on("error", (cause) => {
clearTimeout(timer);
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(message));
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, code: code ?? 1 });
});
});
}
export const runCommandTool: BuiltinTool = {
name: "run_command",
description:
"Run a shell command in the workspace. Requires UWF_BUILTIN_ALLOW_SHELL=1. Output is truncated.",
parameters: {
type: "object",
required: ["command"],
properties: {
command: { type: "string", description: "Shell command to execute." },
cwd: {
type: "string",
description: "Optional working directory relative to workspace root.",
},
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (process.env.UWF_BUILTIN_ALLOW_SHELL !== "1") {
return "Error: run_command disabled. Set UWF_BUILTIN_ALLOW_SHELL=1 to enable.";
}
if (!isRecord(args) || typeof args.command !== "string") {
return "Error: command must be a string";
}
let workDir = ctx.cwd;
if (args.cwd !== undefined && args.cwd !== null) {
if (typeof args.cwd !== "string") {
return "Error: cwd must be a string";
}
const resolved = resolvePathInWorkspace(ctx.cwd, args.cwd);
if (resolved === null) {
return "Error: cwd escapes workspace root";
}
workDir = resolved;
}
try {
const { stdout, stderr, code } = await runShell(args.command, workDir);
const out = truncate(
`exit_code: ${code}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
MAX_OUTPUT_CHARS,
);
return out;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -0,0 +1,13 @@
import type { JSONSchema } from "@uncaged/json-cas";
export type ToolContext = {
cwd: string;
storageRoot: string;
};
export type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema;
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
@@ -0,0 +1,39 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const writeFileTool: BuiltinTool = {
name: "write_file",
description: "Write UTF-8 text to a file in the workspace (creates parent directories).",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Relative or absolute path within the workspace." },
content: { type: "string", description: "File contents to write." },
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (!isRecord(args) || typeof args.path !== "string" || typeof args.content !== "string") {
return "Error: path and content must be strings";
}
const resolved = resolvePathInWorkspace(ctx.cwd, args.path);
if (resolved === null) {
return "Error: path escapes workspace root";
}
try {
await mkdir(dirname(resolved), { recursive: true });
await writeFile(resolved, args.content, "utf8");
return `Wrote ${args.content.length} bytes to ${args.path}`;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -0,0 +1,50 @@
import type { ChatMessage } from "./llm/index.js";
export type BuiltinToolCallRecord = {
id: string;
name: string;
args: string;
};
export type BuiltinToolResultRecord = {
toolCallId: string;
name: string;
content: string;
};
export type BuiltinLoopTurn = {
assistantContent: string | null;
toolCalls: BuiltinToolCallRecord[] | null;
toolResults: BuiltinToolResultRecord[] | null;
};
export type BuiltinSessionState = {
sessionId: string;
model: string;
startedAtMs: number;
messages: ChatMessage[];
turns: BuiltinLoopTurn[];
};
export type BuiltinTurnRole = "assistant" | "tool";
export type BuiltinToolCall = {
name: string;
args: string;
};
export type BuiltinTurnPayload = {
index: number;
role: BuiltinTurnRole;
content: string;
toolCalls: BuiltinToolCall[] | null;
reasoning: string | null;
};
export type BuiltinDetailPayload = {
sessionId: string;
model: string;
duration: number;
turnCount: number;
turns: string[];
};
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }]
}