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:
@@ -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>;
|
||||
};
|
||||
};
|
||||
@@ -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" }]
|
||||
}
|
||||
Reference in New Issue
Block a user