Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju 5643a06a39 refactor: remove table output format, keep json and yaml only
Table format adds complexity without readability gain over yaml.

Refs #328
2026-05-18 13:56:53 +00:00
414 changed files with 390 additions and 6349 deletions
-2
View File
@@ -9,5 +9,3 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
+182 -392
View File
@@ -1,478 +1,268 @@
# uwf — 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 **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
Grouped by responsibility (npm name → folder).
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@uncaged/uwf-protocol``uwf-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Moderator | `@uncaged/uwf-moderator``uwf-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
| Agent framework | `@uncaged/uwf-agent-kit``uwf-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/uwf-agent-hermes``uwf-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@uncaged/cli-uwf``cli-uwf` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|-------|---------|----------------|
| 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-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. |
| 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 |
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `jsonata` | JSONata expression evaluator (used by `uwf-moderator`). |
| `commander` | CLI argument parsing (used by `cli-uwf`). |
| `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["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/uwf-protocol"]
protocol["@uncaged/workflow-protocol"]
end
subgraph L1["Layer 1 — shared"]
subgraph L1["Layer 1 — on protocol"]
runtime["@uncaged/workflow-runtime"]
util["@uncaged/workflow-util"]
moderator["@uncaged/uwf-moderator"]
reactor["@uncaged/workflow-reactor"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@uncaged/uwf-agent-kit"]
subgraph L2["Layer 2 — protocol + util"]
cas["@uncaged/workflow-cas"]
register["@uncaged/workflow-register"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@uncaged/uwf-agent-hermes"]
subgraph L3["Layer 3 — engine"]
execute["@uncaged/workflow-execute"]
end
subgraph L4["Layer 4 — CLI"]
cli["@uncaged/cli-uwf"]
cli["@uncaged/cli-workflow"]
end
protocol --> jcasfs
runtime --> protocol
util --> protocol
moderator --> 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 --> moderator
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"
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
outputSchema:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent. Implement the plan."
outputSchema:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer. Review the implementation."
outputSchema:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
Key properties:
- **`roles`** — inline role definitions; each `outputSchema` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
- **`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-uwf/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: WorkflowPayload + ModeratorContext { start, steps[] }
Engine: JSONata conditions evaluated against the graph
│ 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 outputSchema
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/uwf-protocol/src/types.ts`:
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 JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
- **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. `uwf-agent-kit` (`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 `outputSchema` 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 `uwf-agent-kit`) 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 JSON Schema.
## Two-layer extract
Structured output extraction uses a two-layer strategy (`uwf-agent-kit`):
### 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 `outputSchema`
5. Validate with `json-cas` 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
`uwf-agent-kit` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `outputSchema`, 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"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_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" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
### StepNode
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against outputSchema)
detail: "7BQST3VW9F2MA" # cas_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"
apiKeyEnv: "OPENROUTER_API_KEY"
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. |
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. 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 `json-cas`. 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 |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
File diff suppressed because it is too large Load Diff
@@ -1,387 +0,0 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
});
});
@@ -1,113 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("runDocxDiff", () => {
test("exit 0: success, returns DifferMeta JSON", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "original.docx");
const modifiedDocx = join(dir, "modified.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// simulate docx-diff creating the diff file
writeFileSync(diffDocx, "");
const raw = await runDocxDiff(
{ command: "docx-diff" },
sourceDocx,
modifiedDocx,
diffDocx,
spawnFn,
);
const meta = JSON.parse(raw);
expect(meta.sourceDocx).toBe(sourceDocx);
expect(meta.modifiedDocx).toBe(modifiedDocx);
expect(meta.diffDocx).toBe(diffDocx);
expect(spawnFn.mock.calls[0][1]).toEqual([
sourceDocx,
modifiedDocx,
"--output",
"docx",
"--out-file",
diffDocx,
]);
});
test("exit 1 (changes found): treated as success", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "s.docx");
const modifiedDocx = join(dir, "m.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
).resolves.toBeDefined();
});
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("docx-diff failed");
});
test("timeout: throws error", async () => {
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("timed out");
});
test("throws when diff file not created", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// do NOT create diffDocx
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
).rejects.toThrow("diff file not found");
});
test("uses PATH docx-diff when command is null", async () => {
const dir = tempDir();
const diffDocx = join(dir, "diff.docx");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
});
});
@@ -1,29 +0,0 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-util": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,29 +0,0 @@
import * as z from "zod/v4";
import { dirname, join } from "node:path";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(
config,
writerMeta.sourceDocx,
writerMeta.outputDocx,
diffDocx,
);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createDocxDiffAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { DocxDiffAgentConfig } from "./types.js";
@@ -1,17 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to docx-diff CLI binary; null uses PATH.",
},
},
additionalProperties: false,
},
};
@@ -1,47 +0,0 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
export async function runDocxDiff(
config: DocxDiffAgentConfig,
sourceDocx: string,
modifiedDocx: string,
diffDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<string> {
const command = config.command ?? "docx-diff";
const result = await spawnCliFn(
command,
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null },
);
if (!result.ok) {
const e = result.error;
// exit 1 = changes found (normal for docx-diff)
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// fall through to file check
} else {
throwSpawnError(e);
}
}
try {
await stat(diffDocx);
} catch {
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
}
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
}
@@ -1,3 +0,0 @@
export type DocxDiffAgentConfig = {
command: string | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-template-document" }
]
}
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
});
test("has outputDir in configSchema required", () => {
const schema = packageDescriptor.configSchema as { required: string[] };
expect(schema.required).toContain("outputDir");
});
});
@@ -1,129 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `office-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("generateDocument", () => {
test("calls office-agent create with correct args and returns outputDocx path", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
// Simulate CLI creating the file
const outFile = join(base, "thread1", "output.docx");
mkdirSync(join(base, "thread1"), { recursive: true });
writeFileSync(outFile, "");
const result = await generateDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"thread1",
"Write a report",
spawnFn,
);
expect(result.outputDocx).toBe(outFile);
expect(result.sourceDocx).toBeNull();
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
});
test("uses PATH office-agent when command is null", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
mkdirSync(join(base, "t2"), { recursive: true });
writeFileSync(join(base, "t2", "output.docx"), "");
await generateDocument(
{ outputDir: base, command: null, timeout: null },
"t2",
"Generate",
spawnFn,
);
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
});
test("throws on non_zero_exit", async () => {
const base = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
).rejects.toThrow("office-agent failed (exit 1)");
});
test("throws on timeout", async () => {
const base = tempDir();
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
).rejects.toThrow("office-agent: timed out");
});
test("throws when output file not created", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Do NOT create output.docx
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
).rejects.toThrow("output file not found");
});
});
describe("editDocument", () => {
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
const base = tempDir();
// Create a fake inputDocx
const inputFile = join(base, "source.docx");
writeFileSync(inputFile, "original content");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Simulate CLI overwriting modified.docx
const outDir = join(base, "te1");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "modified.docx"), "modified content");
const result = await editDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"te1",
"Edit the doc",
inputFile,
spawnFn,
);
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
});
test("throws on spawn_failed", async () => {
const base = tempDir();
const inputFile = join(base, "src.docx");
writeFileSync(inputFile, "");
const spawnFn = makeSpawn(
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
);
await expect(
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
).rejects.toThrow("spawn failed");
});
});
@@ -1,25 +0,0 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,44 +0,0 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
type ParsedInput = { prompt: string; inputDocx: string | null };
function parseStartInput(content: string): ParsedInput {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (typeof parsed.prompt === "string") {
return {
prompt: parsed.prompt,
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
};
}
} catch {
// not JSON — treat whole content as prompt, generate mode
}
return { prompt: content, inputDocx: null };
}
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
let raw: string;
if (inputDocx === null) {
const result = await generateDocument(config, ctx.threadId, prompt);
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
}
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createOfficeAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { OfficeAgentConfig } from "./types.js";
@@ -1,26 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: {
type: "string",
description: "Root directory for workflow outputs; subdirs are created per threadId.",
},
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to office-agent CLI binary; null uses PATH.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -1,65 +0,0 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
async function assertFileExists(path: string): Promise<void> {
try {
await stat(path);
} catch {
throw new Error(`office-agent: output file not found: ${path}`);
}
}
export async function generateDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: null }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
const outputDocx = join(outputDir, "output.docx");
await assertFileExists(outputDocx);
return { outputDocx, sourceDocx: null };
}
export async function editDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
inputDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: string }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const originalDocx = join(outputDir, "original.docx");
const modifiedDocx = join(outputDir, "modified.docx");
await copyFile(inputDocx, originalDocx);
await copyFile(inputDocx, modifiedDocx);
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
await assertFileExists(modifiedDocx);
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
}
@@ -1,5 +0,0 @@
export type OfficeAgentConfig = {
outputDir: string;
command: string | null;
timeout: number | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-util-agent" }
]
}
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
import { buildDocumentDescriptor } from "../src/descriptor.js";
import { documentTable } from "../src/moderator.js";
import type { DifferMeta, WriterMeta } from "../src/roles/index.js";
import type { DocumentMeta } from "../src/roles.js";
const documentModerator = tableToModerator(documentTable);
function makeCtx(
steps: ModeratorContext<DocumentMeta>["steps"],
): ModeratorContext<DocumentMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
steps,
};
}
function writerGenerateStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER001",
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function writerEditStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER002",
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function differStep(): RoleStep<DocumentMeta> {
return {
role: "differ",
contentHash: "STUBHASHDIFF001",
meta: {
sourceDocx: "/out/original.docx",
modifiedDocx: "/out/modified.docx",
diffDocx: "/out/diff.docx",
} satisfies DifferMeta,
refs: [],
timestamp: 2,
};
}
describe("documentTable", () => {
test("START → writer", () => {
expect(documentModerator(makeCtx([]))).toBe("writer");
});
test("writer (generate) → END", () => {
expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END);
});
test("writer (edit) → differ", () => {
expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ");
});
test("differ → END", () => {
expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END);
});
});
describe("buildDocumentDescriptor", () => {
test("descriptor passes validation", () => {
const descriptor = buildDocumentDescriptor();
expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow();
});
test("descriptor has writer and differ roles", () => {
const descriptor = buildDocumentDescriptor();
expect(Object.keys(descriptor.roles)).toContain("writer");
expect(Object.keys(descriptor.roles)).toContain("differ");
});
});
@@ -1,28 +0,0 @@
{
"name": "@uncaged/workflow-template-document",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-protocol": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,11 +0,0 @@
import { buildDescriptor } from "@uncaged/workflow-register";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js";
export function buildDocumentDescriptor() {
return buildDescriptor({
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
});
}
@@ -1,27 +0,0 @@
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, type DocumentMeta, documentRoles } from "./roles.js";
export { buildDocumentDescriptor } from "./descriptor.js";
export { documentTable } from "./moderator.js";
export {
type DifferMeta,
differMetaSchema,
differRole,
type WriterMeta,
writerMetaSchema,
writerRole,
} from "./roles/index.js";
export {
DOCUMENT_WORKFLOW_DESCRIPTION,
type DocumentMeta,
type DocumentRoles,
documentRoles,
} from "./roles.js";
export type { DocumentStartInput } from "./types.js";
export const documentWorkflowDefinition: WorkflowDefinition<DocumentMeta> = {
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
};
@@ -1,27 +0,0 @@
import {
END,
type ModeratorCondition,
type ModeratorTable,
START,
} from "@uncaged/workflow-runtime";
import type { WriterMeta } from "./roles/writer.js";
import type { DocumentMeta } from "./roles.js";
const writerIsEditMode: ModeratorCondition<DocumentMeta> = {
name: "writerIsEditMode",
description: "Writer ran in edit mode and produced a modified document",
check: (ctx) => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) return false;
return (writerStep.meta as WriterMeta).mode === "edit";
},
};
export const documentTable: ModeratorTable<DocumentMeta> = {
[START]: [{ condition: "FALLBACK", role: "writer" }],
writer: [
{ condition: writerIsEditMode, role: "differ" },
{ condition: "FALLBACK", role: END },
],
differ: [{ condition: "FALLBACK", role: END }],
};
@@ -1,20 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type DifferMeta, differRole } from "./roles/differ.js";
import { type WriterMeta, writerRole } from "./roles/writer.js";
export const DOCUMENT_WORKFLOW_DESCRIPTION =
"Generates a new Word document from a prompt, or edits an existing one and produces a diff report.";
export type DocumentMeta = {
writer: WriterMeta;
differ: DifferMeta;
};
export type DocumentRoles = {
[K in keyof DocumentMeta]: RoleDefinition<DocumentMeta[K]>;
};
export const documentRoles: DocumentRoles = {
writer: writerRole,
differ: differRole,
};
@@ -1,16 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
export type DifferMeta = z.infer<typeof differMetaSchema>;
export const differRole: RoleDefinition<DifferMeta> = {
description: "Produces a Word-format diff report of the writer's changes (edit mode only).",
systemPrompt: "",
schema: differMetaSchema,
};
@@ -1,4 +0,0 @@
export type { DifferMeta } from "./differ.js";
export { differMetaSchema, differRole } from "./differ.js";
export type { WriterMeta } from "./writer.js";
export { writerMetaSchema, writerRole } from "./writer.js";
@@ -1,23 +0,0 @@
import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(),
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(),
sourceDocx: z.string(),
}),
]);
export type WriterMeta = z.infer<typeof writerMetaSchema>;
export const writerRole: RoleDefinition<WriterMeta> = {
description: "Generates or modifies a Word document via an external agent.",
systemPrompt: "",
schema: writerMetaSchema,
};
@@ -1,4 +0,0 @@
export type DocumentStartInput = {
prompt: string;
inputDocx: string | null;
};
@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-register" }
]
}
@@ -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 };
};
};
}
+3 -6
View File
@@ -11,8 +11,8 @@
"uwf": "./src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@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:^",
@@ -22,12 +22,9 @@
"yaml": "^2.8.4"
},
"scripts": {
"test": "vitest run"
"test": "bun test"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"vitest": "^4.1.6"
}
}
@@ -1,391 +0,0 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId } from "@uncaged/uwf-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
cmdThreadRead,
cmdThreadStepDetails,
extractLastAssistantContent,
THREAD_READ_DEFAULT_QUOTA,
} from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { saveThreadsIndex } from "../store.js";
// ── schemas used in tests ────────────────────────────────────────────────────
const TURN_SCHEMA = {
title: "hermes-turn",
type: "object" as const,
required: ["index", "role", "content"],
properties: {
index: { type: "integer" as const },
role: { type: "string" as const },
content: { type: "string" as const },
toolCalls: {
anyOf: [
{ type: "array" as const, items: { type: "object" as const } },
{ type: "null" as const },
],
},
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
},
additionalProperties: false,
};
const DETAIL_SCHEMA = {
title: "hermes-detail",
type: "object" as const,
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" as const },
model: { type: "string" as const },
duration: { type: "integer" as const },
turnCount: { type: "integer" as const },
turns: {
type: "array" as const,
items: { type: "string" as const, format: "cas_ref" },
},
},
additionalProperties: false,
};
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, TURN_SCHEMA),
putSchema(store, DETAIL_SCHEMA),
]);
return { turn, detail };
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-test-"));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── extractLastAssistantContent ───────────────────────────────────────────────
describe("extractLastAssistantContent", () => {
test("returns last non-empty assistant content from turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "intermediate",
toolCalls: null,
reasoning: null,
});
const turn2 = await uwf.store.put(schemas.turn, {
index: 1,
role: "tool",
content: "ok",
toolCalls: null,
reasoning: null,
});
const turn3 = await uwf.store.put(schemas.turn, {
index: 2,
role: "assistant",
content: "final answer",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s1",
model: "m1",
duration: 1000,
turnCount: 3,
turns: [turn1, turn2, turn3],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBe("final answer");
});
test("returns null when detail node does not exist in store", async () => {
const uwf = await makeUwfStore(tmpDir);
expect(extractLastAssistantContent(uwf, "nonexistent00" as CasRef)).toBeNull();
});
test("returns null when turns array is empty", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s2",
model: "m2",
duration: 0,
turnCount: 0,
turns: [],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
});
test("returns null when all assistant turns have empty content", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s3",
model: "m3",
duration: 0,
turnCount: 1,
turns: [turn1],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBeNull();
});
test("skips whitespace-only assistant content and returns earlier match", async () => {
const uwf = await makeUwfStore(tmpDir);
const schemas = await registerDetailSchemas(uwf.store);
const turn1 = await uwf.store.put(schemas.turn, {
index: 0,
role: "assistant",
content: "real content",
toolCalls: null,
reasoning: null,
});
const turn2 = await uwf.store.put(schemas.turn, {
index: 1,
role: "assistant",
content: " ",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(schemas.detail, {
sessionId: "s4",
model: "m4",
duration: 0,
turnCount: 2,
turns: [turn1, turn2],
});
expect(extractLastAssistantContent(uwf, detailHash)).toBe("real content");
});
});
// ── cmdThreadRead: ### Content section ───────────────────────────────────────
describe("cmdThreadRead ### Content section", () => {
test("includes ### Content before ### Output when detail has assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf",
description: "desc",
roles: {
writer: {
description: "Write",
systemPrompt: "You are a writer.",
outputSchema: "placeholder00" as CasRef,
},
},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Write something",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "The assistant response text",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sx",
model: "mx",
duration: 500,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "writer",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000001" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).toContain("### Content");
expect(markdown).toContain("The assistant response text");
const contentIdx = markdown.indexOf("### Content");
const outputIdx = markdown.indexOf("### Output");
expect(contentIdx).toBeGreaterThanOrEqual(0);
expect(outputIdx).toBeGreaterThanOrEqual(0);
expect(contentIdx).toBeLessThan(outputIdx);
});
test("omits ### Content when detail has no matching assistant turns", async () => {
const uwf = await makeUwfStore(tmpDir);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "test-wf2",
description: "desc",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Do stuff",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
// A detail ref that doesn't exist in the store → extractLastAssistantContent returns null
const missingDetailRef = "missingdetail0" as CasRef;
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "worker",
output: outputHash,
detail: missingDetailRef,
agent: "uwf-hermes",
});
const threadId = "01JTEST0000000000000000002" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("### Content");
expect(markdown).toContain("### Output");
});
});
// ── cmdThreadStepDetails ──────────────────────────────────────────────────────
describe("cmdThreadStepDetails", () => {
test("returns expanded detail node with turns inlined", async () => {
const uwf = await makeUwfStore(tmpDir);
const detailSchemas = await registerDetailSchemas(uwf.store);
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "wf",
description: "",
roles: {},
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "p",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
const turnHash = await uwf.store.put(detailSchemas.turn, {
index: 0,
role: "assistant",
content: "done",
toolCalls: null,
reasoning: null,
});
const detailHash = await uwf.store.put(detailSchemas.detail, {
sessionId: "sess42",
model: "gpt-4o",
duration: 3000,
turnCount: 1,
turns: [turnHash],
});
const stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: null,
role: "coder",
output: outputHash,
detail: detailHash,
agent: "uwf-hermes",
});
const result = await cmdThreadStepDetails(tmpDir, stepHash);
expect(result).toMatchObject({
sessionId: "sess42",
model: "gpt-4o",
duration: 3000,
turnCount: 1,
});
const expanded = result as Record<string, unknown>;
expect(Array.isArray(expanded.turns)).toBe(true);
const turns = expanded.turns as unknown[];
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({
index: 0,
role: "assistant",
content: "done",
});
});
test("throws when step hash does not exist", async () => {
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
});
});
+55 -127
View File
@@ -1,34 +1,28 @@
#!/usr/bin/env bun
import type { ThreadId } from "@uncaged/uwf-protocol";
import { Command } from "commander";
import { stringify as yamlStringify } from "yaml";
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 {
cmdCasCat,
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasRefs,
cmdCasReindex,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdThreadFork,
cmdThreadKill,
cmdThreadList,
cmdThreadRead,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadStepDetails,
cmdThreadSteps,
THREAD_READ_DEFAULT_QUOTA,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { formatOutput, type OutputFormat } from "./format.js";
import { resolveStorageRoot } from "./store.js";
import { type OutputFormat, formatOutput } from "./format.js";
function writeOutput(data: unknown): void {
const fmt = program.opts().format as OutputFormat;
@@ -150,71 +144,6 @@ thread
});
});
thread
.command("steps")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadSteps(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("read")
.description("Read thread context as human-readable markdown")
.argument("<thread-id>", "Thread ULID")
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
.option("--start", "Include start step in output")
.action(
(threadId: string, opts: { quota: string; before: string | undefined; start: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const quota = Number.parseInt(opts.quota, 10);
if (!Number.isFinite(quota) || quota < 1) {
process.stderr.write("invalid --quota: must be a positive integer\n");
process.exit(1);
}
const before = opts.before ?? null;
const markdown = await cmdThreadRead(
storageRoot,
threadId as ThreadId,
quota,
before,
opts.start ?? false,
);
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
});
},
);
thread
.command("fork")
.description("Fork a thread from a specific step")
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, stepHash);
writeOutput(result);
});
});
thread
.command("step-details")
.description("Dump the full detail node of a step as YAML")
.argument("<step-hash>", "CAS hash of the StepNode")
.action((stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const detail = await cmdThreadStepDetails(storageRoot, stepHash);
process.stdout.write(yamlStringify(detail));
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
@@ -223,48 +152,57 @@ program
.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 ?? undefined,
storageRoot,
});
writeOutput(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",
);
}
});
},
);
.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 ?? undefined,
storageRoot,
});
writeOutput(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",
);
}
});
});
const cas = program.command("cas").description("Content-addressable storage operations");
cas
.command("get")
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
.description("Read a CAS node as JSON")
.argument("<hash>", "CAS hash (13 char)")
.option("--timestamp", "Include timestamp in output")
.action((hash: string, opts: { timestamp?: boolean }) => {
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasGet(storageRoot, hash, opts));
writeOutput(await cmdCasGet(storageRoot, hash));
});
});
cas
.command("cat")
.description("Output a CAS node (--payload for payload only)")
.argument("<hash>", "CAS hash (13 char)")
.option("--payload", "Output only the payload")
.action((hash: string, opts: { payload?: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasCat(storageRoot, hash, opts));
});
});
@@ -313,16 +251,6 @@ cas
});
});
cas
.command("reindex")
.description("Rebuild type index from all CAS nodes")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasReindex(storageRoot));
});
});
const casSchema = cas.command("schema").description("CAS schema operations");
casSchema
+15 -18
View File
@@ -28,18 +28,26 @@ function readJsonArg(fileOrInline: string): unknown {
export async function cmdCasGet(
storageRoot: string,
hash: string,
opts: { timestamp?: boolean },
): Promise<unknown> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
if (opts.timestamp) {
return node;
return node;
}
export async function cmdCasCat(
storageRoot: string,
hash: string,
opts: { payload?: boolean },
): Promise<unknown> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
const { timestamp: _, ...rest } = node as Record<string, unknown>;
return rest;
return opts.payload ? node.payload : node;
}
export async function cmdCasPut(
@@ -100,10 +108,10 @@ export async function cmdCasSchemaList(
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const hash of store.listByType(metaHash)) {
for (const hash of store.list()) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
if (node !== null && node.type === metaHash) {
const schema = node.payload as JSONSchema;
const title =
(schema.title as string | undefined) ??
@@ -115,17 +123,6 @@ export async function cmdCasSchemaList(
return entries;
}
export async function cmdCasReindex(
storageRoot: string,
): Promise<{ status: string }> {
const indexDir = join(storageRoot, "cas", "_index");
const { rmSync } = await import("node:fs");
rmSync(indexDir, { recursive: true, force: true });
// Re-open store to trigger migration rebuild
openStore(storageRoot);
return { status: "reindexed" };
}
export async function cmdCasSchemaGet(
storageRoot: string,
hash: string,
+2 -381
View File
@@ -1,6 +1,6 @@
import { execFileSync } from "node:child_process";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
@@ -8,23 +8,18 @@ import type {
AgentConfig,
CasRef,
ModeratorContext,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/uwf-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import { stringify } from "yaml";
import {
appendThreadHistory,
@@ -41,7 +36,6 @@ import {
import { isCasRef } from "../validate.js";
const END_ROLE = "$END";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
type ChainState = {
startHash: CasRef;
@@ -50,12 +44,6 @@ type ChainState = {
headIsStart: boolean;
};
type OrderedStepItem = {
hash: CasRef;
payload: StepNodePayload;
timestamp: number;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
@@ -274,247 +262,6 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
return node.payload;
}
/**
* Recursively expand all cas_ref fields in a CAS node's payload,
* replacing hash strings with the referenced node's expanded payload.
*/
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
const seen = visited ?? new Set<string>();
if (seen.has(hash)) return hash; // cycle guard
seen.add(hash);
const node = store.get(hash);
if (node === null) return hash;
const schema = getSchema(store, node.type);
if (schema === null) return node.payload;
return expandValue(store, schema, node.payload, seen);
}
function expandValue(
store: CasStore,
schema: JSONSchema,
value: unknown,
visited: Set<string>,
): unknown {
// If this field is a cas_ref, expand it
if (schema.format === "cas_ref") {
if (typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
return value;
}
// anyOf (nullable refs)
if (Array.isArray(schema.anyOf)) {
for (const sub of schema.anyOf as JSONSchema[]) {
if (sub.format === "cas_ref" && typeof value === "string") {
return expandDeep(store, value as CasRef, visited);
}
}
return value;
}
// Array of cas_ref items
if (schema.type === "array" && schema.items && Array.isArray(value)) {
const itemSchema = schema.items as JSONSchema;
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
}
// Object with properties
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
const props = schema.properties as Record<string, JSONSchema>;
const obj = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const propSchema = props[key];
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
}
return result;
}
return value;
}
function collectOrderedSteps(
uwf: UwfStore,
headHash: CasRef,
chain: ChainState,
): OrderedStepItem[] {
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null || node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
hashToNode.set(hash, { payload, timestamp: node.timestamp });
hash = payload.prev;
}
let cur: CasRef | null = chain.headIsStart ? null : headHash;
const ordered: OrderedStepItem[] = [];
while (cur !== null) {
const entry = hashToNode.get(cur);
if (entry === undefined) {
break;
}
ordered.push({ hash: cur, ...entry });
cur = entry.payload.prev;
}
ordered.reverse();
return ordered;
}
function formatYaml(value: unknown): string {
return stringify(value).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
return [
`## Step ${index}: ${item.payload.role}`,
"",
`- **Hash:** \`${item.hash}\``,
`- **Agent:** ${item.payload.agent}`,
"",
"### Output",
"",
"```yaml",
outputYaml,
"```",
].join("\n");
}
export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): string | null {
const detailNode = uwf.store.get(detailRef);
if (detailNode === null) {
return null;
}
const detail = detailNode.payload as Record<string, unknown>;
const turns = detail.turns;
if (!Array.isArray(turns) || turns.length === 0) {
return null;
}
for (let i = turns.length - 1; i >= 0; i--) {
const turnRef = turns[i];
if (typeof turnRef !== "string") {
continue;
}
const turnNode = uwf.store.get(turnRef as CasRef);
if (turnNode === null) {
continue;
}
const turn = turnNode.payload as Record<string, unknown>;
if (
turn.role === "assistant" &&
typeof turn.content === "string" &&
turn.content.trim() !== ""
) {
return turn.content;
}
}
return null;
}
function formatThreadReadMarkdown(options: {
threadId: ThreadId;
workflowName: string;
workflowHash: CasRef;
prompt: string;
ordered: OrderedStepItem[];
uwf: UwfStore;
workflow: WorkflowPayload;
quota: number;
before: CasRef | null;
showStart: boolean;
}): string {
const { ordered, uwf, workflow, quota, before, showStart } = options;
// Determine which steps to consider
let candidates = ordered;
if (before !== null) {
const idx = candidates.findIndex((s) => s.hash === before);
if (idx === -1) {
fail(`step ${before} not found in thread ${options.threadId}`);
}
candidates = candidates.slice(0, idx);
}
// Walk backward from newest, accumulating chars until quota exceeded
const selected: OrderedStepItem[] = [];
let totalChars = 0;
for (let i = candidates.length - 1; i >= 0; i--) {
const item = candidates[i];
if (item === undefined) continue;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
selected.unshift(item);
totalChars += blockLen;
if (totalChars > quota) break;
}
const skippedCount = candidates.length - selected.length;
const parts: string[] = [];
// Start section
if (before === null || showStart) {
parts.push(
[
`# Thread \`${options.threadId}\``,
"",
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
"",
"## Task",
"",
options.prompt,
].join("\n"),
);
}
// Skip hint
if (skippedCount > 0 && selected.length > 0) {
const firstSelected = selected[0];
if (firstSelected !== undefined) {
parts.push(
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
);
}
}
// Step blocks
const startIndex = candidates.length - selected.length;
for (let i = 0; i < selected.length; i++) {
const item = selected[i];
if (item === undefined) continue;
const stepNum = startIndex + i + 1;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
const stepLines = [
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef) {
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
}
if (item.payload.detail) {
const content = extractLastAssistantContent(uwf, item.payload.detail);
if (content !== null) {
stepLines.push("", "### Content", "", content);
}
}
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
parts.push(stepLines.join("\n"));
}
return parts.join("\n\n---\n\n");
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
@@ -690,132 +437,6 @@ export async function cmdThreadStep(
};
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
return activeHead;
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return hist.head;
}
fail(`thread not found: ${threadId}`);
}
export async function cmdThreadSteps(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
const ordered = collectOrderedSteps(uwf, headHash, chain);
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail,
agent: item.payload.agent,
timestamp: item.timestamp,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
export async function cmdThreadRead(
storageRoot: string,
threadId: ThreadId,
quota: number = THREAD_READ_DEFAULT_QUOTA,
before: CasRef | null = null,
showStart: boolean = false,
): Promise<string> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
const ordered = collectOrderedSteps(uwf, headHash, chain);
return formatThreadReadMarkdown({
threadId,
workflowName: workflow.name,
workflowHash: chain.start.workflow,
prompt: chain.start.prompt,
ordered,
uwf,
workflow,
quota,
before,
showStart,
});
}
export async function cmdThreadFork(
storageRoot: string,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StartNode or StepNode`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
return {
thread: newThreadId,
forkedFrom: {
step: stepHash,
},
};
}
export async function cmdThreadStepDetails(
storageRoot: string,
stepHash: CasRef,
): Promise<unknown> {
const uwf = await createUwfStore(storageRoot);
const node = uwf.store.get(stepHash);
if (node === null) {
fail(`CAS node not found: ${stepHash}`);
}
if (node.type !== uwf.schemas.stepNode) {
fail(`node ${stepHash} is not a StepNode`);
}
const payload = node.payload as StepNodePayload;
if (!payload.detail) {
fail(`step ${stepHash} has no detail`);
}
return expandDeep(uwf.store, payload.detail);
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
},
});

Some files were not shown because too many files have changed in this diff Show More