diff --git a/package.json b/package.json index 4509f84..3b31dd5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "@uncaged/workflow-monorepo", "private": true, "workspaces": [ - "packages/*" + "packages/*", + "../json-cas/packages/json-cas", + "../json-cas/packages/json-cas-fs" ], "scripts": { "build": "bunx tsc --build", diff --git a/packages/cli-uwf/package.json b/packages/cli-uwf/package.json new file mode 100644 index 0000000..5451a21 --- /dev/null +++ b/packages/cli-uwf/package.json @@ -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" + } +} diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts new file mode 100644 index 0000000..159aecd --- /dev/null +++ b/packages/cli-uwf/src/cli.ts @@ -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 { + 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("", "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("", "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 name or hash") + .requiredOption("-p, --prompt ", "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 ULID") + .option("--agent ", "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 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 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); +}); diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts new file mode 100644 index 0000000..e2b058c --- /dev/null +++ b/packages/cli-uwf/src/commands/thread.ts @@ -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 { + 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 { + 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 { + 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 { + const workflow = resolveWorkflowFromHead(uwf, head); + if (workflow === null) { + return null; + } + return { thread: threadId, workflow, head }; +} + +export async function cmdThreadList( + storageRoot: string, + includeAll: boolean, +): Promise { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/cli-uwf/src/commands/workflow.ts b/packages/cli-uwf/src/commands/workflow.ts new file mode 100644 index 0000000..3ebf39b --- /dev/null +++ b/packages/cli-uwf/src/commands/workflow.ts @@ -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 { + 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 { + const roles: Record = {}; + 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 { + 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 { + 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 { + const registry = await loadWorkflowRegistry(storageRoot); + return Object.entries(registry).map(([name, hash]) => ({ name, hash })); +} diff --git a/packages/cli-uwf/src/schemas.ts b/packages/cli-uwf/src/schemas.ts new file mode 100644 index 0000000..cc8bf14 --- /dev/null +++ b/packages/cli-uwf/src/schemas.ts @@ -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 { + 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 }; +} diff --git a/packages/cli-uwf/src/store.ts b/packages/cli-uwf/src/store.ts new file mode 100644 index 0000000..68cbfe5 --- /dev/null +++ b/packages/cli-uwf/src/store.ts @@ -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; + +/** 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 { + 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 { + 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)) { + 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 { + 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 { + 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)) { + 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 { + 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 { + 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; + 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 { + 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 { + const path = getHistoryPath(storageRoot); + await mkdir(storageRoot, { recursive: true }); + const line = `${JSON.stringify(entry)}\n`; + await appendFile(path, line, "utf8"); +} diff --git a/packages/cli-uwf/src/validate.ts b/packages/cli-uwf/src/validate.ts new file mode 100644 index 0000000..33f6a3f --- /dev/null +++ b/packages/cli-uwf/src/validate.ts @@ -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 { + 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; +} diff --git a/packages/cli-uwf/tsconfig.json b/packages/cli-uwf/tsconfig.json new file mode 100644 index 0000000..ceee718 --- /dev/null +++ b/packages/cli-uwf/tsconfig.json @@ -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" } + ] +} diff --git a/packages/uwf-agent-hermes/package.json b/packages/uwf-agent-hermes/package.json new file mode 100644 index 0000000..c20f7ba --- /dev/null +++ b/packages/uwf-agent-hermes/package.json @@ -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" + } +} diff --git a/packages/uwf-agent-hermes/src/cli.ts b/packages/uwf-agent-hermes/src/cli.ts new file mode 100644 index 0000000..39941d1 --- /dev/null +++ b/packages/uwf-agent-hermes/src/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bun + +import { createHermesAgent } from "./hermes.js"; + +const main = createHermesAgent(); +void main(); diff --git a/packages/uwf-agent-hermes/src/hermes.ts b/packages/uwf-agent-hermes/src/hermes.ts new file mode 100644 index 0000000..6851928 --- /dev/null +++ b/packages/uwf-agent-hermes/src/hermes.ts @@ -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 { + 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 { + const fullPrompt = buildHermesPrompt(ctx); + return spawnHermesChat(fullPrompt); +} + +/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */ +export function createHermesAgent(): () => Promise { + return createAgent({ + name: "hermes", + run: runHermes, + }); +} diff --git a/packages/uwf-agent-hermes/src/index.ts b/packages/uwf-agent-hermes/src/index.ts new file mode 100644 index 0000000..b27f12a --- /dev/null +++ b/packages/uwf-agent-hermes/src/index.ts @@ -0,0 +1 @@ +export { buildHermesPrompt, createHermesAgent } from "./hermes.js"; diff --git a/packages/uwf-agent-hermes/tsconfig.json b/packages/uwf-agent-hermes/tsconfig.json new file mode 100644 index 0000000..c00cc38 --- /dev/null +++ b/packages/uwf-agent-hermes/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../uwf-agent-kit" }] +} diff --git a/packages/uwf-agent-kit/__tests__/resolve-extract-model.test.ts b/packages/uwf-agent-kit/__tests__/resolve-extract-model.test.ts new file mode 100644 index 0000000..ec1d4b0 --- /dev/null +++ b/packages/uwf-agent-kit/__tests__/resolve-extract-model.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/uwf-agent-kit/package.json b/packages/uwf-agent-kit/package.json new file mode 100644 index 0000000..9d4eefa --- /dev/null +++ b/packages/uwf-agent-kit/package.json @@ -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" + } +} diff --git a/packages/uwf-agent-kit/src/context.ts b/packages/uwf-agent-kit/src/context.ts new file mode 100644 index 0000000..6563d49 --- /dev/null +++ b/packages/uwf-agent-kit/src/context.ts @@ -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>["store"], + schemas: Awaited>["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>["store"], + outputRef: CasRef, +): unknown { + const node = store.get(outputRef); + if (node === null) { + return {}; + } + return node.payload; +} + +async function buildHistory( + store: Awaited>["store"], + stepsNewestFirst: StepNodePayload[], +): Promise { + 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>["store"], + schemas: Awaited>["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 { + 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>["store"]; + schemas: Awaited>["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 { + 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 }, + }; +} diff --git a/packages/uwf-agent-kit/src/extract.ts b/packages/uwf-agent-kit/src/extract.ts new file mode 100644 index 0000000..cc22ef3 --- /dev/null +++ b/packages/uwf-agent-kit/src/extract.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/uwf-agent-kit/src/index.ts b/packages/uwf-agent-kit/src/index.ts new file mode 100644 index 0000000..acd6eb1 --- /dev/null +++ b/packages/uwf-agent-kit/src/index.ts @@ -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"; diff --git a/packages/uwf-agent-kit/src/run.ts b/packages/uwf-agent-kit/src/run.ts new file mode 100644 index 0000000..43f9722 --- /dev/null +++ b/packages/uwf-agent-kit/src/run.ts @@ -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: "); + } + if (role === undefined || role === "") { + fail("usage: "); + } + return { threadId: threadId as ThreadId, role }; +} + +function runWithMessage(label: string, fn: () => Promise): Promise { + 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 { + 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 { + return runWithMessage("agent run failed", () => options.run(ctx)); +} + +async function extractOutput( + rawOutput: string, + outputSchema: CasRef, + storageRoot: string, +): Promise { + 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>; + rawOutput: string; + outputHash: CasRef; + agentName: string; +}): Promise { + 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 (` `), runs the agent, extracts structured output, + * writes StepNode to CAS, and prints the new node hash to stdout. + */ +export function createAgent(options: AgentOptions): () => Promise { + return async function main(): Promise { + 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`); + }; +} diff --git a/packages/uwf-agent-kit/src/schemas.ts b/packages/uwf-agent-kit/src/schemas.ts new file mode 100644 index 0000000..0500d74 --- /dev/null +++ b/packages/uwf-agent-kit/src/schemas.ts @@ -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 { + 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 }; +} diff --git a/packages/uwf-agent-kit/src/storage.ts b/packages/uwf-agent-kit/src/storage.ts new file mode 100644 index 0000000..b02af71 --- /dev/null +++ b/packages/uwf-agent-kit/src/storage.ts @@ -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>; +}; + +export async function createAgentStore(storageRoot: string): Promise { + const store = createFsStore(getCasDir(storageRoot)); + const schemas = await registerAgentSchemas(store); + return { storageRoot, store, schemas }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeProviders(raw: unknown): Record { + if (!isRecord(raw)) { + throw new Error("config.providers must be a mapping"); + } + const providers: Record = {}; + 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 { + if (!isRecord(raw)) { + throw new Error("config.models must be a mapping"); + } + const models: Record = {}; + 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 { + if (!isRecord(raw)) { + throw new Error("config.agents must be a mapping"); + } + const agents: Record = {}; + 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 | 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 = {}; + for (const [scene, alias] of Object.entries(raw)) { + if (typeof alias === "string") { + overrides[scene] = alias; + } + } + return overrides; +} + +function normalizeAgentOverrides( + raw: unknown, +): Record> | 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> = {}; + for (const [workflowName, rolesRaw] of Object.entries(raw)) { + if (!isRecord(rolesRaw)) { + continue; + } + const roles: Record = {}; + 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 { + 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 { + 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; + } +} diff --git a/packages/uwf-agent-kit/src/types.ts b/packages/uwf-agent-kit/src/types.ts new file mode 100644 index 0000000..2940909 --- /dev/null +++ b/packages/uwf-agent-kit/src/types.ts @@ -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; + +export type AgentOptions = { + name: string; + run: AgentRunFn; +}; diff --git a/packages/uwf-agent-kit/tsconfig.json b/packages/uwf-agent-kit/tsconfig.json new file mode 100644 index 0000000..a9954af --- /dev/null +++ b/packages/uwf-agent-kit/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../uwf-protocol" }] +} diff --git a/packages/uwf-moderator/__tests__/evaluate.test.ts b/packages/uwf-moderator/__tests__/evaluate.test.ts new file mode 100644 index 0000000..a8e4331 --- /dev/null +++ b/packages/uwf-moderator/__tests__/evaluate.test.ts @@ -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" }); + }); +}); diff --git a/packages/uwf-moderator/package.json b/packages/uwf-moderator/package.json new file mode 100644 index 0000000..fc8278e --- /dev/null +++ b/packages/uwf-moderator/package.json @@ -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" + } +} diff --git a/packages/uwf-moderator/src/evaluate.ts b/packages/uwf-moderator/src/evaluate.ts new file mode 100644 index 0000000..05da020 --- /dev/null +++ b/packages/uwf-moderator/src/evaluate.ts @@ -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> { + 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> { + 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}"`), + }; +} diff --git a/packages/uwf-moderator/src/index.ts b/packages/uwf-moderator/src/index.ts new file mode 100644 index 0000000..2d5203f --- /dev/null +++ b/packages/uwf-moderator/src/index.ts @@ -0,0 +1 @@ +export { evaluate } from "./evaluate.js"; diff --git a/packages/uwf-moderator/src/types.ts b/packages/uwf-moderator/src/types.ts new file mode 100644 index 0000000..4b92a78 --- /dev/null +++ b/packages/uwf-moderator/src/types.ts @@ -0,0 +1 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; diff --git a/packages/uwf-moderator/tsconfig.json b/packages/uwf-moderator/tsconfig.json new file mode 100644 index 0000000..a9954af --- /dev/null +++ b/packages/uwf-moderator/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../uwf-protocol" }] +} diff --git a/packages/uwf-protocol/package.json b/packages/uwf-protocol/package.json new file mode 100644 index 0000000..a8c5f7f --- /dev/null +++ b/packages/uwf-protocol/package.json @@ -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" + } +} diff --git a/packages/uwf-protocol/src/index.ts b/packages/uwf-protocol/src/index.ts new file mode 100644 index 0000000..84f315e --- /dev/null +++ b/packages/uwf-protocol/src/index.ts @@ -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"; diff --git a/packages/uwf-protocol/src/schemas.ts b/packages/uwf-protocol/src/schemas.ts new file mode 100644 index 0000000..7f7bfc5 --- /dev/null +++ b/packages/uwf-protocol/src/schemas.ts @@ -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, +}; diff --git a/packages/uwf-protocol/src/types.ts b/packages/uwf-protocol/src/types.ts new file mode 100644 index 0000000..85095da --- /dev/null +++ b/packages/uwf-protocol/src/types.ts @@ -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; + conditions: Record; + graph: Record; +}; + +// ── 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 & { + 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; + models: Record; + agents: Record; + defaultAgent: AgentAlias; + agentOverrides: Record> | null; + defaultModel: ModelAlias; + modelOverrides: Record | null; +}; + +/** ~/.uncaged/workflow/threads.yaml */ +export type ThreadsIndex = Record; diff --git a/packages/uwf-protocol/tsconfig.json b/packages/uwf-protocol/tsconfig.json new file mode 100644 index 0000000..75eba9f --- /dev/null +++ b/packages/uwf-protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/scripts/mock-agent.ts b/scripts/mock-agent.ts new file mode 100644 index 0000000..48cc195 --- /dev/null +++ b/scripts/mock-agent.ts @@ -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(); diff --git a/templates/solve-issue.yaml b/templates/solve-issue.yaml new file mode 100644 index 0000000..bb33e26 --- /dev/null +++ b/templates/solve-issue.yaml @@ -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 diff --git a/tsconfig.json b/tsconfig.json index e3f6e82..34287bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,11 @@ { "path": "packages/workflow-agent-react" }, { "path": "packages/cli-workflow" }, { "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" } ] }