From 391915411e2755c977712205598718a938195c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 08:53:37 +0000 Subject: [PATCH 1/9] feat: add @uncaged/uwf-protocol with all shared types Refs #309, #310 --- packages/uwf-protocol/package.json | 23 +++++ packages/uwf-protocol/src/index.ts | 27 ++++++ packages/uwf-protocol/src/types.ts | 127 ++++++++++++++++++++++++++++ packages/uwf-protocol/tsconfig.json | 8 ++ 4 files changed, 185 insertions(+) create mode 100644 packages/uwf-protocol/package.json create mode 100644 packages/uwf-protocol/src/index.ts create mode 100644 packages/uwf-protocol/src/types.ts create mode 100644 packages/uwf-protocol/tsconfig.json diff --git a/packages/uwf-protocol/package.json b/packages/uwf-protocol/package.json new file mode 100644 index 0000000..025df59 --- /dev/null +++ b/packages/uwf-protocol/package.json @@ -0,0 +1,23 @@ +{ + "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" + } + }, + "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..3eab6da --- /dev/null +++ b/packages/uwf-protocol/src/index.ts @@ -0,0 +1,27 @@ +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/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"] +} From 2a4d35399b716835f520b4c0a99c4767acedecc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 08:58:21 +0000 Subject: [PATCH 2/9] feat: add @uncaged/uwf-moderator with JSONata evaluation engine 5 tests passing: transition, condition match, fallback, missing role error, output expansion. Refs #309, #311 --- .../uwf-moderator/__tests__/evaluate.test.ts | 120 ++++++++++++++++++ packages/uwf-moderator/package.json | 30 +++++ packages/uwf-moderator/src/evaluate.ts | 82 ++++++++++++ packages/uwf-moderator/src/index.ts | 1 + packages/uwf-moderator/src/types.ts | 1 + packages/uwf-moderator/tsconfig.json | 9 ++ 6 files changed, 243 insertions(+) create mode 100644 packages/uwf-moderator/__tests__/evaluate.test.ts create mode 100644 packages/uwf-moderator/package.json create mode 100644 packages/uwf-moderator/src/evaluate.ts create mode 100644 packages/uwf-moderator/src/index.ts create mode 100644 packages/uwf-moderator/src/types.ts create mode 100644 packages/uwf-moderator/tsconfig.json diff --git a/packages/uwf-moderator/__tests__/evaluate.test.ts b/packages/uwf-moderator/__tests__/evaluate.test.ts new file mode 100644 index 0000000..ba5a17e --- /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)", () => { + const result = evaluate(solveIssueWorkflow, makeContext([])); + expect(result).toEqual({ ok: true, value: "planner" }); + }); + + test("condition match (notApproved → developer)", () => { + const context = makeContext([ + { + role: "reviewer", + output: { approved: false }, + detail: "2MXBG6PN4A8JR", + agent: "uwf-hermes", + }, + ]); + const result = evaluate(solveIssueWorkflow, context); + expect(result).toEqual({ ok: true, value: "developer" }); + }); + + test("fallback when condition does not match → $END", () => { + const context = makeContext([ + { + role: "reviewer", + output: { approved: true }, + detail: "2MXBG6PN4A8JR", + agent: "uwf-hermes", + }, + ]); + const result = evaluate(solveIssueWorkflow, context); + expect(result).toEqual({ ok: true, value: "$END" }); + }); + + test("missing role in graph → error", () => { + const context = makeContext([ + { + role: "unknown-role", + output: {}, + detail: "2MXBG6PN4A8JR", + agent: "uwf-hermes", + }, + ]); + const result = 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", () => { + const context = makeContext([ + { + role: "planner", + output: { needsClarification: true }, + detail: "7BQST3VW9F2MA", + agent: "uwf-hermes", + }, + ]); + const result = 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..cf21556 --- /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; +} + +function evaluateJsonata(expression: string, context: ModeratorContext): Result { + try { + const result = 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 function evaluate( + workflow: WorkflowPayload, + context: ModeratorContext, +): Result { + 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 = 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" }] +} From a8e2aa85f84047ee8433c9dc6733cf55aff9a45e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:03:55 +0000 Subject: [PATCH 3/9] feat: add @uncaged/cli-uwf with workflow put/show/list commands Refs #309, #312 --- packages/cli-uwf/package.json | 26 ++++ packages/cli-uwf/src/cli.ts | 110 +++++++++++++++ packages/cli-uwf/src/commands/thread.ts | 9 ++ packages/cli-uwf/src/commands/workflow.ts | 160 ++++++++++++++++++++++ packages/cli-uwf/src/schemas.ts | 85 ++++++++++++ packages/cli-uwf/src/store.ts | 105 ++++++++++++++ packages/cli-uwf/src/validate.ts | 73 ++++++++++ packages/cli-uwf/tsconfig.json | 9 ++ templates/solve-issue.yaml | 59 ++++++++ 9 files changed, 636 insertions(+) create mode 100644 packages/cli-uwf/package.json create mode 100644 packages/cli-uwf/src/cli.ts create mode 100644 packages/cli-uwf/src/commands/thread.ts create mode 100644 packages/cli-uwf/src/commands/workflow.ts create mode 100644 packages/cli-uwf/src/schemas.ts create mode 100644 packages/cli-uwf/src/store.ts create mode 100644 packages/cli-uwf/src/validate.ts create mode 100644 packages/cli-uwf/tsconfig.json create mode 100644 templates/solve-issue.yaml diff --git a/packages/cli-uwf/package.json b/packages/cli-uwf/package.json new file mode 100644 index 0000000..98ca5c7 --- /dev/null +++ b/packages/cli-uwf/package.json @@ -0,0 +1,26 @@ +{ + "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-protocol": "workspace:^", + "commander": "^14.0.3", + "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..730b2df --- /dev/null +++ b/packages/cli-uwf/src/cli.ts @@ -0,0 +1,110 @@ +#!/usr/bin/env bun + +import { Command } from "commander"; + +import { cmdThreadPlaceholder } 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 execution (Phase 4)"); + +thread + .command("start") + .description("Create a thread without executing") + .argument("", "Workflow name or hash") + .requiredOption("-p, --prompt ", "User prompt") + .action(() => { + cmdThreadPlaceholder("start"); + }); + +thread + .command("step") + .description("Execute one step") + .argument("", "Thread ULID") + .option("--agent ", "Override agent command") + .action(() => { + cmdThreadPlaceholder("step"); + }); + +thread + .command("show") + .description("Show thread head pointer") + .argument("", "Thread ULID") + .action(() => { + cmdThreadPlaceholder("show"); + }); + +thread + .command("list") + .description("List active threads") + .option("--all", "Include archived threads") + .action(() => { + cmdThreadPlaceholder("list"); + }); + +thread + .command("kill") + .description("Terminate and archive a thread") + .argument("", "Thread ULID") + .action(() => { + cmdThreadPlaceholder("kill"); + }); + +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..0265ebd --- /dev/null +++ b/packages/cli-uwf/src/commands/thread.ts @@ -0,0 +1,9 @@ +function fail(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +/** Phase 4 placeholder — thread commands are not implemented yet. */ +export function cmdThreadPlaceholder(command: string): never { + fail(`uwf thread ${command}: not implemented (Phase 4)`); +} diff --git a/packages/cli-uwf/src/commands/workflow.ts b/packages/cli-uwf/src/commands/workflow.ts new file mode 100644 index 0000000..833042a --- /dev/null +++ b/packages/cli-uwf/src/commands/workflow.ts @@ -0,0 +1,160 @@ +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); + if (hash === null) { + fail(`workflow not found: ${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..70b7590 --- /dev/null +++ b/packages/cli-uwf/src/schemas.ts @@ -0,0 +1,85 @@ +import type { Hash, JSONSchema, Store } from "@uncaged/json-cas"; +import { putSchema } 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, +}; + +const WORKFLOW: 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, +}; + +const START_NODE: JSONSchema = { + type: "object", + required: ["workflow", "prompt"], + properties: { + workflow: { type: "string", format: "cas_ref" }, + prompt: { type: "string" }, + }, + additionalProperties: false, +}; + +export type UwfSchemaHashes = { + workflow: Hash; + startNode: Hash; +}; + +/** + * Register Workflow and StartNode JSON Schemas in the CAS store. + * Idempotent: safe to call on every CLI invocation. + */ +export async function registerUwfSchemas(store: Store): Promise { + const [workflow, startNode] = await Promise.all([ + putSchema(store, WORKFLOW), + putSchema(store, START_NODE), + ]); + return { workflow, startNode }; +} diff --git a/packages/cli-uwf/src/store.ts b/packages/cli-uwf/src/store.ts new file mode 100644 index 0000000..f15870b --- /dev/null +++ b/packages/cli-uwf/src/store.ts @@ -0,0 +1,105 @@ +import { 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 } 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 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 | null { + if (registry[id] !== undefined) { + return registry[id]; + } + return 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; +} 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..a9954af --- /dev/null +++ b/packages/cli-uwf/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../uwf-protocol" }] +} 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 From 0d5678c96170e65e80d8e93ac5fa8d9a819c7ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:09:10 +0000 Subject: [PATCH 4/9] feat: add thread start/show/list/kill commands - thread start: ULID generation, StartNode to CAS, threads.yaml - thread show: active (done:false) or archived (done:true) - thread list: active threads, --all includes history - thread kill: archive to history.jsonl Refs #309, #313 --- packages/cli-uwf/package.json | 1 + packages/cli-uwf/src/cli.ts | 39 +++-- packages/cli-uwf/src/commands/thread.ts | 209 +++++++++++++++++++++++- packages/cli-uwf/src/store.ts | 114 ++++++++++++- 4 files changed, 347 insertions(+), 16 deletions(-) diff --git a/packages/cli-uwf/package.json b/packages/cli-uwf/package.json index 98ca5c7..a056665 100644 --- a/packages/cli-uwf/package.json +++ b/packages/cli-uwf/package.json @@ -14,6 +14,7 @@ "@uncaged/json-cas": "workspace:^", "@uncaged/json-cas-fs": "workspace:^", "@uncaged/uwf-protocol": "workspace:^", + "@uncaged/workflow-util": "workspace:^", "commander": "^14.0.3", "yaml": "^2.8.4" }, diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts index 730b2df..c080175 100644 --- a/packages/cli-uwf/src/cli.ts +++ b/packages/cli-uwf/src/cli.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; -import { cmdThreadPlaceholder } from "./commands/thread.js"; +import { cmdThreadKill, cmdThreadList, cmdThreadShow, cmdThreadStart } from "./commands/thread.js"; import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js"; import { resolveStorageRoot } from "./store.js"; @@ -59,15 +59,19 @@ workflow }); }); -const thread = program.command("thread").description("Thread execution (Phase 4)"); +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(() => { - cmdThreadPlaceholder("start"); + .action((workflow: string, opts: { prompt: string }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdThreadStart(storageRoot, workflow, opts.prompt); + writeJson(result); + }); }); thread @@ -76,31 +80,44 @@ thread .argument("", "Thread ULID") .option("--agent ", "Override agent command") .action(() => { - cmdThreadPlaceholder("step"); + process.stderr.write("uwf thread step: not implemented\n"); + process.exit(1); }); thread .command("show") .description("Show thread head pointer") .argument("", "Thread ULID") - .action(() => { - cmdThreadPlaceholder("show"); + .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(() => { - cmdThreadPlaceholder("list"); + .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(() => { - cmdThreadPlaceholder("kill"); + .action((threadId: string) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + const result = await cmdThreadKill(storageRoot, threadId); + writeJson(result); + }); }); program.parseAsync(process.argv).catch((e: unknown) => { diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index 0265ebd..ade3ae8 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -1,9 +1,212 @@ +import { validate } from "@uncaged/json-cas"; +import type { + CasRef, + StartNodePayload, + StartOutput, + StepNodePayload, + StepOutput, + ThreadId, + ThreadListItem, +} from "@uncaged/uwf-protocol"; +import { generateUlid } from "@uncaged/workflow-util"; + +import { + appendThreadHistory, + createUwfStore, + findThreadInHistory, + loadThreadHistory, + loadThreadsIndex, + loadWorkflowRegistry, + resolveWorkflowHash, + saveThreadsIndex, + type ThreadHistoryLine, + type UwfStore, +} from "../store.js"; +import { isCasRef } from "../validate.js"; + +export type KillOutput = { + thread: ThreadId; + archived: boolean; +}; + function fail(message: string): never { process.stderr.write(`${message}\n`); process.exit(1); } -/** Phase 4 placeholder — thread commands are not implemented yet. */ -export function cmdThreadPlaceholder(command: string): never { - fail(`uwf thread ${command}: not implemented (Phase 4)`); +async function resolveWorkflowCasRef( + uwf: UwfStore, + storageRoot: string, + workflowId: string, +): Promise { + const registry = await loadWorkflowRegistry(storageRoot); + const hash = resolveWorkflowHash(registry, workflowId); + if (hash === null) { + fail(`workflow not found: ${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; +} + +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/store.ts b/packages/cli-uwf/src/store.ts index f15870b..207234c 100644 --- a/packages/cli-uwf/src/store.ts +++ b/packages/cli-uwf/src/store.ts @@ -1,10 +1,10 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +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 } from "@uncaged/uwf-protocol"; +import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol"; import { parse, stringify } from "yaml"; import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js"; @@ -40,6 +40,18 @@ 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; @@ -103,3 +115,101 @@ export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string } 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"); +} From 4d477c67c03abd8da51a8b73df506ce2379f2601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:15:25 +0000 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20add=20@uncaged/uwf-agent-kit=20?= =?UTF-8?q?=E2=80=94=20agent=20CLI=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createAgent() API for building agent CLIs - Context builder: reads CAS chain, builds AgentContext - Extract: LLM-based structured output extraction - StepNode writer: writes to CAS without touching threads.yaml - Stdout: outputs StepNode hash Refs #309, #314 --- .../__tests__/resolve-extract-model.test.ts | 42 ++++ packages/uwf-agent-kit/package.json | 33 +++ packages/uwf-agent-kit/src/context.ts | 199 +++++++++++++++ packages/uwf-agent-kit/src/extract.ts | 181 ++++++++++++++ packages/uwf-agent-kit/src/index.ts | 10 + packages/uwf-agent-kit/src/run.ts | 135 +++++++++++ packages/uwf-agent-kit/src/schemas.ts | 72 ++++++ packages/uwf-agent-kit/src/storage.ts | 227 ++++++++++++++++++ packages/uwf-agent-kit/src/types.ts | 17 ++ packages/uwf-agent-kit/tsconfig.json | 9 + 10 files changed, 925 insertions(+) create mode 100644 packages/uwf-agent-kit/__tests__/resolve-extract-model.test.ts create mode 100644 packages/uwf-agent-kit/package.json create mode 100644 packages/uwf-agent-kit/src/context.ts create mode 100644 packages/uwf-agent-kit/src/extract.ts create mode 100644 packages/uwf-agent-kit/src/index.ts create mode 100644 packages/uwf-agent-kit/src/run.ts create mode 100644 packages/uwf-agent-kit/src/schemas.ts create mode 100644 packages/uwf-agent-kit/src/storage.ts create mode 100644 packages/uwf-agent-kit/src/types.ts create mode 100644 packages/uwf-agent-kit/tsconfig.json 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..5506a31 --- /dev/null +++ b/packages/uwf-agent-kit/src/index.ts @@ -0,0 +1,10 @@ +export type { BuildContextMeta } from "./context.js"; +export { buildContext, buildContextWithMeta } from "./context.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..18bc398 --- /dev/null +++ b/packages/uwf-agent-kit/src/schemas.ts @@ -0,0 +1,72 @@ +import type { Hash, JSONSchema, Store } from "@uncaged/json-cas"; +import { putSchema } from "@uncaged/json-cas"; + +const STEP_NODE: 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, +}; + +const START_NODE: JSONSchema = { + type: "object", + required: ["workflow", "prompt"], + properties: { + workflow: { type: "string", format: "cas_ref" }, + prompt: { type: "string" }, + }, + additionalProperties: false, +}; + +const WORKFLOW: JSONSchema = { + type: "object", + required: ["name", "description", "roles", "conditions", "graph"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + roles: { + type: "object", + additionalProperties: { + type: "object", + required: ["description", "systemPrompt", "outputSchema"], + properties: { + description: { type: "string" }, + systemPrompt: { type: "string" }, + outputSchema: { type: "string", format: "cas_ref" }, + }, + additionalProperties: false, + }, + }, + conditions: { type: "object" }, + graph: { type: "object" }, + }, + additionalProperties: false, +}; + +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), + putSchema(store, START_NODE), + putSchema(store, STEP_NODE), + ]); + 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" }] +} From b165049a1392d52ecb1fb2d66ef3d48bf5faafd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:19:37 +0000 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20implement=20thread=20step=20?= =?UTF-8?q?=E2=80=94=20moderator=20=E2=86=92=20agent=20=E2=86=92=20update?= =?UTF-8?q?=20head?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Walk CAS chain to build ModeratorContext with expanded output - Call uwf-moderator evaluate() for role decision - Agent resolution: --agent > config overrides > default - Spawn agent CLI, capture StepNode hash - Update threads.yaml, check done via second evaluate - Archive on $END Refs #309, #315 --- packages/cli-uwf/package.json | 3 + packages/cli-uwf/src/cli.ts | 18 +- packages/cli-uwf/src/commands/thread.ts | 252 ++++++++++++++++++++++++ packages/cli-uwf/src/schemas.ts | 24 ++- packages/cli-uwf/tsconfig.json | 6 +- packages/uwf-agent-kit/src/index.ts | 1 + 6 files changed, 296 insertions(+), 8 deletions(-) diff --git a/packages/cli-uwf/package.json b/packages/cli-uwf/package.json index a056665..5451a21 100644 --- a/packages/cli-uwf/package.json +++ b/packages/cli-uwf/package.json @@ -13,9 +13,12 @@ "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": { diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts index c080175..159aecd 100644 --- a/packages/cli-uwf/src/cli.ts +++ b/packages/cli-uwf/src/cli.ts @@ -2,7 +2,13 @@ import { Command } from "commander"; -import { cmdThreadKill, cmdThreadList, cmdThreadShow, cmdThreadStart } from "./commands/thread.js"; +import { + cmdThreadKill, + cmdThreadList, + cmdThreadShow, + cmdThreadStart, + cmdThreadStep, +} from "./commands/thread.js"; import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js"; import { resolveStorageRoot } from "./store.js"; @@ -79,9 +85,13 @@ thread .description("Execute one step") .argument("", "Thread ULID") .option("--agent ", "Override agent command") - .action(() => { - process.stderr.write("uwf thread step: not implemented\n"); - process.exit(1); + .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 diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index ade3ae8..47dba49 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -1,14 +1,25 @@ +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, @@ -24,6 +35,15 @@ import { } 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; @@ -184,6 +204,238 @@ export async function cmdThreadList( 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 = 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); + + const newNode = uwf.store.get(newHead); + if (newNode === null || newNode.type !== uwf.schemas.stepNode) { + fail(`agent returned hash that is not a StepNode: ${newHead}`); + } + + index[threadId] = newHead; + await saveThreadsIndex(storageRoot, index); + + const chainAfter = walkChain(uwf, newHead); + const contextAfter = buildModeratorContext(uwf, chainAfter); + const afterResult = 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]; diff --git a/packages/cli-uwf/src/schemas.ts b/packages/cli-uwf/src/schemas.ts index 70b7590..d3c3a49 100644 --- a/packages/cli-uwf/src/schemas.ts +++ b/packages/cli-uwf/src/schemas.ts @@ -67,19 +67,37 @@ const START_NODE: JSONSchema = { additionalProperties: false, }; +const STEP_NODE: 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, +}; + export type UwfSchemaHashes = { workflow: Hash; startNode: Hash; + stepNode: Hash; }; /** - * Register Workflow and StartNode JSON Schemas in the CAS store. + * 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] = await Promise.all([ + const [workflow, startNode, stepNode] = await Promise.all([ putSchema(store, WORKFLOW), putSchema(store, START_NODE), + putSchema(store, STEP_NODE), ]); - return { workflow, startNode }; + return { workflow, startNode, stepNode }; } diff --git a/packages/cli-uwf/tsconfig.json b/packages/cli-uwf/tsconfig.json index a9954af..ceee718 100644 --- a/packages/cli-uwf/tsconfig.json +++ b/packages/cli-uwf/tsconfig.json @@ -5,5 +5,9 @@ "outDir": "dist" }, "include": ["src"], - "references": [{ "path": "../uwf-protocol" }] + "references": [ + { "path": "../uwf-protocol" }, + { "path": "../uwf-moderator" }, + { "path": "../uwf-agent-kit" } + ] } diff --git a/packages/uwf-agent-kit/src/index.ts b/packages/uwf-agent-kit/src/index.ts index 5506a31..acd6eb1 100644 --- a/packages/uwf-agent-kit/src/index.ts +++ b/packages/uwf-agent-kit/src/index.ts @@ -1,5 +1,6 @@ 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, From ba012d98bcd9a5a21647d3a2d65e73df035a4561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:22:12 +0000 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20add=20@uncaged/uwf-agent-hermes=20?= =?UTF-8?q?=E2=80=94=20Hermes=20agent=20CLI=20adapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawns 'hermes chat' with assembled prompt from agent-kit context. Agent-kit handles extract, StepNode write, and stdout output. Refs #309, #316 --- packages/uwf-agent-hermes/package.json | 32 +++++++++ packages/uwf-agent-hermes/src/cli.ts | 6 ++ packages/uwf-agent-hermes/src/hermes.ts | 90 +++++++++++++++++++++++++ packages/uwf-agent-hermes/src/index.ts | 1 + packages/uwf-agent-hermes/tsconfig.json | 9 +++ 5 files changed, 138 insertions(+) create mode 100644 packages/uwf-agent-hermes/package.json create mode 100644 packages/uwf-agent-hermes/src/cli.ts create mode 100644 packages/uwf-agent-hermes/src/hermes.ts create mode 100644 packages/uwf-agent-hermes/src/index.ts create mode 100644 packages/uwf-agent-hermes/tsconfig.json 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" }] +} From 0727e0e8d5a3cad472210a7328c255571de3ebe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 09:33:52 +0000 Subject: [PATCH 8/9] fix: reload CAS store after agent spawn + share schemas via uwf-protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent subprocess writes StepNode to CAS on disk, but the parent process had an in-memory cache from createFsStore init. Fix: re-create store after agent spawn to pick up new nodes. Also centralized JSON Schemas in uwf-protocol so cli-uwf and agent-kit produce identical type hashes. E2E smoke test passing: workflow put → thread start → 3x step → done Refs #309 --- packages/cli-uwf/src/commands/thread.ts | 10 +-- packages/cli-uwf/src/schemas.ts | 95 +++---------------------- packages/uwf-agent-kit/src/schemas.ts | 64 +++-------------- packages/uwf-protocol/package.json | 3 + packages/uwf-protocol/src/index.ts | 5 ++ packages/uwf-protocol/src/schemas.ts | 83 +++++++++++++++++++++ scripts/mock-agent.ts | 12 ++++ 7 files changed, 127 insertions(+), 145 deletions(-) create mode 100644 packages/uwf-protocol/src/schemas.ts create mode 100644 scripts/mock-agent.ts diff --git a/packages/cli-uwf/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index 47dba49..f73a514 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -408,16 +408,18 @@ export async function cmdThreadStep( loadDotenv({ path: getEnvPath(storageRoot) }); const newHead = spawnAgent(agent, threadId, role); - const newNode = uwf.store.get(newHead); - if (newNode === null || newNode.type !== uwf.schemas.stepNode) { + // 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}`); } index[threadId] = newHead; await saveThreadsIndex(storageRoot, index); - const chainAfter = walkChain(uwf, newHead); - const contextAfter = buildModeratorContext(uwf, chainAfter); + const chainAfter = walkChain(uwfAfter, newHead); + const contextAfter = buildModeratorContext(uwfAfter, chainAfter); const afterResult = evaluate(workflow, contextAfter); if (!afterResult.ok) { fail(afterResult.error.message); diff --git a/packages/cli-uwf/src/schemas.ts b/packages/cli-uwf/src/schemas.ts index d3c3a49..cc8bf14 100644 --- a/packages/cli-uwf/src/schemas.ts +++ b/packages/cli-uwf/src/schemas.ts @@ -1,87 +1,10 @@ -import type { Hash, JSONSchema, Store } from "@uncaged/json-cas"; +import type { Hash, Store } from "@uncaged/json-cas"; import { putSchema } 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, -}; - -const WORKFLOW: 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, -}; - -const START_NODE: JSONSchema = { - type: "object", - required: ["workflow", "prompt"], - properties: { - workflow: { type: "string", format: "cas_ref" }, - prompt: { type: "string" }, - }, - additionalProperties: false, -}; - -const STEP_NODE: 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, -}; +import { + START_NODE_SCHEMA, + STEP_NODE_SCHEMA, + WORKFLOW_SCHEMA, +} from "@uncaged/uwf-protocol"; export type UwfSchemaHashes = { workflow: Hash; @@ -95,9 +18,9 @@ export type UwfSchemaHashes = { */ export async function registerUwfSchemas(store: Store): Promise { const [workflow, startNode, stepNode] = await Promise.all([ - putSchema(store, WORKFLOW), - putSchema(store, START_NODE), - putSchema(store, STEP_NODE), + 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/schemas.ts b/packages/uwf-agent-kit/src/schemas.ts index 18bc398..0500d74 100644 --- a/packages/uwf-agent-kit/src/schemas.ts +++ b/packages/uwf-agent-kit/src/schemas.ts @@ -1,56 +1,10 @@ -import type { Hash, JSONSchema, Store } from "@uncaged/json-cas"; +import type { Hash, Store } from "@uncaged/json-cas"; import { putSchema } from "@uncaged/json-cas"; - -const STEP_NODE: 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, -}; - -const START_NODE: JSONSchema = { - type: "object", - required: ["workflow", "prompt"], - properties: { - workflow: { type: "string", format: "cas_ref" }, - prompt: { type: "string" }, - }, - additionalProperties: false, -}; - -const WORKFLOW: JSONSchema = { - type: "object", - required: ["name", "description", "roles", "conditions", "graph"], - properties: { - name: { type: "string" }, - description: { type: "string" }, - roles: { - type: "object", - additionalProperties: { - type: "object", - required: ["description", "systemPrompt", "outputSchema"], - properties: { - description: { type: "string" }, - systemPrompt: { type: "string" }, - outputSchema: { type: "string", format: "cas_ref" }, - }, - additionalProperties: false, - }, - }, - conditions: { type: "object" }, - graph: { type: "object" }, - }, - additionalProperties: false, -}; +import { + START_NODE_SCHEMA, + STEP_NODE_SCHEMA, + WORKFLOW_SCHEMA, +} from "@uncaged/uwf-protocol"; export type UwfAgentSchemaHashes = { workflow: Hash; @@ -64,9 +18,9 @@ export type UwfAgentSchemaHashes = { */ export async function registerAgentSchemas(store: Store): Promise { const [workflow, startNode, stepNode] = await Promise.all([ - putSchema(store, WORKFLOW), - putSchema(store, START_NODE), - putSchema(store, STEP_NODE), + putSchema(store, WORKFLOW_SCHEMA), + putSchema(store, START_NODE_SCHEMA), + putSchema(store, STEP_NODE_SCHEMA), ]); return { workflow, startNode, stepNode }; } diff --git a/packages/uwf-protocol/package.json b/packages/uwf-protocol/package.json index 025df59..a8c5f7f 100644 --- a/packages/uwf-protocol/package.json +++ b/packages/uwf-protocol/package.json @@ -14,6 +14,9 @@ "import": "./dist/index.js" } }, + "dependencies": { + "@uncaged/json-cas": "workspace:^" + }, "devDependencies": { "typescript": "^5.8.3" }, diff --git a/packages/uwf-protocol/src/index.ts b/packages/uwf-protocol/src/index.ts index 3eab6da..84f315e 100644 --- a/packages/uwf-protocol/src/index.ts +++ b/packages/uwf-protocol/src/index.ts @@ -1,3 +1,8 @@ +export { + START_NODE_SCHEMA, + STEP_NODE_SCHEMA, + WORKFLOW_SCHEMA, +} from "./schemas.js"; export type { AgentAlias, AgentConfig, 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/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(); From d90e29ad0561d40602acef57f437c06d849690bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 10:05:11 +0000 Subject: [PATCH 9/9] fix: address 3 critical PR review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. threads.yaml race condition: reload threads index after agent subprocess completes before updating head pointer (cli-uwf/commands/thread.ts) 2. evaluateJsonata not awaited: jsonata evaluate() returns Promise for async expressions — now properly awaited (uwf-moderator/evaluate.ts) 3. resolveWorkflowHash dead code: function always returns a value, removed impossible null return type and dead null-check branches at call sites (cli-uwf/store.ts, commands/thread.ts, commands/workflow.ts) --- package.json | 4 +++- packages/cli-uwf/src/commands/thread.ts | 13 ++++++------ packages/cli-uwf/src/commands/workflow.ts | 3 --- packages/cli-uwf/src/store.ts | 7 ++----- .../uwf-moderator/__tests__/evaluate.test.ts | 20 +++++++++---------- packages/uwf-moderator/src/evaluate.ts | 10 +++++----- tsconfig.json | 7 ++++++- 7 files changed, 32 insertions(+), 32 deletions(-) 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/src/commands/thread.ts b/packages/cli-uwf/src/commands/thread.ts index f73a514..e2b058c 100644 --- a/packages/cli-uwf/src/commands/thread.ts +++ b/packages/cli-uwf/src/commands/thread.ts @@ -61,9 +61,6 @@ async function resolveWorkflowCasRef( ): Promise { const registry = await loadWorkflowRegistry(storageRoot); const hash = resolveWorkflowHash(registry, workflowId); - if (hash === null) { - fail(`workflow not found: ${workflowId}`); - } if (!isCasRef(hash)) { fail(`workflow not found: ${workflowId}`); } @@ -386,7 +383,7 @@ export async function cmdThreadStep( const workflow = loadWorkflowPayload(uwf, workflowHash); const context = buildModeratorContext(uwf, chain); - const nextResult = evaluate(workflow, context); + const nextResult = await evaluate(workflow, context); if (!nextResult.ok) { fail(nextResult.error.message); } @@ -415,12 +412,14 @@ export async function cmdThreadStep( fail(`agent returned hash that is not a StepNode: ${newHead}`); } - index[threadId] = newHead; - await saveThreadsIndex(storageRoot, index); + // 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 = evaluate(workflow, contextAfter); + const afterResult = await evaluate(workflow, contextAfter); if (!afterResult.ok) { fail(afterResult.error.message); } diff --git a/packages/cli-uwf/src/commands/workflow.ts b/packages/cli-uwf/src/commands/workflow.ts index 833042a..3ebf39b 100644 --- a/packages/cli-uwf/src/commands/workflow.ts +++ b/packages/cli-uwf/src/commands/workflow.ts @@ -132,9 +132,6 @@ export async function cmdWorkflowShow( const uwf = await createUwfStore(storageRoot); const registry = await loadWorkflowRegistry(storageRoot); const hash = resolveWorkflowHash(registry, id); - if (hash === null) { - fail(`workflow not found: ${id}`); - } const node = uwf.store.get(hash); if (node === null) { diff --git a/packages/cli-uwf/src/store.ts b/packages/cli-uwf/src/store.ts index 207234c..68cbfe5 100644 --- a/packages/cli-uwf/src/store.ts +++ b/packages/cli-uwf/src/store.ts @@ -100,11 +100,8 @@ export async function saveWorkflowRegistry( await writeFile(path, text, "utf8"); } -export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef | null { - if (registry[id] !== undefined) { - return registry[id]; - } - return id; +export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef { + return registry[id] !== undefined ? registry[id] : id; } export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null { diff --git a/packages/uwf-moderator/__tests__/evaluate.test.ts b/packages/uwf-moderator/__tests__/evaluate.test.ts index ba5a17e..a8e4331 100644 --- a/packages/uwf-moderator/__tests__/evaluate.test.ts +++ b/packages/uwf-moderator/__tests__/evaluate.test.ts @@ -58,12 +58,12 @@ function makeContext(steps: ModeratorContext["steps"]): ModeratorContext { } describe("evaluate", () => { - test("$START → first role (fallback)", () => { - const result = evaluate(solveIssueWorkflow, makeContext([])); + test("$START → first role (fallback)", async () => { + const result = await evaluate(solveIssueWorkflow, makeContext([])); expect(result).toEqual({ ok: true, value: "planner" }); }); - test("condition match (notApproved → developer)", () => { + test("condition match (notApproved → developer)", async () => { const context = makeContext([ { role: "reviewer", @@ -72,11 +72,11 @@ describe("evaluate", () => { agent: "uwf-hermes", }, ]); - const result = evaluate(solveIssueWorkflow, context); + const result = await evaluate(solveIssueWorkflow, context); expect(result).toEqual({ ok: true, value: "developer" }); }); - test("fallback when condition does not match → $END", () => { + test("fallback when condition does not match → $END", async () => { const context = makeContext([ { role: "reviewer", @@ -85,11 +85,11 @@ describe("evaluate", () => { agent: "uwf-hermes", }, ]); - const result = evaluate(solveIssueWorkflow, context); + const result = await evaluate(solveIssueWorkflow, context); expect(result).toEqual({ ok: true, value: "$END" }); }); - test("missing role in graph → error", () => { + test("missing role in graph → error", async () => { const context = makeContext([ { role: "unknown-role", @@ -98,14 +98,14 @@ describe("evaluate", () => { agent: "uwf-hermes", }, ]); - const result = evaluate(solveIssueWorkflow, context); + 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", () => { + test("output expansion in context works with JSONata", async () => { const context = makeContext([ { role: "planner", @@ -114,7 +114,7 @@ describe("evaluate", () => { agent: "uwf-hermes", }, ]); - const result = evaluate(solveIssueWorkflow, context); + const result = await evaluate(solveIssueWorkflow, context); expect(result).toEqual({ ok: true, value: "developer" }); }); }); diff --git a/packages/uwf-moderator/src/evaluate.ts b/packages/uwf-moderator/src/evaluate.ts index cf21556..05da020 100644 --- a/packages/uwf-moderator/src/evaluate.ts +++ b/packages/uwf-moderator/src/evaluate.ts @@ -21,9 +21,9 @@ function isTruthy(value: unknown): boolean { return true; } -function evaluateJsonata(expression: string, context: ModeratorContext): Result { +async function evaluateJsonata(expression: string, context: ModeratorContext): Promise> { try { - const result = jsonata(expression).evaluate(context); + const result = await jsonata(expression).evaluate(context); return { ok: true, value: result }; } catch (error) { return { @@ -40,10 +40,10 @@ function currentRole(context: ModeratorContext): string { return context.steps[context.steps.length - 1].role; } -export function evaluate( +export async function evaluate( workflow: WorkflowPayload, context: ModeratorContext, -): Result { +): Promise> { const role = currentRole(context); const transitions = workflow.graph[role]; if (transitions === undefined) { @@ -66,7 +66,7 @@ export function evaluate( }; } - const evalResult = evaluateJsonata(conditionDef.expression, context); + const evalResult = await evaluateJsonata(conditionDef.expression, context); if (!evalResult.ok) { return evalResult; } 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" } ] }