docs: replace RFC-001 with up-to-date architecture doc
RFC-001 was severely outdated (pre-refactor types). New architecture.md covers the current three-phase engine, pure data roles, AgentBinding, ExtractFn, and all design decisions. 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
# @uncaged/workflow — Architecture
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
| Package | npm Name | Purpose |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
|
||||||
|
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
|
||||||
|
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||||
|
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||||
|
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||||
|
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
||||||
|
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
||||||
|
| `workflow-role-reviewer` | `@uncaged/workflow-role-reviewer` | Pure data: code review prompt + schema |
|
||||||
|
| `workflow-role-committer` | `@uncaged/workflow-role-committer` | Pure data: git commit prompt + schema |
|
||||||
|
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Composes roles + moderator into a complete workflow |
|
||||||
|
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||||
|
|
||||||
|
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||||
|
|
||||||
|
## Core Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// --- Sentinel values ---
|
||||||
|
const START = "__start__";
|
||||||
|
const END = "__end__";
|
||||||
|
|
||||||
|
// --- RoleMeta: maps role names → their meta types ---
|
||||||
|
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
// --- Role Definition: pure data, no execution logic ---
|
||||||
|
type RoleDefinition<Meta> = {
|
||||||
|
description: string; // human-readable
|
||||||
|
systemPrompt: string; // given to agent
|
||||||
|
extractPrompt: string; // given to extractor
|
||||||
|
schema: z.ZodType<Meta>; // meta shape (Zod v4)
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Workflow Definition: pure data, no agent binding ---
|
||||||
|
type WorkflowDefinition<M extends RoleMeta> = {
|
||||||
|
description: string;
|
||||||
|
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||||
|
moderator: Moderator<M>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Agent: raw string output, reads role info from context ---
|
||||||
|
type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||||
|
|
||||||
|
// --- Agent Binding: runtime assignment ---
|
||||||
|
type AgentBinding = {
|
||||||
|
agent: AgentFn;
|
||||||
|
overrides?: Partial<Record<string, AgentFn>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Extract: structured data from context ---
|
||||||
|
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
|
||||||
|
|
||||||
|
// --- Moderator: pure routing function ---
|
||||||
|
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||||
|
|
||||||
|
// --- Composition ---
|
||||||
|
// createWorkflow(def, binding, extract) => WorkflowFn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Three-Phase Engine Loop
|
||||||
|
|
||||||
|
Each role execution has three distinct phases with progressive context:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─→ Phase 1: MODERATOR
|
||||||
|
│ Context: ModeratorContext { threadId, start, steps }
|
||||||
|
│ Action: moderator(ctx) → role name | END
|
||||||
|
│
|
||||||
|
│ Phase 2: AGENT
|
||||||
|
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||||
|
│ Action: agent(ctx) → raw string
|
||||||
|
│
|
||||||
|
│ Phase 3: EXTRACTOR
|
||||||
|
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||||
|
│ Action: extract(schema, extractPrompt, ctx) → typed meta
|
||||||
|
│
|
||||||
|
│ Merge: RoleStep { role, content, meta, timestamp }
|
||||||
|
│ Append to steps
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Types (progressive)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Phase 1: Moderator sees accumulated state only
|
||||||
|
type ModeratorContext<M> = {
|
||||||
|
threadId: string;
|
||||||
|
start: StartStep;
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 2: Agent knows its identity
|
||||||
|
type AgentContext<M> = ModeratorContext<M> & {
|
||||||
|
currentRole: { name: string; systemPrompt: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 3: Extractor has agent output
|
||||||
|
type ExtractContext<M> = AgentContext<M> & {
|
||||||
|
agentContent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ThreadContext is an alias for AgentContext (backward compat)
|
||||||
|
type ThreadContext<M> = AgentContext<M>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Properties
|
||||||
|
|
||||||
|
- **Moderator is synchronous and pure** — no I/O, no state mutation
|
||||||
|
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
|
||||||
|
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
|
||||||
|
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
|
||||||
|
|
||||||
|
## Agent Information Sources
|
||||||
|
|
||||||
|
An agent has exactly three information sources:
|
||||||
|
|
||||||
|
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 extracts it from context using `ExtractFn`.
|
||||||
|
|
||||||
|
## Bundle Contract
|
||||||
|
|
||||||
|
A workflow bundle is a single `.esm.js` file with two named exports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Named exports (no default export)
|
||||||
|
export const descriptor: WorkflowDescriptor;
|
||||||
|
export const run: WorkflowFn;
|
||||||
|
|
||||||
|
type WorkflowFn = (
|
||||||
|
input: { prompt: string; steps: RoleOutput[] },
|
||||||
|
options: { threadId: string; maxRounds: number },
|
||||||
|
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- Single `.esm.js` file
|
||||||
|
- No dynamic `import()`
|
||||||
|
- All static imports must be Node built-in modules only
|
||||||
|
- XXH64 hash (Crockford Base32) = globally unique version ID
|
||||||
|
|
||||||
|
### Why AsyncGenerator?
|
||||||
|
|
||||||
|
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
|
||||||
|
- `return` → engine marks thread complete
|
||||||
|
- Fork = pass historical steps as `input.steps` to a new generator
|
||||||
|
- Zero injection — bundle doesn't import from the engine
|
||||||
|
|
||||||
|
## Storage Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.uncaged/workflow/
|
||||||
|
├── bundles/
|
||||||
|
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||||
|
│ └── C9NMV6V2TQT81.yaml # Role descriptor
|
||||||
|
├── logs/ # One folder per bundle hash
|
||||||
|
│ └── C9NMV6V2TQT81/
|
||||||
|
│ ├── 01KQXKW…YG.data.jsonl # Thread state
|
||||||
|
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||||
|
└── workflow.yaml # Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Encoding: Crockford Base32
|
||||||
|
|
||||||
|
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||||
|
- Bundle hash: XXH64 → 13-char
|
||||||
|
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||||
|
|
||||||
|
### Registry (`workflow.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflows:
|
||||||
|
solve-issue:
|
||||||
|
hash: "C9NMV6V2TQT81"
|
||||||
|
timestamp: 1714963200000
|
||||||
|
history:
|
||||||
|
- hash: "A7BKR3M1NPQ40"
|
||||||
|
timestamp: 1714876800000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Thread JSONL
|
||||||
|
|
||||||
|
**`.data.jsonl`** — Line 1: start record, Line 2+: role outputs
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Start record
|
||||||
|
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
|
||||||
|
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
|
||||||
|
"timestamp": 1714963200000 }
|
||||||
|
// Role output
|
||||||
|
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "timestamp": ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.info.jsonl`** — Structured debug log
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
|
||||||
|
|
||||||
|
## Execution Model
|
||||||
|
|
||||||
|
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
|
||||||
|
- Same bundle's threads share one process (memory efficiency)
|
||||||
|
- Process exits when all threads complete
|
||||||
|
- Thread termination via IPC within the process
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
All commands implemented and tested. ✅
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| **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; clean separation |
|
||||||
|
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
|
||||||
|
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
|
||||||
|
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
|
||||||
|
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||||
|
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
|
||||||
|
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
# RFC-001: Workflow Engine Design
|
|
||||||
|
|
||||||
**Author:** 小橘 🍊(NEKO Team)
|
|
||||||
**Date:** 2026-05-06
|
|
||||||
**Status:** Draft
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Package Structure
|
|
||||||
|
|
||||||
| Package | npm Name | Binary |
|
|
||||||
|---------|----------|--------|
|
|
||||||
| Core lib | `@uncaged/workflow` | — |
|
|
||||||
| CLI | `@uncaged/cli-workflow` | `uncaged-workflow` |
|
|
||||||
|
|
||||||
Future: `@uncaged/cli` umbrella, invoke via `uncaged workflow <subcommand>`.
|
|
||||||
|
|
||||||
Monorepo uses **bun workspace**.
|
|
||||||
|
|
||||||
## 2. Workflow Physical Implementation
|
|
||||||
|
|
||||||
A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** What each yield produces — one role's output. */
|
|
||||||
type RoleOutput = {
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
meta: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** What the generator returns when done. */
|
|
||||||
type WorkflowResult = {
|
|
||||||
returnCode: number;
|
|
||||||
summary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Input to a workflow — prompt + optional historical steps for fork/resume. */
|
|
||||||
type ThreadInput = {
|
|
||||||
prompt: string;
|
|
||||||
steps: RoleOutput[]; // [] for new thread, pre-filled for fork/resume
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The bundle contract — an AsyncGenerator, not a Promise. */
|
|
||||||
type WorkflowFn = (
|
|
||||||
input: ThreadInput,
|
|
||||||
options: { threadId: string; maxRounds: number }
|
|
||||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why AsyncGenerator?
|
|
||||||
|
|
||||||
The workflow **yields** each role output instead of writing to an injected writer or
|
|
||||||
exporting a framework-specific shape:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Example bundle — zero framework dependency (named exports only)
|
|
||||||
export const descriptor = {
|
|
||||||
description: "Fix auth bug",
|
|
||||||
roles: {
|
|
||||||
planner: {
|
|
||||||
description: "Plans the fix",
|
|
||||||
schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } },
|
|
||||||
},
|
|
||||||
coder: {
|
|
||||||
description: "Implements the plan",
|
|
||||||
schema: { type: "object", properties: { diff: { type: "string" } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const run = async function* (input, options) {
|
|
||||||
const plan = await callLLM("plan: " + input.prompt);
|
|
||||||
yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } };
|
|
||||||
|
|
||||||
const code = await callLLM("implement: " + plan);
|
|
||||||
yield { role: "coder", content: code, meta: { diff: "..." } };
|
|
||||||
|
|
||||||
return { returnCode: 0, summary: "Fixed auth bug" };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Engine controls the loop**, not the bundle:
|
|
||||||
- Each `yield` → engine writes to `.data.jsonl`, checks `AbortSignal`, handles pause/resume
|
|
||||||
- `return` → engine writes the final result, marks thread complete
|
|
||||||
- **Fork** = read historical steps from `.data.jsonl`, pass as `input.steps` to a new generator
|
|
||||||
- **Zero injection** — the bundle doesn't import or receive anything from the engine
|
|
||||||
|
|
||||||
### Fork/Resume via ThreadInput
|
|
||||||
|
|
||||||
When using the `createRoleModerator` helper, fork is **naturally handled**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// The moderator receives ThreadContext with historical steps
|
|
||||||
// It sees planner already ran → routes to coder automatically
|
|
||||||
const gen = workflow(
|
|
||||||
{ prompt: "fix bug #3", steps: [{ role: "planner", content: "...", meta: {} }] },
|
|
||||||
{ threadId: "01KQXKW18CT8G75T53R8F4G7YG", maxRounds: 10 }
|
|
||||||
);
|
|
||||||
// First yield will be coder's output, not planner's
|
|
||||||
```
|
|
||||||
|
|
||||||
No special replay logic needed — the moderator/role pattern inherently supports
|
|
||||||
resuming from any snapshot, because moderator routing is a pure function of the
|
|
||||||
accumulated steps.
|
|
||||||
|
|
||||||
This follows the **Dependency Inversion Principle**: the engine depends on the
|
|
||||||
generator protocol (a language primitive), not on a framework-specific `WorkflowDefinition`.
|
|
||||||
Bundles remain pure functions with no coupling to `@uncaged/workflow`.
|
|
||||||
|
|
||||||
### Relationship to Role/Moderator Pattern
|
|
||||||
|
|
||||||
The Role + Moderator pattern from Section 8 is one **implementation strategy** inside a
|
|
||||||
bundle, not the bundle contract itself. A helper like `createRoleModerator(roles, moderator)`
|
|
||||||
can produce the AsyncGenerator internally, but simple workflows can yield directly without
|
|
||||||
any framework types.
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- Single `.esm.js` file
|
|
||||||
- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object)
|
|
||||||
- No default export
|
|
||||||
- No dynamic `import()`
|
|
||||||
- All static imports must be Node built-in modules only
|
|
||||||
|
|
||||||
This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier.
|
|
||||||
|
|
||||||
### Role Descriptor (`export const descriptor`)
|
|
||||||
|
|
||||||
The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption.
|
|
||||||
|
|
||||||
Shape: `{ description: string, roles: Record<string, { description: string, schema: JSONSchema }> }`
|
|
||||||
|
|
||||||
When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
description: "Workflow brief introduction"
|
|
||||||
roles:
|
|
||||||
planner:
|
|
||||||
description: "Analyzes the issue and creates a plan"
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
plan:
|
|
||||||
type: string
|
|
||||||
files:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
coder:
|
|
||||||
description: "Implements the plan"
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
diff:
|
|
||||||
type: string
|
|
||||||
```
|
|
||||||
|
|
||||||
Execution uses `run` only; YAML is for tooling and introspection.
|
|
||||||
|
|
||||||
## 3. Storage Layout
|
|
||||||
|
|
||||||
All data lives under `~/.uncaged/workflow/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.uncaged/workflow/
|
|
||||||
├── bundles/ # ESM bundles
|
|
||||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash
|
|
||||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time)
|
|
||||||
├── logs/ # Thread data, one folder per bundle hash
|
|
||||||
│ └── C9NMV6V2TQT81/
|
|
||||||
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
|
|
||||||
│ └── 01KQXKW18CT8G75T53R8F4G7YG.info.jsonl
|
|
||||||
└── workflow.yaml # Registry
|
|
||||||
```
|
|
||||||
|
|
||||||
**Not** a git repo. **Not** an npm package. Bundles are self-contained single files.
|
|
||||||
|
|
||||||
### ID Encoding
|
|
||||||
|
|
||||||
All IDs use **Crockford Base32**:
|
|
||||||
- Better readability than Base64
|
|
||||||
- Higher density than hex (shorter filenames)
|
|
||||||
- ULID: 10 chars timestamp (high 2 bits zero-padded for future use) + 16 chars random
|
|
||||||
|
|
||||||
## 4. Registry (`workflow.yaml`)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
workflows:
|
|
||||||
solve-issue:
|
|
||||||
hash: "C9NMV6V2TQT81"
|
|
||||||
timestamp: 1714963200000
|
|
||||||
history:
|
|
||||||
- hash: "A7BKR3M1NPQ40"
|
|
||||||
timestamp: 1714876800000
|
|
||||||
- hash: "X2FGH8J4KLM56"
|
|
||||||
timestamp: 1714790400000
|
|
||||||
```
|
|
||||||
|
|
||||||
Type:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
workflows: Record<string, {
|
|
||||||
hash: string; // Crockford Base32 of current XXH64
|
|
||||||
timestamp: number;
|
|
||||||
history: { hash: string; timestamp: number }[];
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No concurrency control or timeout settings in the registry — those belong to each workflow/role/adapter.
|
|
||||||
|
|
||||||
## 5. Thread JSONL Format
|
|
||||||
|
|
||||||
### `.data.jsonl` — Thread State
|
|
||||||
|
|
||||||
**Line 1: Start record**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"name": "solve-issue",
|
|
||||||
"hash": "C9NMV6V2TQT81",
|
|
||||||
"threadId": "01KQXKW18CT8G75T53R8F4G7YG",
|
|
||||||
"parameters": {
|
|
||||||
"prompt": "Fix the login redirect bug in #3",
|
|
||||||
"options": {
|
|
||||||
"maxRounds": 5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"timestamp": 1714963200000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Line 2+: Role outputs**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"role": "planner",
|
|
||||||
"content": "Plan: modify auth middleware...",
|
|
||||||
"meta": { "plan": "...", "files": ["src/auth.ts"] },
|
|
||||||
"timestamp": 1714963201000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.info.jsonl` — Debug Log
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"tag": "4KNMR2PX", // 40-bit random, Crockford Base32 (8 chars)
|
|
||||||
"content": "Loading workflow bundle...",
|
|
||||||
"timestamp": 1714963200500
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Execution Model
|
|
||||||
|
|
||||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process.
|
|
||||||
- Same bundle's threads share one process (memory efficiency).
|
|
||||||
- Process exits automatically when all threads complete.
|
|
||||||
- Thread termination requires **IPC** within the process (not just kill PID).
|
|
||||||
|
|
||||||
## 7. CLI Requirements
|
|
||||||
|
|
||||||
### P1 (Must Have)
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uncaged-workflow add <name> <file.esm.js> [--types <path>]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) |
|
|
||||||
| `uncaged-workflow list` | List registered workflows |
|
|
||||||
| `uncaged-workflow show <name>` | Show workflow details |
|
|
||||||
| `uncaged-workflow remove <name>` | Remove a workflow |
|
|
||||||
| `uncaged-workflow run <name> [--prompt] [--max-rounds]` | Start a thread |
|
|
||||||
| `uncaged-workflow threads [name]` | List threads (optionally filter by workflow) |
|
|
||||||
| `uncaged-workflow thread <id>` | Show thread state |
|
|
||||||
| `uncaged-workflow thread rm <id>` | Delete a thread |
|
|
||||||
| `uncaged-workflow ps` | List running threads |
|
|
||||||
| `uncaged-workflow kill <thread-id>` | Terminate a running thread (via IPC) |
|
|
||||||
|
|
||||||
### P2 (Should Have)
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uncaged-workflow history <name>` | Show version history |
|
|
||||||
| `uncaged-workflow rollback <name> [hash]` | Switch to a previous version |
|
|
||||||
| `uncaged-workflow pause <thread-id>` | Pause a running thread |
|
|
||||||
| `uncaged-workflow resume <thread-id>` | Resume a paused thread |
|
|
||||||
|
|
||||||
### P3 (Nice to Have)
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `uncaged-workflow fork <thread-id> [--from-role <role>]` | Fork from a historical thread state |
|
|
||||||
|
|
||||||
## 8. Role/Moderator Pattern (Helper, Not Contract)
|
|
||||||
|
|
||||||
The bundle contract is the AsyncGenerator from Section 2. The Role + Moderator pattern
|
|
||||||
below is a **convenience helper** for the common case of multi-role workflows with a
|
|
||||||
routing function. It lives in `@uncaged/workflow` as an optional utility.
|
|
||||||
|
|
||||||
### Helper Function
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function createRoleModerator<M extends RoleMeta>(
|
|
||||||
def: { roles: { [K in keyof M & string]: Role<M[K]> }; moderator: Moderator<M> }
|
|
||||||
): WorkflowFn; // returns (input: ThreadInput, options) => AsyncGenerator
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage in a bundle:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createRoleModerator, END } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
export const descriptor = {
|
|
||||||
description: "Example multi-role workflow",
|
|
||||||
roles: {
|
|
||||||
planner: { description: "Plans work", schema: {} },
|
|
||||||
coder: { description: "Writes code", schema: {} },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const run = createRoleModerator({
|
|
||||||
roles: { planner, coder },
|
|
||||||
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
|
|
||||||
});
|
|
||||||
// Accepts ThreadInput — fork with pre-filled steps works automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
### Supporting Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** Sentinel values for automaton control flow. */
|
|
||||||
const START = "__start__" as const;
|
|
||||||
const END = "__end__" as const;
|
|
||||||
|
|
||||||
/** Maps role names → their meta types. Single generic drives all inference. */
|
|
||||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
|
||||||
|
|
||||||
/** Typed output of a Role execution. */
|
|
||||||
type RoleResult<Meta> = { content: string; meta: Meta };
|
|
||||||
|
|
||||||
/** Engine start frame: initial prompt + thread identity. */
|
|
||||||
type StartStep = {
|
|
||||||
role: START;
|
|
||||||
content: string; // the user prompt
|
|
||||||
meta: { maxRounds: number; threadId: string };
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A completed role step in the thread. */
|
|
||||||
type RoleStep<M extends RoleMeta> = {
|
|
||||||
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
|
||||||
}[keyof M & string];
|
|
||||||
|
|
||||||
/** Thread-scoped context passed to roles and moderator. */
|
|
||||||
type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
|
||||||
threadId: string;
|
|
||||||
start: StartStep;
|
|
||||||
steps: RoleStep<M>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Role — receives full thread context, returns typed content + meta.
|
|
||||||
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
|
||||||
*/
|
|
||||||
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An Agent — raw string output interface for LLM/CLI adapters.
|
|
||||||
* Structured meta is extracted by the role's extract layer.
|
|
||||||
*/
|
|
||||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Moderator — a pure routing function.
|
|
||||||
* Receives the full thread context (start + all prior steps).
|
|
||||||
* On initial call, `steps` is empty.
|
|
||||||
* Returns the next role name or END to terminate.
|
|
||||||
*/
|
|
||||||
type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
|
|
||||||
|
|
||||||
/** Complete workflow definition as authored by users. */
|
|
||||||
type WorkflowDefinition<M extends RoleMeta> = {
|
|
||||||
name: string;
|
|
||||||
roles: { [K in keyof M & string]: Role<M[K]> };
|
|
||||||
moderator: Moderator<M>;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Execution Flow (when using createRoleModerator)
|
|
||||||
|
|
||||||
```
|
|
||||||
START (prompt) → Moderator → Role A → Moderator → Role B → ... → Moderator → END
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Engine creates a `StartStep` with the user prompt and maxRounds
|
|
||||||
2. Moderator is called with `steps = []`, returns the first role name
|
|
||||||
3. Role executes, appends a `RoleStep` to the thread
|
|
||||||
4. Moderator is called again with updated steps, returns next role or END
|
|
||||||
5. Repeat until END or maxRounds reached
|
|
||||||
|
|
||||||
### Responsibilities
|
|
||||||
|
|
||||||
| Component | Responsibility | Purity |
|
|
||||||
|-----------|---------------|--------|
|
|
||||||
| **Moderator** | Route to next role based on thread state | Pure function, no side effects |
|
|
||||||
| **Role** | Execute a step (call LLM, run script, etc.) | Async, may have side effects |
|
|
||||||
| **AgentFn** | Low-level LLM/CLI invocation adapter | Async, side effects |
|
|
||||||
|
|
||||||
### Key Constraints
|
|
||||||
|
|
||||||
- Moderator is **synchronous and pure** — no I/O, no state mutation
|
|
||||||
- Roles receive the **full thread context** (not just the last message)
|
|
||||||
- Round count = `steps.length`; max rounds in `start.meta.maxRounds`
|
|
||||||
- The `meta` field on each step is **typed per role** via the `RoleMeta` generic
|
|
||||||
|
|
||||||
## 9. Design Decisions & Rationale
|
|
||||||
|
|
||||||
### Why single-file ESM?
|
|
||||||
- Hash = version. No ambiguity.
|
|
||||||
- No dependency hell. Self-contained.
|
|
||||||
- Simple to distribute, store, and verify.
|
|
||||||
|
|
||||||
### Why no daemon?
|
|
||||||
- Unnecessary complexity for process-per-bundle model.
|
|
||||||
- OS process management (systemd, etc.) handles restarts.
|
|
||||||
- IPC within process handles thread lifecycle.
|
|
||||||
|
|
||||||
### Why Crockford Base32?
|
|
||||||
- Case-insensitive, filesystem-safe.
|
|
||||||
- No ambiguous characters (0/O, 1/I/L).
|
|
||||||
- More compact than hex (13 chars for 64-bit vs 16).
|
|
||||||
|
|
||||||
### Why not control concurrency in registry?
|
|
||||||
- Different workflows have different constraints.
|
|
||||||
- Same workflow may allow cross-project concurrency but not intra-project.
|
|
||||||
- Concurrency belongs at workflow/role/adapter level.
|
|
||||||
Reference in New Issue
Block a user