Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5342fa95f2 | |||
| 6cbd2e306e | |||
| 08c5476f28 | |||
| 684c2e8be4 | |||
| 58aabb7bf5 | |||
| 7bbe5b62d4 | |||
| 9e351de294 | |||
| df748982e6 | |||
| d93d299677 | |||
| 701f320e23 | |||
| 68ec2884d6 | |||
| a2f24e1eb5 | |||
| 1e9f4d650d | |||
| 288a93dcce | |||
| c07d4cca4d | |||
| 8ee5112c9d | |||
| 1a397c1dc8 | |||
| ca24e6abde | |||
| 4e126a258a |
@@ -0,0 +1,35 @@
|
||||
# Uncaged Workflow Architecture
|
||||
|
||||
Uncaged Workflow is a monorepo implementing a workflow engine that executes single-file ESM bundles. Each workflow is identified by an XXH64 hash (Crockford Base32); execution state is stored in a content-addressable store (CAS) as immutable Merkle nodes. Agents are pluggable — the same workflow definition runs with Cursor, Hermes, a raw LLM, or a ReAct loop.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Card | Description |
|
||||
|------|-------------|
|
||||
| [Bundle](./bundle.md) | A single-file `.esm.js` module with an XXH64 hash identity, stored in `~/.uncaged/workflow/bundles/` |
|
||||
| [Thread](./thread.md) | A single execution instance of a workflow, identified by a ULID, with CAS-linked state nodes |
|
||||
| [CAS](./cas.md) | The content-addressable store that holds all immutable blobs — content, start nodes, and state nodes |
|
||||
| [Registry](./registry.md) | `workflow.yaml` — maps workflow names to current and historical bundle hashes |
|
||||
|
||||
## Execution
|
||||
|
||||
| Card | Description |
|
||||
|------|-------------|
|
||||
| [Engine](./engine.md) | The three-phase loop that drives the workflow `AsyncGenerator` and writes each step to CAS |
|
||||
| [Role](./role.md) | A named actor defined as pure data (`RoleDefinition`) — description, system prompt, and Zod schema |
|
||||
| [Agent Binding](./agent-binding.md) | The runtime binding that connects a role to a concrete agent implementation via `AdapterFn` |
|
||||
| [Reactor](./reactor.md) | The ReAct loop abstraction for LLM function-calling, used by both the extract phase and agent adapters |
|
||||
|
||||
## Tooling
|
||||
|
||||
| Card | Description |
|
||||
|------|-------------|
|
||||
| [CLI](./cli.md) | The `uncaged-workflow` command-line tool for managing workflows, threads, and CAS |
|
||||
| [Dashboard](./dashboard.md) | A private React app for inspecting threads, workflows, and live execution via the gateway |
|
||||
| [Package Map](./package-map.md) | All packages in the monorepo with their layer positions and dependency graph |
|
||||
|
||||
## Authoring
|
||||
|
||||
| Card | Description |
|
||||
|------|-------------|
|
||||
| [Workflow Templates](./workflow-templates.md) | The `solve-issue` and `develop` reference templates and how to author custom workflows |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Agent Binding
|
||||
|
||||
> The runtime connection between a workflow's role definitions and a concrete agent implementation, expressed as an `AdapterBinding` passed to `createWorkflow`.
|
||||
|
||||
## Overview
|
||||
|
||||
Agent binding is how a workflow author specifies which agent executes each role. Roles are pure data (see [Role](./role.md)); the binding supplies the execution strategy. The same `WorkflowDefinition` can be run with different agents by changing the `AdapterBinding` — useful for testing, cost optimization, or environment-specific deployment.
|
||||
|
||||
An `AdapterFn` receives a role's `systemPrompt` and Zod `schema`, and returns a `RoleFn` — a function that takes `ThreadContext` and `WorkflowRuntime` and returns `RoleResult<T>`. The adapter is responsible for producing typed structured output directly; there is no separate extract phase when using adapters.
|
||||
|
||||
## Key Types
|
||||
|
||||
```typescript
|
||||
// The core adapter interface
|
||||
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
||||
|
||||
type RoleResult<T> = { meta: T; childThread: string | null };
|
||||
|
||||
// The binding passed to createWorkflow
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
```
|
||||
|
||||
`overrides` allows per-role adapters — for example, using Cursor for one role and an LLM for another within the same workflow.
|
||||
|
||||
## AgentFn (Legacy / Low-level)
|
||||
|
||||
Below the adapter layer, the original `AgentFn` type still exists for agent implementations that produce raw strings rather than structured output:
|
||||
|
||||
```typescript
|
||||
type AgentFn<Opt = void> = Opt extends void
|
||||
? (ctx: ThreadContext) => Promise<string>
|
||||
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
```
|
||||
|
||||
The `createAgentAdapter` utility in `@uncaged/workflow-util-agent` wraps an `AgentFn` into an `AdapterFn` by composing it with extraction logic.
|
||||
|
||||
## Concrete Implementations
|
||||
|
||||
| Package | Export | Agent |
|
||||
|---------|--------|-------|
|
||||
| `@uncaged/workflow-agent-cursor` | `createCursorAgent` | Runs `cursor` CLI non-interactively in a workspace directory |
|
||||
| `@uncaged/workflow-agent-hermes` | `createHermesAgent` | Runs `hermes chat` with `--yolo --quiet` (Nerve-style argv) |
|
||||
| `@uncaged/workflow-agent-llm` | `createLlmAdapter` | Direct LLM completion via the OpenAI-compatible chat endpoint |
|
||||
| `@uncaged/workflow-agent-react` | `createReactAdapter` | ReAct loop with file and shell tools (read, write, patch, exec) |
|
||||
|
||||
All four return an `AdapterFn` suitable for use in `AdapterBinding.adapter`.
|
||||
|
||||
## workflow-util-agent
|
||||
|
||||
`@uncaged/workflow-util-agent` provides two helpers shared by adapter implementations:
|
||||
|
||||
- **`buildThreadInput(ctx)`** — constructs the user-message string from thread context (task, previous steps, tool hints). Used by all CLI-based agents.
|
||||
- **`spawnCli(command, args, opts)`** — spawns an external process (e.g., `cursor`, `hermes`) and captures stdout, with optional timeout.
|
||||
- **`createAgentAdapter(agentFn, optionsFn)`** — wraps an `AgentFn<Opt>` into an `AdapterFn`, handling the options extraction step.
|
||||
|
||||
## Cursor Agent
|
||||
|
||||
`createCursorAgent(config)` invokes the `cursor` CLI binary:
|
||||
|
||||
```
|
||||
cursor -p <fullPrompt> --model <model> --workspace <path> --output-format text --trust --force
|
||||
```
|
||||
|
||||
The workspace path is taken from `config.workspace` or extracted from the thread context via `runtime.extract`.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
`createHermesAgent(config)` invokes `hermes chat`:
|
||||
|
||||
```
|
||||
hermes chat -q <fullPrompt> --yolo --max-turns 90 --quiet [--model <model>]
|
||||
```
|
||||
|
||||
## LLM Adapter
|
||||
|
||||
`createLlmAdapter(provider)` calls the OpenAI-compatible chat completions endpoint directly. It builds a two-message conversation (system + user) from the role's `systemPrompt` and `buildThreadInput` output, then extracts structured output from the response.
|
||||
|
||||
## React Adapter
|
||||
|
||||
`createReactAdapter(config)` creates a ReAct loop agent with four default tools: `read_file`, `write_file`, `patch_file`, and `shell_exec`. The loop continues until the agent calls the structured extraction tool or until `maxRounds` is exceeded.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `AdapterFn`, `AdapterBinding`, `RoleFn`, `RoleResult`, `AgentFn` |
|
||||
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — dispatches `adapterForRole` each iteration |
|
||||
| `@uncaged/workflow-util-agent` | `src/build-agent-prompt.ts` | `buildThreadInput`, `buildAgentPrompt` |
|
||||
| `@uncaged/workflow-util-agent` | `src/spawn-cli.ts` | `spawnCli` — subprocess runner with timeout |
|
||||
| `@uncaged/workflow-util-agent` | `src/create-agent-adapter.ts` | `createAgentAdapter` — wraps `AgentFn` into `AdapterFn` |
|
||||
| `@uncaged/workflow-agent-cursor` | `src/index.ts` | `createCursorAgent` |
|
||||
| `@uncaged/workflow-agent-hermes` | `src/index.ts` | `createHermesAgent` |
|
||||
| `@uncaged/workflow-agent-llm` | `src/create-llm-adapter.ts` | `createLlmAdapter` |
|
||||
| `@uncaged/workflow-agent-react` | `src/create-react-adapter.ts` | `createReactAdapter` |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Role](./role.md) — the pure data that the binding executes
|
||||
- [Engine](./engine.md) — the loop that invokes the bound adapter each step
|
||||
@@ -0,0 +1,83 @@
|
||||
# Bundle
|
||||
|
||||
> A self-contained single-file ESM module (`.esm.js`) that implements one workflow, identified by its XXH64 hash encoded as 13-char Crockford Base32.
|
||||
|
||||
## Overview
|
||||
|
||||
A bundle is the physical unit of workflow distribution. Workflow authors build their TypeScript source into a single ESM file using `bun build` with `@uncaged/*` packages as externals. The resulting `.esm.js` is the artifact that gets registered and executed.
|
||||
|
||||
Every bundle is immutable and content-addressed: its identity is the XXH64 hash of its bytes, encoded as 13 characters of Crockford Base32 (e.g., `3TNKQRJ7BM4XH`). Registering a bundle with a new version simply adds a new hash entry; old hashes stay in the registry history and remain valid.
|
||||
|
||||
Bundles are stored on disk at `~/.uncaged/workflow/bundles/<hash>/` after registration. The `cas/` and `threads.json` for that bundle's execution state live under the same directory.
|
||||
|
||||
## Exports
|
||||
|
||||
Every valid bundle must export exactly two named exports — no default export is permitted:
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `run` | `WorkflowFn` | The `AsyncGenerator` that drives the execution loop |
|
||||
| `descriptor` | `WorkflowDescriptor` | Serializable metadata: description, roles, and routing graph |
|
||||
|
||||
```typescript
|
||||
// Minimal bundle shape
|
||||
export const run: WorkflowFn = createWorkflow(def, binding);
|
||||
export const descriptor: WorkflowDescriptor = buildDescriptor(def);
|
||||
```
|
||||
|
||||
The validator in `@uncaged/workflow-register` enforces this contract before a bundle can be registered — see `extractBundleExports`.
|
||||
|
||||
## Hash Algorithm
|
||||
|
||||
The bundle hash is computed with **XXH64** (seed 0) over the raw bytes of the `.esm.js` file, then encoded as 13-char Crockford Base32 using `encodeUint64AsCrockford`:
|
||||
|
||||
```typescript
|
||||
// packages/workflow-cas/src/hash.ts
|
||||
export function hashWorkflowBundleBytes(data: Uint8Array): string {
|
||||
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
||||
const digest = XXH.h64(0).update(buf).digest();
|
||||
return encodeUint64AsCrockford(digestToUint64(digest));
|
||||
}
|
||||
```
|
||||
|
||||
The same algorithm hashes CAS blob content (`hashString`), so all IDs in the system are consistent Crockford Base32 strings.
|
||||
|
||||
## Build Process
|
||||
|
||||
Bundles are not distributed from the monorepo directly. The typical flow is:
|
||||
|
||||
1. Create a separate workspace (e.g., `my-workflows/`) with `@uncaged/workflow-runtime` as a dependency.
|
||||
2. Write a TypeScript workflow module that imports `createWorkflow` from `@uncaged/workflow-runtime`.
|
||||
3. Run `bun build --entrypoints src/my-workflow.ts --outfile dist/my-workflow.esm.js --format esm --external '@uncaged/*'`.
|
||||
4. Register with `uncaged-workflow workflow add <name> dist/my-workflow.esm.js`.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
workflow.yaml # registry (name → hash mapping)
|
||||
bundles/
|
||||
<hash>/
|
||||
threads.json # active thread index
|
||||
history/
|
||||
YYYY-MM-DD.jsonl # completed thread records
|
||||
cas/
|
||||
<hash>.txt # CAS blobs (all bundles share one global CAS)
|
||||
```
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashWorkflowBundleBytes` and `hashString` — XXH64 + Crockford encoding |
|
||||
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Loads a `.esm.js` bundle and validates `run` + `descriptor` |
|
||||
| `@uncaged/workflow-register` | `src/bundle/bundle-validator.ts` | Schema validation of bundle exports |
|
||||
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the primary bundle authoring function |
|
||||
| `@uncaged/workflow-util` | `src/base32.ts` | `encodeUint64AsCrockford` — Crockford Base32 encoding |
|
||||
| `@uncaged/workflow-util` | `src/storage-root.ts` | `getDefaultWorkflowStorageRoot` → `~/.uncaged/workflow` |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Registry](./registry.md) — how bundles are registered and named in `workflow.yaml`
|
||||
- [Thread](./thread.md) — how a bundle's `run` export is executed as a thread
|
||||
- [Engine](./engine.md) — the executor that drives the bundle's `AsyncGenerator`
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# CAS (Content-Addressable Storage)
|
||||
|
||||
> An append-only store where every blob is identified by its XXH64 hash, used to persist all workflow thread state as immutable Merkle nodes.
|
||||
|
||||
## Overview
|
||||
|
||||
CAS is the persistence substrate for the entire workflow engine. Rather than mutating a database row, every piece of state — agent output, role metadata, thread start parameters — is serialized as a YAML blob and stored under its hash. Because content determines identity, the same content always maps to the same hash, and writes are idempotent.
|
||||
|
||||
The `CasStore` interface is intentionally simple: `put`, `get`, `delete`, `list`. The default filesystem implementation stores each blob as `<hash>.txt` under `~/.uncaged/workflow/cas/`. Writes use an atomic rename-from-tmp pattern to prevent partial writes.
|
||||
|
||||
## Hash Algorithm
|
||||
|
||||
All hashes in the system are **XXH64** (seed 0) over UTF-8 content, encoded as 13-char Crockford Base32. This applies to both CAS blob hashes and bundle file hashes. The encoding function `encodeUint64AsCrockford` lives in `@uncaged/workflow-util`.
|
||||
|
||||
## Node Types
|
||||
|
||||
The CAS holds three types of YAML nodes, all sharing the `{ type, payload, refs }` envelope:
|
||||
|
||||
### `content` node
|
||||
Stores the raw text output of an agent or the initial prompt. `refs` lists any artifact hashes the content references.
|
||||
|
||||
```yaml
|
||||
type: content
|
||||
payload: "The implementation is complete. Changed files: src/foo.ts"
|
||||
refs:
|
||||
- 3TNKQRJ7BM4XH # optional artifact refs
|
||||
```
|
||||
|
||||
### `start` node
|
||||
Written once when a thread begins. Anchors the thread to a specific workflow name, bundle hash, and depth level.
|
||||
|
||||
```yaml
|
||||
type: start
|
||||
payload:
|
||||
name: solve-issue
|
||||
hash: 3TNKQRJ7BM4XH
|
||||
depth: 0
|
||||
parentState: null
|
||||
refs:
|
||||
- <promptHash>
|
||||
```
|
||||
|
||||
### `state` node
|
||||
Written once per completed role step. Points back to the `start` node, the role's content node, and maintains an ancestor skip-list for traversal.
|
||||
|
||||
```yaml
|
||||
type: state
|
||||
payload:
|
||||
role: coder
|
||||
meta: { status: "done", completedPhase: "..." }
|
||||
start: <startHash>
|
||||
content: <contentHash>
|
||||
ancestors: [<prev_state>, ...]
|
||||
compact: null
|
||||
timestamp: 1716000000000
|
||||
childThread: null
|
||||
refs:
|
||||
- <contentHash>
|
||||
- <startHash>
|
||||
- <ancestor hashes>
|
||||
```
|
||||
|
||||
## Merkle Structure
|
||||
|
||||
The `ancestors` array in each `StateNode` implements a **skip-list** capped at 11 entries (1 direct parent + up to 10 skip-list ancestors). This allows `O(log n)` traversal of the chain without loading every node, while keeping each blob self-contained.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S[StartNode] --> C1[content₁]
|
||||
N1[StateNode₁] --> S
|
||||
N1 --> C1
|
||||
N2[StateNode₂] --> N1
|
||||
N2 --> S
|
||||
N2 --> C2[content₂]
|
||||
END[StateNode __end__] --> N2
|
||||
END --> S
|
||||
```
|
||||
|
||||
## CasStore Interface
|
||||
|
||||
```typescript
|
||||
type CasStore = {
|
||||
put(content: string): Promise<string>; // returns hash
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
```
|
||||
|
||||
`put` normalizes raw strings into `content` Merkle nodes before hashing; pre-serialized RFC v3 nodes pass through unchanged.
|
||||
|
||||
## Garbage Collection
|
||||
|
||||
`cas gc` performs a mark-and-sweep over all CAS blobs. It seeds the reachable set from `head` and `start` hashes in every `threads.json` and `history/*.jsonl`, then traverses `refs` edges transitively. Unreachable blobs are deleted. The result reports `scannedThreads`, `activeRefs`, and `deletedEntries`.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `CasStore` interface definition |
|
||||
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StateNode`, `ContentMerkleNode` types |
|
||||
| `@uncaged/workflow-cas` | `src/cas.ts` | `createCasStore` — filesystem implementation |
|
||||
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashString`, `hashWorkflowBundleBytes` — XXH64 + Crockford |
|
||||
| `@uncaged/workflow-cas` | `src/nodes.ts` | `putStartNode`, `putStateNode`, `putContentNodeWithRefs`, `parseCasThreadNode` |
|
||||
| `@uncaged/workflow-cas` | `src/merkle.ts` | `parseMerkleNode`, `serializeMerkleNode`, `getContentMerklePayload` |
|
||||
| `@uncaged/workflow-cas` | `src/reachable.ts` | Reachability traversal for GC |
|
||||
| `@uncaged/workflow-execute` | `src/engine/gc.ts` | GC orchestration |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Thread](./thread.md) — how thread execution state maps to CAS nodes
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# CLI
|
||||
|
||||
> `uncaged-workflow` — the command-line tool for registering bundles, running threads, inspecting CAS, and connecting to the gateway.
|
||||
|
||||
## Overview
|
||||
|
||||
The CLI (`@uncaged/cli-workflow`) is the primary human interface to the workflow engine. It is a multi-level command dispatcher: top-level command groups (`workflow`, `thread`, `cas`, `init`, `setup`) each have a set of subcommands. Two shortcuts (`run`, `live`) alias frequently-used subcommands.
|
||||
|
||||
The storage root defaults to `~/.uncaged/workflow` and can be overridden with `WORKFLOW_STORAGE_ROOT` or `UNCAGED_WORKFLOW_STORAGE_ROOT` environment variables.
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Workflow Registry (`workflow`)
|
||||
|
||||
| Subcommand | Args | Description |
|
||||
|-----------|------|-------------|
|
||||
| `workflow add` | `<name> <file.esm.js> [--types <path>]` | Register a workflow bundle in the registry |
|
||||
| `workflow list` | | List all registered workflows |
|
||||
| `workflow show` | `<name>` | Show bundle hash, timestamp, and descriptor |
|
||||
| `workflow rm` | `<name>` | Remove a workflow from the registry |
|
||||
| `workflow history` | `<name>` | Show version history for a workflow |
|
||||
| `workflow rollback` | `<name> [hash]` | Roll back to a previous version |
|
||||
|
||||
### Thread Execution (`thread`)
|
||||
|
||||
| Subcommand | Args | Description |
|
||||
|-----------|------|-------------|
|
||||
| `thread run` | `<name> [--prompt <text>]` | Start a new thread for a workflow; prints thread ID |
|
||||
| `thread list` | `[name]` | List threads, optionally filtered by workflow name |
|
||||
| `thread show` | `<id>` | Show thread steps and state from CAS |
|
||||
| `thread rm` | `<id>` | Remove a thread (from index and history) |
|
||||
| `thread fork` | `<thread-id> [--from-role <role>]` | Fork from an existing thread |
|
||||
| `thread ps` | | List running (active) threads |
|
||||
| `thread kill` | `<thread-id>` | Send kill signal to a running thread |
|
||||
| `thread live` | `<thread-id> \| --latest [--debug] [--role <name>]` | Attach and stream output live |
|
||||
| `thread pause` | `<thread-id>` | Pause a running thread |
|
||||
| `thread resume` | `<thread-id>` | Resume a paused thread |
|
||||
|
||||
### CAS Inspection (`cas`)
|
||||
|
||||
| Subcommand | Args | Description |
|
||||
|-----------|------|-------------|
|
||||
| `cas get` | `<hash>` | Print a CAS blob by hash |
|
||||
| `cas put` | `<content>` | Store content in CAS, print hash |
|
||||
| `cas list` | | List all hashes in CAS |
|
||||
| `cas rm` | `<hash>` | Remove a CAS entry |
|
||||
| `cas gc` | | Garbage-collect unreferenced entries |
|
||||
|
||||
### Other Commands
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| `run <name> [...]` | | Shortcut for `thread run` |
|
||||
| `live <id> [...]` | | Shortcut for `thread live` |
|
||||
| `init` | | Scaffold a workflow workspace |
|
||||
| `setup` | | Configure LLM providers in `workflow.yaml` |
|
||||
| `connect [--name NAME] [--gateway URL]` | | Connect to gateway via WebSocket |
|
||||
| `skill [topic]` | | Print agent-consumable docs (`cli`, `develop`, `author`) |
|
||||
|
||||
## Common Usage Examples
|
||||
|
||||
```bash
|
||||
# Register a bundle
|
||||
uncaged-workflow workflow add solve-issue dist/solve-issue.esm.js
|
||||
|
||||
# Run a workflow (prints thread ID)
|
||||
uncaged-workflow run solve-issue --prompt "Fix the login bug in auth.ts"
|
||||
|
||||
# Watch live output
|
||||
uncaged-workflow live <thread-id>
|
||||
|
||||
# Inspect a CAS blob
|
||||
uncaged-workflow cas get 3TNKQRJ7BM4XH
|
||||
|
||||
# Show all running threads
|
||||
uncaged-workflow thread ps
|
||||
|
||||
# Garbage-collect
|
||||
uncaged-workflow cas gc
|
||||
|
||||
# Roll back to previous version
|
||||
uncaged-workflow workflow rollback solve-issue
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `WORKFLOW_STORAGE_ROOT` | Override storage directory (default: `~/.uncaged/workflow`) |
|
||||
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override; takes priority over `WORKFLOW_STORAGE_ROOT` |
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command router (`COMMAND_TABLE`) |
|
||||
| `@uncaged/cli-workflow` | `src/cli-usage.ts` | Usage text formatting |
|
||||
| `@uncaged/cli-workflow` | `src/commands/workflow/dispatch.ts` | `WORKFLOW_SUBCOMMAND_TABLE` |
|
||||
| `@uncaged/cli-workflow` | `src/commands/thread/dispatch.ts` | `THREAD_SUBCOMMAND_TABLE` |
|
||||
| `@uncaged/cli-workflow` | `src/commands/cas/dispatch.ts` | `CAS_SUBCOMMAND_TABLE` |
|
||||
| `@uncaged/cli-workflow` | `src/cli.ts` | CLI entry point |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Bundle](./bundle.md) — what `workflow add` registers
|
||||
- [Thread](./thread.md) — what `thread run` creates
|
||||
- [Registry](./registry.md) — the `workflow.yaml` that `workflow` commands manage
|
||||
@@ -0,0 +1,74 @@
|
||||
# Dashboard
|
||||
|
||||
> A private React single-page application for browsing workflows, inspecting thread execution records, and triggering runs via a connected gateway.
|
||||
|
||||
## Overview
|
||||
|
||||
The dashboard (`workflow-dashboard`) is a read-mostly web UI that surfaces thread history and workflow metadata. It is a private package (not published to npm) and is deployed separately from the CLI. It communicates with one or more remote workflow engine instances through the `workflow-gateway` WebSocket gateway, which proxies API calls back to each connected CLI client.
|
||||
|
||||
The dashboard is not required to use the workflow engine — it is an optional observability layer on top of the same data that the CLI exposes.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Concern | Choice |
|
||||
|---------|--------|
|
||||
| Framework | React (functional components, hooks) |
|
||||
| Build | Vite |
|
||||
| Styling | CSS variables via Tailwind-compatible utility classes |
|
||||
| Charts/graphs | ReactFlow (workflow graph visualization) |
|
||||
| HTTP | Native `fetch` with Bearer token auth |
|
||||
| Transport | REST over HTTP (proxied through the gateway) |
|
||||
|
||||
## Data Sources
|
||||
|
||||
The dashboard consumes four REST endpoints per connected client (proxied by the gateway):
|
||||
|
||||
| Endpoint | Data |
|
||||
|----------|------|
|
||||
| `GET /workflows` | List of registered workflows with current hash and timestamp |
|
||||
| `GET /workflows/:name` | Full workflow detail including `WorkflowDescriptor` and version history |
|
||||
| `GET /threads` | All threads (active + completed) with summary fields |
|
||||
| `GET /threads/:id` | Thread records: `ThreadStartRecord`, `RoleRecord[]`, `WorkflowResultRecord` |
|
||||
|
||||
The gateway multiplexes multiple CLI clients; the sidebar allows switching between them.
|
||||
|
||||
## Views
|
||||
|
||||
| View | Description |
|
||||
|------|-------------|
|
||||
| **Workflows** | Lists all registered workflows; clicking shows hash, descriptor, role graph, and version history |
|
||||
| **Threads** | Lists all threads; clicking shows the full step-by-step execution record with role metadata |
|
||||
| **Run dialog** | Form to start a new thread by picking a workflow and entering a prompt |
|
||||
|
||||
### Workflow Graph
|
||||
|
||||
Each workflow's `WorkflowDescriptor.graph` is rendered as an interactive ReactFlow diagram. Nodes represent roles (plus `__start__` and `__end__` terminals); edges represent moderator transitions labeled with condition names.
|
||||
|
||||
## Authentication
|
||||
|
||||
A Bearer token (stored in `localStorage` under `workflow-api-key`) is sent with every API request. The login page prompts for this key on first load. The gateway validates the token before proxying requests to connected clients.
|
||||
|
||||
## Gateway Connection
|
||||
|
||||
`uncaged-workflow connect [--name NAME] [--gateway URL]` registers the local workflow engine as a named client with the gateway over a WebSocket. The gateway then forwards REST API calls from the dashboard to the connected CLI process. The dashboard calls `GET /api/gateway/endpoints` to discover connected clients.
|
||||
|
||||
## Private App Status
|
||||
|
||||
`workflow-dashboard` has `"private": true` in its `package.json` and is excluded from the changeset versioning pipeline. It is developed alongside the engine packages but distributed separately (e.g., as a static build hosted alongside the gateway server).
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `workflow-dashboard` | `src/app.tsx` | Root component — routing, auth state, view switching |
|
||||
| `workflow-dashboard` | `src/api.ts` | All API functions + endpoint types (`ThreadRecord`, `WorkflowDetail`, etc.) |
|
||||
| `workflow-dashboard` | `src/components/thread-detail.tsx` | Thread step viewer |
|
||||
| `workflow-dashboard` | `src/components/workflow-graph/workflow-graph.tsx` | ReactFlow graph of workflow roles and transitions |
|
||||
| `workflow-dashboard` | `src/components/sidebar.tsx` | Client selector and view navigation |
|
||||
| `@uncaged/workflow-gateway` | `src/index.ts` | Gateway server entry point |
|
||||
| `@uncaged/workflow-gateway` | `src/ws-protocol.ts` | WebSocket message protocol between CLI and gateway |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Thread](./thread.md) — the execution records the dashboard displays
|
||||
- [Engine](./engine.md) — the process that produces those records
|
||||
@@ -0,0 +1,110 @@
|
||||
# Engine
|
||||
|
||||
> The execution loop that drives a workflow bundle's `AsyncGenerator`, persisting each yielded `RoleOutput` as a CAS `StateNode` and managing thread lifecycle.
|
||||
|
||||
## Overview
|
||||
|
||||
The engine (`executeThread`) takes a `WorkflowFn` and runs it to completion. It is responsible for three concerns: persisting each role output to CAS, updating the active-thread index after every step, and terminating the thread cleanly when the generator finishes, is aborted, or is killed by the supervisor.
|
||||
|
||||
The engine does not interact with LLMs directly — that responsibility belongs to the workflow bundle's `run` function and its bound agent adapters. The engine only observes `RoleOutput` values yielded by the generator.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[executeThread] --> B[putStartNode → CAS]
|
||||
B --> C[publishHead → threads.json]
|
||||
C --> D{generator.next}
|
||||
D -- done --> E[finalizeThread]
|
||||
D -- yield RoleOutput --> F[appendStateForStep → CAS]
|
||||
F --> G[publishHead → threads.json]
|
||||
G --> H{supervisorInterval?}
|
||||
H -- kill --> E
|
||||
H -- continue --> I{awaitAfterEachYield}
|
||||
I --> D
|
||||
D -- AbortSignal --> J[finalizeAbortedThread]
|
||||
E --> K[removeThreadEntry]
|
||||
K --> L[appendThreadHistoryEntry]
|
||||
```
|
||||
|
||||
## Role Loop (inside the bundle's `createWorkflow`)
|
||||
|
||||
The `WorkflowFn` produced by `createWorkflow` runs its own loop — one iteration per role step:
|
||||
|
||||
1. **Moderator**: calls `pickNext(ctx)` (derived from the `ModeratorTable`) → returns a role name or `END`.
|
||||
2. **Adapter**: calls the bound `AdapterFn` with the role's `systemPrompt` and Zod schema → returns `RoleFn` → executes → returns `RoleResult<T>`.
|
||||
3. **Persist**: calls `putContentNodeWithRefs` to store the role output in CAS, constructs a `RoleStep`, and `yield`s a `RoleOutput` to the engine.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as Engine
|
||||
participant W as WorkflowFn (bundle)
|
||||
participant M as Moderator
|
||||
participant A as AdapterFn
|
||||
participant C as CAS
|
||||
|
||||
E->>W: generator.next()
|
||||
W->>M: pickNext(ctx) → roleName
|
||||
W->>A: adapter(systemPrompt, schema)(ctx, runtime)
|
||||
A-->>W: RoleResult { meta, childThread }
|
||||
W->>C: putContentNodeWithRefs(JSON.stringify(meta))
|
||||
W-->>E: yield RoleOutput
|
||||
E->>C: putStateNode(StateNodePayload)
|
||||
E->>E: publishHead(threads.json)
|
||||
```
|
||||
|
||||
## Key Types
|
||||
|
||||
```typescript
|
||||
// Engine input
|
||||
type ExecuteThreadOptions = {
|
||||
depth: number;
|
||||
parentStateHash: string | null;
|
||||
signal: AbortSignal;
|
||||
awaitAfterEachYield: () => Promise<void>; // used for pause/resume gate
|
||||
forkContinuation: ForkContinuationOptions | null;
|
||||
prefilledDiskSteps: PrefilledDiskStep[] | null;
|
||||
replayTimestamps: readonly number[] | null;
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
// Engine output
|
||||
type WorkflowResult = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
rootHash: string; // hash of the __end__ StateNode
|
||||
};
|
||||
```
|
||||
|
||||
## Pause Gate
|
||||
|
||||
`awaitAfterEachYield` is a function injected by the worker/runner that can block the loop between steps. The `ThreadPauseGate` in `thread-pause-gate.ts` provides `pause()` / `resume()` operations that control this gate. When paused, the loop suspends after writing the current step but before requesting the next one.
|
||||
|
||||
## Supervisor
|
||||
|
||||
If `workflowConfig.supervisorInterval > 0`, the engine runs a supervisor check after every `supervisorInterval` steps. The supervisor calls an LLM with a summary of recent steps and returns `"continue"` or `"kill"`. A `"kill"` decision finalizes the thread immediately with `returnCode: 1` and a summary string.
|
||||
|
||||
## Summarizer
|
||||
|
||||
On normal completion (generator returns), the engine calls `createSummarizer` to produce a single LLM-generated summary string from recent step content. This summary replaces the bundle's raw `WorkflowCompletion.summary` in the final history record.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — main engine entry point |
|
||||
| `@uncaged/workflow-execute` | `src/engine/types.ts` | `ExecuteThreadOptions`, `ExecuteThreadIo`, `ChainState`, `ThreadPauseGate` |
|
||||
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` persistence, history append |
|
||||
| `@uncaged/workflow-execute` | `src/engine/supervisor.ts` | Supervisor LLM check (`"continue"` / `"kill"`) |
|
||||
| `@uncaged/workflow-execute` | `src/engine/summarizer.ts` | Post-completion LLM summary |
|
||||
| `@uncaged/workflow-execute` | `src/engine/thread-pause-gate.ts` | Pause/resume gate |
|
||||
| `@uncaged/workflow-execute` | `src/engine/worker.ts` | Worker-process entry that spawns `executeThread` in a subprocess |
|
||||
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the role loop inside the bundle |
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowFn`, `RoleOutput`, `WorkflowCompletion`, `AdvanceOutcome` |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Role](./role.md) — what the moderator selects each iteration
|
||||
- [Agent Binding](./agent-binding.md) — what executes a role and returns its output
|
||||
- [Reactor](./reactor.md) — used internally for the extract and supervisor LLM calls
|
||||
- [Thread](./thread.md) — the CAS-persisted result of running the engine
|
||||
@@ -0,0 +1,129 @@
|
||||
# Package Map
|
||||
|
||||
> All packages in the monorepo with their responsibilities, dependency layers, and publication status.
|
||||
|
||||
## Overview
|
||||
|
||||
The monorepo is organized as a strict dependency DAG. Each layer may only depend on layers below it. The execution stack flows from the shared protocol types at the bottom up to the CLI at the top. Agent packages and template packages are leaf nodes that depend on the runtime layer but are not depended upon by the core stack.
|
||||
|
||||
## Package List
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@uncaged/workflow-protocol` | Shared types (`ThreadContext`, `RoleDefinition`, `CasStore`, `Result`, etc.) and constants (`START`, `END`) |
|
||||
| `@uncaged/workflow-runtime` | `createWorkflow`, type re-exports; primary dependency for bundle authors |
|
||||
| `@uncaged/workflow-util` | Utilities: Crockford Base32, ULID, structured logger, storage paths |
|
||||
| `@uncaged/workflow-reactor` | `createThreadReactor` (ReAct loop), `createLlmFn` (OpenAI-compatible LLM caller) |
|
||||
| `@uncaged/workflow-cas` | `createCasStore` (filesystem CAS), XXH64 hashing, Merkle node serialization |
|
||||
| `@uncaged/workflow-register` | Bundle validation, `workflow.yaml` registry read/write, model resolution |
|
||||
| `@uncaged/workflow-execute` | Engine (`executeThread`), extract phase, fork, GC, `workflowAsAgent` |
|
||||
| `@uncaged/cli-workflow` | `uncaged-workflow` CLI — command dispatcher for all user-facing operations |
|
||||
| `@uncaged/workflow-agent-cursor` | Adapter that runs the `cursor` CLI non-interactively in a workspace |
|
||||
| `@uncaged/workflow-agent-hermes` | Adapter that runs `hermes chat` (Nerve-style CLI agent) |
|
||||
| `@uncaged/workflow-agent-llm` | Adapter for direct LLM chat completions |
|
||||
| `@uncaged/workflow-agent-react` | Adapter with ReAct loop and file/shell tools |
|
||||
| `@uncaged/workflow-util-agent` | Shared agent utilities: `buildThreadInput`, `spawnCli`, `createAgentAdapter` |
|
||||
| `@uncaged/workflow-template-develop` | `develop` workflow template (planner → coder → reviewer → tester → committer) |
|
||||
| `@uncaged/workflow-template-solve-issue` | `solve-issue` workflow template (preparer → developer → submitter) |
|
||||
| `@uncaged/workflow-gateway` | WebSocket gateway for remote CLI-to-dashboard communication |
|
||||
| `workflow-dashboard` | React dashboard (private, unpublished) — thread/workflow viewer |
|
||||
|
||||
## Dependency Layer Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Layer 0 — Protocol
|
||||
P[workflow-protocol]
|
||||
end
|
||||
|
||||
subgraph Layer 1 — Foundations
|
||||
RT[workflow-runtime]
|
||||
UT[workflow-util]
|
||||
RX[workflow-reactor]
|
||||
end
|
||||
|
||||
subgraph Layer 2 — Storage & Register
|
||||
CAS[workflow-cas]
|
||||
REG[workflow-register]
|
||||
end
|
||||
|
||||
subgraph Layer 3 — Execute
|
||||
EX[workflow-execute]
|
||||
end
|
||||
|
||||
subgraph Layer 4 — CLI
|
||||
CLI[cli-workflow]
|
||||
end
|
||||
|
||||
subgraph Agents (leaf)
|
||||
AGC[workflow-agent-cursor]
|
||||
AGH[workflow-agent-hermes]
|
||||
AGL[workflow-agent-llm]
|
||||
AGR[workflow-agent-react]
|
||||
UA[workflow-util-agent]
|
||||
end
|
||||
|
||||
subgraph Templates (leaf)
|
||||
TD[workflow-template-develop]
|
||||
TS[workflow-template-solve-issue]
|
||||
end
|
||||
|
||||
subgraph Dashboard
|
||||
GW[workflow-gateway]
|
||||
DB[workflow-dashboard]
|
||||
end
|
||||
|
||||
RT --> P
|
||||
UT --> P
|
||||
RX --> P
|
||||
CAS --> P
|
||||
REG --> P
|
||||
REG --> UT
|
||||
EX --> RT
|
||||
EX --> UT
|
||||
EX --> CAS
|
||||
EX --> REG
|
||||
EX --> RX
|
||||
CLI --> EX
|
||||
CLI --> UT
|
||||
CLI --> REG
|
||||
AGC --> RT
|
||||
AGC --> UT
|
||||
AGC --> UA
|
||||
AGH --> RT
|
||||
AGH --> UA
|
||||
AGL --> RT
|
||||
AGR --> RT
|
||||
AGR --> RX
|
||||
UA --> RT
|
||||
TD --> RT
|
||||
TS --> RT
|
||||
DB --> GW
|
||||
```
|
||||
|
||||
## Published vs. Private
|
||||
|
||||
All `@uncaged/*` packages are published to **npmjs.org** under a fixed versioning scheme (all packages share the same version number via `@changesets/cli` in fixed mode).
|
||||
|
||||
| Status | Packages |
|
||||
|--------|---------|
|
||||
| **Published** | All packages with `@uncaged/` scope |
|
||||
| **Private** | `workflow-dashboard` (no `@uncaged/` scope, `"private": true`) |
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | Root type definitions for the entire stack |
|
||||
| `@uncaged/workflow-runtime` | `src/index.ts` | Public API for bundle authors |
|
||||
| `@uncaged/workflow-util` | `src/index.ts` | Utility re-exports |
|
||||
| `@uncaged/workflow-execute` | `src/index.ts` | Engine public API |
|
||||
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command table |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Bundle](./bundle.md) — produced by workspace authors using `@uncaged/workflow-runtime`
|
||||
- [Engine](./engine.md) — the core of `@uncaged/workflow-execute`
|
||||
- [Reactor](./reactor.md) — `@uncaged/workflow-reactor`
|
||||
- [Registry](./registry.md) — `@uncaged/workflow-register`
|
||||
- [CLI](./cli.md) — `@uncaged/cli-workflow`
|
||||
@@ -0,0 +1,102 @@
|
||||
# Reactor
|
||||
|
||||
> A generic ReAct (Reason + Act) loop that drives an LLM through multiple tool-call rounds until it produces structured output matching a Zod schema.
|
||||
|
||||
## Overview
|
||||
|
||||
The reactor is a reusable abstraction for LLM interactions that require tool use. It runs a multi-turn conversation loop: the LLM is presented with a user message and a set of tools, and responds either with a tool call (which the reactor dispatches and feeds back) or with a plain JSON object matching the expected schema. The loop repeats until structured output is obtained or `maxRounds` is exhausted.
|
||||
|
||||
The reactor is used in two places:
|
||||
|
||||
1. **Extract phase** — `createExtract` in `@uncaged/workflow-execute` uses a CAS-backed reactor to extract typed `meta` from a role's content hash.
|
||||
2. **React agent** — `createReactAdapter` in `@uncaged/workflow-agent-react` uses the reactor as its execution backbone.
|
||||
|
||||
## createThreadReactor
|
||||
|
||||
```typescript
|
||||
function createThreadReactor<TThread>(
|
||||
config: ThreadReactorConfig<TThread>,
|
||||
): ThreadReactorFn<TThread>
|
||||
```
|
||||
|
||||
`ThreadReactorConfig` bundles:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `llm` | The `LlmFn` to call each round |
|
||||
| `staticTools` | Tools always available (e.g., `cas_get`) |
|
||||
| `structuredToolFromSchema` | Derives a schema-specific extraction tool from the Zod schema |
|
||||
| `systemPromptForStructuredTool` | Constructs the system prompt given the extraction tool name |
|
||||
| `toolHandler` | Handles non-structured tool calls; receives the raw `ToolCall` and thread context |
|
||||
| `maxRounds` | Hard stop after N rounds; returns `err("max_react_rounds_exceeded")` |
|
||||
|
||||
## Round Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Reactor
|
||||
participant L as LLM
|
||||
participant H as toolHandler
|
||||
|
||||
R->>L: messages + tools
|
||||
L-->>R: response
|
||||
|
||||
alt plain JSON (valid schema)
|
||||
R-->>R: return ok(value)
|
||||
else plain JSON (invalid)
|
||||
R->>L: correction message
|
||||
else tool_calls
|
||||
loop each call
|
||||
alt structured tool
|
||||
R-->>R: validate args → return ok(value)
|
||||
else static tool
|
||||
R->>H: toolHandler(call, thread)
|
||||
H-->>R: content string
|
||||
R->>L: tool result message
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## LlmFn
|
||||
|
||||
```typescript
|
||||
type LlmFn = (input: {
|
||||
messages: ChatMessage[];
|
||||
tools: readonly ToolDefinition[];
|
||||
}) => Promise<Result<string, string>>;
|
||||
```
|
||||
|
||||
`createLlmFn(provider)` in `@uncaged/workflow-reactor` builds an `LlmFn` that calls the OpenAI-compatible chat completions endpoint and returns the raw response body as a string for the reactor to parse.
|
||||
|
||||
## Extract Phase
|
||||
|
||||
`createExtract(provider, { cas })` in `@uncaged/workflow-execute` creates a `CasReactor` — a preconfigured `ThreadReactorFn` with a `cas_get` static tool. The extract function loads the content payload for a given hash, sends it to the reactor with the role's Zod schema, and returns `ExtractResult<T>`.
|
||||
|
||||
```typescript
|
||||
type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
```
|
||||
|
||||
The `cas_get` tool allows the LLM to dereference CAS hashes during extraction — important when the content node references artifact hashes.
|
||||
|
||||
## Relationship to Engine
|
||||
|
||||
The reactor is called within `AdapterFn` implementations (e.g., `createLlmAdapter`, `createReactAdapter`) when the agent needs multi-turn tool interaction to complete a role. The engine itself does not call the reactor directly — it only drives the outer `WorkflowFn` generator and persists `RoleOutput` values.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-reactor` | `src/thread-reactor.ts` | `createThreadReactor` — generic ReAct loop |
|
||||
| `@uncaged/workflow-reactor` | `src/llm-fn.ts` | `createLlmFn` — OpenAI-compatible LLM caller |
|
||||
| `@uncaged/workflow-reactor` | `src/types.ts` | `LlmFn`, `ThreadReactorConfig`, `ToolCall`, `ToolDefinition`, `ChatMessage` |
|
||||
| `@uncaged/workflow-execute` | `src/cas-reactor.ts` | `createCasReactor` — reactor with `cas_get` static tool |
|
||||
| `@uncaged/workflow-execute` | `src/extract/extract-fn.ts` | `createExtract` — extract phase using the CAS reactor |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Engine](./engine.md) — drives the workflow generator; extract is called inside the adapter layer
|
||||
- [Agent Binding](./agent-binding.md) — adapter implementations that use the reactor internally
|
||||
@@ -0,0 +1,95 @@
|
||||
# Registry
|
||||
|
||||
> `workflow.yaml` — the local file that maps workflow names to their current and historical bundle hashes, plus global LLM provider configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
The registry is a single YAML file at `<storageRoot>/workflow.yaml` (default: `~/.uncaged/workflow/workflow.yaml`). It is the authoritative index of which bundles are available on a machine and what name each one is known by. All CLI workflow commands read or write this file.
|
||||
|
||||
The registry is read on every `uncaged-workflow run` invocation to look up the bundle hash for a given name, then used again to resolve the `extract` model configuration. It is written atomically via the `writeWorkflowRegistry` function.
|
||||
|
||||
## Schema
|
||||
|
||||
```yaml
|
||||
config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 5
|
||||
providers:
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKey: "sk-or-..."
|
||||
models:
|
||||
extract: "openrouter/anthropic/claude-sonnet-4-5"
|
||||
supervisor: "openrouter/anthropic/claude-haiku-3-5"
|
||||
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: "3TNKQRJ7BM4XH"
|
||||
timestamp: 1716000000000
|
||||
history:
|
||||
- hash: "2BMJPQ6YAK3WG"
|
||||
timestamp: 1715000000000
|
||||
develop:
|
||||
hash: "7VQWX8NRHK1ZT"
|
||||
timestamp: 1716100000000
|
||||
history: []
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
type WorkflowRegistryFile = {
|
||||
config: WorkflowConfig | null;
|
||||
workflows: Record<string, WorkflowRegistryEntry>;
|
||||
};
|
||||
|
||||
type WorkflowRegistryEntry = {
|
||||
hash: string; // current bundle hash (13-char Crockford Base32)
|
||||
timestamp: number; // Unix epoch ms when this version was registered
|
||||
history: WorkflowHistoryEntry[];
|
||||
};
|
||||
|
||||
type WorkflowHistoryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
};
|
||||
```
|
||||
|
||||
## Bundle Registration Flow
|
||||
|
||||
1. `uncaged-workflow workflow add <name> <file.esm.js>` is called.
|
||||
2. The bundle bytes are hashed with XXH64 → 13-char Crockford Base32.
|
||||
3. The bundle file is copied into `<storageRoot>/bundles/<hash>/` (if not already present).
|
||||
4. `registerWorkflowVersion` prepends the current head to `history` and sets the new hash as head.
|
||||
5. The updated registry is written back to `workflow.yaml`.
|
||||
|
||||
## Version History
|
||||
|
||||
Every `workflow add` on an already-registered name pushes the previous hash into `history`. History is ordered most-recent-first. `workflow rollback <name> [hash]` swaps the specified history entry back to head (or defaults to `history[0]`).
|
||||
|
||||
## Model Resolution
|
||||
|
||||
The `config.models` section uses `provider/model` references (e.g., `"openrouter/anthropic/claude-sonnet-4-5"`). `resolveModel` splits the reference on the first `/`, looks up the provider in `config.providers`, and returns a `ResolvedModel` with `{ baseUrl, apiKey, model }`. This is used by the engine to configure the `extract` LLM.
|
||||
|
||||
```typescript
|
||||
// packages/workflow-register/src/config/resolve-model.ts
|
||||
export function resolveModel(
|
||||
config: WorkflowConfig,
|
||||
modelKey: string,
|
||||
): Result<ResolvedModel, string>
|
||||
```
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-register` | `src/registry/registry.ts` | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `rollbackWorkflowToHistoryHash` |
|
||||
| `@uncaged/workflow-register` | `src/registry/types.ts` | `WorkflowRegistryFile`, `WorkflowRegistryEntry`, `WorkflowHistoryEntry` |
|
||||
| `@uncaged/workflow-register` | `src/registry/registry-normalize.ts` | YAML normalization for the registry root |
|
||||
| `@uncaged/workflow-register` | `src/config/resolve-model.ts` | `resolveModel` — splits `provider/model` refs |
|
||||
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Validates bundle exports before registration |
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowConfig`, `ProviderConfig`, `ResolvedModel` |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Bundle](./bundle.md) — what is stored and indexed in the registry
|
||||
@@ -0,0 +1,72 @@
|
||||
# Role
|
||||
|
||||
> A named actor within a workflow defined entirely as pure data — a description, a system prompt, an extraction schema, and an optional refs extractor — with no embedded agent logic.
|
||||
|
||||
## Overview
|
||||
|
||||
A role is a `RoleDefinition<Meta>` value: a plain TypeScript object that describes what an actor in the workflow does and how its output should be structured. Roles are authored in the template or bundle source and passed to `createWorkflow` as part of the `WorkflowDefinition`. They never hold a reference to an agent implementation.
|
||||
|
||||
This separation of concerns is deliberate. The same role definition can be executed by different agents (Cursor, Hermes, an LLM, a React loop) simply by changing the `AdapterBinding` passed to `createWorkflow`. Roles are also serialized into the `WorkflowDescriptor` for tooling like the dashboard.
|
||||
|
||||
## RoleDefinition Type
|
||||
|
||||
```typescript
|
||||
type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
```
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `description` | Human-readable summary for tooling and the `WorkflowDescriptor` |
|
||||
| `systemPrompt` | Passed to the adapter as the agent's persona/instruction for this role |
|
||||
| `schema` | Zod v4 schema that defines the structured output (`Meta`) of the role |
|
||||
| `extractRefs` | Optional function that extracts CAS hashes from `meta` to record as artifact refs |
|
||||
|
||||
## Schema and Extraction
|
||||
|
||||
Each role's `schema` is a Zod v4 type parameterized to the role's `Meta` type. When a role executes via an `AdapterFn`, the adapter is responsible for producing a value that satisfies this schema directly (the `AdapterFn` receives the schema and system prompt and returns a `RoleFn` that yields `RoleResult<T>`).
|
||||
|
||||
If `extractRefs` is non-null, the engine calls it on the completed `meta` to collect additional CAS hashes that should appear in the `StateNode.refs` skip-list, enabling traversal of artifacts produced by the role.
|
||||
|
||||
## WorkflowDefinition
|
||||
|
||||
Roles are collected into a `WorkflowDefinition<M>` alongside the moderator table:
|
||||
|
||||
```typescript
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
table: ModeratorTable<M>;
|
||||
};
|
||||
```
|
||||
|
||||
`M` is the `RoleMeta` map that binds each role name to its concrete `Meta` type. This gives full TypeScript type safety across the moderator, adapter, and CAS storage layers.
|
||||
|
||||
## WorkflowRoleDescriptor (Serialized)
|
||||
|
||||
The `WorkflowDescriptor` (stored in the bundle's `descriptor` export) contains a `roles` map of `WorkflowRoleDescriptor` objects — a JSON-serializable projection of each `RoleDefinition`:
|
||||
|
||||
```typescript
|
||||
type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: WorkflowRoleSchema; // JSON-compatible schema shape
|
||||
};
|
||||
```
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `RoleDefinition`, `WorkflowDefinition`, `RoleMeta`, `WorkflowRoleDescriptor`, `WorkflowDescriptor` |
|
||||
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | Consumes `WorkflowDefinition` roles in the adapter dispatch loop |
|
||||
| `@uncaged/workflow-register` | `src/bundle/build-descriptor.ts` | Serializes `RoleDefinition[]` to `WorkflowDescriptor` |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Engine](./engine.md) — the loop that selects and executes roles
|
||||
- [Agent Binding](./agent-binding.md) — the runtime binding that executes a role via a concrete agent
|
||||
@@ -0,0 +1,97 @@
|
||||
# Thread
|
||||
|
||||
> A single execution instance of a workflow, identified by a ULID, whose state is stored as a linked chain of immutable CAS nodes.
|
||||
|
||||
## Overview
|
||||
|
||||
A thread is the runtime envelope around one call to a workflow's `run` function. It carries a unique ULID (26-char Crockford Base32) and tracks the full sequence of role steps that have executed. Because all state is written to CAS as immutable blobs, threads are append-only and fully auditable.
|
||||
|
||||
Every thread belongs to a specific workflow bundle (identified by hash). The engine writes a `StartNode` when the thread begins and one `StateNode` per completed role step — including a final `__end__` state on completion or abort. Steps accumulate in `ThreadContext.steps` and are replayed into the context whenever a thread is resumed.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Active: thread run / fork
|
||||
Active --> Active: role step yielded
|
||||
Active --> Paused: pause signal
|
||||
Paused --> Active: resume signal
|
||||
Active --> Completed: generator returns WorkflowCompletion
|
||||
Active --> Aborted: kill signal / AbortSignal
|
||||
Completed --> [*]: entry in history/*.jsonl
|
||||
Aborted --> [*]: entry in history/*.jsonl (returnCode=130)
|
||||
```
|
||||
|
||||
## Identity
|
||||
|
||||
Thread IDs are ULIDs: 26-char Crockford Base32 strings composed of a 10-char timestamp prefix and a 16-char random suffix. Generated by `generateUlid` from `@uncaged/workflow-util`.
|
||||
|
||||
## State Storage
|
||||
|
||||
Thread state is stored entirely in CAS as a linked list of nodes:
|
||||
|
||||
```
|
||||
StartNode (type: "start")
|
||||
payload: { name, hash, depth, parentState }
|
||||
refs: [promptHash, parentState?]
|
||||
|
||||
StateNode (type: "state") ← one per role step
|
||||
payload: { role, meta, start, content, ancestors[], compact, timestamp, childThread }
|
||||
refs: [contentHash, startHash, ancestor hashes...]
|
||||
|
||||
StateNode (type: "state", role: "__end__") ← final node
|
||||
payload: { returnCode, summary }
|
||||
```
|
||||
|
||||
The `ancestors` array implements a skip-list (capped at 11 entries: 1 direct parent + up to 10 ancestors) to allow efficient traversal without loading every node in the chain.
|
||||
|
||||
## Index Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `<bundleDir>/threads.json` | Active thread index — maps `threadId → { head, start, updatedAt }` |
|
||||
| `<bundleDir>/history/YYYY-MM-DD.jsonl` | Completed thread records — one JSON line per completed/aborted thread |
|
||||
| `<storageRoot>/cas/` | All CAS blobs shared across all bundles |
|
||||
|
||||
A thread is "active" while it appears in `threads.json`. On completion, its entry is removed from `threads.json` and a record appended to the appropriate `history/*.jsonl` file.
|
||||
|
||||
## ThreadContext
|
||||
|
||||
The `ThreadContext` type is the read-only view passed into every role and moderator call:
|
||||
|
||||
```typescript
|
||||
type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
depth: number;
|
||||
bundleHash: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
```
|
||||
|
||||
`depth` tracks nesting for sub-workflow invocations (workflow-as-agent). `steps` grows by one entry after each successful role execution.
|
||||
|
||||
## Fork
|
||||
|
||||
A thread can be forked from any completed role step via `thread fork <id> [--from-role <role>]`. The fork reuses the original `StartNode` (same `startHash`) and replays CAS steps up to the fork point before resuming the generator. The forked thread gets a new ULID.
|
||||
|
||||
## Debug Logs
|
||||
|
||||
Each thread writes structured JSONL debug logs to `.info.jsonl` in the bundle directory. Each log line is `{ tag, content, timestamp }` where `tag` is an 8-char Crockford Base32 call-site identifier.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-protocol` | `src/types.ts` | `ThreadContext`, `StartStep`, `RoleStep`, `RoleMeta` types |
|
||||
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StartNodePayload`, `StateNode`, `StateNodePayload` |
|
||||
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` read/write, history append, `ThreadIndexEntry` |
|
||||
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — starts, drives, and finalizes a thread |
|
||||
| `@uncaged/workflow-execute` | `src/engine/fork-thread.ts` | Fork logic |
|
||||
| `@uncaged/workflow-util` | `src/ulid.ts` | `generateUlid` — ULID generation |
|
||||
|
||||
## See Also
|
||||
|
||||
- [CAS](./cas.md) — the storage layer that holds all thread state nodes
|
||||
- [Engine](./engine.md) — the execution loop that drives the thread
|
||||
- [Bundle](./bundle.md) — the workflow being executed in this thread
|
||||
@@ -0,0 +1,153 @@
|
||||
# Workflow Templates
|
||||
|
||||
> Pre-built `WorkflowDefinition` objects exported from `@uncaged/workflow-template-*` packages that bundle authors can import, customize, or use directly.
|
||||
|
||||
## Overview
|
||||
|
||||
Templates are the reference implementations of common workflow patterns. They export a complete `WorkflowDefinition<M>` — typed roles with Zod schemas, and a `ModeratorTable` — ready to be passed to `createWorkflow`. A bundle author imports a template definition, supplies an `AdapterBinding`, calls `createWorkflow`, and exports the result as `run`.
|
||||
|
||||
Templates are published as regular `@uncaged/*` npm packages. They are not bundles themselves; they are TypeScript libraries that become part of a bundle when the author's workspace is built.
|
||||
|
||||
## solve-issue Template
|
||||
|
||||
**Package**: `@uncaged/workflow-template-solve-issue`
|
||||
|
||||
Resolves an issue end-to-end by preparing the repository, delegating implementation to a nested `develop` workflow, and opening a pull request.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| `preparer` | Reads the issue, clones/checks out the repo, sets up the environment |
|
||||
| `developer` | Delegates to the `develop` workflow via `workflowAsAgent` (child thread) |
|
||||
| `submitter` | Opens a pull request with the completed changes |
|
||||
|
||||
### Moderator Table
|
||||
|
||||
```
|
||||
__start__ → preparer → developer → submitter → __end__
|
||||
```
|
||||
|
||||
Linear routing — each role runs exactly once in sequence.
|
||||
|
||||
### Meta Types
|
||||
|
||||
```typescript
|
||||
type SolveIssueMeta = {
|
||||
preparer: PreparerMeta;
|
||||
developer: DeveloperMeta;
|
||||
submitter: SubmitterMeta;
|
||||
};
|
||||
```
|
||||
|
||||
## develop Template
|
||||
|
||||
**Package**: `@uncaged/workflow-template-develop`
|
||||
|
||||
Plans an implementation in phases, codes each phase incrementally, reviews, verifies with tests/build/lint, and commits.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| `planner` | Produces an ordered list of implementation phases with hashes |
|
||||
| `coder` | Implements one phase; reports `completedPhase` hash in meta |
|
||||
| `reviewer` | Reviews the accumulated changes; approves or requests changes |
|
||||
| `tester` | Runs tests/lint/build; reports `passed` or `failed` |
|
||||
| `committer` | Creates the final git commit |
|
||||
|
||||
### Moderator Table
|
||||
|
||||
```
|
||||
__start__ → planner
|
||||
planner → __end__ (if status == "aborted")
|
||||
planner → coder (fallback)
|
||||
coder → reviewer (if allPhasesComplete)
|
||||
coder → coder (fallback — repeat per phase)
|
||||
reviewer → tester (if status == "approved")
|
||||
reviewer → coder (fallback — request changes)
|
||||
tester → committer (if status == "passed")
|
||||
tester → coder (fallback — fix failures)
|
||||
committer → __end__
|
||||
```
|
||||
|
||||
### Meta Types
|
||||
|
||||
```typescript
|
||||
type DevelopMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
```
|
||||
|
||||
## Writing a Custom Template
|
||||
|
||||
A minimal custom workflow:
|
||||
|
||||
```typescript
|
||||
import { createWorkflow, type WorkflowDefinition, END, START } from "@uncaged/workflow-runtime";
|
||||
import { z } from "zod/v4";
|
||||
import type { AdapterBinding } from "@uncaged/workflow-runtime";
|
||||
|
||||
type MyMeta = {
|
||||
analyst: { summary: string; confidence: number };
|
||||
writer: { report: string };
|
||||
};
|
||||
|
||||
const def: WorkflowDefinition<MyMeta> = {
|
||||
description: "Analyse then write a report.",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyses the input and produces a structured summary.",
|
||||
systemPrompt: "You are an expert analyst...",
|
||||
schema: z.object({ summary: z.string(), confidence: z.number() }),
|
||||
extractRefs: null,
|
||||
},
|
||||
writer: {
|
||||
description: "Writes the final report.",
|
||||
systemPrompt: "You are a technical writer...",
|
||||
schema: z.object({ report: z.string() }),
|
||||
extractRefs: null,
|
||||
},
|
||||
},
|
||||
table: {
|
||||
[START]: [{ condition: "FALLBACK", role: "analyst" }],
|
||||
analyst: [{ condition: "FALLBACK", role: "writer" }],
|
||||
writer: [{ condition: "FALLBACK", role: END }],
|
||||
},
|
||||
};
|
||||
|
||||
// In the bundle entry point:
|
||||
export const run = createWorkflow(def, binding);
|
||||
export const descriptor = buildDescriptor(def);
|
||||
```
|
||||
|
||||
## Template → Bundle Relationship
|
||||
|
||||
Templates are TypeScript library packages, not bundles. To use a template:
|
||||
|
||||
1. Install the template package from npm: `bun add @uncaged/workflow-template-develop`.
|
||||
2. Import the definition: `import { developWorkflowDefinition } from "@uncaged/workflow-template-develop"`.
|
||||
3. Supply an `AdapterBinding` and call `createWorkflow`.
|
||||
4. Build with `bun build` to produce `.esm.js`.
|
||||
5. Register with `uncaged-workflow workflow add`.
|
||||
|
||||
## Code Pointers
|
||||
|
||||
| Package | File | What it does |
|
||||
|---------|------|-------------|
|
||||
| `@uncaged/workflow-template-solve-issue` | `src/index.ts` | `solveIssueWorkflowDefinition`, role and moderator exports |
|
||||
| `@uncaged/workflow-template-solve-issue` | `src/roles.ts` | `SolveIssueMeta`, `solveIssueRoles` |
|
||||
| `@uncaged/workflow-template-solve-issue` | `src/moderator.ts` | `solveIssueTable` — linear transition table |
|
||||
| `@uncaged/workflow-template-develop` | `src/index.ts` | `developWorkflowDefinition`, role and moderator exports |
|
||||
| `@uncaged/workflow-template-develop` | `src/roles.ts` | `DevelopMeta`, `developRoles` |
|
||||
| `@uncaged/workflow-template-develop` | `src/moderator.ts` | `developTable` — conditional multi-phase table |
|
||||
|
||||
## See Also
|
||||
|
||||
- [Bundle](./bundle.md) — the build artifact produced from a template + adapter
|
||||
- [Role](./role.md) — the `RoleDefinition` type each template role implements
|
||||
- [Engine](./engine.md) — the execution loop that drives the template's `WorkflowFn`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"mode": "exit",
|
||||
"mode": "pre",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"@uncaged/cli-workflow": "0.4.5",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -1,3 +1,27 @@
|
||||
# No Dynamic Import
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Sync Readme
|
||||
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
@@ -1,26 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun install
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bun run check
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps or commands to reproduce:
|
||||
|
||||
```bash
|
||||
uwf ...
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened. Include error messages or logs.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS:
|
||||
- Bun version:
|
||||
- uwf version (`uwf --version`):
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Describe the feature or improvement.
|
||||
|
||||
## Why
|
||||
|
||||
Why is this needed? What problem does it solve?
|
||||
|
||||
## Proposed solution
|
||||
|
||||
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
|
||||
@@ -1,15 +0,0 @@
|
||||
## What
|
||||
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
|
||||
- `path/to/file` — what changed and why
|
||||
|
||||
## Ref
|
||||
|
||||
Fixes #
|
||||
@@ -1,28 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bunx biome check .
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
+5
-5
@@ -9,8 +9,8 @@ bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp.worktrees/
|
||||
.worktrees/
|
||||
|
||||
# Prevent wrong package manager lockfiles
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
name: "e2e-walkthrough"
|
||||
description: "End-to-end walkthrough of uwf CLI. Dogfooding: uwf tests uwf. Each role validates a phase of the CLI surface inside an isolated Docker container."
|
||||
roles:
|
||||
bootstrap:
|
||||
description: "Start Docker container with isolated storage, verify uwf is runnable"
|
||||
goal: "You are an E2E test runner. Set up an isolated Docker environment and verify basic uwf functionality."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
1. Start a Docker container with isolated storage.
|
||||
IMPORTANT: Mount the source code READ-ONLY to prevent the container
|
||||
from overwriting host files (e.g. bun install would replace macOS bun with Linux bun).
|
||||
Use a container-local HOME so bun/npm installs stay inside the container.
|
||||
Add host.docker.internal mapping for LLM API access from inside the container.
|
||||
```
|
||||
docker run -d --name uwf-e2e-$$ \
|
||||
-v "$(pwd):/workspace:ro" \
|
||||
-e HOME=/root \
|
||||
-e UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-w /workspace \
|
||||
node:22-bookworm \
|
||||
sleep infinity
|
||||
```
|
||||
NOTE: Run this from the workflow monorepo root directory.
|
||||
On macOS Docker Desktop, host.docker.internal is already available;
|
||||
--add-host ensures it also works on Linux Docker.
|
||||
|
||||
2. Inside the container, copy source to a writable location, install bun, install deps,
|
||||
then `bun link` all packages so that `uwf`, `uwf-hermes`, `uwf-builtin` are on PATH:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
# Copy source to writable location (mount is read-only)
|
||||
cp -r /workspace /root/workflow
|
||||
|
||||
# Install bun
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
|
||||
# Isolated storage
|
||||
mkdir -p $UNCAGED_WORKFLOW_STORAGE_ROOT
|
||||
|
||||
# Install workspace deps
|
||||
cd /root/workflow && bun install
|
||||
|
||||
# bun link each package that has a bin entry
|
||||
cd packages/cli-workflow && bun link && cd ../..
|
||||
cd packages/workflow-agent-hermes && bun link && cd ../..
|
||||
cd packages/workflow-agent-builtin && bun link && cd ../..
|
||||
'
|
||||
```
|
||||
3. Verify all three commands are available inside the container:
|
||||
```
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf --version'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-hermes --help'
|
||||
docker exec uwf-e2e-$$ bash -c 'export PATH="$HOME/.bun/bin:$PATH" && uwf-builtin --help'
|
||||
```
|
||||
4. Copy host uwf config into the container's isolated storage.
|
||||
The host config contains provider credentials and model settings needed for LLM calls.
|
||||
Also rewrite any localhost URLs to host.docker.internal so the container can reach host services.
|
||||
```
|
||||
docker cp ~/.uncaged/workflow/config.yaml uwf-e2e-$$:/tmp/uwf-e2e-storage/config.yaml 2>/dev/null || true
|
||||
docker exec uwf-e2e-$$ bash -c '
|
||||
if [ -f $UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml ]; then
|
||||
sed -i "s|localhost|host.docker.internal|g; s|127\.0\.0\.1|host.docker.internal|g" \
|
||||
$UNCAGED_WORKFLOW_STORAGE_ROOT/config.yaml
|
||||
fi
|
||||
'
|
||||
```
|
||||
|
||||
Report the container name and confirm uwf + agents are working.
|
||||
Set containerName to the Docker container name for subsequent roles.
|
||||
output: "Report uwf version and container readiness. Set $status to pass with containerName, or fail with error."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
required: [$status, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
config-and-registry:
|
||||
description: "Validate uwf config commands and workflow registration"
|
||||
goal: "You are an E2E test runner. Validate uwf config operations and workflow registration inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container from the previous step (containerName is in your prompt).
|
||||
All commands run via: `docker exec <containerName> bash -c '...'`
|
||||
All commands use `uwf` (installed via `bun link` inside the container).
|
||||
Remember to set env vars in each exec:
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
export UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Config tests:
|
||||
1. `uwf config list` — verify it returns valid JSON
|
||||
2. `uwf config set models.test.name test-model` — set a test key
|
||||
3. `uwf config get models.test.name` — verify it returns "test-model"
|
||||
|
||||
Workflow registration tests:
|
||||
4. `uwf workflow add /root/workflow/examples/debate.yaml` — register a workflow (use debate.yaml as it has no $SUSPEND dependency)
|
||||
5. Verify the output contains a hash
|
||||
6. `uwf workflow list` — verify non-empty array
|
||||
7. Capture the workflow name from the list
|
||||
8. `uwf workflow show <name>` — verify it returns roles
|
||||
|
||||
Report all test results with pass/fail counts.
|
||||
output: "Report test results. Set $status to pass (with workflowName and containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
thread-ops:
|
||||
description: "Test thread start, list, show, and exec"
|
||||
goal: "You are an E2E test runner. Validate thread creation and execution inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and workflow (workflowName) from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
1. `uwf thread start <workflowName> -p 'E2E test: what is 2+2?'` — capture thread ID from JSON output
|
||||
2. `uwf thread list` — verify the thread appears in the list
|
||||
3. `uwf thread show <threadId>` — verify head pointer exists
|
||||
4. `uwf thread exec <threadId> --agent uwf-builtin` — execute one step
|
||||
5. Verify exec returns JSON with a head field
|
||||
|
||||
Report results. Pass threadId and containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
inspect:
|
||||
description: "Test step list/show, thread read, and CAS operations"
|
||||
goal: "You are an E2E test runner. Validate read and inspect operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use the container (containerName) and threadId from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Step inspection:
|
||||
1. `uwf step list <threadId>` — verify steps array has length > 1
|
||||
2. Capture the last step hash from the output
|
||||
3. `uwf step show <lastStepHash>` — verify it returns a role field
|
||||
|
||||
Thread read:
|
||||
4. `uwf thread read <threadId>` — verify non-empty output
|
||||
|
||||
CAS operations:
|
||||
5. `uwf cas get <lastStepHash>` — verify returns a type field
|
||||
6. `uwf cas has <lastStepHash>` — verify exits 0
|
||||
7. `uwf cas refs <lastStepHash>` — list refs (may be empty)
|
||||
8. `uwf cas walk <lastStepHash>` — verify returns non-empty array
|
||||
|
||||
Report results. Pass threadId, lastStepHash, workflowName, containerName forward.
|
||||
output: "Report test results. Set $status to pass (with threadId, lastStepHash, workflowName, containerName) or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
threadId: { type: string }
|
||||
lastStepHash: { type: string }
|
||||
workflowName: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, threadId, lastStepHash, workflowName, containerName]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cancel-and-fork:
|
||||
description: "Test thread cancel, step fork, and log inspection"
|
||||
goal: "You are an E2E test runner. Validate cancel, fork, and log operations inside the Docker container."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Use containerName, threadId, lastStepHash, and workflowName from your prompt.
|
||||
All commands via: `docker exec <containerName> bash -c '...'`
|
||||
Set env: PATH="$HOME/.bun/bin:$PATH" UNCAGED_WORKFLOW_STORAGE_ROOT=/tmp/uwf-e2e-storage
|
||||
|
||||
Cancel:
|
||||
1. Start a second thread: `uwf thread start <workflowName> -p 'E2E cancel test'`
|
||||
2. Cancel it: `uwf thread cancel <secondThreadId>`
|
||||
3. Verify it appears in cancelled list: `uwf thread list --status cancelled`
|
||||
|
||||
Fork:
|
||||
4. Fork from the first thread's last step: `uwf step fork <lastStepHash>`
|
||||
5. Verify fork creates a new thread with a different ID
|
||||
|
||||
Logs:
|
||||
6. `uwf log list` — verify output (may be empty)
|
||||
7. `uwf log show --thread <threadId>` — verify runs without error
|
||||
|
||||
Report results with summary.
|
||||
output: "Report test results with summary. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
containerName: { type: string }
|
||||
summary: { type: string }
|
||||
required: [$status, containerName, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
containerName: { type: string }
|
||||
required: [$status, error, containerName]
|
||||
|
||||
cleanup:
|
||||
description: "Remove Docker container"
|
||||
goal: "You are an E2E test runner. Clean up the Docker container used for testing."
|
||||
capabilities:
|
||||
- docker
|
||||
- shell
|
||||
procedure: |
|
||||
Remove the Docker container (containerName is in your prompt):
|
||||
1. `docker rm -f <containerName>`
|
||||
2. Verify the container is gone: `docker ps -a --filter name=<containerName> --format '{{.Names}}'` should return empty
|
||||
|
||||
Report cleanup result.
|
||||
output: "Report cleanup result. Set $status to pass or fail."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "pass" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
$status: { const: "fail" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "bootstrap", prompt: "Set up the Docker container and verify uwf is runnable." }
|
||||
bootstrap:
|
||||
pass: { role: "config-and-registry", prompt: "Container {{{containerName}}} is ready. Validate config and workflow registration." }
|
||||
fail: { role: "$END", prompt: "Bootstrap failed: {{{error}}}. No container was created." }
|
||||
config-and-registry:
|
||||
pass: { role: "thread-ops", prompt: "Config and registry OK. Workflow '{{{workflowName}}}' registered. Container: {{{containerName}}}. Now test thread operations." }
|
||||
fail: { role: "cleanup", prompt: "Config/registry failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
thread-ops:
|
||||
pass: { role: "inspect", prompt: "Thread ops OK. threadId={{{threadId}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test inspect operations." }
|
||||
fail: { role: "cleanup", prompt: "Thread ops failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
inspect:
|
||||
pass: { role: "cancel-and-fork", prompt: "Inspect OK. threadId={{{threadId}}}, lastStepHash={{{lastStepHash}}}, workflowName={{{workflowName}}}, containerName={{{containerName}}}. Now test cancel, fork, and logs." }
|
||||
fail: { role: "cleanup", prompt: "Inspect failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cancel-and-fork:
|
||||
pass: { role: "cleanup", prompt: "All tests passed! {{{summary}}}. Clean up container {{{containerName}}}." }
|
||||
fail: { role: "cleanup", prompt: "Cancel/fork failed: {{{error}}}. Clean up container {{{containerName}}}." }
|
||||
cleanup:
|
||||
pass: { role: "$END", prompt: "E2E walkthrough complete. {{{summary}}}" }
|
||||
fail: { role: "$END", prompt: "Cleanup failed: {{{error}}}. Manual cleanup may be needed." }
|
||||
@@ -1,246 +0,0 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
```bash
|
||||
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
|
||||
```
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it for tea/API calls.
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, plan, repoPath, repoRemote]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
- If tests fail on first run:
|
||||
* Read the test output carefully for missing imports or setup issues
|
||||
* Check if you're running tests from the correct working directory (package root vs workspace root)
|
||||
* Fix the immediate issue and rerun ONCE
|
||||
* If tests still fail after 2 attempts: check the test spec for ambiguities
|
||||
* If stuck after 3 test cycles: set $status=failed with detailed error report rather than continuing blind retries
|
||||
12. MANDATORY VERIFICATION before reporting done:
|
||||
- Run `git branch --show-current` and confirm branch name matches expected
|
||||
- Run `git status` and verify changed files exist
|
||||
- Run `ls -la <key-implementation-files>` to verify they exist on disk
|
||||
- If ANY verification fails: retry the implementation, do NOT report done
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
CRITICAL: You MUST execute every verification command below. Do NOT report results without running the actual commands. Do NOT rely on prior context or assumptions.
|
||||
|
||||
Before reviewing, verify the worktree and branch exist:
|
||||
0. Run `cd <worktree-path> && pwd` to confirm the path is accessible
|
||||
- If the cd fails: the worktree truly doesn't exist, reject with that reason
|
||||
- If the cd succeeds: proceed with step 1 below
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
repoRemote: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Check `git status` — if working tree is clean and branch is ahead of origin, skip to step 3 (push).
|
||||
2. If there are unstaged/uncommitted changes: `git add -A` then `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.
|
||||
- If no output or push failed: capture the error, mark hook_failed
|
||||
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):
|
||||
```bash
|
||||
GITEA_TOKEN=$(cfg get GITEA_TOKEN)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
||||
"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls" \
|
||||
-d '{"title":"...","body":"...","head":"<branch>","base":"main"}'
|
||||
```
|
||||
- The repo remote (owner/repo format, e.g. "uncaged/workflow") is given in your task prompt — use it directly.
|
||||
- PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
6. **Verify PR was created** — parse the curl response JSON: it must contain a `"number"` field. Print the PR URL.
|
||||
- If curl returns an error or no number field: capture the response, mark hook_failed
|
||||
7. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
repoRemote: { type: string }
|
||||
worktree: { type: string }
|
||||
branch: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Repo remote: {{{repoRemote}}}." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance. Repo remote: {{{repoRemote}}}." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec. Repo remote: {{{repoRemote}}}." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}. Repo remote (owner/repo): {{{repoRemote}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit. Worktree: {{{worktree}}}. Repo remote: {{{repoRemote}}}." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
# UWF Bootstrap Guide
|
||||
|
||||
This guide helps any AI agent set up `uwf` (Uncaged Workflow) from scratch — or self-check and upgrade an existing installation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **bun** — `uwf` is built with bun. Install: `curl -fsSL https://bun.sh/install | bash`
|
||||
- **Network access** — to install npm packages
|
||||
|
||||
> **Already have uwf?** Jump to [Self-Check & Upgrade](#self-check--upgrade).
|
||||
|
||||
---
|
||||
|
||||
## Fresh Install
|
||||
|
||||
### 1. Install uwf CLI
|
||||
|
||||
```bash
|
||||
bun install -g @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf --version` prints a version number (e.g. `0.5.1`).
|
||||
|
||||
### 2. Install Agent Adapter
|
||||
|
||||
Install the adapter that matches your agent runtime. Pick **one**:
|
||||
|
||||
| Agent | Package | Binary |
|
||||
|-------|---------|--------|
|
||||
| Hermes | `@uncaged/workflow-agent-hermes` | `uwf-hermes` |
|
||||
|
||||
```bash
|
||||
# Example: Hermes agent
|
||||
bun install -g @uncaged/workflow-agent-hermes
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf-hermes --version` prints a version number.
|
||||
|
||||
### 3. Setup
|
||||
|
||||
Run the interactive wizard:
|
||||
|
||||
```bash
|
||||
uwf setup
|
||||
```
|
||||
|
||||
Or configure non-interactively:
|
||||
|
||||
```bash
|
||||
uwf setup \
|
||||
--provider <name> \
|
||||
--base-url <url> \
|
||||
--api-key <key> \
|
||||
--model <model-name> \
|
||||
--agent hermes
|
||||
```
|
||||
|
||||
This creates `~/.uncaged/workflow/config.yaml` with your provider, model, and default agent.
|
||||
|
||||
#### Config Structure
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
my-provider:
|
||||
baseUrl: https://api.example.com/v1
|
||||
apiKey: sk-xxx
|
||||
models:
|
||||
default:
|
||||
provider: my-provider
|
||||
name: my-model
|
||||
agents:
|
||||
hermes:
|
||||
command: uwf-hermes
|
||||
args: []
|
||||
defaultAgent: hermes
|
||||
defaultModel: default
|
||||
```
|
||||
|
||||
✅ **Check:** `cat ~/.uncaged/workflow/config.yaml` shows valid provider, model, and agent config.
|
||||
|
||||
### 4. Verify Installation
|
||||
|
||||
```bash
|
||||
uwf workflow list # should return empty array or existing workflows
|
||||
uwf skill user # prints usage guide
|
||||
uwf skill author # prints workflow authoring guide
|
||||
```
|
||||
|
||||
✅ **Check:** All three commands run without errors.
|
||||
|
||||
### 5. Add the uwf Skill
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/skills/devops/uwf
|
||||
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
✅ **Check:** `cat ~/.hermes/skills/devops/uwf/SKILL.md` shows the skill content with triggers `uwf`, `workflow`, `工作流`.
|
||||
|
||||
### 6. Smoke Test
|
||||
|
||||
```bash
|
||||
# Register an example workflow
|
||||
uwf workflow add examples/analyze-topic.yaml
|
||||
|
||||
# Start a thread
|
||||
uwf thread start analyze-topic -p "Analyze the concept of technical debt"
|
||||
|
||||
# Execute it (one moderator → agent → extract cycle)
|
||||
uwf thread exec <thread-id>
|
||||
```
|
||||
|
||||
✅ **Check:** Thread reaches `completed` status. Verify with `uwf thread list`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Check & Upgrade
|
||||
|
||||
Already have uwf installed? Run through this checklist to verify and upgrade.
|
||||
|
||||
### Version Check
|
||||
|
||||
```bash
|
||||
uwf --version
|
||||
uwf-hermes --version # or your agent adapter
|
||||
```
|
||||
|
||||
Compare with latest published versions:
|
||||
|
||||
```bash
|
||||
bun pm ls -g | grep -E "cli-workflow|workflow-agent"
|
||||
npm info @uncaged/cli-workflow version
|
||||
npm info @uncaged/workflow-agent-hermes version
|
||||
```
|
||||
|
||||
If local version < published version, upgrade:
|
||||
|
||||
```bash
|
||||
bun install -g @uncaged/cli-workflow@latest
|
||||
bun install -g @uncaged/workflow-agent-hermes@latest
|
||||
```
|
||||
|
||||
✅ **Check:** `uwf --version` matches `npm info @uncaged/cli-workflow version`.
|
||||
|
||||
### Config Check
|
||||
|
||||
```bash
|
||||
cat ~/.uncaged/workflow/config.yaml
|
||||
```
|
||||
|
||||
Verify:
|
||||
- [ ] `providers` has at least one entry with valid `baseUrl` and `apiKey`
|
||||
- [ ] `models.default` references an existing provider
|
||||
- [ ] `agents` has your adapter configured
|
||||
- [ ] `defaultAgent` and `defaultModel` are set
|
||||
|
||||
### Skill Check
|
||||
|
||||
```bash
|
||||
cat ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
Verify the skill is up to date:
|
||||
|
||||
```bash
|
||||
uwf skill bootstrap | diff - ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
If `diff` produces any output, the local skill is outdated. Update:
|
||||
|
||||
```bash
|
||||
uwf skill bootstrap > ~/.hermes/skills/devops/uwf/SKILL.md
|
||||
```
|
||||
|
||||
### Functional Check
|
||||
|
||||
```bash
|
||||
uwf workflow list # should not error
|
||||
uwf skill user # should print usage guide
|
||||
uwf skill author # should print authoring guide
|
||||
```
|
||||
|
||||
✅ All green? You're good to go.
|
||||
@@ -2,40 +2,46 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||
| **CAS** | Content-Addressed Storage via `@ocas/core` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||
docs/ # Architecture docs
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
- External CAS: `@ocas/core` (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -103,6 +109,8 @@ type WorkflowEntry = {
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
@@ -128,10 +136,10 @@ export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -152,7 +160,7 @@ Workflow names use **verb-first** kebab-case:
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
|
||||
## Error Handling
|
||||
@@ -181,7 +189,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
||||
const log = createLogger();
|
||||
|
||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||
log("4KNMR2PX", "Loading workflow...");
|
||||
log("4KNMR2PX", "Loading workflow bundle...");
|
||||
log("7BQST3VW", `Role ${role} started`);
|
||||
```
|
||||
|
||||
@@ -196,7 +204,7 @@ log("7BQST3VW", `Role ${role} started`);
|
||||
|
||||
### Why fixed tags?
|
||||
|
||||
- `grep "4KNMR2PX"` in logs → instant code location
|
||||
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||
- No need for file/line info in the log — tag is the locator
|
||||
- Survives refactoring (tag stays the same when code moves)
|
||||
|
||||
@@ -213,81 +221,74 @@ console.log(result);
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime |
|
||||
| **bun** | Package manager + runtime + test runner |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
||||
|
||||
### Development Workflow
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# ── Setup ──
|
||||
bun install # install all workspace dependencies
|
||||
|
||||
# ── Daily development ──
|
||||
bun run build # tsc --build (all packages, dependency order)
|
||||
bun run check # tsc --build + biome check + lint-log-tags
|
||||
bun run format # biome format --write
|
||||
bun test # run tests across all packages
|
||||
|
||||
# ── Before committing ──
|
||||
bun run check # must pass — typecheck + lint + log tag validation
|
||||
bun test # must pass — all package tests
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Publishing
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# 1. Add a changeset describing the change
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Bump all package versions + generate CHANGELOGs
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
|
||||
# Or publish manually with a tag:
|
||||
node scripts/publish-all.mjs --tag alpha
|
||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### End-to-end: Author → Register → Run
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
```
|
||||
examples/solve-issue.yaml — write a workflow YAML definition
|
||||
│ uwf workflow put
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
~/.uncaged/json-cas/ — Workflow stored as CAS node (unified CAS store)
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
||||
│ uwf thread step <thread-id>
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
moderator → agent → extract — one step per invocation, repeat until $END
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -295,5 +296,5 @@ moderator → agent → extract — one step per invocation, repeat until $
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
||||
scope: workflow | cli | rfc-001 | ...
|
||||
```
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# Contributing to @uncaged/workflow
|
||||
|
||||
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (latest)
|
||||
- [Node.js](https://nodejs.org/) 20+
|
||||
- Git
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shazhou-ww/uncaged-workflow.git
|
||||
cd uncaged-workflow
|
||||
bun install
|
||||
bun run build
|
||||
bun test
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
bun run build # TypeScript compilation (all packages)
|
||||
bun run check # tsc + biome lint + log tag validation
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
|
||||
|
||||
- **Functional-first** — `function` + `type`, not `class` + `interface`
|
||||
- **No optional properties** — use `T | null` instead of `?:`
|
||||
- **Named exports only** — no default exports
|
||||
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
|
||||
- **Static imports only** — no `await import()` in production code
|
||||
- **Biome** for lint + format — run `bun run check` before committing
|
||||
|
||||
## Commit Messages
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat(moderator): add cycle detection to graph evaluator`
|
||||
- `fix(cli): handle missing config file gracefully`
|
||||
- `docs(protocol): update StepNode field descriptions`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
|
||||
2. **Implement** your change with tests
|
||||
3. **Run checks**: `bun run check && bun test`
|
||||
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
|
||||
5. **Push** and open a PR
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```
|
||||
## What
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
- `path/to/file.ts` — what changed and why
|
||||
|
||||
## Ref
|
||||
Fixes #N
|
||||
```
|
||||
|
||||
## Adding a Changeset
|
||||
|
||||
For any user-facing change (feat, fix, breaking change), add a changeset:
|
||||
|
||||
```bash
|
||||
bun changeset
|
||||
```
|
||||
|
||||
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow-protocol/ # Shared types and JSON Schema
|
||||
workflow-util/ # Encoding, IDs, logging, frontmatter
|
||||
workflow-util-agent/ # createAgent factory, extract pipeline
|
||||
workflow-agent-hermes/ # Hermes ACP agent
|
||||
workflow-agent-builtin/ # Built-in LLM agent
|
||||
workflow-agent-claude-code/ # Claude Code agent
|
||||
cli-workflow/ # uwf CLI binary
|
||||
workflow-dashboard/ # Web UI (private, alpha)
|
||||
```
|
||||
|
||||
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Uncaged
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,115 +1,71 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
[](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@uncaged/cli-workflow)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-protocol)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||
## Core Concepts
|
||||
|
||||
## Overview
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
|
||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
||||
## Monorepo Packages
|
||||
|
||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @uncaged/cli-workflow
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
```
|
||||
|
||||
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure provider, model, and default agent
|
||||
uwf setup
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow add examples/solve-issue.yaml
|
||||
# Build all packages
|
||||
bun run build
|
||||
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread exec <thread-id>
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
```
|
||||
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
## CLI Usage
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-util-agent createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
```
|
||||
|
||||
External CAS: [`@ocas/core`](https://www.npmjs.com/package/@ocas/core) (store API, hashing, schema validation) + `@ocas/fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||
| **step** | `list`, `show`, `read`, `fork` |
|
||||
| **workflow** | `add`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
||||
|
||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
||||
|
||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # Install dependencies
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome + lint-log-tags
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
|
||||
+3
-26
@@ -1,13 +1,10 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!.worktrees",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
@@ -18,15 +15,6 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
@@ -39,8 +27,7 @@
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noConsole": "off"
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
@@ -49,7 +36,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
||||
"includes": ["**/*.d.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -57,16 +44,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
+182
-401
@@ -1,490 +1,271 @@
|
||||
# Workflow Engine — Architecture
|
||||
# Uncaged workflow — Architecture
|
||||
|
||||
**Last updated:** 2026-05-19
|
||||
**Last updated:** 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
||||
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
## Package map
|
||||
|
||||
Grouped by responsibility (npm name → folder).
|
||||
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|---------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||
| Agent framework | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
|
||||
|-------|---------|----------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-office` → `workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
|
||||
| | `@uncaged/workflow-agent-docx-diff` → `workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
|
||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-document` → `workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
|
||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||
|
||||
### External dependencies
|
||||
## Dependency graph (workspace packages)
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||
| `@ocas/fs` | Filesystem backend for `ocas`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
|
||||
## Dependency graph
|
||||
Bottom-up layering for the execution stack:
|
||||
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph External
|
||||
jcas["@ocas/core"]
|
||||
jcasfs["@ocas/fs"]
|
||||
end
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — shared"]
|
||||
subgraph L1["Layer 1 — on protocol"]
|
||||
runtime["@uncaged/workflow-runtime"]
|
||||
util["@uncaged/workflow-util"]
|
||||
reactor["@uncaged/workflow-reactor"]
|
||||
end
|
||||
subgraph L2["Layer 2 — agent framework"]
|
||||
kit["@uncaged/workflow-util-agent"]
|
||||
subgraph L2["Layer 2 — protocol + util"]
|
||||
cas["@uncaged/workflow-cas"]
|
||||
register["@uncaged/workflow-register"]
|
||||
end
|
||||
subgraph L3["Layer 3 — agent implementations"]
|
||||
hermes["@uncaged/workflow-agent-hermes"]
|
||||
subgraph L3["Layer 3 — engine"]
|
||||
execute["@uncaged/workflow-execute"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
protocol --> jcasfs
|
||||
runtime --> protocol
|
||||
util --> protocol
|
||||
kit --> protocol
|
||||
kit --> util
|
||||
kit --> jcas
|
||||
kit --> jcasfs
|
||||
hermes --> kit
|
||||
hermes --> jcas
|
||||
reactor --> protocol
|
||||
cas --> protocol
|
||||
cas --> util
|
||||
register --> protocol
|
||||
register --> util
|
||||
execute --> protocol
|
||||
execute --> runtime
|
||||
execute --> util
|
||||
execute --> cas
|
||||
execute --> reactor
|
||||
execute --> register
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> kit
|
||||
cli --> jcas
|
||||
cli --> jcasfs
|
||||
cli --> cas
|
||||
cli --> execute
|
||||
cli --> register
|
||||
cli --> runtime
|
||||
```
|
||||
|
||||
## Workflow definition
|
||||
**Adjacent consumers** (not in the main CLI stack):
|
||||
|
||||
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
|
||||
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||
|
||||
Example (`examples/solve-issue.yaml`):
|
||||
## Package roles (detail)
|
||||
|
||||
```yaml
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
steps: { type: array, items: { type: string } }
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. Implement the plan."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
filesChanged: { type: array, items: { type: string } }
|
||||
summary: { type: string }
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. Review the implementation."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: "Review the implementation against the plan."
|
||||
output: "Approve or reject with detailed comments."
|
||||
meta:
|
||||
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
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
|
||||
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
|
||||
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
|
||||
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
|
||||
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
|
||||
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
|
||||
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
|
||||
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
|
||||
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
|
||||
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
|
||||
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
|
||||
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
|
||||
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
||||
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Input: graph + lastRole + lastOutput
|
||||
│ Engine: Status-based map lookup against lastOutput.status
|
||||
│ Output: next role name | $END
|
||||
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
│ Input: thread-id + role (via argv)
|
||||
│ Engine: agent-kit builds context from CAS chain, prepends
|
||||
│ output format instruction to system prompt, spawns agent
|
||||
│ Output: raw string (frontmatter markdown)
|
||||
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||
│ Action: agent(ctx) → raw string
|
||||
│
|
||||
│ Phase 3: EXTRACT
|
||||
│ Input: raw agent output + role's meta schema
|
||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
||||
│ Output: CasRef to structured output node
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||
│
|
||||
│ Persist: StepNode { start, prev, role, output, detail, agent }
|
||||
│ Update: threads.yaml head pointer
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context types
|
||||
### Context types (progressive)
|
||||
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
type StepContext = {
|
||||
role: string;
|
||||
output: unknown; // CAS node payload, expanded (not hash)
|
||||
detail: CasRef;
|
||||
agent: string;
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload; // { workflow: CasRef, prompt: string }
|
||||
steps: StepContext[]; // chronological, oldest first
|
||||
};
|
||||
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
type ModeratorContext<M> = ThreadContext<M>;
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
};
|
||||
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||
```
|
||||
|
||||
### Key properties
|
||||
|
||||
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
|
||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
||||
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
|
||||
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||
|
||||
## Agent CLI protocol
|
||||
## Agent information sources
|
||||
|
||||
Each agent is an external command invoked by `uwf thread step`:
|
||||
An agent has exactly three information sources:
|
||||
|
||||
```bash
|
||||
<agent-cmd> <thread-id> <role>
|
||||
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||
2. **Thread context** — `AgentContext` (`start`, `steps`, `currentRole`)
|
||||
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||
|
||||
## Bundle contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
```
|
||||
|
||||
Contract:
|
||||
1. `uwf thread step` determines the next role via the moderator
|
||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
||||
- Calls the agent's `run` function
|
||||
- Runs two-layer extract on the raw output
|
||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
||||
- Prints the new `StepNode` CAS hash to stdout
|
||||
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
|
||||
5. Exit 0 = success, non-zero = failure
|
||||
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||
|
||||
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
|
||||
### Constraints
|
||||
|
||||
## Agent output format: frontmatter markdown (RFC #351)
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||
- XXH64 hash (Crockford Base32) = version ID
|
||||
|
||||
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
|
||||
### Why AsyncGenerator?
|
||||
|
||||
```markdown
|
||||
---
|
||||
status: done
|
||||
next: reviewer
|
||||
confidence: 0.9
|
||||
artifacts:
|
||||
- src/auth.ts
|
||||
scope: role
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Fixed the login redirect by updating the auth middleware...
|
||||
```
|
||||
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
|
||||
## Two-layer extract
|
||||
|
||||
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
||||
|
||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||
|
||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `meta` schema
|
||||
5. Validate with `ocas` schema validation
|
||||
6. If valid → return `outputHash` (zero LLM cost)
|
||||
|
||||
### Layer 2: LLM extract fallback (`extract.ts`)
|
||||
|
||||
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
|
||||
|
||||
1. Resolve extract model alias from config (`modelOverrides.extract` → `models.extract` → `defaultModel`)
|
||||
2. Call OpenAI-compatible chat completion with JSON mode
|
||||
3. System prompt: "Extract structured data matching this JSON Schema: ..."
|
||||
4. User message: the raw agent output
|
||||
5. Parse response, `store.put()`, validate
|
||||
6. Return `outputHash`
|
||||
|
||||
## Prompt injection
|
||||
|
||||
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||
|
||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
||||
|
||||
## CAS node types
|
||||
|
||||
### Workflow
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
```
|
||||
|
||||
### StartNode
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
### StepNode
|
||||
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
|
||||
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # ocas_ref → structured output (validated against meta schema)
|
||||
detail: "7BQST3VW9F2MA" # ocas_ref → execution detail (raw turns, session data)
|
||||
agent: "uwf-hermes" # agent command used (plain string)
|
||||
```
|
||||
|
||||
### Chain structure
|
||||
|
||||
```
|
||||
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
|
||||
│
|
||||
▼
|
||||
StepNode (step 3)
|
||||
├── start ──→ StartNode
|
||||
│ ├── workflow → Workflow (CAS)
|
||||
│ └── prompt: "Fix..."
|
||||
├── prev ──→ StepNode (step 2)
|
||||
│ ├── prev ──→ StepNode (step 1)
|
||||
│ │ └── prev: null
|
||||
│ └── ...
|
||||
├── role: "reviewer"
|
||||
├── output → CAS({ approved: true })
|
||||
├── detail → CAS(session turns)
|
||||
└── agent: "uwf-hermes"
|
||||
```
|
||||
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
|
||||
- `return` supplies `WorkflowCompletion`
|
||||
- Fork replays historical steps into a new thread context
|
||||
- Bundle does not import the engine — only protocol/runtime types at build time
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── cas/ # json-cas filesystem store (all CAS nodes)
|
||||
├── config.yaml # Provider, model, agent configuration
|
||||
├── threads.yaml # Active thread head pointers: threadId → CasRef
|
||||
├── history.jsonl # Archived thread records
|
||||
├── registry.yaml # Workflow name → CAS hash mapping
|
||||
└── .env # API keys (loaded by dotenv)
|
||||
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||
│ └── history/
|
||||
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
### Mutable state
|
||||
|
||||
Only three files carry mutable state:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
|
||||
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
|
||||
| `registry.yaml` | Workflow name → current CAS hash |
|
||||
|
||||
Everything else is immutable CAS content.
|
||||
|
||||
### ID encoding: Crockford Base32
|
||||
|
||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
- Bundle hash: XXH64 → 13-char
|
||||
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||
|
||||
### Config (`config.yaml`)
|
||||
### Registry (`workflow.yaml`)
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKey: "sk-..."
|
||||
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
provider: "openrouter"
|
||||
name: "anthropic/claude-sonnet-4"
|
||||
gpt4o-mini:
|
||||
provider: "openai"
|
||||
name: "gpt-4o-mini"
|
||||
### Thread storage (CAS + index)
|
||||
|
||||
agents:
|
||||
hermes:
|
||||
command: "uwf-hermes"
|
||||
args: []
|
||||
cursor:
|
||||
command: "uwf-cursor"
|
||||
args: []
|
||||
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
|
||||
|
||||
defaultAgent: "hermes"
|
||||
agentOverrides:
|
||||
solve-issue:
|
||||
developer: "cursor"
|
||||
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
|
||||
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
|
||||
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
|
||||
|
||||
defaultModel: "sonnet"
|
||||
modelOverrides:
|
||||
extract: "gpt4o-mini"
|
||||
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||
|
||||
```jsonc
|
||||
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||
```
|
||||
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
|
||||
|
||||
## Execution model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
|
||||
- Threads share bundle-scoped workers as implemented in CLI/engine
|
||||
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
|
||||
|
||||
## CLI commands
|
||||
|
||||
Binary: `uwf`
|
||||
|
||||
### Thread commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
|
||||
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
|
||||
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
|
||||
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
|
||||
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
|
||||
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
|
||||
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
|
||||
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
|
||||
|
||||
### Workflow commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
|
||||
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
|
||||
| `uwf workflow list` | List registered workflows. |
|
||||
|
||||
### CAS commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash>` | Read a CAS node. |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
|
||||
| `uwf cas has <hash>` | Check if a hash exists. |
|
||||
| `uwf cas refs <hash>` | List direct CAS references. |
|
||||
| `uwf cas walk <hash>` | Recursive traversal from a node. |
|
||||
| `uwf cas reindex` | Rebuild type index from all nodes. |
|
||||
| `uwf cas schema list` | List registered schemas. |
|
||||
| `uwf cas schema get <hash>` | Show a schema by type hash. |
|
||||
|
||||
### Setup
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format |
|
||||
| **vitest** | Test runner |
|
||||
| Priority | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
| P1 | `add <name> <file.esm.js>` | Register a bundle |
|
||||
| P1 | `list` | List registered workflows |
|
||||
| P1 | `show <name>` | Show workflow details |
|
||||
| P1 | `remove <name>` | Remove a workflow |
|
||||
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
|
||||
| P1 | `threads [name]` | List threads |
|
||||
| P1 | `thread <id>` | Show thread state |
|
||||
| P1 | `thread rm <id>` | Delete a thread |
|
||||
| P1 | `ps` | List running threads |
|
||||
| P1 | `kill <thread-id>` | Terminate a running thread |
|
||||
| P2 | `history <name>` | Show version history |
|
||||
| P2 | `rollback <name> [hash]` | Switch to a previous version |
|
||||
| P2 | `pause <thread-id>` | Pause a running thread |
|
||||
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
||||
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
|
||||
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
||||
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
|
||||
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
|
||||
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
|
||||
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
|
||||
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||
| **Agent bound at runtime** | `WorkflowDefinition` is reusable; agent choice is deployment concern |
|
||||
| **Three-phase context** | Each phase sees only what it needs; types live in `workflow-protocol` |
|
||||
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
|
||||
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
|
||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||
| **No daemon** | OS handles process lifecycle |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
|
||||
@@ -1,779 +0,0 @@
|
||||
# Built-in Role Agent 调研
|
||||
|
||||
## 目标
|
||||
|
||||
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
|
||||
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
|
||||
|
||||
---
|
||||
|
||||
## 关键问题
|
||||
|
||||
### Q1: Agent 接口协议
|
||||
|
||||
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `cli-workflow` 里 `spawnAgent` 的完整实现
|
||||
- AgentConfig 类型定义
|
||||
- agent 进程的 exit code 约定
|
||||
- 环境变量传递(UWF_STORAGE_ROOT 等)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 调用链
|
||||
|
||||
`uwf thread step` → `cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig` → `spawnAgent`。
|
||||
|
||||
#### AgentConfig 类型
|
||||
|
||||
```146:149:packages/workflow-protocol/src/types.ts
|
||||
export type AgentConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
```
|
||||
|
||||
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
|
||||
|
||||
#### spawnAgent 行为
|
||||
|
||||
```627:653:packages/cli-workflow/src/commands/thread.ts
|
||||
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) {
|
||||
// ... stderr 拼进 fail 消息
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
|
||||
| **stdin** | 忽略 |
|
||||
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
|
||||
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
|
||||
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
|
||||
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
|
||||
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
|
||||
|
||||
Agent 解析优先级(`resolveAgentConfig`):
|
||||
|
||||
1. CLI `--agent` override(整段 command + args 字符串)
|
||||
2. `config.agentOverrides[workflow.name][role]`
|
||||
3. `config.defaultAgent`
|
||||
|
||||
#### 环境变量:Storage Root
|
||||
|
||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
||||
|
||||
```33:43:packages/workflow-util-agent/src/storage.ts
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
|
||||
|
||||
#### Agent 侧职责(设计文档 + 实现)
|
||||
|
||||
- 读 `threads.yaml` 链头,构建 context,执行 role
|
||||
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
|
||||
- stdout 打印 step hash
|
||||
- **不**更新 `threads.yaml`
|
||||
|
||||
---
|
||||
|
||||
### Q2: createAgent 工厂
|
||||
|
||||
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
||||
- `AgentRunResult` 的完整定义
|
||||
- retry 逻辑(frontmatter 校验失败后的重试机制)
|
||||
- `persistStep` 写入 CAS 的 StepNode 结构
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 类型定义
|
||||
|
||||
```4:35:packages/workflow-util-agent/src/types.ts
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: CasRef;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
```
|
||||
|
||||
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
|
||||
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
|
||||
|
||||
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
|
||||
|
||||
#### 生命周期(按执行顺序)
|
||||
|
||||
```101:152:packages/workflow-util-agent/src/run.ts
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await buildContextWithMeta(threadId, role);
|
||||
// 1. 校验 role 存在
|
||||
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
|
||||
|
||||
let agentResult = await options.run(ctx);
|
||||
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
|
||||
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) { fail(...); }
|
||||
|
||||
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 阶段 | 行为 |
|
||||
|------|------|
|
||||
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
|
||||
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
|
||||
| Run | `options.run(ctx)` |
|
||||
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
|
||||
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
|
||||
| Persist | `persistStep` → `writeStepNode` |
|
||||
| 输出 | stdout 一行 step CAS hash |
|
||||
|
||||
#### StepNode 写入结构
|
||||
|
||||
```44:68:packages/workflow-util-agent/src/run.ts
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
startHash: CasRef;
|
||||
prevHash: CasRef | null;
|
||||
role: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
prev: options.prevHash,
|
||||
role: options.role,
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
};
|
||||
// store.put(stepNode schema) + validate
|
||||
}
|
||||
```
|
||||
|
||||
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
|
||||
|
||||
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
|
||||
|
||||
---
|
||||
|
||||
### Q3: Context Builder
|
||||
|
||||
`buildContextWithMeta` 构建了什么上下文给 agent?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentContext` 完整类型定义(所有字段)
|
||||
- context 构建过程(CAS chain walk)
|
||||
- `outputFormatInstruction` 怎么生成的
|
||||
- role definition 怎么获取(从 workflow YAML)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### AgentContext 字段
|
||||
|
||||
继承 `ModeratorContext`:
|
||||
|
||||
```60:68:packages/workflow-protocol/src/types.ts
|
||||
export type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[];
|
||||
};
|
||||
```
|
||||
|
||||
```48:51:packages/workflow-protocol/src/types.ts
|
||||
export type StartNodePayload = {
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
};
|
||||
```
|
||||
|
||||
```61:63:packages/workflow-protocol/src/types.ts
|
||||
export type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
`AgentContext` 额外字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `threadId` | `ThreadId` | 当前线程 |
|
||||
| `role` | `string` | 本步要执行的角色名 |
|
||||
| `store` | `Store` | CAS store(读写节点) |
|
||||
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
|
||||
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
|
||||
|
||||
`buildContextWithMeta` 还返回 `meta`:
|
||||
|
||||
```148:154:packages/workflow-util-agent/src/context.ts
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
#### CAS chain walk
|
||||
|
||||
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
||||
|
||||
#### Role definition 来源
|
||||
|
||||
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
|
||||
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
|
||||
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
|
||||
|
||||
#### outputFormatInstruction
|
||||
|
||||
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
|
||||
|
||||
```typescript
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
```
|
||||
|
||||
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
|
||||
|
||||
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
|
||||
|
||||
---
|
||||
|
||||
### Q4: Extract Pipeline
|
||||
|
||||
agent 输出怎么被处理成结构化数据?
|
||||
|
||||
**调研要点:**
|
||||
- frontmatter fast-path 的完整逻辑
|
||||
- LLM extract fallback 的实现(`extract.ts`)
|
||||
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
|
||||
- 校验失败时的 correction prompt 是什么
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Schema 来源
|
||||
|
||||
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
|
||||
|
||||
```66:76:packages/cli-workflow/src/commands/workflow.ts
|
||||
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
|
||||
// 校验为 JSON Schema → putSchema → 返回 CasRef
|
||||
}
|
||||
```
|
||||
|
||||
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
|
||||
|
||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
||||
|
||||
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
|
||||
2. `validateFrontmatter` 失败 → `null`
|
||||
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
|
||||
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
|
||||
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
|
||||
|
||||
**永不抛错**,失败返回 `null`。
|
||||
|
||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
||||
|
||||
```135:181:packages/workflow-util-agent/src/extract.ts
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
```
|
||||
|
||||
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
|
||||
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
|
||||
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
|
||||
- 校验通过后 `store.put(outputSchema, structured)`
|
||||
|
||||
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
|
||||
|
||||
#### Correction prompt(retry)
|
||||
|
||||
```125:128:packages/workflow-util-agent/src/run.ts
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
```
|
||||
|
||||
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
|
||||
|
||||
---
|
||||
|
||||
### Q5: Model 配置与 LLM 调用
|
||||
|
||||
workflow 怎么配置和使用 model?
|
||||
|
||||
**调研要点:**
|
||||
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
|
||||
- `resolveModel` 函数的实现
|
||||
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
|
||||
- 有没有 streaming 支持?tool calling 支持?
|
||||
|
||||
**答案:**
|
||||
|
||||
#### WorkflowConfig
|
||||
|
||||
```136:160:packages/workflow-protocol/src/types.ts
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
provider: ProviderAlias;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
providers: Record<ProviderAlias, ProviderConfig>;
|
||||
models: Record<ModelAlias, ModelConfig>;
|
||||
agents: Record<AgentAlias, AgentConfig>;
|
||||
defaultAgent: AgentAlias;
|
||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||
defaultModel: ModelAlias;
|
||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||
};
|
||||
```
|
||||
|
||||
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
|
||||
|
||||
#### resolveModel
|
||||
|
||||
```32:50:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
const apiKey = providerEntry.apiKey;
|
||||
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
|
||||
}
|
||||
```
|
||||
|
||||
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
|
||||
|
||||
Extract 专用别名解析:
|
||||
|
||||
```18:30:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
||||
}
|
||||
```
|
||||
|
||||
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
|
||||
|
||||
#### chatCompletionText
|
||||
|
||||
```87:124:packages/workflow-util-agent/src/extract.ts
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
| 能力 | 现状 |
|
||||
|------|------|
|
||||
| 协议 | OpenAI 兼容 `POST /chat/completions` |
|
||||
| Streaming | **无**(一次性 `response.text()`) |
|
||||
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
|
||||
| 多模态 | **无**(仅 text `content`) |
|
||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
||||
|
||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||
|
||||
---
|
||||
|
||||
### Q6: Hermes Agent 参考实现
|
||||
|
||||
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
|
||||
|
||||
**调研要点:**
|
||||
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
|
||||
- hermes CLI 的调用参数
|
||||
- session management(resume)
|
||||
- 输出怎么捕获
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Prompt 组装
|
||||
|
||||
```40:53:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.outputFormatInstruction !== "") {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
|
||||
|
||||
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
|
||||
|
||||
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
|
||||
|
||||
#### Hermes CLI 参数
|
||||
|
||||
首次:
|
||||
|
||||
```88:97:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
续聊:
|
||||
|
||||
```100:114:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
#### Session
|
||||
|
||||
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
|
||||
- 会话文件:`~/.hermes/sessions/session_<id>.json`
|
||||
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
|
||||
|
||||
#### 与 createAgent 的衔接
|
||||
|
||||
```157:164:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
|
||||
}
|
||||
```
|
||||
|
||||
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
|
||||
|
||||
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
|
||||
|
||||
---
|
||||
|
||||
### Q7: Toolkit 需求分析
|
||||
|
||||
要实现一个自给自足的 agent,最少需要哪些 tool?
|
||||
|
||||
**调研要点:**
|
||||
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
|
||||
- hermes agent 在 workflow 场景下常用哪些 tool
|
||||
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### solve-issue.yaml 角色能力
|
||||
|
||||
| Role | capabilities | 隐含需求 |
|
||||
|------|----------------|----------|
|
||||
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
|
||||
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
|
||||
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
|
||||
|
||||
#### Hermes 侧
|
||||
|
||||
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
|
||||
|
||||
#### Builtin 最小 toolkit 建议
|
||||
|
||||
| 优先级 | Tool | 用途 |
|
||||
|--------|------|------|
|
||||
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
|
||||
| P0 | `write_file` / `edit_file` | developer 改代码 |
|
||||
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
|
||||
| P1 | `list_dir` / `glob` | 导航代码库 |
|
||||
| P1 | `grep` | 搜索符号/引用 |
|
||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
||||
|
||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
||||
|
||||
#### Agent loop 必须能力
|
||||
|
||||
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
|
||||
2. 将 tool 结果 append 回 messages
|
||||
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
|
||||
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
|
||||
|
||||
---
|
||||
|
||||
## 方案草案
|
||||
|
||||
(调研完成后基于以上答案撰写)
|
||||
|
||||
### 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph cli ["cli-workflow"]
|
||||
Step["uwf thread step"]
|
||||
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
|
||||
Step --> Spawn
|
||||
end
|
||||
|
||||
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
|
||||
Main["createBuiltinAgent() = createAgent({...})"]
|
||||
Prompt["buildBuiltinPrompt(ctx)"]
|
||||
Loop["runBuiltinLoop(provider, messages, tools)"]
|
||||
Tools["Toolkit: read/write/exec/..."]
|
||||
Detail["storeBuiltinDetail(turns)"]
|
||||
Main --> Prompt
|
||||
Main --> Loop
|
||||
Loop --> Tools
|
||||
Loop --> Detail
|
||||
end
|
||||
|
||||
subgraph kit ["workflow-util-agent"]
|
||||
Ctx["buildContextWithMeta"]
|
||||
FM["tryFrontmatterFastPath"]
|
||||
Persist["persistStep"]
|
||||
Ctx --> Main
|
||||
Main --> FM
|
||||
FM --> Persist
|
||||
end
|
||||
|
||||
subgraph cas ["CAS / config"]
|
||||
Config["config.yaml models/providers"]
|
||||
CAS["cas/ + threads.yaml"]
|
||||
end
|
||||
|
||||
Spawn --> Main
|
||||
Config --> Loop
|
||||
CAS --> Ctx
|
||||
Persist --> CAS
|
||||
Spawn -->|"stdout: step hash"| Step
|
||||
```
|
||||
|
||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@ocas/core` 写 detail schema)。
|
||||
|
||||
**分层**:
|
||||
|
||||
| 层 | 职责 |
|
||||
|----|------|
|
||||
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
|
||||
| `builtin/agent.ts` | `run` / `continue` 实现 |
|
||||
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
|
||||
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
|
||||
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
|
||||
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
|
||||
|
||||
**配置集成**:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
builtin:
|
||||
command: "uwf-builtin"
|
||||
args: []
|
||||
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
|
||||
```
|
||||
|
||||
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
|
||||
|
||||
---
|
||||
|
||||
### Agent Run Loop
|
||||
|
||||
伪代码(单次 `run(ctx)`):
|
||||
|
||||
```
|
||||
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
|
||||
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
|
||||
3. messages ← [{ role: "system", content: system }]
|
||||
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
|
||||
5. turns ← []
|
||||
|
||||
6. for turn in 1..MAX_TURNS:
|
||||
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
|
||||
record assistant message + tool_calls in turns
|
||||
|
||||
if response has no tool_calls:
|
||||
finalText ← response.content
|
||||
break
|
||||
|
||||
for each tool_call:
|
||||
result ← executeTool(tool_call, { cwd: process.cwd() })
|
||||
messages.push tool result
|
||||
record in turns
|
||||
|
||||
7. if no finalText with valid frontmatter after loop:
|
||||
optionally one-shot "finalize" message without tools
|
||||
|
||||
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
|
||||
9. return { output: finalText, detailHash, sessionId }
|
||||
```
|
||||
|
||||
**`continue(sessionId, message, store)`**:
|
||||
|
||||
- 从内存/磁盘恢复 `messages` + `turns`
|
||||
- `messages.push({ role: "user", content: message })`(correction 或续聊)
|
||||
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
|
||||
- 返回新的 `AgentRunResult`
|
||||
|
||||
**与 frontmatter 的配合**:
|
||||
|
||||
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
|
||||
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
|
||||
|
||||
**安全**:
|
||||
|
||||
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
|
||||
- 路径:禁止 `..` 逃逸出 workspace root
|
||||
|
||||
---
|
||||
|
||||
### Toolkit 设计
|
||||
|
||||
统一注册表:
|
||||
|
||||
```typescript
|
||||
type BuiltinTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: JSONSchema; // object type
|
||||
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
|
||||
};
|
||||
|
||||
type ToolContext = {
|
||||
cwd: string;
|
||||
storageRoot: string;
|
||||
};
|
||||
```
|
||||
|
||||
| Tool name | OpenAI function | 行为摘要 |
|
||||
|-----------|-----------------|----------|
|
||||
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
|
||||
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
|
||||
| `edit_file` | 可选 | search/replace 块,减少 token |
|
||||
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
|
||||
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
|
||||
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
|
||||
|
||||
**LLM 请求形状**(扩展 extract 客户端):
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "...",
|
||||
"messages": [...],
|
||||
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
|
||||
|
||||
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
|
||||
|
||||
---
|
||||
|
||||
### 与现有架构的集成
|
||||
|
||||
| 集成点 | 方式 |
|
||||
|--------|------|
|
||||
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
|
||||
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
|
||||
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
|
||||
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
|
||||
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
|
||||
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
|
||||
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
|
||||
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
|
||||
|
||||
**明确不做**:
|
||||
|
||||
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
|
||||
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
|
||||
- 首版不实现 streaming、不实现 MCP
|
||||
|
||||
**建议实现顺序**:
|
||||
|
||||
1. `llm.ts`:tool calling HTTP 客户端 + 单测
|
||||
2. P0 tools + `runBuiltinLoop`
|
||||
3. `createBuiltinAgent` + detail CAS
|
||||
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
|
||||
5. (可选)`createAgent` 接入 `extract()` fallback
|
||||
@@ -1,73 +0,0 @@
|
||||
# Issue #418: ACP session/resume 返回空文本
|
||||
|
||||
## 调研日期: 2026-05-23
|
||||
|
||||
## 根因
|
||||
|
||||
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
|
||||
|
||||
### 完整调用链
|
||||
|
||||
```
|
||||
resume_session(sid)
|
||||
→ update_cwd(sid)
|
||||
→ get_session(sid) → _restore(sid)
|
||||
→ _make_agent()
|
||||
→ resolve_runtime_provider("custom") 失败(line 548-561)
|
||||
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
|
||||
→ except Exception 静默吞掉(line 482-484)→ return None
|
||||
→ return None
|
||||
→ state is None → fallback: create_session()(新 sid,无历史)
|
||||
```
|
||||
|
||||
### 关键代码位置(acp_adapter/session.py)
|
||||
|
||||
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
|
||||
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
|
||||
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
|
||||
|
||||
### 实测行为
|
||||
|
||||
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
|
||||
2. Phase 2: `session/resume` + `prompt`
|
||||
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
|
||||
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
|
||||
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
|
||||
|
||||
### 验证脚本
|
||||
|
||||
```python
|
||||
# 直接调用 _restore 验证
|
||||
cd ~/.hermes/hermes-agent
|
||||
python3 -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from acp_adapter.session import SessionManager
|
||||
sm = SessionManager()
|
||||
result = sm._restore('SESSION_ID_HERE')
|
||||
print(result) # None — _make_agent 抛异常被吞掉
|
||||
"
|
||||
```
|
||||
|
||||
### 两个 bug
|
||||
|
||||
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url` 和 `api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
|
||||
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
|
||||
|
||||
### Hermes 版本
|
||||
|
||||
- v0.10.0 (2026.4.16) — 初始测试
|
||||
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
|
||||
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
|
||||
|
||||
### v0.14.0 测试结果 (2026-05-23)
|
||||
|
||||
- `_restore` 仍因 `custom` provider 解析失败返回 None
|
||||
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
|
||||
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
|
||||
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
|
||||
|
||||
### 上游 Issue
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
|
||||
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
|
||||
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,67 +0,0 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+84
-74
@@ -22,7 +22,7 @@ uwf workflow show <workflow-id> # 查看 workflow 定义
|
||||
uwf workflow list # 列出已注册 workflows
|
||||
```
|
||||
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
|
||||
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
|
||||
|
||||
### 1.2 `uwf thread start`
|
||||
|
||||
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出最后链头,退出
|
||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
||||
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
@@ -136,14 +136,14 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||
|
||||
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
|
||||
|
||||
### 2.2 数据节点
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
@@ -153,25 +153,16 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # ocas_ref → JSON Schema 节点
|
||||
systemPrompt: "You are a developer agent..."
|
||||
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # ocas_ref → JSON Schema 节点
|
||||
systemPrompt: "You are a code reviewer..."
|
||||
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
@@ -198,29 +189,37 @@ payload:
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
|
||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||
|
||||
Moderator 的求值逻辑:
|
||||
JSONata 表达式的求值上下文:
|
||||
|
||||
```typescript
|
||||
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
||||
// 2. target = graph[lastRole][status]
|
||||
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
||||
```jsonc
|
||||
{
|
||||
"start": { // StartNode 信息
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"prompt": "Fix the login bug..."
|
||||
},
|
||||
"steps": [ // 所有已完成 steps,从旧到新
|
||||
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
|
||||
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
|
||||
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
|
||||
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
```
|
||||
|
||||
@@ -232,18 +231,18 @@ payload:
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 ocas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
|
||||
- `detail` — ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
### 2.3 链式结构
|
||||
@@ -280,13 +279,13 @@ threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
|
||||
providers:
|
||||
openai:
|
||||
baseUrl: "https://api.openai.com/v1"
|
||||
apiKey: "sk-..."
|
||||
apiKeyEnv: "OPENAI_API_KEY"
|
||||
anthropic:
|
||||
baseUrl: "https://api.anthropic.com/v1"
|
||||
apiKey: "sk-ant-..."
|
||||
apiKeyEnv: "ANTHROPIC_API_KEY"
|
||||
openrouter:
|
||||
baseUrl: "https://openrouter.ai/api/v1"
|
||||
apiKey: "sk-or-..."
|
||||
apiKeyEnv: "OPENROUTER_API_KEY"
|
||||
|
||||
models:
|
||||
sonnet:
|
||||
@@ -337,20 +336,21 @@ OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
## 3. 包结构
|
||||
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`。
|
||||
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`。
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
||||
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
- `@ocas/core` — CAS 存储、hash、schema 校验
|
||||
- `@ocas/fs` — 文件系统 CAS 后端
|
||||
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
|
||||
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
|
||||
|
||||
**现有包全部保留不动**,新旧并存,逐步迁移。
|
||||
|
||||
@@ -358,7 +358,7 @@ packages/
|
||||
|
||||
## 4. 关键数据类型
|
||||
|
||||
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
@@ -369,11 +369,11 @@ type CasRef = string;
|
||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||
type ThreadId = string;
|
||||
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // ocas_ref → 结构化输出节点(符合 role meta schema)
|
||||
detail: CasRef; // ocas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
```
|
||||
@@ -383,23 +383,26 @@ type StepRecord = {
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
|
||||
systemPrompt: string;
|
||||
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Target = {
|
||||
type Transition = {
|
||||
role: string; // 目标 role 名 或 "$END"
|
||||
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
||||
};
|
||||
|
||||
type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string; // JSONata expression
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
||||
};
|
||||
```
|
||||
|
||||
@@ -407,24 +410,30 @@ type WorkflowPayload = {
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
workflow: CasRef; // ocas_ref → Workflow
|
||||
workflow: CasRef; // cas_ref → Workflow
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
type StepNodePayload = StepRecord & {
|
||||
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
|
||||
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
|
||||
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 Moderator 求值
|
||||
### 4.4 JSONata 求值上下文
|
||||
|
||||
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
||||
|
||||
```typescript
|
||||
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||
// $START 角色使用 "_" 作为初始 status
|
||||
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||
/** JSONata 上下文中的 step — output 被展开 */
|
||||
type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[]; // 从旧到新
|
||||
};
|
||||
```
|
||||
|
||||
### 4.5 CLI 输出
|
||||
@@ -465,7 +474,7 @@ type Scenario = string; // e.g. "extract"
|
||||
|
||||
type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string; // API key stored directly
|
||||
apiKeyEnv: string; // env var name to read API key from
|
||||
};
|
||||
|
||||
type ModelConfig = {
|
||||
@@ -513,5 +522,6 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
|
||||
│
|
||||
└── start.workflow → WorkflowPayload
|
||||
├── roles: Record<name, RoleDefinition>
|
||||
└── graph: Record<role, Record<status, Target>>
|
||||
├── conditions: Record<name, JSONata>
|
||||
└── graph: Record<role, Transition[]>
|
||||
```
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
name: "analyze-topic"
|
||||
description: "Single-role topic analysis using four-phase role description"
|
||||
roles:
|
||||
analyst:
|
||||
description: "Analyzes a given topic and produces a structured summary"
|
||||
goal: |
|
||||
You are a research analyst with expertise in breaking down complex topics
|
||||
into clear, structured summaries. You think critically and cite key points.
|
||||
capabilities:
|
||||
- research
|
||||
- critical-thinking
|
||||
- structured-writing
|
||||
procedure: |
|
||||
Analyze the topic by:
|
||||
1. Identifying the main thesis or question
|
||||
2. Listing 3-5 key points with brief explanations
|
||||
3. Noting any counterarguments or caveats
|
||||
Keep your analysis concise (under 500 words).
|
||||
output: |
|
||||
Provide your analysis as markdown under the frontmatter.
|
||||
The frontmatter must include your structured findings.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [$status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
@@ -1,62 +0,0 @@
|
||||
name: "debate"
|
||||
description: "Structured debate between two sides. Tests cross-process session resume."
|
||||
roles:
|
||||
against:
|
||||
description: "Argues against the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing AGAINST the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. If this is the opening, present your strongest argument against the proposition.
|
||||
2. If responding to the other side, directly counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing FOR the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. Read the opposing side's latest argument carefully.
|
||||
2. Counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
@@ -1,235 +0,0 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
CRITICAL: First, determine which mode you are in by scanning the task prompt.
|
||||
Choose EXACTLY ONE mode — do NOT default to Mode A if Mode B applies.
|
||||
|
||||
**How to choose:**
|
||||
- If the prompt contains ANY of these keywords: "PR #", "PR#", "pulls/", "继续修复", "continue", "review feedback", "existing branch", "fix/", or mentions a branch name → **Mode B**
|
||||
- If the prompt was forwarded from tester with fix_spec → **Mode C**
|
||||
- Otherwise → **Mode A**
|
||||
|
||||
**Mode A — Fresh issue (first time, no existing PR):**
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
7. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
**Mode B — Continue on existing PR (prompt mentions PR, branch, or review feedback):**
|
||||
YOU MUST output $status=continue (NOT ready) when in this mode.
|
||||
1. Extract the PR number and branch name from the prompt
|
||||
2. Read the PR and its review comments from Gitea: `tea pr <number> --comments -r <owner/repo>`
|
||||
3. Read the existing issue for full context: `tea issues <number> -r <owner/repo>`
|
||||
4. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
5. Produce a TDD test spec that ONLY covers the changes requested in the review — do NOT re-spec already-implemented features
|
||||
6. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
7. Find the existing worktree: `git worktree list` and locate the branch
|
||||
8. Output **$status=continue** with plan hash, repoPath, branch name, and worktree path
|
||||
|
||||
**Mode C — Bounced back by tester (fix_spec):**
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
3. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
4. Output **$status=ready** with plan hash and repoPath
|
||||
|
||||
IMPORTANT: Extract the repo remote (owner/repo) from git:
|
||||
```bash
|
||||
git remote get-url origin | sed 's|.*[:/]\([^/]*/[^.]*\).*|\1|'
|
||||
```
|
||||
Store the result as repoRemote in your frontmatter output so downstream roles can use it.
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (fresh), continue (existing PR), or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "continue" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, plan, repoPath, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If continuing on existing branch (prompt says "Continue work on existing branch" or provides a worktree path):
|
||||
- cd directly into the worktree path provided in the prompt
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
- Do NOT create a new branch or worktree
|
||||
5. If bounced back from reviewer or tester (branch already exists but no explicit worktree path):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
6. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$SUSPEND", prompt: "信息不足,需要补充:{{{reason}}}" }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
continue: { role: "developer", prompt: "Continue work on existing branch {{{branch}}} at worktree {{{worktree}}}. Implement the revised TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}. Do NOT create a new branch or worktree — cd into the existing worktree and work there." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
@@ -1,76 +0,0 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { runCli } from "./cli-dispatch.js";
|
||||
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const storageRoot = resolveWorkflowStorageRoot();
|
||||
const code = await runCli(storageRoot, argv);
|
||||
process.exit(code);
|
||||
@@ -1,34 +0,0 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
|
||||
|
||||
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-hermes @uncaged/workflow-runtime @uncaged/workflow-util-agent
|
||||
```
|
||||
|
||||
In this monorepo: use `workspace:*` for `@uncaged/workflow-agent-hermes`, `@uncaged/workflow-runtime`, and `@uncaged/workflow-util-agent`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
const agent = createHermesAgent({
|
||||
model: "your-model", // or null to omit --model
|
||||
timeout: 600_000, // ms, or null for no timeout
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
|
||||
| `HermesAgentConfig` | `model`, `timeout` |
|
||||
| `validateHermesAgentConfig` | Config validation result |
|
||||
|
||||
Requires `hermes` on `PATH` at runtime.
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("hermes: timeout");
|
||||
}
|
||||
if (error.kind === "spawn_failed") {
|
||||
throw new Error(`hermes: ${error.message}`);
|
||||
}
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, { prompt }) => {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
fullPrompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_DEFAULT_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
if (config.model !== null) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: null,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
return { prompt };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export type HermesAgentConfig = {
|
||||
/** Absolute path to the hermes CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# @uncaged/workflow-dashboard
|
||||
|
||||
Web dashboard for the Uncaged Workflow engine. Connects to the local
|
||||
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the local API server (in another terminal)
|
||||
uncaged-workflow serve
|
||||
|
||||
# Start the dashboard dev server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output goes to `dist/` — static files ready for CF Pages or any host.
|
||||
@@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Dashboard</title>
|
||||
<script>
|
||||
(() => {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.1",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.11"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Outlet, useParams } from "react-router";
|
||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
import { useTheme } from "./hooks/use-theme.tsx";
|
||||
|
||||
export function Layout() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { client } = useParams();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
if (!authed) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success: "border border-success text-success hover:bg-success/10",
|
||||
warning: "border border-warning text-warning hover:bg-warning/10",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -1,104 +0,0 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,126 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius: 0.625rem;
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-success: hsl(var(--success));
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-sidebar: hsl(var(--sidebar));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--success: 160 60% 40%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 0 0% 98%;
|
||||
--sidebar-foreground: 240 3.8% 46.1%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 6% 6.5%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 6% 6.5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--success: 160 60% 45%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 240 6% 6.5%;
|
||||
--sidebar-foreground: 240 5% 64.9%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes wf-node-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-node-pulse {
|
||||
animation: wf-node-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wf-record-card-highlight {
|
||||
0% {
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
35% {
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
100% {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
.wf-record-card-highlight {
|
||||
animation: wf-record-card-highlight 1.5s ease-out forwards;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { ThemeProvider } from "./hooks/use-theme.tsx";
|
||||
import "./index.css";
|
||||
import { router } from "./router.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createHashRouter, redirect } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { ClientRedirect } from "./components/client-redirect.tsx";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
|
||||
export const router = createHashRouter([
|
||||
{
|
||||
path: "/login",
|
||||
Component: LoginPage,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Component: ClientRedirect,
|
||||
},
|
||||
{
|
||||
path: ":client/threads",
|
||||
Component: ThreadList,
|
||||
},
|
||||
{
|
||||
path: ":client/threads/:threadId",
|
||||
Component: ThreadDetail,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows",
|
||||
Component: WorkflowList,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows/:workflowName",
|
||||
Component: WorkflowDetail,
|
||||
},
|
||||
{
|
||||
path: ":client",
|
||||
loader: ({ params }) => redirect(`/${params.client}/threads`),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "plugins"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:7860",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
# @uncaged/workflow-moderator
|
||||
|
||||
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
||||
|
||||
## Overview
|
||||
|
||||
The moderator (Layer 1) performs a status-based map lookup on the workflow graph. Given the last role and its output, it looks up `graph[lastRole][lastOutput.status]` to find the next `Target` (role + prompt template). The prompt is rendered via Mustache with `lastOutput` as the template context. For `$START`, the unit status `_` is used.
|
||||
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-moderator
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Functions
|
||||
|
||||
```typescript
|
||||
function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: Record<string, unknown> & { status: string },
|
||||
): Result<EvaluateResult, Error>
|
||||
```
|
||||
|
||||
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the rendered edge instruction for the agent.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
};
|
||||
```
|
||||
|
||||
The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok: false; error: E }`), not re-exported from `index.ts`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { evaluate } from "@uncaged/workflow-moderator";
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
|
||||
const result = evaluate(graph, lastRole, lastOutput);
|
||||
if (result.ok && result.value.role !== "$END") {
|
||||
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts Public exports
|
||||
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
|
||||
└── types.ts EvaluateResult, Result
|
||||
```
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
},
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing $status defaults to _ (unit routing)", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-moderator",
|
||||
"version": "0.5.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",
|
||||
"test:ci": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mustache": "^4.2.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"directory": "legacy-packages/workflow-moderator"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
type LastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status =
|
||||
lastRole === START_ROLE
|
||||
? UNIT_STATUS
|
||||
: typeof lastOutput[STATUS_KEY] === "string"
|
||||
? (lastOutput[STATUS_KEY] as string)
|
||||
: UNIT_STATUS;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { evaluate } from "./evaluate.js";
|
||||
export type { EvaluateResult } from "./types.js";
|
||||
@@ -1,7 +0,0 @@
|
||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
/** The result of moderator evaluation — which role to go to, and the edge prompt. */
|
||||
export type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
# @uncaged/workflow-protocol
|
||||
|
||||
Shared workflow types, sentinel constants, and `Result` helpers.
|
||||
|
||||
## What This Package Does
|
||||
|
||||
It defines the cross-package contract for bundles and the engine: thread/step shapes, `WorkflowFn`, agent/extract contexts, descriptor types, and `CasStore` as an interface. Implementations (CAS store, CLI, extract) depend on these types so bundles stay decoupled from Node APIs.
|
||||
|
||||
## Key Exports
|
||||
|
||||
From `src/index.ts`:
|
||||
|
||||
- **Types:** `Result`, `CasStore`, `WorkflowRoleSchema`, `WorkflowRoleDescriptor`, `WorkflowDescriptor`, `RoleMeta`, `RoleOutput`, `StartStep`, `RoleStep`, `ThreadContext`, `ModeratorContext`, `AgentContext`, `ExtractContext`, `WorkflowCompletion`, `WorkflowResult`, `LlmProvider`, `ProviderConfig`, `ResolvedModel`, `WorkflowConfig`, `ExtractFn`, `AgentFn`, `AgentBinding`, `WorkflowRuntime`, `WorkflowFn`, `RoleDefinition`, `Moderator`, `WorkflowDefinition`, `AdvanceOutcome`
|
||||
- **Constants:** `START`, `END`
|
||||
- **Functions:** `ok`, `err`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Peer:** `zod` ^4 — used in type positions for schemas (`ExtractFn`, `RoleDefinition`, etc.)
|
||||
|
||||
No workspace packages; this is the bottom layer.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { END, START, type WorkflowFn, type ThreadContext } from "@uncaged/workflow-protocol";
|
||||
```
|
||||
|
||||
Concrete `WorkflowFn` implementations are built with `@uncaged/workflow-runtime` (`createWorkflow`).
|
||||
@@ -1,56 +0,0 @@
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
ContentMerkleNode,
|
||||
StartNode,
|
||||
StartNodePayload,
|
||||
StateNode,
|
||||
StateNodePayload,
|
||||
} from "./cas-types.js";
|
||||
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowConfig,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export { END, START } from "./types.js";
|
||||
|
||||
// ── Constructor functions ──────────────────────────────────────────
|
||||
|
||||
export { err, ok } from "./result.js";
|
||||
@@ -1,219 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export const START = "__start__" as const;
|
||||
export const END = "__end__" as const;
|
||||
|
||||
// ── Result ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
// ── CAS ────────────────────────────────────────────────────────────
|
||||
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
|
||||
// ── Workflow Descriptor ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Serializable routing edges derived from a moderator transition table. */
|
||||
export type WorkflowGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph;
|
||||
};
|
||||
|
||||
// ── Role & Thread ──────────────────────────────────────────────────
|
||||
|
||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
contentHash: string;
|
||||
content: string | null;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
}[keyof M & string];
|
||||
|
||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
depth: number;
|
||||
bundleHash: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
|
||||
|
||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
||||
currentRole: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Workflow Completion ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowCompletion = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type WorkflowResult = WorkflowCompletion & {
|
||||
rootHash: string;
|
||||
};
|
||||
|
||||
// ── LLM Provider ───────────────────────────────────────────────────
|
||||
|
||||
export type LlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedModel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
||||
export type ExtractResult<T extends Record<string, unknown>> = {
|
||||
meta: T;
|
||||
contentPayload: string;
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
// ── Adapter (replaces Agent) ────────────────────────────────────────
|
||||
|
||||
export type RoleResult<T> = { meta: T; childThread: string | null };
|
||||
|
||||
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
||||
|
||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options (required second argument).
|
||||
*/
|
||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
|
||||
// ── Workflow Runtime & Definition ──────────────────────────────────
|
||||
|
||||
export type WorkflowRuntime = {
|
||||
cas: CasStore;
|
||||
extract: ExtractFn;
|
||||
};
|
||||
|
||||
export type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
ctx: ModeratorContext<M>,
|
||||
) => (keyof M & string) | typeof END;
|
||||
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
table: ModeratorTable<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
export type ModeratorCondition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
description: string;
|
||||
check: (ctx: ModeratorContext<M>) => boolean;
|
||||
};
|
||||
|
||||
export type FALLBACK = "FALLBACK";
|
||||
|
||||
export type ModeratorTransition<M extends RoleMeta> = {
|
||||
condition: ModeratorCondition<M> | FALLBACK;
|
||||
role: (keyof M & string) | typeof END;
|
||||
};
|
||||
|
||||
export type ModeratorTable<M extends RoleMeta> = Record<
|
||||
(keyof M & string) | typeof START,
|
||||
ModeratorTransition<M>[]
|
||||
>;
|
||||
|
||||
// ── Advance Outcome ────────────────────────────────────────────────
|
||||
|
||||
export type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
@@ -1,34 +0,0 @@
|
||||
# @uncaged/workflow-util-agent
|
||||
|
||||
Shared helpers for CLI-backed workflow agents: assemble prompts from thread context and spawn subprocesses with timeouts.
|
||||
|
||||
Used by `@uncaged/workflow-agent-cursor` and `@uncaged/workflow-agent-hermes`. Depends on `@uncaged/workflow` for CAS reads (`getContentMerklePayload`) and `Result` typing.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-util-agent @uncaged/workflow
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for both packages.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { buildAgentPrompt, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
|
||||
const prompt = await buildAgentPrompt(agentContext);
|
||||
const result = await spawnCli("my-cli", ["--json"], { cwd: "/tmp", timeoutMs: 60_000 });
|
||||
if (!result.ok) { /* handle SpawnCliError */ }
|
||||
const stdout = result.value;
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `buildAgentPrompt(ctx)` | System prompt + task + prior step summaries + latest body from CAS; appends `uncaged-workflow thread <id>` tool hint |
|
||||
| `spawnCli(cmd, args, { cwd, timeoutMs })` | `Promise<Result<string, SpawnCliError>>`; captures stdout, non-zero exit and spawn failures as `err` |
|
||||
| `SpawnCliConfig` | `cwd: string \| null`, `timeoutMs: number \| null` |
|
||||
| `SpawnCliError` | `non_zero_exit` \| `timeout` \| `spawn_failed` |
|
||||
| `SpawnCliResult` | Alias for `Result<string, SpawnCliError>` |
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const schema = z.object({ status: z.string() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("## Deliverable Format");
|
||||
expect(result).toContain("status:");
|
||||
expect(result).toContain("confidence:");
|
||||
expect(result).toContain("artifacts:");
|
||||
expect(result).toContain("scope:");
|
||||
});
|
||||
|
||||
test("always includes scope reminder", () => {
|
||||
const schema = z.object({ status: z.string() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("Focus exclusively on YOUR role's deliverable");
|
||||
expect(result).toContain("Do not perform actions outside your role's scope");
|
||||
});
|
||||
|
||||
test("lists fields from a flat ZodObject schema", () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
phases: z.array(z.string()),
|
||||
reason: z.union([z.string(), z.null()]),
|
||||
});
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`title`");
|
||||
expect(result).toContain("`phases`");
|
||||
expect(result).toContain("`reason`");
|
||||
});
|
||||
|
||||
test("lists union of fields from a discriminated union schema", () => {
|
||||
const schema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("planned"), phases: z.array(z.string()) }),
|
||||
z.object({ status: z.literal("aborted"), reason: z.string() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status`");
|
||||
expect(result).toContain("`phases`");
|
||||
expect(result).toContain("`reason`");
|
||||
});
|
||||
|
||||
test("lists fields from a plain ZodUnion schema", () => {
|
||||
const schema = z.union([
|
||||
z.object({ kind: z.literal("a"), valueA: z.string() }),
|
||||
z.object({ kind: z.literal("b"), valueB: z.number() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`kind`");
|
||||
expect(result).toContain("`valueA`");
|
||||
expect(result).toContain("`valueB`");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema (no field list crash)", () => {
|
||||
const schema = z.string();
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("## Deliverable Format");
|
||||
expect(result).toContain("schema fields will be extracted automatically");
|
||||
});
|
||||
|
||||
test("marks frontmatter as the primary deliverable", () => {
|
||||
const schema = z.object({ done: z.boolean() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("no field is listed more than once for a union with overlapping keys", () => {
|
||||
const schema = z.union([
|
||||
z.object({ status: z.literal("a"), shared: z.string() }),
|
||||
z.object({ status: z.literal("b"), shared: z.string() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mock = vi.fn;
|
||||
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createAgentAdapter } from "../src/index.js";
|
||||
|
||||
// ── Minimal test fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: "START" as const,
|
||||
content: "test task",
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCas(): CasStore & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
let seq = 0;
|
||||
return {
|
||||
store,
|
||||
async put(content: string) {
|
||||
const hash = `HASH${String(++seq).padStart(9, "0")}`;
|
||||
store.set(hash, content);
|
||||
return hash;
|
||||
},
|
||||
async get(hash: string) {
|
||||
return store.get(hash) ?? null;
|
||||
},
|
||||
async delete(hash: string) {
|
||||
store.delete(hash);
|
||||
},
|
||||
async list() {
|
||||
return [...store.keys()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Frontmatter-compatible schema ─────────────────────────────────────────────
|
||||
|
||||
// Schema that maps directly to AgentFrontmatter fields so happy path works.
|
||||
const FrontmatterSchema = z.object({
|
||||
status: z.union([
|
||||
z.literal("done"),
|
||||
z.literal("needs_input"),
|
||||
z.literal("in_progress"),
|
||||
z.literal("failed"),
|
||||
z.null(),
|
||||
]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
confidence: z.union([z.number(), z.null()]),
|
||||
artifacts: z.array(z.string()),
|
||||
scope: z.union([z.literal("role"), z.literal("thread")]),
|
||||
});
|
||||
|
||||
type FrontmatterMeta = z.infer<typeof FrontmatterSchema>;
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createAgentAdapter — happy path (valid frontmatter satisfies schema)", () => {
|
||||
test("returns meta from frontmatter without calling runtime.extract", async () => {
|
||||
const cas = makeCas();
|
||||
const extractMock = mock(async () => {
|
||||
throw new Error("runtime.extract must not be called in happy path");
|
||||
});
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractMock as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = [
|
||||
"---",
|
||||
"status: done",
|
||||
"next: reviewer",
|
||||
"confidence: 0.9",
|
||||
"artifacts: [src/foo.ts]",
|
||||
"scope: role",
|
||||
"---",
|
||||
"",
|
||||
"## Summary",
|
||||
"Work is complete.",
|
||||
].join("\n");
|
||||
|
||||
const agentFn = mock(async (_ctx: ThreadContext, _opts: null) => rawOutput);
|
||||
const extractOpts = mock(async () => null);
|
||||
|
||||
const adapter = createAgentAdapter<null>(agentFn, extractOpts);
|
||||
const roleFn = adapter<FrontmatterMeta>("test prompt", FrontmatterSchema);
|
||||
const result = await roleFn(makeCtx(), runtime);
|
||||
|
||||
// Meta must come from frontmatter
|
||||
expect(result.meta.status).toBe("done");
|
||||
expect(result.meta.next).toBe("reviewer");
|
||||
expect(result.meta.confidence).toBe(0.9);
|
||||
expect(result.meta.artifacts).toEqual(["src/foo.ts"]);
|
||||
expect(result.meta.scope).toBe("role");
|
||||
expect(result.childThread).toBeNull();
|
||||
|
||||
// LLM extract must NOT have been called
|
||||
expect(extractMock).not.toHaveBeenCalled();
|
||||
|
||||
// CAS should store the body (without frontmatter) as the CAS node payload
|
||||
const storedContent = [...cas.store.values()][0] ?? "";
|
||||
expect(storedContent).toContain("## Summary");
|
||||
expect(storedContent).toContain("Work is complete.");
|
||||
// The frontmatter block itself must not appear in the stored payload
|
||||
expect(storedContent).not.toContain("status: done\n");
|
||||
});
|
||||
|
||||
test("body stored in CAS does not include the frontmatter block", async () => {
|
||||
const cas = makeCas();
|
||||
const runtime: WorkflowRuntime = {
|
||||
cas,
|
||||
extract: mock(async () => {
|
||||
throw new Error("must not be called");
|
||||
}) as WorkflowRuntime["extract"],
|
||||
};
|
||||
|
||||
const rawOutput =
|
||||
"---\nstatus: done\nnext: null\nconfidence: null\nscope: role\n---\n\nThe actual work content here.";
|
||||
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
// CAS node wraps content as `payload: <body>`; check the payload contains only body
|
||||
const stored = [...cas.store.values()][0] ?? "";
|
||||
expect(stored).toContain("The actual work content here.");
|
||||
// The frontmatter block must be stripped
|
||||
expect(stored).not.toContain("status: done");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback path ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createAgentAdapter — fallback path (no frontmatter)", () => {
|
||||
test("calls runtime.extract when output has no frontmatter block", async () => {
|
||||
const cas = makeCas();
|
||||
const expectedMeta: FrontmatterMeta = {
|
||||
status: "done",
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
};
|
||||
|
||||
const extractFn = mock(async (_schema: unknown, _hash: string) => ({
|
||||
meta: expectedMeta as Record<string, unknown>,
|
||||
contentPayload: "plain text output",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = "This is plain markdown without any frontmatter.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
const result = await roleFn(makeCtx(), runtime);
|
||||
|
||||
// runtime.extract must have been called once
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta).toEqual(expectedMeta);
|
||||
expect(result.childThread).toBeNull();
|
||||
|
||||
// CAS should store the full raw output (as CAS node payload)
|
||||
const stored = [...cas.store.values()][0] ?? "";
|
||||
expect(stored).toContain(rawOutput);
|
||||
});
|
||||
|
||||
test("falls back to runtime.extract when frontmatter is structurally invalid", async () => {
|
||||
const cas = makeCas();
|
||||
const expectedMeta: FrontmatterMeta = {
|
||||
status: null,
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
};
|
||||
const extractFn = mock(async () => ({
|
||||
meta: expectedMeta as Record<string, unknown>,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
// confidence out of range — validateFrontmatter will reject
|
||||
const rawOutput = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("falls back when frontmatter fields do not satisfy schema", async () => {
|
||||
const cas = makeCas();
|
||||
|
||||
// Schema requires a mandatory non-null string field that frontmatter cannot provide
|
||||
const StrictSchema = z.object({
|
||||
requiredField: z.string(),
|
||||
});
|
||||
|
||||
const extractFn = mock(async () => ({
|
||||
meta: { requiredField: "from-llm" } as Record<string, unknown>,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<{ requiredField: string }>("prompt", StrictSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
// frontmatter has no `requiredField`, so schema parse fails → fallback
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
/**
|
||||
* Extract the top-level field names from a Zod schema.
|
||||
*
|
||||
* Handles:
|
||||
* - ZodObject → its `.shape` keys
|
||||
* - ZodDiscriminatedUnion / ZodUnion → union of all variant shapes
|
||||
*
|
||||
* Returns an empty array for schemas that have no inspectable shape
|
||||
* (e.g. primitives, ZodAny).
|
||||
*/
|
||||
function extractSchemaFields(schema: ZodSchema): string[] {
|
||||
const def = schema.def as {
|
||||
type: string;
|
||||
shape?: Record<string, ZodSchema>;
|
||||
options?: ZodSchema[];
|
||||
};
|
||||
|
||||
if (def.type === "object" && def.shape !== undefined) {
|
||||
return Object.keys(def.shape);
|
||||
}
|
||||
|
||||
if ((def.type === "discriminated_union" || def.type === "union") && Array.isArray(def.options)) {
|
||||
const fieldSet = new Set<string>();
|
||||
for (const option of def.options) {
|
||||
for (const field of extractSchemaFields(option as ZodSchema)) {
|
||||
fieldSet.add(field);
|
||||
}
|
||||
}
|
||||
return [...fieldSet];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise output format instruction block for an agent role.
|
||||
*
|
||||
* The instruction describes the expected frontmatter markdown format and lists
|
||||
* the meta fields derived from `schema`. It is injected at the top of the
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*
|
||||
* Focus on YOUR role's deliverable. Do not perform actions outside your role's scope.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: ZodSchema): string {
|
||||
const fields = extractSchemaFields(schema);
|
||||
|
||||
const fieldList =
|
||||
fields.length > 0
|
||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||
: " (schema fields will be extracted automatically)";
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
status: done # done | needs_input | in_progress | failed
|
||||
next: <role-name> # suggested next role, or omit
|
||||
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||
artifacts: # list of file paths or CAS hashes you produced
|
||||
- path/to/file.ts
|
||||
scope: role # role | thread
|
||||
---
|
||||
|
||||
... your markdown work here ...
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
AgentFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
export type ExtractOptionsFn<Opt> = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
runtime: WorkflowRuntime,
|
||||
) => Promise<Opt>;
|
||||
|
||||
/**
|
||||
* Try to satisfy `schema` from frontmatter fields alone.
|
||||
*
|
||||
* Returns the parsed value on success, or `null` when the frontmatter does not
|
||||
* cover all required fields of the schema. Never throws.
|
||||
*/
|
||||
function tryFrontmatterMeta<T>(
|
||||
raw: string,
|
||||
schema: z.ZodType<T>,
|
||||
): { meta: T; body: string } | null {
|
||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||
|
||||
if (frontmatter === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"4KNMR2PX",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Coerce frontmatter into the plain object shape the schema expects.
|
||||
const candidate: Record<string, unknown> = {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: frontmatter.artifacts,
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
|
||||
const result = schema.safeParse(candidate);
|
||||
if (!result.success) {
|
||||
log("7BQST3VW", "frontmatter does not satisfy schema; falling back to extract");
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meta: result.data, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
||||
*
|
||||
* Happy path (zero LLM cost):
|
||||
* 1. extract(ctx, prompt, runtime) → Opt
|
||||
* 2. agent(ctx, options) → raw string
|
||||
* 3. Parse raw as frontmatter markdown
|
||||
* 4. If frontmatter is valid AND satisfies `schema` → use as meta directly
|
||||
* CAS stores the body (without frontmatter block)
|
||||
*
|
||||
* Fallback (safety net):
|
||||
* 4b. Store full raw in CAS
|
||||
* 5b. runtime.extract(schema, contentHash) → typed meta via LLM
|
||||
*/
|
||||
export function createAgentAdapter<Opt>(
|
||||
agent: AgentFn<Opt>,
|
||||
extract: ExtractOptionsFn<Opt>,
|
||||
): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`;
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, augmentedPrompt, runtime);
|
||||
const raw = await agent(ctx, options);
|
||||
|
||||
const frontmatterResult = tryFrontmatterMeta(raw, schema);
|
||||
|
||||
if (frontmatterResult !== null) {
|
||||
log("3VXPW8QR", "frontmatter satisfied schema — skipping LLM extract");
|
||||
await putContentNodeWithRefs(runtime.cas, frontmatterResult.body, []);
|
||||
return { meta: frontmatterResult.meta, childThread: null };
|
||||
}
|
||||
|
||||
log("8MTNJ5YK", "no valid frontmatter — falling back to runtime.extract");
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { createAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliError } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-cas" }]
|
||||
}
|
||||
+10
-22
@@ -1,42 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.14",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"@uncaged/json-cas": "^0.1.0",
|
||||
"@uncaged/json-cas-workflow": "^0.1.0"
|
||||
},
|
||||
"packageManager": "bun@1.3.13",
|
||||
"scripts": {
|
||||
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||
"preinstall": "npx only-allow bun",
|
||||
"prepublishOnly": "echo 'Use bun run release instead' && exit 1",
|
||||
"preinstall": "node scripts/check-pkg-mgr.js",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"test:ci": "bun run --filter './packages/*' test:ci",
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun run test && node scripts/publish-all.mjs"
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
||||
"bun-types": "^1.3.13",
|
||||
"typescript": "^5.8.3",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-uwf",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
} from "./commands/thread.js";
|
||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import { resolveStorageRoot } from "./store.js";
|
||||
|
||||
function writeJson(data: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(data)}\n`);
|
||||
}
|
||||
|
||||
function runAction(action: () => Promise<void>): void {
|
||||
action().catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("uwf").description("Stateless workflow CLI");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
|
||||
workflow
|
||||
.command("put")
|
||||
.description("Register a workflow from YAML")
|
||||
.argument("<file>", "Workflow YAML file")
|
||||
.action((file: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowPut(storageRoot, file);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("show")
|
||||
.description("Show a workflow by name or CAS hash")
|
||||
.argument("<id>", "Workflow name or hash")
|
||||
.action((id: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowShow(storageRoot, id);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("list")
|
||||
.description("List registered workflows")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowList(storageRoot);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||
|
||||
thread
|
||||
.command("start")
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.description("Execute one step")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("show")
|
||||
.description("Show thread head pointer")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("list")
|
||||
.description("List active threads")
|
||||
.option("--all", "Include archived threads")
|
||||
.action((opts: { all: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadList(storageRoot, opts.all);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("kill")
|
||||
.description("Terminate and archive a thread")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadKill(storageRoot, threadId);
|
||||
writeJson(result);
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
.option("--provider <name>", "Provider name")
|
||||
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--model <name>", "Default model name")
|
||||
.option("--agent <name>", "Default agent alias")
|
||||
.action((opts: {
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
agent?: string;
|
||||
}) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||
const result = await cmdSetup({
|
||||
provider: opts.provider,
|
||||
baseUrl: opts.baseUrl,
|
||||
apiKey: opts.apiKey,
|
||||
model: opts.model,
|
||||
agent: opts.agent,
|
||||
storageRoot,
|
||||
});
|
||||
writeJson(result);
|
||||
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
|
||||
await cmdSetupInteractive(storageRoot);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
|
||||
import { stringify, parse } from "yaml";
|
||||
|
||||
/**
|
||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||
* Keep in sync with providers.yaml in cli-workflow.
|
||||
*/
|
||||
const PRESET_PROVIDERS = [
|
||||
// International
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||
// China
|
||||
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
|
||||
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
|
||||
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
|
||||
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
|
||||
// Local
|
||||
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
|
||||
] as const;
|
||||
|
||||
type SetupArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
agent?: string;
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
function getConfigPath(root: string): string {
|
||||
return join(root, "config.yaml");
|
||||
}
|
||||
|
||||
function getEnvPath(root: string): string {
|
||||
return join(root, ".env");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing config.yaml or return empty structure.
|
||||
*/
|
||||
function loadExistingConfig(configPath: string): Record<string, unknown> {
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors, start fresh
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing .env as key=value map.
|
||||
*/
|
||||
function loadEnvFile(envPath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq > 0) {
|
||||
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function saveEnvFile(envPath: string, env: Record<string, string>): void {
|
||||
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
||||
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function apiKeyEnvName(providerName: string): string {
|
||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
|
||||
const models = (typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
models.default = { provider: args.provider, name: args.model };
|
||||
|
||||
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
agents.hermes = { command: "uwf-hermes", args: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
providers,
|
||||
models,
|
||||
agents,
|
||||
defaultAgent: existing.defaultAgent ?? agentName,
|
||||
defaultModel: existing.defaultModel ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive setup. All required args provided via CLI flags.
|
||||
*/
|
||||
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
|
||||
const { storageRoot } = args;
|
||||
mkdirSync(storageRoot, { recursive: true });
|
||||
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
const envPath = getEnvPath(storageRoot);
|
||||
|
||||
const existing = loadExistingConfig(configPath);
|
||||
const merged = mergeConfig(existing, args);
|
||||
|
||||
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
|
||||
|
||||
// Write API key to .env
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
const envData = loadEnvFile(envPath);
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((resolve) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
resolve(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup — prompts user for provider, API key, model.
|
||||
*/
|
||||
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
|
||||
const rl = createInterface({ input, output });
|
||||
|
||||
try {
|
||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||
|
||||
// 1. Provider selection
|
||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||
console.log("Select a provider:\n");
|
||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||
const p = PRESET_PROVIDERS[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
let providerName: string;
|
||||
let baseUrl: string;
|
||||
|
||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
providerName = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
} else {
|
||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
}
|
||||
|
||||
// 2. API key
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key: ");
|
||||
if (!apiKey) throw new Error("API key required");
|
||||
|
||||
// 3. Model selection
|
||||
const rl2 = createInterface({ input, output });
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
let model: string;
|
||||
if (models.length > 0) {
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
const nw = String(models.length).length;
|
||||
// Multi-column layout
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||
const termCols = process.stdout.columns || 100;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
model = models[modelNum - 1] ?? modelInput;
|
||||
} else {
|
||||
model = modelInput;
|
||||
}
|
||||
} else {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
}
|
||||
|
||||
rl2.close();
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
console.log(" uwf thread step <thread-id> Execute next step");
|
||||
console.log("");
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||
import { evaluate } from "@uncaged/uwf-moderator";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef } from "../validate.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
start: StartNodePayload;
|
||||
stepsNewestFirst: StepNodePayload[];
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
export type KillOutput = {
|
||||
thread: ThreadId;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveWorkflowCasRef(
|
||||
uwf: UwfStore,
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
): Promise<CasRef> {
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
if (!isCasRef(hash)) {
|
||||
fail(`workflow not found: ${workflowId}`);
|
||||
}
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||
const node = uwf.store.get(head);
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === uwf.schemas.startNode) {
|
||||
const payload = node.payload as StartNodePayload;
|
||||
return payload.workflow;
|
||||
}
|
||||
|
||||
const payload = node.payload as StepNodePayload;
|
||||
if (typeof payload.start !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(payload.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (startNode.payload as StartNodePayload).workflow;
|
||||
}
|
||||
|
||||
export async function cmdThreadStart(
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
): Promise<StartOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||
|
||||
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
const node = uwf.store.get(headHash);
|
||||
if (node === null || !validate(uwf.store, node)) {
|
||||
fail("stored StartNode failed schema validation");
|
||||
}
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[threadId] = headHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return { workflow: workflowHash, thread: threadId };
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
async function threadListItemFromActive(
|
||||
uwf: UwfStore,
|
||||
threadId: ThreadId,
|
||||
head: CasRef,
|
||||
): Promise<ThreadListItem | null> {
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
return null;
|
||||
}
|
||||
return { thread: threadId, workflow, head };
|
||||
}
|
||||
|
||||
export async function cmdThreadList(
|
||||
storageRoot: string,
|
||||
includeAll: boolean,
|
||||
): Promise<ThreadListItem[]> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const items: ThreadListItem[] = [];
|
||||
|
||||
for (const [threadId, head] of Object.entries(index)) {
|
||||
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAll) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const activeIds = new Set(items.map((i) => i.thread));
|
||||
const history = await loadThreadHistory(storageRoot);
|
||||
for (const entry of history) {
|
||||
if (!activeIds.has(entry.thread)) {
|
||||
items.push({
|
||||
thread: entry.thread,
|
||||
workflow: entry.workflow,
|
||||
head: entry.head,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||
const headNode = uwf.store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
}
|
||||
|
||||
if (headNode.type === uwf.schemas.startNode) {
|
||||
return {
|
||||
startHash: headHash,
|
||||
start: headNode.payload as StartNodePayload,
|
||||
stepsNewestFirst: [],
|
||||
headIsStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (headNode.type !== uwf.schemas.stepNode) {
|
||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const stepsNewestFirst: StepNodePayload[] = [];
|
||||
let hash: CasRef | null = headHash;
|
||||
|
||||
while (hash !== null) {
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found while walking chain: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
stepsNewestFirst.push(payload);
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
const newest = stepsNewestFirst[0];
|
||||
if (newest === undefined) {
|
||||
fail(`empty step chain at head ${headHash}`);
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(newest.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
fail(`StartNode not found: ${newest.start}`);
|
||||
}
|
||||
|
||||
return {
|
||||
startHash: newest.start,
|
||||
start: startNode.payload as StartNodePayload,
|
||||
stepsNewestFirst,
|
||||
headIsStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||
const node = uwf.store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
const node = uwf.store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${workflowRef} is not a Workflow`);
|
||||
}
|
||||
return node.payload as WorkflowPayload;
|
||||
}
|
||||
|
||||
function parseAgentOverride(override: string): AgentConfig {
|
||||
const parts = override
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0);
|
||||
const command = parts[0];
|
||||
if (command === undefined) {
|
||||
fail("agent override must not be empty");
|
||||
}
|
||||
return { command, args: parts.slice(1) };
|
||||
}
|
||||
|
||||
function resolveAgentConfig(
|
||||
config: WorkflowConfig,
|
||||
workflow: WorkflowPayload,
|
||||
role: string,
|
||||
agentOverride: string | null,
|
||||
): AgentConfig {
|
||||
if (agentOverride !== null) {
|
||||
return parseAgentOverride(agentOverride);
|
||||
}
|
||||
|
||||
let alias: AgentAlias = config.defaultAgent;
|
||||
if (config.agentOverrides !== null) {
|
||||
const roleOverrides = config.agentOverrides[workflow.name];
|
||||
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||
alias = roleOverrides[role];
|
||||
}
|
||||
}
|
||||
|
||||
const agentConfig = config.agents[alias];
|
||||
if (agentConfig === undefined) {
|
||||
fail(`unknown agent alias in config: ${alias}`);
|
||||
}
|
||||
return agentConfig;
|
||||
}
|
||||
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||
const stderr =
|
||||
err.stderr === undefined
|
||||
? ""
|
||||
: typeof err.stderr === "string"
|
||||
? err.stderr
|
||||
: err.stderr.toString("utf8");
|
||||
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||
fail(`agent command failed (${agent.command})${detail}`);
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
workflow: CasRef,
|
||||
head: CasRef,
|
||||
): Promise<void> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdThreadStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
if (!nextResult.ok) {
|
||||
fail(nextResult.error.message);
|
||||
}
|
||||
|
||||
if (nextResult.value === END_ROLE) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const role = nextResult.value;
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(agent, threadId, role);
|
||||
|
||||
// Re-create store to pick up nodes written by the agent subprocess
|
||||
const uwfAfter = await createUwfStore(storageRoot);
|
||||
const newNode = uwfAfter.store.get(newHead);
|
||||
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||
}
|
||||
|
||||
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||
freshIndex[threadId] = newHead;
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
if (!afterResult.ok) {
|
||||
fail(afterResult.error.message);
|
||||
}
|
||||
|
||||
const done = afterResult.value === END_ROLE;
|
||||
if (done) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${head}`);
|
||||
}
|
||||
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
const historyEntry: ThreadHistoryLine = {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
await appendThreadHistory(storageRoot, historyEntry);
|
||||
|
||||
return { thread: threadId, archived: true };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
createUwfStore,
|
||||
findRegistryName,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveWorkflowRegistry,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||
|
||||
export type WorkflowListEntry = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
export type WorkflowPutOutput = {
|
||||
name: string;
|
||||
hash: CasRef;
|
||||
};
|
||||
|
||||
export type WorkflowShowOutput = {
|
||||
hash: CasRef;
|
||||
name: string | null;
|
||||
type: CasRef;
|
||||
payload: WorkflowPayload;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function resolveOutputSchemaRef(
|
||||
uwf: UwfStore,
|
||||
outputSchema: string | JSONSchema,
|
||||
): Promise<CasRef> {
|
||||
if (typeof outputSchema === "string") {
|
||||
if (!isCasRef(outputSchema)) {
|
||||
fail(`invalid outputSchema cas_ref: ${outputSchema}`);
|
||||
}
|
||||
if (!uwf.store.has(outputSchema)) {
|
||||
fail(`outputSchema not found in CAS: ${outputSchema}`);
|
||||
}
|
||||
return outputSchema;
|
||||
}
|
||||
if (!isJsonSchema(outputSchema)) {
|
||||
fail("outputSchema must be a cas_ref string or JSON Schema object");
|
||||
}
|
||||
return putSchema(uwf.store, outputSchema);
|
||||
}
|
||||
|
||||
async function materializeWorkflowPayload(
|
||||
uwf: UwfStore,
|
||||
raw: WorkflowPayload,
|
||||
): Promise<WorkflowPayload> {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
for (const [roleName, role] of Object.entries(raw.roles)) {
|
||||
const outputSchema = await resolveOutputSchemaRef(
|
||||
uwf,
|
||||
role.outputSchema as string | JSONSchema,
|
||||
);
|
||||
roles[roleName] = {
|
||||
description: role.description,
|
||||
systemPrompt: role.systemPrompt,
|
||||
outputSchema,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: raw.graph,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowPut(
|
||||
storageRoot: string,
|
||||
filePath: string,
|
||||
): Promise<WorkflowPutOutput> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
fail(`file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parse(text) as unknown;
|
||||
} catch (e) {
|
||||
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const payload = parseWorkflowPayload(raw);
|
||||
if (payload === null) {
|
||||
fail("invalid workflow YAML: expected WorkflowPayload shape");
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||
|
||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null || !validate(uwf.store, node)) {
|
||||
fail("stored workflow failed schema validation");
|
||||
}
|
||||
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
registry[materialized.name] = hash;
|
||||
await saveWorkflowRegistry(storageRoot, registry);
|
||||
|
||||
return { name: materialized.name, hash };
|
||||
}
|
||||
|
||||
export async function cmdWorkflowShow(
|
||||
storageRoot: string,
|
||||
id: string,
|
||||
): Promise<WorkflowShowOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, id);
|
||||
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
|
||||
const payload = node.payload as WorkflowPayload;
|
||||
return {
|
||||
hash,
|
||||
name: findRegistryName(registry, hash),
|
||||
type: node.type,
|
||||
payload,
|
||||
timestamp: node.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { Hash, Store } from "@ocas/core";
|
||||
import { putSchema } from "@ocas/core";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/workflow-protocol";
|
||||
|
||||
export const TEXT_SCHEMA = { type: "string" as const };
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
|
||||
export type UwfSchemaHashes = {
|
||||
workflow: Hash;
|
||||
startNode: Hash;
|
||||
stepNode: Hash;
|
||||
text: Hash;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -16,11 +17,10 @@ export type UwfSchemaHashes = {
|
||||
* Idempotent: safe to call on every CLI invocation.
|
||||
*/
|
||||
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
|
||||
const [workflow, startNode, stepNode, text] = await Promise.all([
|
||||
const [workflow, startNode, stepNode] = await Promise.all([
|
||||
putSchema(store, WORKFLOW_SCHEMA),
|
||||
putSchema(store, START_NODE_SCHEMA),
|
||||
putSchema(store, STEP_NODE_SCHEMA),
|
||||
putSchema(store, TEXT_SCHEMA),
|
||||
]);
|
||||
return { workflow, startNode, stepNode, text };
|
||||
return { workflow, startNode, stepNode };
|
||||
}
|
||||
@@ -1,110 +1,16 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { access, appendFile, mkdir, readdir, 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 { BootstrapCapableStore, Hash } from "@ocas/core";
|
||||
import { createFsStore } from "@ocas/fs";
|
||||
import type {
|
||||
CasRef,
|
||||
ThreadId,
|
||||
ThreadIndexEntry,
|
||||
ThreadListItem,
|
||||
ThreadsIndex,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
createThreadIndexEntry,
|
||||
parseThreadsIndex,
|
||||
serializeThreadsIndex,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
|
||||
|
||||
export type WorkflowRegistry = Record<string, CasRef>;
|
||||
|
||||
/** A workflow entry discovered from the project-local .workflows/ directory. */
|
||||
export type ProjectWorkflowEntry = {
|
||||
/** Workflow name (from YAML `name` field, equals filename stem). */
|
||||
name: string;
|
||||
/** Absolute path to the YAML file. */
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
/** Extract workflow name from a YAML filename (strip .yaml/.yml extension). */
|
||||
function stemFromYaml(name: string): string {
|
||||
if (name.endsWith(".yaml")) return name.slice(0, -5);
|
||||
if (name.endsWith(".yml")) return name.slice(0, -4);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Check if a directory contains an index.yaml or index.yml workflow file. */
|
||||
async function findIndexWorkflow(
|
||||
dir: string,
|
||||
dirName: string,
|
||||
): Promise<ProjectWorkflowEntry | null> {
|
||||
for (const indexName of ["index.yaml", "index.yml"]) {
|
||||
const indexPath = join(dir, dirName, indexName);
|
||||
try {
|
||||
await access(indexPath);
|
||||
return { name: dirName, filePath: indexPath };
|
||||
} catch {
|
||||
// not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single directory for workflow entries (flat YAML files + folder/index.yaml).
|
||||
* Returns discovered entries. Returns empty array if directory does not exist.
|
||||
*/
|
||||
async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
dirents = await readdir(dir, { withFileTypes: true });
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const result: ProjectWorkflowEntry[] = [];
|
||||
for (const entry of dirents) {
|
||||
if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
|
||||
result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) });
|
||||
} else if (entry.isDirectory()) {
|
||||
const found = await findIndexWorkflow(dir, entry.name);
|
||||
if (found !== null) {
|
||||
result.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
|
||||
* .workflow/ takes priority: if a name is found in both, .workflow/ wins.
|
||||
* Returns an empty array if neither directory exists.
|
||||
*/
|
||||
export async function discoverProjectWorkflows(
|
||||
projectRoot: string,
|
||||
): Promise<ProjectWorkflowEntry[]> {
|
||||
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
|
||||
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
|
||||
const seen = new Set(primary.map((e) => e.name));
|
||||
const merged = [...primary];
|
||||
for (const entry of legacy) {
|
||||
if (!seen.has(entry.name)) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
@@ -126,26 +32,10 @@ export function resolveStorageRoot(): string {
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated: Use `getGlobalCasDir()` instead.
|
||||
* Returns the old CAS directory for backward compatibility.
|
||||
*/
|
||||
export function getCasDir(storageRoot: string): string {
|
||||
return join(storageRoot, "cas");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global CAS directory shared by all uwf and json-cas tools.
|
||||
* Priority: UNCAGED_CAS_DIR environment variable → default ~/.uncaged/json-cas
|
||||
*/
|
||||
export function getGlobalCasDir(): string {
|
||||
const envPath = process.env.UNCAGED_CAS_DIR;
|
||||
if (envPath !== undefined && envPath !== "") {
|
||||
return envPath;
|
||||
}
|
||||
return join(homedir(), ".uncaged", "json-cas");
|
||||
}
|
||||
|
||||
export function getRegistryPath(storageRoot: string): string {
|
||||
return join(storageRoot, "workflows.yaml");
|
||||
}
|
||||
@@ -160,17 +50,16 @@ export function getHistoryPath(storageRoot: string): string {
|
||||
|
||||
export type ThreadHistoryLine = ThreadListItem & {
|
||||
completedAt: number;
|
||||
reason: "completed" | "cancelled" | null;
|
||||
};
|
||||
|
||||
export type UwfStore = {
|
||||
storageRoot: string;
|
||||
store: BootstrapCapableStore;
|
||||
store: Store;
|
||||
schemas: UwfSchemaHashes;
|
||||
};
|
||||
|
||||
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
|
||||
const casDir = getGlobalCasDir();
|
||||
const casDir = getCasDir(storageRoot);
|
||||
await mkdir(casDir, { recursive: true });
|
||||
const store = createFsStore(casDir);
|
||||
const schemas = await registerUwfSchemas(store);
|
||||
@@ -215,22 +104,6 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas
|
||||
return registry[id] !== undefined ? registry[id] : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workflow name to a project-local YAML file path.
|
||||
* Returns null if the name is not found in the local entries.
|
||||
*/
|
||||
export function resolveProjectWorkflowFile(
|
||||
localEntries: ProjectWorkflowEntry[],
|
||||
name: string,
|
||||
): string | null {
|
||||
for (const entry of localEntries) {
|
||||
if (entry.name === name) {
|
||||
return entry.filePath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
|
||||
for (const [name, h] of Object.entries(registry)) {
|
||||
if (h === hash) {
|
||||
@@ -245,7 +118,16 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = parse(text) as unknown;
|
||||
return parseThreadsIndex(raw);
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const index: ThreadsIndex = {};
|
||||
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof head === "string") {
|
||||
index[threadId as ThreadId] = head;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
@@ -255,25 +137,10 @@ export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsInde
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept legacy CasRef values for test convenience. */
|
||||
export type ThreadsIndexInput = Record<ThreadId, ThreadIndexEntry | CasRef>;
|
||||
|
||||
function normalizeThreadsIndexInput(index: ThreadsIndexInput): ThreadsIndex {
|
||||
const normalized: ThreadsIndex = {};
|
||||
for (const [threadId, value] of Object.entries(index)) {
|
||||
normalized[threadId as ThreadId] =
|
||||
typeof value === "string" ? createThreadIndexEntry(value as CasRef) : value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function saveThreadsIndex(
|
||||
storageRoot: string,
|
||||
index: ThreadsIndexInput,
|
||||
): Promise<void> {
|
||||
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
|
||||
const path = getThreadsPath(storageRoot);
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
const text = stringify(serializeThreadsIndex(normalizeThreadsIndexInput(index)), { indent: 2 });
|
||||
const text = stringify(index, { indent: 2 });
|
||||
await writeFile(path, text, "utf8");
|
||||
}
|
||||
|
||||
@@ -307,15 +174,7 @@ export async function loadThreadHistory(storageRoot: string): Promise<ThreadHist
|
||||
typeof head === "string" &&
|
||||
typeof completedAt === "number"
|
||||
) {
|
||||
const reason = rec.reason;
|
||||
const parsedReason = reason === "completed" || reason === "cancelled" ? reason : null;
|
||||
lines.push({
|
||||
thread: thread as ThreadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt,
|
||||
reason: parsedReason,
|
||||
});
|
||||
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
|
||||
|
||||
export function isCasRef(value: string): value is CasRef {
|
||||
return CAS_REF_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRoleDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const outputSchema = value.outputSchema;
|
||||
const schemaOk =
|
||||
typeof outputSchema === "string" ||
|
||||
(isRecord(outputSchema) && typeof outputSchema.type === "string");
|
||||
return (
|
||||
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(itemCheck);
|
||||
}
|
||||
|
||||
function isGraph(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
|
||||
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../uwf-protocol" },
|
||||
{ "path": "../uwf-moderator" },
|
||||
{ "path": "../uwf-agent-kit" }
|
||||
]
|
||||
}
|
||||
+58
-203
@@ -1,221 +1,76 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
`uwf` CLI — thread lifecycle, workflow registry, CAS inspection, and setup.
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
## Overview
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
|
||||
|
||||
### Four-Layer Architecture
|
||||
|
||||
```
|
||||
workflow → thread → step → turn
|
||||
模板定义 执行实例 单步结果 agent内部交互
|
||||
```
|
||||
|
||||
- **Workflow** (layer 1): YAML template with roles and routing graph
|
||||
- **Thread** (layer 2): Single workflow execution instance
|
||||
- **Step** (layer 3): One moderator→agent→extract cycle
|
||||
- **Turn** (layer 4): Agent-internal interactions (use `step show` or CAS to inspect)
|
||||
|
||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||
|
||||
**Dependencies:** `@ocas/core`, `@ocas/fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
|
||||
|
||||
## Installation
|
||||
|
||||
Included as the `uwf` binary when you install `@uncaged/cli-workflow`:
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -g @uncaged/cli-workflow
|
||||
# or from the monorepo:
|
||||
bun link packages/cli-workflow
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
### Global options
|
||||
|
||||
```
|
||||
-V, --version Show version
|
||||
--format <json|yaml> Output format (default: json)
|
||||
-h, --help Show help
|
||||
```
|
||||
|
||||
### Thread (Layer 2: Execution Instances)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
|
||||
| `uwf thread exec <thread-id> [--agent <cmd>] [-c <count>] [--background]` | Execute one or more moderator→agent→extract cycles |
|
||||
| `uwf thread show <thread-id>` | Show thread head pointer |
|
||||
| `uwf thread list [--status <status>] [--after <date>] [--before <date>] [--skip <n>] [--take <n>]` | List threads filtered by status (idle, running, completed, active, or comma-separated), time range (ISO or relative like '7d'), with pagination |
|
||||
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
|
||||
|
||||
`thread read`, `step list`, and `step show` work on both active and completed threads.
|
||||
| `uwf thread stop <thread-id>` | Stop background execution (keep thread active) |
|
||||
| `uwf thread cancel <thread-id>` | Cancel thread (stop + archive to history) |
|
||||
|
||||
Examples:
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
|
||||
uwf thread exec 01ARZ3NDEKTSV4RRFFQ69G5FAV --background
|
||||
uwf thread list --status running
|
||||
uwf thread list --status active
|
||||
uwf thread list --status idle,completed
|
||||
uwf thread list --after 7d --take 10
|
||||
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
|
||||
uwf thread stop 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
### Step (Layer 3: Single Cycle Results)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf step list <thread-id>` | List all steps in a thread chronologically |
|
||||
| `uwf step show <step-hash>` | Show step metadata and frontmatter |
|
||||
| `uwf step read <step-hash> [--quota <chars>]` | Read a step's turns as human-readable markdown |
|
||||
| `uwf step fork <step-hash>` | Fork a thread from a specific step |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
uwf step list 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
uwf step show 32GCDE899RRQ3
|
||||
uwf step read 32GCDE899RRQ3 --quota 2000
|
||||
uwf step fork 32GCDE899RRQ3
|
||||
```
|
||||
|
||||
### Workflow (Layer 1: Templates)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf workflow add <file.yaml>` | Register a workflow from YAML |
|
||||
| `uwf workflow show <name-or-hash>` | Show workflow definition |
|
||||
| `uwf workflow list` | List registered workflows |
|
||||
|
||||
### CAS
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf cas get <hash> [--timestamp]` | Read a CAS node |
|
||||
| `uwf cas put <type-hash> <data>` | Store a node, print hash |
|
||||
| `uwf cas put-text <text>` | Store plain text, print hash |
|
||||
| `uwf cas has <hash>` | Check existence |
|
||||
| `uwf cas refs <hash>` | List direct references |
|
||||
| `uwf cas walk <hash>` | Recursive traversal |
|
||||
| `uwf cas reindex` | Rebuild type index |
|
||||
| `uwf cas schema list` | List registered schemas |
|
||||
| `uwf cas schema get <hash>` | Show a schema |
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
uwf setup
|
||||
uwf setup --provider openai --base-url https://api.openai.com/v1 \
|
||||
--api-key sk-... --model gpt-4o --agent hermes
|
||||
```
|
||||
|
||||
Config: `~/.uncaged/workflow/config.yaml` (includes API keys).
|
||||
|
||||
### Skill
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf skill cli` | Print markdown reference of all uwf commands (for agent skills) |
|
||||
|
||||
### Log
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uwf log list` | List log files with sizes |
|
||||
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
|
||||
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Breaking Changes (v0.x → v1.x)
|
||||
|
||||
The CLI was reorganized to clarify the four-layer architecture. **No backward compatibility** — old commands have been removed.
|
||||
|
||||
#### Renamed Commands
|
||||
|
||||
| Old Command | New Command | Notes |
|
||||
|------------|-------------|-------|
|
||||
| `workflow put` | `workflow add` | More intuitive verb |
|
||||
| `thread step` | `thread exec` | Eliminates ambiguity with "step" noun |
|
||||
| `thread list --all` | `thread list --status completed` | Unified status filtering |
|
||||
|
||||
#### Removed Commands (Merged)
|
||||
|
||||
| Old Command | New Command | Notes |
|
||||
|------------|-------------|-------|
|
||||
| `thread running` | `thread list --status running` | Merged into unified list |
|
||||
|
||||
#### Removed Commands (Split)
|
||||
|
||||
| Old Command | New Commands | Notes |
|
||||
|------------|-------------|-------|
|
||||
| `thread kill` | `thread stop` or `thread cancel` | `stop` keeps thread active, `cancel` archives it |
|
||||
|
||||
#### Moved Commands
|
||||
|
||||
| Old Command | New Command | Notes |
|
||||
|------------|-------------|-------|
|
||||
| `thread steps` | `step list` | Moved to step layer |
|
||||
| `thread step-details` | `step show` | Moved to step layer |
|
||||
| `thread fork` | `step fork` | Moved to step layer (forks are step-based) |
|
||||
|
||||
#### Deprecation Errors
|
||||
|
||||
Old commands now show helpful error messages:
|
||||
|
||||
```bash
|
||||
$ uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
Error: Command 'thread step' has been removed.
|
||||
Use 'thread exec' instead.
|
||||
|
||||
For more information, see: uwf help thread exec
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
src/
|
||||
├── cli.ts Commander entrypoint, command registration
|
||||
├── format.ts JSON/YAML output formatting
|
||||
├── store.ts CAS store + registry initialization
|
||||
├── validate.ts Workflow YAML validation
|
||||
├── schemas.ts CLI-local schema registration
|
||||
├── moderator/ Status-based graph evaluator (next role or $END)
|
||||
└── commands/
|
||||
├── thread.ts Thread lifecycle and exec
|
||||
├── step.ts Step operations (list/show/read/fork)
|
||||
├── workflow.ts Workflow registry (add/show/list)
|
||||
├── cas.ts CAS inspection and schema ops
|
||||
├── setup.ts Interactive/non-interactive setup
|
||||
├── skill.ts Built-in skill references
|
||||
└── log.ts Process debug log management
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.uncaged/workflow/config.yaml` | Providers, models, default agent |
|
||||
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
|
||||
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
|
||||
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
|
||||
| `~/.uncaged/json-cas/` | Content-addressed node storage (unified CAS store, shared with `ocas` CLI) |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `UNCAGED_CAS_DIR` | Override the global CAS directory location | `~/.uncaged/json-cas` |
|
||||
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override for workflow metadata storage | `~/.uncaged/workflow` |
|
||||
| `WORKFLOW_STORAGE_ROOT` | User override for workflow metadata storage | `~/.uncaged/workflow` |
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user