Merge pull request 'feat: uwf — Stateless Workflow CLI' (#317) from feat/309-uwf-stateless into main
This commit is contained in:
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
"name": "@uncaged/workflow-monorepo",
|
"name": "@uncaged/workflow-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"../json-cas/packages/json-cas",
|
||||||
|
"../json-cas/packages/json-cas-fs"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/cli-uwf",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"uwf": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas": "workspace:^",
|
||||||
|
"@uncaged/json-cas-fs": "workspace:^",
|
||||||
|
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||||
|
"@uncaged/uwf-moderator": "workspace:^",
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
|
"commander": "^14.0.3",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
import {
|
||||||
|
cmdThreadKill,
|
||||||
|
cmdThreadList,
|
||||||
|
cmdThreadShow,
|
||||||
|
cmdThreadStart,
|
||||||
|
cmdThreadStep,
|
||||||
|
} from "./commands/thread.js";
|
||||||
|
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||||
|
import { resolveStorageRoot } from "./store.js";
|
||||||
|
|
||||||
|
function writeJson(data: unknown): void {
|
||||||
|
process.stdout.write(`${JSON.stringify(data)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action: () => Promise<void>): void {
|
||||||
|
action().catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name("uwf").description("Stateless workflow CLI");
|
||||||
|
|
||||||
|
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("put")
|
||||||
|
.description("Register a workflow from YAML")
|
||||||
|
.argument("<file>", "Workflow YAML file")
|
||||||
|
.action((file: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowPut(storageRoot, file);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("show")
|
||||||
|
.description("Show a workflow by name or CAS hash")
|
||||||
|
.argument("<id>", "Workflow name or hash")
|
||||||
|
.action((id: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowShow(storageRoot, id);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
workflow
|
||||||
|
.command("list")
|
||||||
|
.description("List registered workflows")
|
||||||
|
.action(() => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdWorkflowList(storageRoot);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("start")
|
||||||
|
.description("Create a thread without executing")
|
||||||
|
.argument("<workflow>", "Workflow name or hash")
|
||||||
|
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||||
|
.action((workflow: string, opts: { prompt: string }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("step")
|
||||||
|
.description("Execute one step")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.option("--agent <cmd>", "Override agent command")
|
||||||
|
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const agentOverride = opts.agent ?? null;
|
||||||
|
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("show")
|
||||||
|
.description("Show thread head pointer")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadShow(storageRoot, threadId);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("list")
|
||||||
|
.description("List active threads")
|
||||||
|
.option("--all", "Include archived threads")
|
||||||
|
.action((opts: { all: boolean }) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadList(storageRoot, opts.all);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
thread
|
||||||
|
.command("kill")
|
||||||
|
.description("Terminate and archive a thread")
|
||||||
|
.argument("<thread-id>", "Thread ULID")
|
||||||
|
.action((threadId: string) => {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
runAction(async () => {
|
||||||
|
const result = await cmdThreadKill(storageRoot, threadId);
|
||||||
|
writeJson(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||||
|
import { evaluate } from "@uncaged/uwf-moderator";
|
||||||
|
import type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
CasRef,
|
||||||
|
ModeratorContext,
|
||||||
|
StartNodePayload,
|
||||||
|
StartOutput,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
StepOutput,
|
||||||
|
ThreadId,
|
||||||
|
ThreadListItem,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowPayload,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { generateUlid } from "@uncaged/workflow-util";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
|
import {
|
||||||
|
appendThreadHistory,
|
||||||
|
createUwfStore,
|
||||||
|
findThreadInHistory,
|
||||||
|
loadThreadHistory,
|
||||||
|
loadThreadsIndex,
|
||||||
|
loadWorkflowRegistry,
|
||||||
|
resolveWorkflowHash,
|
||||||
|
saveThreadsIndex,
|
||||||
|
type ThreadHistoryLine,
|
||||||
|
type UwfStore,
|
||||||
|
} from "../store.js";
|
||||||
|
import { isCasRef } from "../validate.js";
|
||||||
|
|
||||||
|
const END_ROLE = "$END";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KillOutput = {
|
||||||
|
thread: ThreadId;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorkflowCasRef(
|
||||||
|
uwf: UwfStore,
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
const hash = resolveWorkflowHash(registry, workflowId);
|
||||||
|
if (!isCasRef(hash)) {
|
||||||
|
fail(`workflow not found: ${workflowId}`);
|
||||||
|
}
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||||
|
const node = uwf.store.get(head);
|
||||||
|
if (node === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === uwf.schemas.startNode) {
|
||||||
|
const payload = node.payload as StartNodePayload;
|
||||||
|
return payload.workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
if (typeof payload.start !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(payload.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (startNode.payload as StartNodePayload).workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStart(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowId: string,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<StartOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||||
|
|
||||||
|
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||||
|
const startPayload: StartNodePayload = {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||||
|
const node = uwf.store.get(headHash);
|
||||||
|
if (node === null || !validate(uwf.store, node)) {
|
||||||
|
fail("stored StartNode failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
index[threadId] = headHash;
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
return { workflow: workflowHash, thread: threadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const activeHead = index[threadId];
|
||||||
|
if (activeHead !== undefined) {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: activeHead,
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||||
|
if (hist !== null) {
|
||||||
|
return {
|
||||||
|
workflow: hist.workflow,
|
||||||
|
thread: threadId,
|
||||||
|
head: hist.head,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function threadListItemFromActive(
|
||||||
|
uwf: UwfStore,
|
||||||
|
threadId: ThreadId,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<ThreadListItem | null> {
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { thread: threadId, workflow, head };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadList(
|
||||||
|
storageRoot: string,
|
||||||
|
includeAll: boolean,
|
||||||
|
): Promise<ThreadListItem[]> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const items: ThreadListItem[] = [];
|
||||||
|
|
||||||
|
for (const [threadId, head] of Object.entries(index)) {
|
||||||
|
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||||
|
if (item !== null) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includeAll) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIds = new Set(items.map((i) => i.thread));
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
for (const entry of history) {
|
||||||
|
if (!activeIds.has(entry.thread)) {
|
||||||
|
items.push({
|
||||||
|
thread: entry.thread,
|
||||||
|
workflow: entry.workflow,
|
||||||
|
head: entry.head,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||||
|
const headNode = uwf.store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === uwf.schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== uwf.schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = uwf.store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||||
|
const node = uwf.store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||||
|
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||||
|
const steps: StepContext[] = chronological.map((step) => ({
|
||||||
|
role: step.role,
|
||||||
|
output: expandOutput(uwf, step.output),
|
||||||
|
detail: step.detail,
|
||||||
|
agent: step.agent,
|
||||||
|
}));
|
||||||
|
return { start: chain.start, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||||
|
const node = uwf.store.get(workflowRef);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${workflowRef} is not a Workflow`);
|
||||||
|
}
|
||||||
|
return node.payload as WorkflowPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentOverride(override: string): AgentConfig {
|
||||||
|
const parts = override
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
const command = parts[0];
|
||||||
|
if (command === undefined) {
|
||||||
|
fail("agent override must not be empty");
|
||||||
|
}
|
||||||
|
return { command, args: parts.slice(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentConfig(
|
||||||
|
config: WorkflowConfig,
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
role: string,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): AgentConfig {
|
||||||
|
if (agentOverride !== null) {
|
||||||
|
return parseAgentOverride(agentOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias: AgentAlias = config.defaultAgent;
|
||||||
|
if (config.agentOverrides !== null) {
|
||||||
|
const roleOverrides = config.agentOverrides[workflow.name];
|
||||||
|
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||||
|
alias = roleOverrides[role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentConfig = config.agents[alias];
|
||||||
|
if (agentConfig === undefined) {
|
||||||
|
fail(`unknown agent alias in config: ${alias}`);
|
||||||
|
}
|
||||||
|
return agentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||||
|
const argv = [...agent.args, threadId, role];
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
stdout = execFileSync(agent.command, argv, {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: process.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||||
|
const stderr =
|
||||||
|
err.stderr === undefined
|
||||||
|
? ""
|
||||||
|
: typeof err.stderr === "string"
|
||||||
|
? err.stderr
|
||||||
|
: err.stderr.toString("utf8");
|
||||||
|
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||||
|
fail(`agent command failed (${agent.command})${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||||
|
if (!isCasRef(line)) {
|
||||||
|
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveThread(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
workflow: CasRef,
|
||||||
|
head: CasRef,
|
||||||
|
): Promise<void> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
await appendThreadHistory(storageRoot, {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadStep(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
agentOverride: string | null,
|
||||||
|
): Promise<StepOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const chain = walkChain(uwf, headHash);
|
||||||
|
const workflowHash = chain.start.workflow;
|
||||||
|
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||||
|
const context = buildModeratorContext(uwf, chain);
|
||||||
|
|
||||||
|
const nextResult = await evaluate(workflow, context);
|
||||||
|
if (!nextResult.ok) {
|
||||||
|
fail(nextResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextResult.value === END_ROLE) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: headHash,
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = nextResult.value;
|
||||||
|
const config = await loadWorkflowConfig(storageRoot);
|
||||||
|
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||||
|
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
const newHead = spawnAgent(agent, threadId, role);
|
||||||
|
|
||||||
|
// Re-create store to pick up nodes written by the agent subprocess
|
||||||
|
const uwfAfter = await createUwfStore(storageRoot);
|
||||||
|
const newNode = uwfAfter.store.get(newHead);
|
||||||
|
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||||
|
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||||
|
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||||
|
freshIndex[threadId] = newHead;
|
||||||
|
await saveThreadsIndex(storageRoot, freshIndex);
|
||||||
|
|
||||||
|
const chainAfter = walkChain(uwfAfter, newHead);
|
||||||
|
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||||
|
const afterResult = await evaluate(workflow, contextAfter);
|
||||||
|
if (!afterResult.ok) {
|
||||||
|
fail(afterResult.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = afterResult.value === END_ROLE;
|
||||||
|
if (done) {
|
||||||
|
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: workflowHash,
|
||||||
|
thread: threadId,
|
||||||
|
head: newHead,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const head = index[threadId];
|
||||||
|
if (head === undefined) {
|
||||||
|
fail(`thread not active: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||||
|
if (workflow === null) {
|
||||||
|
fail(`failed to resolve workflow from head: ${head}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete index[threadId];
|
||||||
|
await saveThreadsIndex(storageRoot, index);
|
||||||
|
|
||||||
|
const historyEntry: ThreadHistoryLine = {
|
||||||
|
thread: threadId,
|
||||||
|
workflow,
|
||||||
|
head,
|
||||||
|
completedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await appendThreadHistory(storageRoot, historyEntry);
|
||||||
|
|
||||||
|
return { thread: threadId, archived: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
import { putSchema, validate } from "@uncaged/json-cas";
|
||||||
|
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createUwfStore,
|
||||||
|
findRegistryName,
|
||||||
|
loadWorkflowRegistry,
|
||||||
|
resolveWorkflowHash,
|
||||||
|
saveWorkflowRegistry,
|
||||||
|
type UwfStore,
|
||||||
|
} from "../store.js";
|
||||||
|
import { isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||||
|
|
||||||
|
export type WorkflowListEntry = {
|
||||||
|
name: string;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowPutOutput = {
|
||||||
|
name: string;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowShowOutput = {
|
||||||
|
hash: CasRef;
|
||||||
|
name: string | null;
|
||||||
|
type: CasRef;
|
||||||
|
payload: WorkflowPayload;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonSchema(value: unknown): value is JSONSchema {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOutputSchemaRef(
|
||||||
|
uwf: UwfStore,
|
||||||
|
outputSchema: string | JSONSchema,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
if (typeof outputSchema === "string") {
|
||||||
|
if (!isCasRef(outputSchema)) {
|
||||||
|
fail(`invalid outputSchema cas_ref: ${outputSchema}`);
|
||||||
|
}
|
||||||
|
if (!uwf.store.has(outputSchema)) {
|
||||||
|
fail(`outputSchema not found in CAS: ${outputSchema}`);
|
||||||
|
}
|
||||||
|
return outputSchema;
|
||||||
|
}
|
||||||
|
if (!isJsonSchema(outputSchema)) {
|
||||||
|
fail("outputSchema must be a cas_ref string or JSON Schema object");
|
||||||
|
}
|
||||||
|
return putSchema(uwf.store, outputSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function materializeWorkflowPayload(
|
||||||
|
uwf: UwfStore,
|
||||||
|
raw: WorkflowPayload,
|
||||||
|
): Promise<WorkflowPayload> {
|
||||||
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
|
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||||
|
const outputSchema = await resolveOutputSchemaRef(
|
||||||
|
uwf,
|
||||||
|
role.outputSchema as string | JSONSchema,
|
||||||
|
);
|
||||||
|
roles[roleName] = {
|
||||||
|
description: role.description,
|
||||||
|
systemPrompt: role.systemPrompt,
|
||||||
|
outputSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
description: raw.description,
|
||||||
|
roles,
|
||||||
|
conditions: raw.conditions,
|
||||||
|
graph: raw.graph,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowPut(
|
||||||
|
storageRoot: string,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<WorkflowPutOutput> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
fail(`file not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = parse(text) as unknown;
|
||||||
|
} catch (e) {
|
||||||
|
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseWorkflowPayload(raw);
|
||||||
|
if (payload === null) {
|
||||||
|
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||||
|
|
||||||
|
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null || !validate(uwf.store, node)) {
|
||||||
|
fail("stored workflow failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
registry[materialized.name] = hash;
|
||||||
|
await saveWorkflowRegistry(storageRoot, registry);
|
||||||
|
|
||||||
|
return { name: materialized.name, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowShow(
|
||||||
|
storageRoot: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<WorkflowShowOutput> {
|
||||||
|
const uwf = await createUwfStore(storageRoot);
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
const hash = resolveWorkflowHash(registry, id);
|
||||||
|
|
||||||
|
const node = uwf.store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== uwf.schemas.workflow) {
|
||||||
|
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = node.payload as WorkflowPayload;
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
name: findRegistryName(registry, hash),
|
||||||
|
type: node.type,
|
||||||
|
payload,
|
||||||
|
timestamp: node.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||||
|
const registry = await loadWorkflowRegistry(storageRoot);
|
||||||
|
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type UwfSchemaHashes = {
|
||||||
|
workflow: Hash;
|
||||||
|
startNode: Hash;
|
||||||
|
stepNode: Hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||||
|
* Idempotent: safe to call on every CLI invocation.
|
||||||
|
*/
|
||||||
|
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||||
|
const [workflow, startNode, stepNode] = await Promise.all([
|
||||||
|
putSchema(store, WORKFLOW_SCHEMA),
|
||||||
|
putSchema(store, START_NODE_SCHEMA),
|
||||||
|
putSchema(store, STEP_NODE_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { workflow, startNode, stepNode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
|
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||||
|
|
||||||
|
export type WorkflowRegistry = Record<string, CasRef>;
|
||||||
|
|
||||||
|
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||||
|
export function getDefaultStorageRoot(): string {
|
||||||
|
return join(homedir(), ".uncaged", "workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve storage root.
|
||||||
|
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||||
|
*/
|
||||||
|
export function resolveStorageRoot(): string {
|
||||||
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (internal !== undefined && internal !== "") {
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
|
}
|
||||||
|
return getDefaultStorageRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCasDir(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "cas");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistryPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "workflows.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadsPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "threads.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "history.jsonl");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadHistoryLine = ThreadListItem & {
|
||||||
|
completedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UwfStore = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Store;
|
||||||
|
schemas: UwfSchemaHashes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||||
|
const casDir = getCasDir(storageRoot);
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
|
||||||
|
const path = getRegistryPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const registry: WorkflowRegistry = {};
|
||||||
|
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (typeof hash === "string") {
|
||||||
|
registry[name] = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorkflowRegistry(
|
||||||
|
storageRoot: string,
|
||||||
|
registry: WorkflowRegistry,
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getRegistryPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const text = stringify(registry, { indent: 2 });
|
||||||
|
await writeFile(path, text, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
|
||||||
|
return registry[id] !== undefined ? registry[id] : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||||
|
for (const [name, h] of Object.entries(registry)) {
|
||||||
|
if (h === hash) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const index: ThreadsIndex = {};
|
||||||
|
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (typeof head === "string") {
|
||||||
|
index[threadId as ThreadId] = head;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const text = stringify(index, { indent: 2 });
|
||||||
|
await writeFile(path, text, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
|
||||||
|
const path = getHistoryPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const lines: ThreadHistoryLine[] = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
const thread = rec.thread;
|
||||||
|
const workflow = rec.workflow;
|
||||||
|
const head = rec.head;
|
||||||
|
const completedAt = rec.completedAt;
|
||||||
|
if (
|
||||||
|
typeof thread === "string" &&
|
||||||
|
typeof workflow === "string" &&
|
||||||
|
typeof head === "string" &&
|
||||||
|
typeof completedAt === "number"
|
||||||
|
) {
|
||||||
|
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findThreadInHistory(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): Promise<ThreadHistoryLine | null> {
|
||||||
|
const history = await loadThreadHistory(storageRoot);
|
||||||
|
for (let i = history.length - 1; i >= 0; i--) {
|
||||||
|
const entry = history[i];
|
||||||
|
if (entry !== undefined && entry.thread === threadId) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendThreadHistory(
|
||||||
|
storageRoot: string,
|
||||||
|
entry: ThreadHistoryLine,
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getHistoryPath(storageRoot);
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
const line = `${JSON.stringify(entry)}\n`;
|
||||||
|
await appendFile(path, line, "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||||
|
|
||||||
|
export function isCasRef(value: string): value is CasRef {
|
||||||
|
return CAS_REF_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoleDefinition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const outputSchema = value.outputSchema;
|
||||||
|
const schemaOk =
|
||||||
|
typeof outputSchema === "string" ||
|
||||||
|
(isRecord(outputSchema) && typeof outputSchema.type === "string");
|
||||||
|
return (
|
||||||
|
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConditionDefinition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return typeof value.description === "string" && typeof value.expression === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTransition(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const condition = value.condition;
|
||||||
|
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(value).every(itemCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGraph(value: unknown): boolean {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(value).every(
|
||||||
|
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||||
|
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||||
|
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||||
|
!isGraph(raw.graph)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw as WorkflowPayload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../uwf-protocol" },
|
||||||
|
{ "path": "../uwf-moderator" },
|
||||||
|
{ "path": "../uwf-agent-kit" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-agent-hermes",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"uwf-hermes": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { createHermesAgent } from "./hermes.js";
|
||||||
|
|
||||||
|
const main = createHermesAgent();
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
|
||||||
|
|
||||||
|
const HERMES_COMMAND = "hermes";
|
||||||
|
const HERMES_MAX_TURNS = 90;
|
||||||
|
|
||||||
|
function buildHistorySummary(history: AgentContext["history"]): string {
|
||||||
|
if (history.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ["## Previous Steps"];
|
||||||
|
for (let i = 0; i < history.length; i++) {
|
||||||
|
const step = history[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 system prompt, task, and prior step outputs for Hermes. */
|
||||||
|
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||||
|
const parts: string[] = [ctx.systemPrompt, "", "## Task", ctx.prompt];
|
||||||
|
const historyBlock = buildHistorySummary(ctx.history);
|
||||||
|
if (historyBlock !== "") {
|
||||||
|
parts.push("", historyBlock);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnHermesChat(prompt: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
"chat",
|
||||||
|
"-q",
|
||||||
|
prompt,
|
||||||
|
"--yolo",
|
||||||
|
"--max-turns",
|
||||||
|
String(HERMES_MAX_TURNS),
|
||||||
|
"--quiet",
|
||||||
|
];
|
||||||
|
const child = spawn(HERMES_COMMAND, args, {
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (cause) => {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
reject(new Error(`hermes spawn failed: ${message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||||
|
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHermes(ctx: AgentContext): Promise<string> {
|
||||||
|
const fullPrompt = buildHermesPrompt(ctx);
|
||||||
|
return spawnHermesChat(fullPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||||
|
export function createHermesAgent(): () => Promise<void> {
|
||||||
|
return createAgent({
|
||||||
|
name: "hermes",
|
||||||
|
run: runHermes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-agent-kit" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||||
|
import { resolveExtractModelAlias } from "../src/extract.js";
|
||||||
|
|
||||||
|
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
|
||||||
|
return {
|
||||||
|
providers: {},
|
||||||
|
models: {
|
||||||
|
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||||
|
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
|
||||||
|
},
|
||||||
|
agents: {},
|
||||||
|
defaultAgent: "hermes",
|
||||||
|
agentOverrides: null,
|
||||||
|
defaultModel: "sonnet",
|
||||||
|
modelOverrides: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveExtractModelAlias", () => {
|
||||||
|
test("uses modelOverrides.extract when set", () => {
|
||||||
|
const config = baseConfig({
|
||||||
|
modelOverrides: { extract: "gpt4o-mini" },
|
||||||
|
});
|
||||||
|
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to models.extract alias when present", () => {
|
||||||
|
const config = baseConfig({
|
||||||
|
models: {
|
||||||
|
extract: { provider: "openai", name: "gpt-4o-mini" },
|
||||||
|
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveExtractModelAlias(config)).toBe("extract");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to defaultModel", () => {
|
||||||
|
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-agent-kit",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas": "workspace:^",
|
||||||
|
"@uncaged/json-cas-fs": "workspace:^",
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
CasRef,
|
||||||
|
StartNodePayload,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
ThreadId,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||||
|
import type { AgentContext } from "./types.js";
|
||||||
|
|
||||||
|
type ChainState = {
|
||||||
|
startHash: CasRef;
|
||||||
|
start: StartNodePayload;
|
||||||
|
stepsNewestFirst: StepNodePayload[];
|
||||||
|
headIsStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkChain(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||||
|
headHash: CasRef,
|
||||||
|
): ChainState {
|
||||||
|
const headNode = store.get(headHash);
|
||||||
|
if (headNode === null) {
|
||||||
|
fail(`CAS node not found: ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type === schemas.startNode) {
|
||||||
|
return {
|
||||||
|
startHash: headHash,
|
||||||
|
start: headNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst: [],
|
||||||
|
headIsStart: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headNode.type !== schemas.stepNode) {
|
||||||
|
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsNewestFirst: StepNodePayload[] = [];
|
||||||
|
let hash: CasRef | null = headHash;
|
||||||
|
|
||||||
|
while (hash !== null) {
|
||||||
|
const node = store.get(hash);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`CAS node not found while walking chain: ${hash}`);
|
||||||
|
}
|
||||||
|
if (node.type !== schemas.stepNode) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const payload = node.payload as StepNodePayload;
|
||||||
|
stepsNewestFirst.push(payload);
|
||||||
|
hash = payload.prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = stepsNewestFirst[0];
|
||||||
|
if (newest === undefined) {
|
||||||
|
fail(`empty step chain at head ${headHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNode = store.get(newest.start);
|
||||||
|
if (startNode === null || startNode.type !== schemas.startNode) {
|
||||||
|
fail(`StartNode not found: ${newest.start}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHash: newest.start,
|
||||||
|
start: startNode.payload as StartNodePayload,
|
||||||
|
stepsNewestFirst,
|
||||||
|
headIsStart: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutput(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
outputRef: CasRef,
|
||||||
|
): unknown {
|
||||||
|
const node = store.get(outputRef);
|
||||||
|
if (node === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return node.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHistory(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
stepsNewestFirst: StepNodePayload[],
|
||||||
|
): Promise<StepContext[]> {
|
||||||
|
const chronological = [...stepsNewestFirst].reverse();
|
||||||
|
const history: StepContext[] = [];
|
||||||
|
for (const step of chronological) {
|
||||||
|
history.push({
|
||||||
|
role: step.role,
|
||||||
|
output: expandOutput(store, step.output),
|
||||||
|
detail: step.detail,
|
||||||
|
agent: step.agent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkflow(
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||||
|
workflowRef: CasRef,
|
||||||
|
) {
|
||||||
|
const node = store.get(workflowRef);
|
||||||
|
if (node === null) {
|
||||||
|
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||||
|
}
|
||||||
|
if (node.type !== schemas.workflow) {
|
||||||
|
fail(`node ${workflowRef} is not a Workflow`);
|
||||||
|
}
|
||||||
|
return node.payload as AgentContext["workflow"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build agent execution context from thread head in threads.yaml.
|
||||||
|
* Walks the CAS chain from head to StartNode and expands step outputs.
|
||||||
|
*/
|
||||||
|
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
|
const { store, schemas } = agentStore;
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = walkChain(store, schemas, headHash);
|
||||||
|
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||||
|
const roleDef = workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
role,
|
||||||
|
systemPrompt: roleDef.systemPrompt,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
history,
|
||||||
|
workflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildContextMeta = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
|
||||||
|
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
|
||||||
|
headHash: CasRef;
|
||||||
|
chain: ChainState;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
|
||||||
|
*/
|
||||||
|
export async function buildContextWithMeta(
|
||||||
|
threadId: ThreadId,
|
||||||
|
role: string,
|
||||||
|
): Promise<AgentContext & { meta: BuildContextMeta }> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
const agentStore = await createAgentStore(storageRoot);
|
||||||
|
const { store, schemas } = agentStore;
|
||||||
|
|
||||||
|
const index = await loadThreadsIndex(storageRoot);
|
||||||
|
const headHash = index[threadId];
|
||||||
|
if (headHash === undefined) {
|
||||||
|
fail(`thread not found in threads.yaml: ${threadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = walkChain(store, schemas, headHash);
|
||||||
|
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
|
||||||
|
const roleDef = workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threadId,
|
||||||
|
role,
|
||||||
|
systemPrompt: roleDef.systemPrompt,
|
||||||
|
prompt: chain.start.prompt,
|
||||||
|
history,
|
||||||
|
workflow,
|
||||||
|
meta: { storageRoot, store, schemas, headHash, chain },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { getSchema, validate } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||||
|
|
||||||
|
export type ResolvedLlmProvider = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
|
||||||
|
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||||
|
const fromOverride = config.modelOverrides?.extract ?? null;
|
||||||
|
if (fromOverride !== null) {
|
||||||
|
return fromOverride;
|
||||||
|
}
|
||||||
|
if (config.models.extract !== undefined) {
|
||||||
|
return "extract";
|
||||||
|
}
|
||||||
|
if (config.models.default !== undefined) {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
return config.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||||
|
const modelEntry = config.models[alias];
|
||||||
|
if (modelEntry === undefined) {
|
||||||
|
throw new Error(`unknown model alias: ${alias}`);
|
||||||
|
}
|
||||||
|
const providerEntry = config.providers[modelEntry.provider];
|
||||||
|
if (providerEntry === undefined) {
|
||||||
|
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
|
||||||
|
}
|
||||||
|
const apiKey = process.env[providerEntry.apiKeyEnv];
|
||||||
|
if (apiKey === undefined || apiKey === "") {
|
||||||
|
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: providerEntry.baseUrl,
|
||||||
|
apiKey,
|
||||||
|
model: modelEntry.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
|
return `${trimmed}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonFromAssistantText(text: string): unknown {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||||
|
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||||
|
return JSON.parse(candidate) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssistantText(parsed: unknown): string {
|
||||||
|
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 content = messageObj.content;
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new Error("LLM message has no text content");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatCompletionText(
|
||||||
|
provider: ResolvedLlmProvider,
|
||||||
|
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||||
|
): Promise<string> {
|
||||||
|
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,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} 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 parseAssistantText(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtractResult = {
|
||||||
|
value: unknown;
|
||||||
|
hash: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
|
||||||
|
* Loads config.yaml and .env from the workflow storage root.
|
||||||
|
*/
|
||||||
|
export async function extract(
|
||||||
|
rawOutput: string,
|
||||||
|
outputSchema: CasRef,
|
||||||
|
config: WorkflowConfig,
|
||||||
|
): Promise<ExtractResult> {
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
|
||||||
|
const { store } = await createAgentStore(storageRoot);
|
||||||
|
const schema = getSchema(store, outputSchema);
|
||||||
|
if (schema === null) {
|
||||||
|
throw new Error(`output schema not found in CAS: ${outputSchema}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelAlias = resolveExtractModelAlias(config);
|
||||||
|
const provider = resolveModel(config, modelAlias);
|
||||||
|
|
||||||
|
const schemaText = JSON.stringify(schema, null, 2);
|
||||||
|
const assistantText = await chatCompletionText(provider, [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
|
||||||
|
schemaText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: rawOutput,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let structured: unknown;
|
||||||
|
try {
|
||||||
|
structured = extractJsonFromAssistantText(assistantText);
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
throw new Error(`failed to parse extracted JSON: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputHash = await store.put(outputSchema, structured);
|
||||||
|
const node = store.get(outputHash);
|
||||||
|
if (node === null || !validate(store, node)) {
|
||||||
|
throw new Error("extracted output failed JSON Schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: structured, hash: outputHash };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type { BuildContextMeta } from "./context.js";
|
||||||
|
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||||
|
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||||
|
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||||
|
export {
|
||||||
|
extract,
|
||||||
|
resolveExtractModelAlias,
|
||||||
|
resolveModel,
|
||||||
|
} from "./extract.js";
|
||||||
|
export { createAgent } from "./run.js";
|
||||||
|
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { validate } from "@uncaged/json-cas";
|
||||||
|
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
|
||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
|
||||||
|
import { buildContextWithMeta } from "./context.js";
|
||||||
|
import { extract } from "./extract.js";
|
||||||
|
import type { AgentStore } from "./storage.js";
|
||||||
|
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||||
|
import type { AgentContext, AgentOptions } from "./types.js";
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentLabel(name: string): string {
|
||||||
|
if (name.startsWith("uwf-")) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return `uwf-${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
|
||||||
|
const threadId = argv[2];
|
||||||
|
const role = argv[3];
|
||||||
|
if (threadId === undefined || threadId === "") {
|
||||||
|
fail("usage: <agent-cli> <thread-id> <role>");
|
||||||
|
}
|
||||||
|
if (role === undefined || role === "") {
|
||||||
|
fail("usage: <agent-cli> <thread-id> <role>");
|
||||||
|
}
|
||||||
|
return { threadId: threadId as ThreadId, role };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return fn().catch((e: unknown) => {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
fail(`${label}: ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStepNode(options: {
|
||||||
|
store: AgentStore["store"];
|
||||||
|
schemas: AgentStore["schemas"];
|
||||||
|
startHash: CasRef;
|
||||||
|
prevHash: CasRef | null;
|
||||||
|
role: string;
|
||||||
|
outputHash: CasRef;
|
||||||
|
detailHash: CasRef;
|
||||||
|
agentName: string;
|
||||||
|
}): Promise<CasRef> {
|
||||||
|
const payload: StepNodePayload = {
|
||||||
|
start: options.startHash,
|
||||||
|
prev: options.prevHash,
|
||||||
|
role: options.role,
|
||||||
|
output: options.outputHash,
|
||||||
|
detail: options.detailHash,
|
||||||
|
agent: options.agentName,
|
||||||
|
};
|
||||||
|
const hash = await options.store.put(options.schemas.stepNode, payload);
|
||||||
|
const node = options.store.get(hash);
|
||||||
|
if (node === null || !validate(options.store, node)) {
|
||||||
|
fail("stored StepNode failed schema validation");
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<string> {
|
||||||
|
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractOutput(
|
||||||
|
rawOutput: string,
|
||||||
|
outputSchema: CasRef,
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<CasRef> {
|
||||||
|
const config = await runWithMessage("failed to load config", () =>
|
||||||
|
loadWorkflowConfig(storageRoot),
|
||||||
|
);
|
||||||
|
const extracted = await runWithMessage("extract failed", () =>
|
||||||
|
extract(rawOutput, outputSchema, config),
|
||||||
|
);
|
||||||
|
return extracted.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistStep(options: {
|
||||||
|
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
|
||||||
|
rawOutput: string;
|
||||||
|
outputHash: CasRef;
|
||||||
|
agentName: string;
|
||||||
|
}): Promise<CasRef> {
|
||||||
|
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||||
|
const detailHash = await store.put(null, options.rawOutput);
|
||||||
|
return writeStepNode({
|
||||||
|
store,
|
||||||
|
schemas,
|
||||||
|
startHash: chain.startHash,
|
||||||
|
prevHash: chain.headIsStart ? null : headHash,
|
||||||
|
role: options.ctx.role,
|
||||||
|
outputHash: options.outputHash,
|
||||||
|
detailHash,
|
||||||
|
agentName: options.agentName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an agent CLI entrypoint.
|
||||||
|
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
|
||||||
|
* writes StepNode to CAS, and prints the new node hash to stdout.
|
||||||
|
*/
|
||||||
|
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||||
|
return async function main(): Promise<void> {
|
||||||
|
const { threadId, role } = parseArgv(process.argv);
|
||||||
|
const storageRoot = resolveStorageRoot();
|
||||||
|
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||||
|
|
||||||
|
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
|
||||||
|
|
||||||
|
const roleDef = ctx.workflow.roles[role];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
fail(`unknown role: ${role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOutput = await runAgent(options, ctx);
|
||||||
|
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
|
||||||
|
const stepHash = await persistStep({
|
||||||
|
ctx,
|
||||||
|
rawOutput,
|
||||||
|
outputHash,
|
||||||
|
agentName: agentLabel(options.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdout.write(`${stepHash}\n`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Hash, Store } from "@uncaged/json-cas";
|
||||||
|
import { putSchema } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type UwfAgentSchemaHashes = {
|
||||||
|
workflow: Hash;
|
||||||
|
startNode: Hash;
|
||||||
|
stepNode: Hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
|
||||||
|
* Idempotent: safe to call on every agent invocation.
|
||||||
|
*/
|
||||||
|
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
|
||||||
|
const [workflow, startNode, stepNode] = await Promise.all([
|
||||||
|
putSchema(store, WORKFLOW_SCHEMA),
|
||||||
|
putSchema(store, START_NODE_SCHEMA),
|
||||||
|
putSchema(store, STEP_NODE_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { workflow, startNode, stepNode };
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
ModelAlias,
|
||||||
|
ModelConfig,
|
||||||
|
ProviderAlias,
|
||||||
|
ProviderConfig,
|
||||||
|
Scenario,
|
||||||
|
ThreadId,
|
||||||
|
ThreadsIndex,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowName,
|
||||||
|
} from "@uncaged/uwf-protocol";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import { registerAgentSchemas } from "./schemas.js";
|
||||||
|
|
||||||
|
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||||
|
export function getDefaultStorageRoot(): string {
|
||||||
|
return join(homedir(), ".uncaged", "workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve storage root.
|
||||||
|
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
|
||||||
|
*/
|
||||||
|
export function resolveStorageRoot(): string {
|
||||||
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (internal !== undefined && internal !== "") {
|
||||||
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
|
}
|
||||||
|
return getDefaultStorageRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCasDir(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "cas");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "config.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadsPath(storageRoot: string): string {
|
||||||
|
return join(storageRoot, "threads.yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentStore = {
|
||||||
|
storageRoot: string;
|
||||||
|
store: Store;
|
||||||
|
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
|
||||||
|
const store = createFsStore(getCasDir(storageRoot));
|
||||||
|
const schemas = await registerAgentSchemas(store);
|
||||||
|
return { storageRoot, store, schemas };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.providers must be a mapping");
|
||||||
|
}
|
||||||
|
const providers: Record<ProviderAlias, ProviderConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.providers.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const baseUrl = entry.baseUrl;
|
||||||
|
const apiKeyEnv = entry.apiKeyEnv;
|
||||||
|
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
|
||||||
|
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
|
||||||
|
}
|
||||||
|
providers[name] = { baseUrl, apiKeyEnv };
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.models must be a mapping");
|
||||||
|
}
|
||||||
|
const models: Record<ModelAlias, ModelConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.models.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const provider = entry.provider;
|
||||||
|
const modelName = entry.name;
|
||||||
|
if (typeof provider !== "string" || typeof modelName !== "string") {
|
||||||
|
throw new Error(`config.models.${name} requires provider and name`);
|
||||||
|
}
|
||||||
|
models[name] = { provider, name: modelName };
|
||||||
|
}
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.agents must be a mapping");
|
||||||
|
}
|
||||||
|
const agents: Record<AgentAlias, AgentConfig> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new Error(`config.agents.${name} must be a mapping`);
|
||||||
|
}
|
||||||
|
const command = entry.command;
|
||||||
|
const argsRaw = entry.args;
|
||||||
|
if (typeof command !== "string") {
|
||||||
|
throw new Error(`config.agents.${name} requires command`);
|
||||||
|
}
|
||||||
|
const args = Array.isArray(argsRaw)
|
||||||
|
? argsRaw.filter((a): a is string => typeof a === "string")
|
||||||
|
: [];
|
||||||
|
agents[name] = { command, args };
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.modelOverrides must be a mapping or null");
|
||||||
|
}
|
||||||
|
const overrides: Record<Scenario, ModelAlias> = {};
|
||||||
|
for (const [scene, alias] of Object.entries(raw)) {
|
||||||
|
if (typeof alias === "string") {
|
||||||
|
overrides[scene] = alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentOverrides(
|
||||||
|
raw: unknown,
|
||||||
|
): Record<WorkflowName, Record<string, AgentAlias>> | null {
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.agentOverrides must be a mapping or null");
|
||||||
|
}
|
||||||
|
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
|
||||||
|
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
|
||||||
|
if (!isRecord(rolesRaw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const roles: Record<string, AgentAlias> = {};
|
||||||
|
for (const [roleName, alias] of Object.entries(rolesRaw)) {
|
||||||
|
if (typeof alias === "string") {
|
||||||
|
roles[roleName] = alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrides[workflowName] = roles;
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
throw new Error("config.yaml root must be a mapping");
|
||||||
|
}
|
||||||
|
const defaultAgent = raw.defaultAgent;
|
||||||
|
const defaultModel = raw.defaultModel;
|
||||||
|
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
|
||||||
|
throw new Error("config requires defaultAgent and defaultModel");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
providers: normalizeProviders(raw.providers),
|
||||||
|
models: normalizeModels(raw.models),
|
||||||
|
agents: normalizeAgents(raw.agents),
|
||||||
|
defaultAgent,
|
||||||
|
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
|
||||||
|
defaultModel,
|
||||||
|
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
|
||||||
|
const path = getConfigPath(storageRoot);
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
return normalizeWorkflowConfig(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
|
||||||
|
const path = getThreadsPath(storageRoot);
|
||||||
|
try {
|
||||||
|
const text = await readFile(path, "utf8");
|
||||||
|
const raw = parse(text) as unknown;
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const index: ThreadsIndex = {};
|
||||||
|
for (const [threadId, head] of Object.entries(raw)) {
|
||||||
|
if (typeof head === "string") {
|
||||||
|
index[threadId as ThreadId] = head;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
export type AgentContext = {
|
||||||
|
threadId: ThreadId;
|
||||||
|
role: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
prompt: string;
|
||||||
|
history: StepContext[];
|
||||||
|
workflow: WorkflowPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
|
||||||
|
|
||||||
|
export type AgentOptions = {
|
||||||
|
name: string;
|
||||||
|
run: AgentRunFn;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-protocol" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
|
||||||
|
import { evaluate } from "../src/evaluate.js";
|
||||||
|
|
||||||
|
const solveIssueWorkflow: WorkflowPayload = {
|
||||||
|
name: "solve-issue",
|
||||||
|
description: "End-to-end issue resolution",
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Creates implementation plan",
|
||||||
|
systemPrompt: "You are a planning agent...",
|
||||||
|
outputSchema: "5GWKR8TN1V3JA",
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
description: "Implements code changes",
|
||||||
|
systemPrompt: "You are a developer agent...",
|
||||||
|
outputSchema: "8CNWT4KR6D1HV",
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
description: "Reviews code changes",
|
||||||
|
systemPrompt: "You are a code reviewer...",
|
||||||
|
outputSchema: "1VPBG9SM5E7WK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
needsClarification: {
|
||||||
|
description: "Planner requests clarification from user",
|
||||||
|
expression: "$exists(steps[-1].output.needsClarification)",
|
||||||
|
},
|
||||||
|
notApproved: {
|
||||||
|
description: "Reviewer rejected the implementation",
|
||||||
|
expression: "steps[-1].output.approved = false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: [{ role: "planner", condition: null }],
|
||||||
|
planner: [
|
||||||
|
{ role: "developer", condition: "needsClarification" },
|
||||||
|
{ role: "$END", condition: null },
|
||||||
|
],
|
||||||
|
developer: [{ role: "reviewer", condition: null }],
|
||||||
|
reviewer: [
|
||||||
|
{ role: "developer", condition: "notApproved" },
|
||||||
|
{ role: "$END", condition: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
workflow: "4KNM2PXR3B1QW",
|
||||||
|
prompt: "Fix the login bug",
|
||||||
|
},
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluate", () => {
|
||||||
|
test("$START → first role (fallback)", async () => {
|
||||||
|
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||||
|
expect(result).toEqual({ ok: true, value: "planner" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("condition match (notApproved → developer)", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: false },
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fallback when condition does not match → $END", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "reviewer",
|
||||||
|
output: { approved: true },
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "$END" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing role in graph → error", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "unknown-role",
|
||||||
|
output: {},
|
||||||
|
detail: "2MXBG6PN4A8JR",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("output expansion in context works with JSONata", async () => {
|
||||||
|
const context = makeContext([
|
||||||
|
{
|
||||||
|
role: "planner",
|
||||||
|
output: { needsClarification: true },
|
||||||
|
detail: "7BQST3VW9F2MA",
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-moderator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/uwf-protocol": "workspace:^",
|
||||||
|
"jsonata": "^1.8.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||||
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
|
import type { Result } from "./types.js";
|
||||||
|
|
||||||
|
const START_ROLE = "$START";
|
||||||
|
|
||||||
|
function isTruthy(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value !== 0 && !Number.isNaN(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.length > 0;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
|
||||||
|
try {
|
||||||
|
const result = await jsonata(expression).evaluate(context);
|
||||||
|
return { ok: true, value: result };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRole(context: ModeratorContext): string {
|
||||||
|
if (context.steps.length === 0) {
|
||||||
|
return START_ROLE;
|
||||||
|
}
|
||||||
|
return context.steps[context.steps.length - 1].role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluate(
|
||||||
|
workflow: WorkflowPayload,
|
||||||
|
context: ModeratorContext,
|
||||||
|
): Promise<Result<string, Error>> {
|
||||||
|
const role = currentRole(context);
|
||||||
|
const transitions = workflow.graph[role];
|
||||||
|
if (transitions === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`no transitions defined for role "${role}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of transitions) {
|
||||||
|
if (transition.condition === null) {
|
||||||
|
return { ok: true, value: transition.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionDef = workflow.conditions[transition.condition];
|
||||||
|
if (conditionDef === undefined) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`unknown condition "${transition.condition}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||||
|
if (!evalResult.ok) {
|
||||||
|
return evalResult;
|
||||||
|
}
|
||||||
|
if (isTruthy(evalResult.value)) {
|
||||||
|
return { ok: true, value: transition.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: new Error(`no transition matched for role "${role}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { evaluate } from "./evaluate.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../uwf-protocol" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/uwf-protocol",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"bun": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/json-cas": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export {
|
||||||
|
START_NODE_SCHEMA,
|
||||||
|
STEP_NODE_SCHEMA,
|
||||||
|
WORKFLOW_SCHEMA,
|
||||||
|
} from "./schemas.js";
|
||||||
|
export type {
|
||||||
|
AgentAlias,
|
||||||
|
AgentConfig,
|
||||||
|
CasRef,
|
||||||
|
ConditionDefinition,
|
||||||
|
ModelAlias,
|
||||||
|
ModelConfig,
|
||||||
|
ModeratorContext,
|
||||||
|
ProviderAlias,
|
||||||
|
ProviderConfig,
|
||||||
|
RoleDefinition,
|
||||||
|
RoleName,
|
||||||
|
Scenario,
|
||||||
|
StartNodePayload,
|
||||||
|
StartOutput,
|
||||||
|
StepContext,
|
||||||
|
StepNodePayload,
|
||||||
|
StepOutput,
|
||||||
|
StepRecord,
|
||||||
|
ThreadId,
|
||||||
|
ThreadListItem,
|
||||||
|
ThreadsIndex,
|
||||||
|
Transition,
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowName,
|
||||||
|
WorkflowPayload,
|
||||||
|
} from "./types.js";
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
const ROLE_DEFINITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["description", "systemPrompt", "outputSchema"],
|
||||||
|
properties: {
|
||||||
|
description: { type: "string" },
|
||||||
|
systemPrompt: { type: "string" },
|
||||||
|
outputSchema: { type: "string", format: "cas_ref" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONDITION_DEFINITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["description", "expression"],
|
||||||
|
properties: {
|
||||||
|
description: { type: "string" },
|
||||||
|
expression: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITION: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["role", "condition"],
|
||||||
|
properties: {
|
||||||
|
role: { type: "string" },
|
||||||
|
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["name", "description", "roles", "conditions", "graph"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
roles: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: ROLE_DEFINITION,
|
||||||
|
},
|
||||||
|
conditions: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: CONDITION_DEFINITION,
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "array",
|
||||||
|
items: TRANSITION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const START_NODE_SCHEMA: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["workflow", "prompt"],
|
||||||
|
properties: {
|
||||||
|
workflow: { type: "string", format: "cas_ref" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["start", "prev", "role", "output", "detail", "agent"],
|
||||||
|
properties: {
|
||||||
|
start: { type: "string", format: "cas_ref" },
|
||||||
|
prev: {
|
||||||
|
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
|
||||||
|
},
|
||||||
|
role: { type: "string" },
|
||||||
|
output: { type: "string", format: "cas_ref" },
|
||||||
|
detail: { type: "string", format: "cas_ref" },
|
||||||
|
agent: { type: "string" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// ── 4.1 公共类型 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** CAS hash — XXH64, 13-char Crockford Base32 */
|
||||||
|
export type CasRef = string;
|
||||||
|
|
||||||
|
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||||
|
export type ThreadId = string;
|
||||||
|
|
||||||
|
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||||
|
export type StepRecord = {
|
||||||
|
role: string;
|
||||||
|
output: CasRef;
|
||||||
|
detail: CasRef;
|
||||||
|
agent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RoleDefinition = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
outputSchema: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Transition = {
|
||||||
|
role: string;
|
||||||
|
condition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionDefinition = {
|
||||||
|
description: string;
|
||||||
|
expression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowPayload = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
roles: Record<string, RoleDefinition>;
|
||||||
|
conditions: Record<string, ConditionDefinition>;
|
||||||
|
graph: Record<string, Transition[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type StartNodePayload = {
|
||||||
|
workflow: CasRef;
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StepNodePayload = StepRecord & {
|
||||||
|
start: CasRef;
|
||||||
|
prev: CasRef | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.4 JSONata 求值上下文 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** JSONata 上下文中的 step — output 被展开 */
|
||||||
|
export type StepContext = Omit<StepRecord, "output"> & {
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModeratorContext = {
|
||||||
|
start: StartNodePayload;
|
||||||
|
steps: StepContext[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** uwf thread start */
|
||||||
|
export type StartOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread step / uwf thread show */
|
||||||
|
export type StepOutput = {
|
||||||
|
workflow: CasRef;
|
||||||
|
thread: ThreadId;
|
||||||
|
head: CasRef;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** uwf thread list */
|
||||||
|
export type ThreadListItem = {
|
||||||
|
thread: ThreadId;
|
||||||
|
workflow: CasRef;
|
||||||
|
head: CasRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4.6 配置 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Alias types for config references */
|
||||||
|
export type AgentAlias = string;
|
||||||
|
export type ModelAlias = string;
|
||||||
|
export type ProviderAlias = string;
|
||||||
|
export type WorkflowName = string;
|
||||||
|
export type RoleName = string;
|
||||||
|
export type Scenario = string;
|
||||||
|
|
||||||
|
export type ProviderConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeyEnv: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelConfig = {
|
||||||
|
provider: ProviderAlias;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentConfig = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/config.yaml */
|
||||||
|
export type WorkflowConfig = {
|
||||||
|
providers: Record<ProviderAlias, ProviderConfig>;
|
||||||
|
models: Record<ModelAlias, ModelConfig>;
|
||||||
|
agents: Record<AgentAlias, AgentConfig>;
|
||||||
|
defaultAgent: AgentAlias;
|
||||||
|
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||||
|
defaultModel: ModelAlias;
|
||||||
|
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ~/.uncaged/workflow/threads.yaml */
|
||||||
|
export type ThreadsIndex = Record<ThreadId, CasRef>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
// Mock agent for smoke testing
|
||||||
|
import { createAgent } from "../packages/uwf-agent-kit/src/index.js";
|
||||||
|
|
||||||
|
const agent = createAgent({
|
||||||
|
name: "mock",
|
||||||
|
run: async (ctx) => {
|
||||||
|
return `Mock output for role ${ctx.role}: task was "${ctx.prompt}"`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: "solve-issue"
|
||||||
|
description: "End-to-end issue resolution"
|
||||||
|
roles:
|
||||||
|
planner:
|
||||||
|
description: "Creates implementation plan"
|
||||||
|
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||||
|
outputSchema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
plan:
|
||||||
|
type: string
|
||||||
|
steps:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
required: [plan, steps]
|
||||||
|
developer:
|
||||||
|
description: "Implements code changes"
|
||||||
|
systemPrompt: "You are a developer agent. Implement the plan."
|
||||||
|
outputSchema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
filesChanged:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
required: [filesChanged, summary]
|
||||||
|
reviewer:
|
||||||
|
description: "Reviews code changes"
|
||||||
|
systemPrompt: "You are a code reviewer. Review the implementation."
|
||||||
|
outputSchema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
approved:
|
||||||
|
type: boolean
|
||||||
|
comments:
|
||||||
|
type: string
|
||||||
|
required: [approved, comments]
|
||||||
|
conditions:
|
||||||
|
notApproved:
|
||||||
|
description: "Reviewer rejected the implementation"
|
||||||
|
expression: "steps[-1].output.approved = false"
|
||||||
|
graph:
|
||||||
|
$START:
|
||||||
|
- role: "planner"
|
||||||
|
condition: null
|
||||||
|
planner:
|
||||||
|
- role: "developer"
|
||||||
|
condition: null
|
||||||
|
developer:
|
||||||
|
- role: "reviewer"
|
||||||
|
condition: null
|
||||||
|
reviewer:
|
||||||
|
- role: "developer"
|
||||||
|
condition: "notApproved"
|
||||||
|
- role: "$END"
|
||||||
|
condition: null
|
||||||
+6
-1
@@ -32,6 +32,11 @@
|
|||||||
{ "path": "packages/workflow-agent-react" },
|
{ "path": "packages/workflow-agent-react" },
|
||||||
{ "path": "packages/cli-workflow" },
|
{ "path": "packages/cli-workflow" },
|
||||||
{ "path": "packages/workflow-template-solve-issue" },
|
{ "path": "packages/workflow-template-solve-issue" },
|
||||||
{ "path": "packages/workflow-template-develop" }
|
{ "path": "packages/workflow-template-develop" },
|
||||||
|
{ "path": "packages/uwf-protocol" },
|
||||||
|
{ "path": "packages/uwf-moderator" },
|
||||||
|
{ "path": "packages/cli-uwf" },
|
||||||
|
{ "path": "packages/uwf-agent-kit" },
|
||||||
|
{ "path": "packages/uwf-agent-hermes" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user