Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5643a06a39 |
@@ -1,3 +1,27 @@
|
||||
# No Dynamic Import
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Sync Readme
|
||||
|
||||
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## To reproduce
|
||||
|
||||
Steps or commands to reproduce:
|
||||
|
||||
```bash
|
||||
uwf ...
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened. Include error messages or logs.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS:
|
||||
- Bun version:
|
||||
- uwf version (`uwf --version`):
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Describe the feature or improvement.
|
||||
|
||||
## Why
|
||||
|
||||
Why is this needed? What problem does it solve?
|
||||
|
||||
## Proposed solution
|
||||
|
||||
How should it work? Include API sketches, CLI examples, or workflow YAML snippets if applicable.
|
||||
@@ -1,15 +0,0 @@
|
||||
## What
|
||||
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
|
||||
- `path/to/file` — what changed and why
|
||||
|
||||
## Ref
|
||||
|
||||
Fixes #
|
||||
@@ -1,28 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: Lint
|
||||
run: bunx biome check .
|
||||
|
||||
- name: Test
|
||||
run: bun run test:ci
|
||||
@@ -9,7 +9,3 @@ bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
.claude
|
||||
tmp
|
||||
@@ -1,83 +0,0 @@
|
||||
# Test Spec: uwf setup model connectivity validation (#335)
|
||||
|
||||
## Context
|
||||
|
||||
File: `packages/cli-workflow/src/commands/setup.ts`
|
||||
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
|
||||
|
||||
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
|
||||
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
|
||||
- Use `AbortSignal.timeout(15_000)` for the request
|
||||
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
|
||||
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
|
||||
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
|
||||
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
|
||||
|
||||
## Test Cases (vitest)
|
||||
|
||||
### 1. `validateModel` — success path
|
||||
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: true, value: undefined }`
|
||||
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
|
||||
|
||||
### 2. `validateModel` — HTTP error (401 unauthorized)
|
||||
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
|
||||
- Call `validateModel(baseUrl, apiKey, model)`
|
||||
- Assert returns `{ ok: false, error: <string containing "401"> }`
|
||||
|
||||
### 3. `validateModel` — HTTP error (404 model not found)
|
||||
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
|
||||
- Assert returns `{ ok: false, error: <string containing "404"> }`
|
||||
|
||||
### 4. `validateModel` — network timeout
|
||||
- Mock `fetch` to throw `DOMException` with name `AbortError`
|
||||
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
|
||||
|
||||
### 5. `validateModel` — network error (DNS failure, connection refused)
|
||||
- Mock `fetch` to throw `TypeError("fetch failed")`
|
||||
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
|
||||
|
||||
### 6. `cmdSetup` — includes validation result on success
|
||||
- Mock global `fetch` for `/chat/completions` to succeed
|
||||
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
|
||||
- Assert returned object has `validation: { ok: true, value: undefined }`
|
||||
- Assert config files are still written (existing behavior preserved)
|
||||
|
||||
### 7. `cmdSetup` — includes validation result on failure (config still saved)
|
||||
- Mock global `fetch` for `/chat/completions` to return 401
|
||||
- Call `cmdSetup({ ... })`
|
||||
- Assert returned object has `validation: { ok: false, error: ... }`
|
||||
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
|
||||
|
||||
### 8. `cmdSetupInteractive` — prints success message on validation pass
|
||||
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
|
||||
- Mock stdin to provide valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a success message like "Model verified" or "✓"
|
||||
|
||||
### 9. `cmdSetupInteractive` — prints warning on validation failure
|
||||
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
|
||||
- Mock stdin for valid selections
|
||||
- Capture console output
|
||||
- Assert output contains a warning about model not being reachable and suggests trying a different model
|
||||
|
||||
### 10. `validateModel` — request body correctness
|
||||
- Mock `fetch` to capture the request body
|
||||
- Call `validateModel(baseUrl, apiKey, "test-model")`
|
||||
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
|
||||
|
||||
## Export Requirements
|
||||
|
||||
- `validateModel` must be exported (for direct unit testing)
|
||||
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
|
||||
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
|
||||
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
|
||||
@@ -1,198 +0,0 @@
|
||||
name: "solve-issue"
|
||||
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes issue and outputs a TDD test spec"
|
||||
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: |
|
||||
On first run (no previous steps):
|
||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||
3. Assess whether the issue has enough information to produce a test spec
|
||||
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output $status=insufficient_info
|
||||
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||
|
||||
On subsequent runs (bounced back by tester with fix_spec):
|
||||
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||
2. Revise the test spec accordingly
|
||||
|
||||
After producing the test spec:
|
||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||
3. Set repoPath to the absolute path of the repository root
|
||||
output: "Output a brief summary of the test spec. Set $status to ready (with plan hash and repoPath) or insufficient_info."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "insufficient_info" }
|
||||
required: [$status]
|
||||
developer:
|
||||
description: "TDD implementation per test spec"
|
||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The repo path and other details are provided in your task prompt.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. cd into the repo path provided in your task prompt
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. First time (no existing branch):
|
||||
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||
4. If bounced back from reviewer or tester (branch already exists):
|
||||
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||
- `git fetch origin && git rebase origin/main`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then implement TDD:
|
||||
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||
8. Write tests first based on the spec
|
||||
9. Implement the code to make tests pass
|
||||
10. Ensure `bun run build` passes with no errors
|
||||
11. Run `bun test` to verify all tests pass
|
||||
|
||||
If you cannot complete the implementation (e.g. the issue is too complex, blocked by external factors,
|
||||
or repeated attempts fail), set $status=failed with a reason.
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Code standards compliance check"
|
||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Before reviewing, verify the git branch:
|
||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||
2. If the branch doesn't correspond to the issue, flag it in your output and reject
|
||||
|
||||
Then perform code review:
|
||||
Hard checks (must all pass):
|
||||
3. `bun run build` — no build errors
|
||||
4. `bunx biome check` — no lint violations
|
||||
5. TypeScript strict mode — no type errors
|
||||
|
||||
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||
- Naming conventions, module boundaries, code style
|
||||
- No `console.log` in production code
|
||||
- No dynamic imports in production code
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
capabilities:
|
||||
- testing
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
1. Run `bun test` for automated test verification
|
||||
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner step in the thread history)
|
||||
3. Verify each scenario in the spec is covered and passing
|
||||
4. Determine outcome:
|
||||
- passed: all scenarios verified, tests pass
|
||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
||||
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "passed" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "fix_code" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
- properties:
|
||||
$status: { const: "fix_spec" }
|
||||
report: { type: string }
|
||||
required: [$status, report]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --repo <owner/repo> --title "..." --description "..."`
|
||||
- Extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
||||
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||
tester:
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||
@@ -2,40 +2,46 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, status-based routing, and a directed graph. Stored as a CAS node, identified by its XXH64 hash. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||
| **Moderator** | Status-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
||||
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
|
||||
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
|
||||
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
||||
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||
docs/ # Architecture docs
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -103,6 +109,8 @@ type WorkflowEntry = {
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
@@ -128,10 +136,10 @@ export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
@@ -152,7 +160,7 @@ Workflow names use **verb-first** kebab-case:
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- CAS hash: XXH64 → 13-char Crockford Base32
|
||||
- Bundle hash: XXH64 → 13-char Crockford Base32
|
||||
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
|
||||
|
||||
## Error Handling
|
||||
@@ -181,7 +189,7 @@ import { createLogger } from "@uncaged/workflow-util";
|
||||
const log = createLogger();
|
||||
|
||||
// Each call site has a fixed 8-char Crockford Base32 tag
|
||||
log("4KNMR2PX", "Loading workflow...");
|
||||
log("4KNMR2PX", "Loading workflow bundle...");
|
||||
log("7BQST3VW", `Role ${role} started`);
|
||||
```
|
||||
|
||||
@@ -196,7 +204,7 @@ log("7BQST3VW", `Role ${role} started`);
|
||||
|
||||
### Why fixed tags?
|
||||
|
||||
- `grep "4KNMR2PX"` in logs → instant code location
|
||||
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
|
||||
- No need for file/line info in the log — tag is the locator
|
||||
- Survives refactoring (tag stays the same when code moves)
|
||||
|
||||
@@ -213,81 +221,74 @@ console.log(result);
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **bun** | Package manager + runtime |
|
||||
| **bun** | Package manager + runtime + test runner |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
|
||||
|
||||
### Development Workflow
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# ── Setup ──
|
||||
bun install # install all workspace dependencies
|
||||
|
||||
# ── Daily development ──
|
||||
bun run build # tsc --build (all packages, dependency order)
|
||||
bun run check # tsc --build + biome check + lint-log-tags
|
||||
bun run format # biome format --write
|
||||
bun test # run tests across all packages
|
||||
|
||||
# ── Before committing ──
|
||||
bun run check # must pass — typecheck + lint + log tag validation
|
||||
bun test # must pass — all package tests
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Publishing
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# 1. Add a changeset describing the change
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Bump all package versions + generate CHANGELOGs
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
|
||||
# Or publish manually with a tag:
|
||||
node scripts/publish-all.mjs --tag alpha
|
||||
node scripts/publish-all.mjs --dry-run # preview without publishing
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### End-to-end: Author → Register → Run
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
```
|
||||
examples/solve-issue.yaml — write a workflow YAML definition
|
||||
│ uwf workflow put
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
|
||||
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
|
||||
│ uwf thread start <name> -p "..."
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
~/.uncaged/workflow/threads.yaml — new thread head pointer
|
||||
│ uwf thread step <thread-id>
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
moderator → agent → extract — one step per invocation, repeat until $END
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Author** — write a workflow YAML file with roles, conditions, and graph
|
||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
||||
3. **Run** — `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
|
||||
|
||||
## Project Rules
|
||||
|
||||
- [docs/sync-readme.md](docs/sync-readme.md) — README sync conventions
|
||||
- [docs/no-dynamic-import.md](docs/no-dynamic-import.md) — no dynamic import in production code
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -295,5 +296,5 @@ moderator → agent → extract — one step per invocation, repeat until $
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
|
||||
scope: workflow | cli | rfc-001 | ...
|
||||
```
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# Contributing to @uncaged/workflow
|
||||
|
||||
Thank you for your interest in contributing! This guide covers setup, conventions, and the PR workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (latest)
|
||||
- [Node.js](https://nodejs.org/) 20+
|
||||
- Git
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/shazhou-ww/uncaged-workflow.git
|
||||
cd uncaged-workflow
|
||||
bun install
|
||||
bun run build
|
||||
bun test
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
bun run build # TypeScript compilation (all packages)
|
||||
bun run check # tsc + biome lint + log tag validation
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
```
|
||||
|
||||
All three (`build`, `check`, `test`) must pass before submitting a PR. A pre-push hook runs `check` + `test` automatically.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for the full coding standard. Key points:
|
||||
|
||||
- **Functional-first** — `function` + `type`, not `class` + `interface`
|
||||
- **No optional properties** — use `T | null` instead of `?:`
|
||||
- **Named exports only** — no default exports
|
||||
- **No `console.log`** — use the structured logger from `@uncaged/workflow-util`
|
||||
- **Static imports only** — no `await import()` in production code
|
||||
- **Biome** for lint + format — run `bun run check` before committing
|
||||
|
||||
## Commit Messages
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: cli | moderator | agent-kit | hermes | builtin | claude-code | util | protocol | dashboard
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `feat(moderator): add cycle detection to graph evaluator`
|
||||
- `fix(cli): handle missing config file gracefully`
|
||||
- `docs(protocol): update StepNode field descriptions`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Branch** from `main`: `git checkout -b feat/123-short-description`
|
||||
2. **Implement** your change with tests
|
||||
3. **Run checks**: `bun run check && bun test`
|
||||
4. **Commit** with a descriptive message referencing the issue: `Fixes #123`
|
||||
5. **Push** and open a PR
|
||||
|
||||
### PR Description Template
|
||||
|
||||
```
|
||||
## What
|
||||
What this PR does.
|
||||
|
||||
## Why
|
||||
Why the change is needed.
|
||||
|
||||
## Changes
|
||||
- `path/to/file.ts` — what changed and why
|
||||
|
||||
## Ref
|
||||
Fixes #N
|
||||
```
|
||||
|
||||
## Adding a Changeset
|
||||
|
||||
For any user-facing change (feat, fix, breaking change), add a changeset:
|
||||
|
||||
```bash
|
||||
bun changeset
|
||||
```
|
||||
|
||||
This creates a markdown file in `.changeset/` describing the change. It will be consumed on the next release to bump versions and generate CHANGELOG entries.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow-protocol/ # Shared types and JSON Schema
|
||||
workflow-util/ # Encoding, IDs, logging, frontmatter
|
||||
workflow-util-agent/ # createAgent factory, extract pipeline
|
||||
workflow-agent-hermes/ # Hermes ACP agent
|
||||
workflow-agent-builtin/ # Built-in LLM agent
|
||||
workflow-agent-claude-code/ # Claude Code agent
|
||||
cli-workflow/ # uwf CLI binary
|
||||
workflow-dashboard/ # Web UI (private, alpha)
|
||||
```
|
||||
|
||||
Dependency flows downward — lower layers have no dependency on higher layers. See [CLAUDE.md](CLAUDE.md) for the full architecture.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Uncaged
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,115 +1,71 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
[](https://github.com/shazhou-ww/uncaged-workflow/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@uncaged/cli-workflow)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-protocol)
|
||||
[](https://www.npmjs.com/package/@uncaged/workflow-util-agent)
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, status-based routing, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
||||
## Core Concepts
|
||||
|
||||
## Overview
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
|
||||
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates status-based routing to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
|
||||
## Monorepo Packages
|
||||
|
||||
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
|
||||
|
||||
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @uncaged/cli-workflow
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
```
|
||||
|
||||
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure provider, model, and default agent
|
||||
uwf setup
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# 2. Register a workflow from YAML
|
||||
uwf workflow add examples/solve-issue.yaml
|
||||
# Build all packages
|
||||
bun run build
|
||||
|
||||
# 3. Start a thread (creates head pointer; does not execute)
|
||||
uwf thread start solve-issue -p "Fix the login redirect bug"
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
|
||||
# 4. Execute steps (one at a time, until done)
|
||||
uwf thread exec <thread-id>
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
```
|
||||
|
||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||
## CLI Usage
|
||||
|
||||
## Architecture
|
||||
|
||||
Dependency layers (lower layers have no dependency on higher layers):
|
||||
|
||||
```
|
||||
Layer 0 — Contract
|
||||
workflow-protocol Shared types and JSON Schema definitions
|
||||
|
||||
Layer 1 — Shared infra
|
||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
||||
|
||||
Layer 2 — Agent framework
|
||||
workflow-util-agent createAgent factory, context builder, extract pipeline
|
||||
|
||||
Layer 3 — Agent implementations
|
||||
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
|
||||
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
|
||||
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
|
||||
|
||||
Layer 4 — CLI
|
||||
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup (includes status-based moderator)
|
||||
|
||||
App (uses protocol; not in the runtime engine stack)
|
||||
workflow-dashboard Web UI for visual workflow editing
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
```
|
||||
|
||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | npm | Description | Type | README |
|
||||
|---------|-----|-------------|------|--------|
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
|
||||
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-util-agent/README.md) |
|
||||
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
|
||||
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
|
||||
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
|
||||
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||
|
||||
| Group | Commands |
|
||||
|-------|----------|
|
||||
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
|
||||
| **step** | `list`, `show`, `read`, `fork` |
|
||||
| **workflow** | `add`, `show`, `list` |
|
||||
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
|
||||
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
|
||||
| **skill** | `cli` — print markdown reference of all uwf commands |
|
||||
| **log** | `list`, `show`, `clean` — process-level debug logs |
|
||||
|
||||
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
|
||||
|
||||
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun install --no-cache # Install dependencies
|
||||
bun run build # tsc --build (all packages)
|
||||
bun run check # tsc + biome + lint-log-tags
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run all tests
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
|
||||
+1
-22
@@ -5,8 +5,6 @@
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!**/legacy-packages",
|
||||
"!scripts",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
@@ -17,15 +15,6 @@
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
@@ -47,7 +36,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.d.ts", "**/vitest.config.*"],
|
||||
"includes": ["**/*.d.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -55,16 +44,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/cli.ts", "**/setup.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
+179
-401
@@ -1,490 +1,268 @@
|
||||
# Workflow Engine — Architecture
|
||||
# Uncaged workflow — Architecture
|
||||
|
||||
**Last updated:** 2026-05-19
|
||||
**Last updated:** 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@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/workflow-protocol` → `workflow-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. |
|
||||
| Agent framework | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
|
||||
| Agent: Hermes | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
|
||||
|-------|---------|----------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-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`. |
|
||||
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||
| `dotenv` | Loads `.env` files for API keys. |
|
||||
| `yaml` | YAML parse/stringify. |
|
||||
|
||||
## Dependency graph
|
||||
Bottom-up layering for the execution stack:
|
||||
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph External
|
||||
jcas["@uncaged/json-cas"]
|
||||
jcasfs["@uncaged/json-cas-fs"]
|
||||
end
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — shared"]
|
||||
subgraph L1["Layer 1 — on protocol"]
|
||||
runtime["@uncaged/workflow-runtime"]
|
||||
util["@uncaged/workflow-util"]
|
||||
reactor["@uncaged/workflow-reactor"]
|
||||
end
|
||||
subgraph L2["Layer 2 — agent framework"]
|
||||
kit["@uncaged/workflow-util-agent"]
|
||||
subgraph L2["Layer 2 — protocol + util"]
|
||||
cas["@uncaged/workflow-cas"]
|
||||
register["@uncaged/workflow-register"]
|
||||
end
|
||||
subgraph L3["Layer 3 — agent implementations"]
|
||||
hermes["@uncaged/workflow-agent-hermes"]
|
||||
subgraph L3["Layer 3 — engine"]
|
||||
execute["@uncaged/workflow-execute"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
protocol --> jcasfs
|
||||
runtime --> protocol
|
||||
util --> protocol
|
||||
kit --> protocol
|
||||
kit --> util
|
||||
kit --> jcas
|
||||
kit --> jcasfs
|
||||
hermes --> kit
|
||||
hermes --> jcas
|
||||
reactor --> protocol
|
||||
cas --> protocol
|
||||
cas --> util
|
||||
register --> protocol
|
||||
register --> util
|
||||
execute --> protocol
|
||||
execute --> runtime
|
||||
execute --> util
|
||||
execute --> cas
|
||||
execute --> reactor
|
||||
execute --> register
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> kit
|
||||
cli --> jcas
|
||||
cli --> jcasfs
|
||||
cli --> cas
|
||||
cli --> execute
|
||||
cli --> register
|
||||
cli --> runtime
|
||||
```
|
||||
|
||||
## Workflow definition
|
||||
**Adjacent consumers** (not in the main CLI stack):
|
||||
|
||||
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
|
||||
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||
|
||||
Example (`examples/solve-issue.yaml`):
|
||||
## Package roles (detail)
|
||||
|
||||
```yaml
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
|
||||
output: "Output the plan summary and list of concrete steps."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
plan: { type: string }
|
||||
steps: { type: array, items: { type: string } }
|
||||
required: [plan, steps]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. Implement the plan."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
filesChanged: { type: array, items: { type: string } }
|
||||
summary: { type: string }
|
||||
required: [filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. Review the implementation."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: "Review the implementation against the plan."
|
||||
output: "Approve or reject with detailed comments."
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
approved: { type: boolean }
|
||||
comments: { type: string }
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
- role: "$END"
|
||||
condition: null
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||
- **No Zod** — all schemas are JSON Schema, validated through `@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-workflow/src/commands/thread.ts` (`cmdThreadStep`).
|
||||
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Input: graph + lastRole + lastOutput
|
||||
│ Engine: Status-based map lookup against lastOutput.status
|
||||
│ Output: next role name | $END
|
||||
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
│ Input: thread-id + role (via argv)
|
||||
│ Engine: agent-kit builds context from CAS chain, prepends
|
||||
│ output format instruction to system prompt, spawns agent
|
||||
│ Output: raw string (frontmatter markdown)
|
||||
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||
│ Action: agent(ctx) → raw string
|
||||
│
|
||||
│ Phase 3: EXTRACT
|
||||
│ Input: raw agent output + role's meta schema
|
||||
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
|
||||
│ Output: CasRef to structured output node
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||
│
|
||||
│ Persist: StepNode { start, prev, role, output, detail, agent }
|
||||
│ Update: threads.yaml head pointer
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context types
|
||||
### Context types (progressive)
|
||||
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
type StepContext = {
|
||||
role: string;
|
||||
output: unknown; // CAS node payload, expanded (not hash)
|
||||
detail: CasRef;
|
||||
agent: string;
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload; // { workflow: CasRef, prompt: string }
|
||||
steps: StepContext[]; // chronological, oldest first
|
||||
};
|
||||
|
||||
type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
type ModeratorContext<M> = ThreadContext<M>;
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
};
|
||||
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||
```
|
||||
|
||||
### Key properties
|
||||
|
||||
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
|
||||
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
|
||||
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
|
||||
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||
|
||||
## Agent CLI protocol
|
||||
## Agent information sources
|
||||
|
||||
Each agent is an external command invoked by `uwf thread step`:
|
||||
An agent has exactly three information sources:
|
||||
|
||||
```bash
|
||||
<agent-cmd> <thread-id> <role>
|
||||
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||
2. **Thread context** — `AgentContext` (`start`, `steps`, `currentRole`)
|
||||
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||
|
||||
## Bundle contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
```
|
||||
|
||||
Contract:
|
||||
1. `uwf thread step` determines the next role via the moderator
|
||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
||||
- Parses argv
|
||||
- Loads `.env` from storage root
|
||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
|
||||
- Calls the agent's `run` function
|
||||
- Runs two-layer extract on the raw output
|
||||
- Writes `StepNode` to CAS (output + detail + prev link)
|
||||
- Prints the new `StepNode` CAS hash to stdout
|
||||
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
|
||||
5. Exit 0 = success, non-zero = failure
|
||||
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||
|
||||
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
|
||||
### Constraints
|
||||
|
||||
## Agent output format: frontmatter markdown (RFC #351)
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||
- XXH64 hash (Crockford Base32) = version ID
|
||||
|
||||
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
|
||||
### Why AsyncGenerator?
|
||||
|
||||
```markdown
|
||||
---
|
||||
status: done
|
||||
next: reviewer
|
||||
confidence: 0.9
|
||||
artifacts:
|
||||
- src/auth.ts
|
||||
scope: role
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Fixed the login redirect by updating the auth middleware...
|
||||
```
|
||||
|
||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
||||
|
||||
## Two-layer extract
|
||||
|
||||
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
||||
|
||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||
|
||||
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
|
||||
2. Validate required fields (`validateFrontmatter`)
|
||||
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
|
||||
4. `store.put()` the candidate against the role's `meta` schema
|
||||
5. Validate with `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
|
||||
|
||||
`workflow-util-agent` prepends two pieces of context to the agent's system prompt:
|
||||
|
||||
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
|
||||
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||
|
||||
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
|
||||
|
||||
## CAS node types
|
||||
|
||||
### Workflow
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # 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 meta schema)
|
||||
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. |
|
||||
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
|
||||
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
|
||||
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
|
||||
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
||||
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `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 |
|
||||
|
||||
@@ -1,779 +0,0 @@
|
||||
# Built-in Role Agent 调研
|
||||
|
||||
## 目标
|
||||
|
||||
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
|
||||
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
|
||||
|
||||
---
|
||||
|
||||
## 关键问题
|
||||
|
||||
### Q1: Agent 接口协议
|
||||
|
||||
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `cli-workflow` 里 `spawnAgent` 的完整实现
|
||||
- AgentConfig 类型定义
|
||||
- agent 进程的 exit code 约定
|
||||
- 环境变量传递(UWF_STORAGE_ROOT 等)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 调用链
|
||||
|
||||
`uwf thread step` → `cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig` → `spawnAgent`。
|
||||
|
||||
#### AgentConfig 类型
|
||||
|
||||
```146:149:packages/workflow-protocol/src/types.ts
|
||||
export type AgentConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
```
|
||||
|
||||
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
|
||||
|
||||
#### spawnAgent 行为
|
||||
|
||||
```627:653:packages/cli-workflow/src/commands/thread.ts
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
// ... stderr 拼进 fail 消息
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
|
||||
| **stdin** | 忽略 |
|
||||
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
|
||||
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
|
||||
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
|
||||
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
|
||||
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
|
||||
|
||||
Agent 解析优先级(`resolveAgentConfig`):
|
||||
|
||||
1. CLI `--agent` override(整段 command + args 字符串)
|
||||
2. `config.agentOverrides[workflow.name][role]`
|
||||
3. `config.defaultAgent`
|
||||
|
||||
#### 环境变量:Storage Root
|
||||
|
||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
||||
|
||||
```33:43:packages/workflow-util-agent/src/storage.ts
|
||||
export function resolveStorageRoot(): string {
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultStorageRoot();
|
||||
}
|
||||
```
|
||||
|
||||
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
|
||||
|
||||
#### Agent 侧职责(设计文档 + 实现)
|
||||
|
||||
- 读 `threads.yaml` 链头,构建 context,执行 role
|
||||
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
|
||||
- stdout 打印 step hash
|
||||
- **不**更新 `threads.yaml`
|
||||
|
||||
---
|
||||
|
||||
### Q2: createAgent 工厂
|
||||
|
||||
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
||||
- `AgentRunResult` 的完整定义
|
||||
- retry 逻辑(frontmatter 校验失败后的重试机制)
|
||||
- `persistStep` 写入 CAS 的 StepNode 结构
|
||||
|
||||
**答案:**
|
||||
|
||||
#### 类型定义
|
||||
|
||||
```4:35:packages/workflow-util-agent/src/types.ts
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
outputFormatInstruction: string;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: CasRef;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type AgentContinueFn = (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
store: AgentContext["store"],
|
||||
) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
run: AgentRunFn;
|
||||
continue: AgentContinueFn;
|
||||
};
|
||||
```
|
||||
|
||||
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
|
||||
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
|
||||
|
||||
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
|
||||
|
||||
#### 生命周期(按执行顺序)
|
||||
|
||||
```101:152:packages/workflow-util-agent/src/run.ts
|
||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
return async function main(): Promise<void> {
|
||||
const { threadId, role } = parseArgv(process.argv);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
|
||||
const ctx = await buildContextWithMeta(threadId, role);
|
||||
// 1. 校验 role 存在
|
||||
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
|
||||
|
||||
let agentResult = await options.run(ctx);
|
||||
|
||||
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
|
||||
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
|
||||
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
|
||||
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
|
||||
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
}
|
||||
|
||||
if (outputHash === null) { fail(...); }
|
||||
|
||||
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
|
||||
process.stdout.write(`${stepHash}\n`);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 阶段 | 行为 |
|
||||
|------|------|
|
||||
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
|
||||
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
|
||||
| Run | `options.run(ctx)` |
|
||||
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
|
||||
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
|
||||
| Persist | `persistStep` → `writeStepNode` |
|
||||
| 输出 | stdout 一行 step CAS hash |
|
||||
|
||||
#### StepNode 写入结构
|
||||
|
||||
```44:68:packages/workflow-util-agent/src/run.ts
|
||||
async function writeStepNode(options: {
|
||||
store: AgentStore["store"];
|
||||
schemas: AgentStore["schemas"];
|
||||
startHash: CasRef;
|
||||
prevHash: CasRef | null;
|
||||
role: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const payload: StepNodePayload = {
|
||||
start: options.startHash,
|
||||
prev: options.prevHash,
|
||||
role: options.role,
|
||||
output: options.outputHash,
|
||||
detail: options.detailHash,
|
||||
agent: options.agentName,
|
||||
};
|
||||
// store.put(stepNode schema) + validate
|
||||
}
|
||||
```
|
||||
|
||||
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
|
||||
|
||||
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
|
||||
|
||||
---
|
||||
|
||||
### Q3: Context Builder
|
||||
|
||||
`buildContextWithMeta` 构建了什么上下文给 agent?
|
||||
|
||||
**调研要点:**
|
||||
- `AgentContext` 完整类型定义(所有字段)
|
||||
- context 构建过程(CAS chain walk)
|
||||
- `outputFormatInstruction` 怎么生成的
|
||||
- role definition 怎么获取(从 workflow YAML)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### AgentContext 字段
|
||||
|
||||
继承 `ModeratorContext`:
|
||||
|
||||
```60:68:packages/workflow-protocol/src/types.ts
|
||||
export type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[];
|
||||
};
|
||||
```
|
||||
|
||||
```48:51:packages/workflow-protocol/src/types.ts
|
||||
export type StartNodePayload = {
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
};
|
||||
```
|
||||
|
||||
```61:63:packages/workflow-protocol/src/types.ts
|
||||
export type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
`AgentContext` 额外字段:
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `threadId` | `ThreadId` | 当前线程 |
|
||||
| `role` | `string` | 本步要执行的角色名 |
|
||||
| `store` | `Store` | CAS store(读写节点) |
|
||||
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
|
||||
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
|
||||
|
||||
`buildContextWithMeta` 还返回 `meta`:
|
||||
|
||||
```148:154:packages/workflow-util-agent/src/context.ts
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
```
|
||||
|
||||
#### CAS chain walk
|
||||
|
||||
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
||||
|
||||
#### Role definition 来源
|
||||
|
||||
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
|
||||
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
|
||||
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
|
||||
|
||||
#### outputFormatInstruction
|
||||
|
||||
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
|
||||
|
||||
```typescript
|
||||
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
|
||||
```
|
||||
|
||||
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
|
||||
|
||||
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
|
||||
|
||||
---
|
||||
|
||||
### Q4: Extract Pipeline
|
||||
|
||||
agent 输出怎么被处理成结构化数据?
|
||||
|
||||
**调研要点:**
|
||||
- frontmatter fast-path 的完整逻辑
|
||||
- LLM extract fallback 的实现(`extract.ts`)
|
||||
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
|
||||
- 校验失败时的 correction prompt 是什么
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Schema 来源
|
||||
|
||||
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
|
||||
|
||||
```66:76:packages/cli-workflow/src/commands/workflow.ts
|
||||
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
|
||||
// 校验为 JSON Schema → putSchema → 返回 CasRef
|
||||
}
|
||||
```
|
||||
|
||||
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
|
||||
|
||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
||||
|
||||
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
||||
export async function tryFrontmatterFastPath(
|
||||
raw: string,
|
||||
outputSchema: CasRef,
|
||||
store: Store,
|
||||
): Promise<FrontmatterFastPathResult | null>
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
|
||||
2. `validateFrontmatter` 失败 → `null`
|
||||
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
|
||||
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
|
||||
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
|
||||
|
||||
**永不抛错**,失败返回 `null`。
|
||||
|
||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
||||
|
||||
```135:181:packages/workflow-util-agent/src/extract.ts
|
||||
export async function extract(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
config: WorkflowConfig,
|
||||
): Promise<ExtractResult>
|
||||
```
|
||||
|
||||
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
|
||||
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
|
||||
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
|
||||
- 校验通过后 `store.put(outputSchema, structured)`
|
||||
|
||||
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
|
||||
|
||||
#### Correction prompt(retry)
|
||||
|
||||
```125:128:packages/workflow-util-agent/src/run.ts
|
||||
const correctionMessage =
|
||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||
"Please output ONLY the corrected frontmatter block followed by your work.";
|
||||
```
|
||||
|
||||
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
|
||||
|
||||
---
|
||||
|
||||
### Q5: Model 配置与 LLM 调用
|
||||
|
||||
workflow 怎么配置和使用 model?
|
||||
|
||||
**调研要点:**
|
||||
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
|
||||
- `resolveModel` 函数的实现
|
||||
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
|
||||
- 有没有 streaming 支持?tool calling 支持?
|
||||
|
||||
**答案:**
|
||||
|
||||
#### WorkflowConfig
|
||||
|
||||
```136:160:packages/workflow-protocol/src/types.ts
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKeyEnv: string;
|
||||
};
|
||||
|
||||
export type ModelConfig = {
|
||||
provider: ProviderAlias;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
providers: Record<ProviderAlias, ProviderConfig>;
|
||||
models: Record<ModelAlias, ModelConfig>;
|
||||
agents: Record<AgentAlias, AgentConfig>;
|
||||
defaultAgent: AgentAlias;
|
||||
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||
defaultModel: ModelAlias;
|
||||
modelOverrides: Record<Scenario, ModelAlias> | null;
|
||||
};
|
||||
```
|
||||
|
||||
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
|
||||
|
||||
#### resolveModel
|
||||
|
||||
```32:50:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||
const modelEntry = config.models[alias];
|
||||
const providerEntry = config.providers[modelEntry.provider];
|
||||
const apiKey = process.env[providerEntry.apiKeyEnv];
|
||||
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
|
||||
}
|
||||
```
|
||||
|
||||
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
|
||||
|
||||
Extract 专用别名解析:
|
||||
|
||||
```18:30:packages/workflow-util-agent/src/extract.ts
|
||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
||||
}
|
||||
```
|
||||
|
||||
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
|
||||
|
||||
#### chatCompletionText
|
||||
|
||||
```87:124:packages/workflow-util-agent/src/extract.ts
|
||||
async function chatCompletionText(
|
||||
provider: ResolvedLlmProvider,
|
||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
| 能力 | 现状 |
|
||||
|------|------|
|
||||
| 协议 | OpenAI 兼容 `POST /chat/completions` |
|
||||
| Streaming | **无**(一次性 `response.text()`) |
|
||||
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
|
||||
| 多模态 | **无**(仅 text `content`) |
|
||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
||||
|
||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||
|
||||
---
|
||||
|
||||
### Q6: Hermes Agent 参考实现
|
||||
|
||||
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
|
||||
|
||||
**调研要点:**
|
||||
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
|
||||
- hermes CLI 的调用参数
|
||||
- session management(resume)
|
||||
- 输出怎么捕获
|
||||
|
||||
**答案:**
|
||||
|
||||
#### Prompt 组装
|
||||
|
||||
```40:53:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
|
||||
const parts: string[] = [];
|
||||
if (ctx.outputFormatInstruction !== "") {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
|
||||
|
||||
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
|
||||
|
||||
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
|
||||
|
||||
#### Hermes CLI 参数
|
||||
|
||||
首次:
|
||||
|
||||
```88:97:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
续聊:
|
||||
|
||||
```100:114:packages/workflow-agent-hermes/src/hermes.ts
|
||||
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
|
||||
```
|
||||
|
||||
#### Session
|
||||
|
||||
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
|
||||
- 会话文件:`~/.hermes/sessions/session_<id>.json`
|
||||
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
|
||||
|
||||
#### 与 createAgent 的衔接
|
||||
|
||||
```157:164:packages/workflow-agent-hermes/src/hermes.ts
|
||||
export function createHermesAgent(): () => Promise<void> {
|
||||
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
|
||||
}
|
||||
```
|
||||
|
||||
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
|
||||
|
||||
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
|
||||
|
||||
---
|
||||
|
||||
### Q7: Toolkit 需求分析
|
||||
|
||||
要实现一个自给自足的 agent,最少需要哪些 tool?
|
||||
|
||||
**调研要点:**
|
||||
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
|
||||
- hermes agent 在 workflow 场景下常用哪些 tool
|
||||
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
|
||||
|
||||
**答案:**
|
||||
|
||||
#### solve-issue.yaml 角色能力
|
||||
|
||||
| Role | capabilities | 隐含需求 |
|
||||
|------|----------------|----------|
|
||||
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
|
||||
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
|
||||
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
|
||||
|
||||
#### Hermes 侧
|
||||
|
||||
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
|
||||
|
||||
#### Builtin 最小 toolkit 建议
|
||||
|
||||
| 优先级 | Tool | 用途 |
|
||||
|--------|------|------|
|
||||
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
|
||||
| P0 | `write_file` / `edit_file` | developer 改代码 |
|
||||
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
|
||||
| P1 | `list_dir` / `glob` | 导航代码库 |
|
||||
| P1 | `grep` | 搜索符号/引用 |
|
||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
||||
|
||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
||||
|
||||
#### Agent loop 必须能力
|
||||
|
||||
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
|
||||
2. 将 tool 结果 append 回 messages
|
||||
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
|
||||
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
|
||||
|
||||
---
|
||||
|
||||
## 方案草案
|
||||
|
||||
(调研完成后基于以上答案撰写)
|
||||
|
||||
### 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph cli ["cli-workflow"]
|
||||
Step["uwf thread step"]
|
||||
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
|
||||
Step --> Spawn
|
||||
end
|
||||
|
||||
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
|
||||
Main["createBuiltinAgent() = createAgent({...})"]
|
||||
Prompt["buildBuiltinPrompt(ctx)"]
|
||||
Loop["runBuiltinLoop(provider, messages, tools)"]
|
||||
Tools["Toolkit: read/write/exec/..."]
|
||||
Detail["storeBuiltinDetail(turns)"]
|
||||
Main --> Prompt
|
||||
Main --> Loop
|
||||
Loop --> Tools
|
||||
Loop --> Detail
|
||||
end
|
||||
|
||||
subgraph kit ["workflow-util-agent"]
|
||||
Ctx["buildContextWithMeta"]
|
||||
FM["tryFrontmatterFastPath"]
|
||||
Persist["persistStep"]
|
||||
Ctx --> Main
|
||||
Main --> FM
|
||||
FM --> Persist
|
||||
end
|
||||
|
||||
subgraph cas ["CAS / config"]
|
||||
Config["config.yaml models/providers"]
|
||||
CAS["cas/ + threads.yaml"]
|
||||
end
|
||||
|
||||
Spawn --> Main
|
||||
Config --> Loop
|
||||
CAS --> Ctx
|
||||
Persist --> CAS
|
||||
Spawn -->|"stdout: step hash"| Step
|
||||
```
|
||||
|
||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
||||
|
||||
**分层**:
|
||||
|
||||
| 层 | 职责 |
|
||||
|----|------|
|
||||
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
|
||||
| `builtin/agent.ts` | `run` / `continue` 实现 |
|
||||
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
|
||||
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
|
||||
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
|
||||
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
|
||||
|
||||
**配置集成**:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
builtin:
|
||||
command: "uwf-builtin"
|
||||
args: []
|
||||
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
|
||||
```
|
||||
|
||||
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
|
||||
|
||||
---
|
||||
|
||||
### Agent Run Loop
|
||||
|
||||
伪代码(单次 `run(ctx)`):
|
||||
|
||||
```
|
||||
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
|
||||
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
|
||||
3. messages ← [{ role: "system", content: system }]
|
||||
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
|
||||
5. turns ← []
|
||||
|
||||
6. for turn in 1..MAX_TURNS:
|
||||
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
|
||||
record assistant message + tool_calls in turns
|
||||
|
||||
if response has no tool_calls:
|
||||
finalText ← response.content
|
||||
break
|
||||
|
||||
for each tool_call:
|
||||
result ← executeTool(tool_call, { cwd: process.cwd() })
|
||||
messages.push tool result
|
||||
record in turns
|
||||
|
||||
7. if no finalText with valid frontmatter after loop:
|
||||
optionally one-shot "finalize" message without tools
|
||||
|
||||
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
|
||||
9. return { output: finalText, detailHash, sessionId }
|
||||
```
|
||||
|
||||
**`continue(sessionId, message, store)`**:
|
||||
|
||||
- 从内存/磁盘恢复 `messages` + `turns`
|
||||
- `messages.push({ role: "user", content: message })`(correction 或续聊)
|
||||
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
|
||||
- 返回新的 `AgentRunResult`
|
||||
|
||||
**与 frontmatter 的配合**:
|
||||
|
||||
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
|
||||
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
|
||||
|
||||
**安全**:
|
||||
|
||||
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
|
||||
- 路径:禁止 `..` 逃逸出 workspace root
|
||||
|
||||
---
|
||||
|
||||
### Toolkit 设计
|
||||
|
||||
统一注册表:
|
||||
|
||||
```typescript
|
||||
type BuiltinTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: JSONSchema; // object type
|
||||
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
|
||||
};
|
||||
|
||||
type ToolContext = {
|
||||
cwd: string;
|
||||
storageRoot: string;
|
||||
};
|
||||
```
|
||||
|
||||
| Tool name | OpenAI function | 行为摘要 |
|
||||
|-----------|-----------------|----------|
|
||||
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
|
||||
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
|
||||
| `edit_file` | 可选 | search/replace 块,减少 token |
|
||||
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
|
||||
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
|
||||
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
|
||||
|
||||
**LLM 请求形状**(扩展 extract 客户端):
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "...",
|
||||
"messages": [...],
|
||||
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
|
||||
|
||||
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
|
||||
|
||||
---
|
||||
|
||||
### 与现有架构的集成
|
||||
|
||||
| 集成点 | 方式 |
|
||||
|--------|------|
|
||||
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
|
||||
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
|
||||
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
|
||||
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
|
||||
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
|
||||
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
|
||||
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
|
||||
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
|
||||
|
||||
**明确不做**:
|
||||
|
||||
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
|
||||
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
|
||||
- 首版不实现 streaming、不实现 MCP
|
||||
|
||||
**建议实现顺序**:
|
||||
|
||||
1. `llm.ts`:tool calling HTTP 客户端 + 单测
|
||||
2. P0 tools + `runBuiltinLoop`
|
||||
3. `createBuiltinAgent` + detail CAS
|
||||
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
|
||||
5. (可选)`createAgent` 接入 `extract()` fallback
|
||||
@@ -1,73 +0,0 @@
|
||||
# Issue #418: ACP session/resume 返回空文本
|
||||
|
||||
## 调研日期: 2026-05-23
|
||||
|
||||
## 根因
|
||||
|
||||
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
|
||||
|
||||
### 完整调用链
|
||||
|
||||
```
|
||||
resume_session(sid)
|
||||
→ update_cwd(sid)
|
||||
→ get_session(sid) → _restore(sid)
|
||||
→ _make_agent()
|
||||
→ resolve_runtime_provider("custom") 失败(line 548-561)
|
||||
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
|
||||
→ except Exception 静默吞掉(line 482-484)→ return None
|
||||
→ return None
|
||||
→ state is None → fallback: create_session()(新 sid,无历史)
|
||||
```
|
||||
|
||||
### 关键代码位置(acp_adapter/session.py)
|
||||
|
||||
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
|
||||
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
|
||||
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
|
||||
|
||||
### 实测行为
|
||||
|
||||
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
|
||||
2. Phase 2: `session/resume` + `prompt`
|
||||
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
|
||||
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
|
||||
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
|
||||
|
||||
### 验证脚本
|
||||
|
||||
```python
|
||||
# 直接调用 _restore 验证
|
||||
cd ~/.hermes/hermes-agent
|
||||
python3 -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from acp_adapter.session import SessionManager
|
||||
sm = SessionManager()
|
||||
result = sm._restore('SESSION_ID_HERE')
|
||||
print(result) # None — _make_agent 抛异常被吞掉
|
||||
"
|
||||
```
|
||||
|
||||
### 两个 bug
|
||||
|
||||
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url` 和 `api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
|
||||
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
|
||||
|
||||
### Hermes 版本
|
||||
|
||||
- v0.10.0 (2026.4.16) — 初始测试
|
||||
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
|
||||
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
|
||||
|
||||
### v0.14.0 测试结果 (2026-05-23)
|
||||
|
||||
- `_restore` 仍因 `custom` provider 解析失败返回 None
|
||||
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
|
||||
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
|
||||
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
|
||||
|
||||
### 上游 Issue
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
|
||||
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
|
||||
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Exception (must include a comment explaining why)
|
||||
|
||||
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const mod = await import(bundlePath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
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,67 +0,0 @@
|
||||
# Sync README
|
||||
|
||||
When updating README.md files in this monorepo, follow these conventions.
|
||||
|
||||
## Scope
|
||||
|
||||
- Root `README.md` — project overview and navigation hub
|
||||
- Per-package `packages/*/README.md` — each package self-contained
|
||||
|
||||
## Root README Structure
|
||||
|
||||
The root README should have these sections in order:
|
||||
|
||||
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||
3. **Architecture** — dependency layer diagram (text-based)
|
||||
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||
7. **Development** — bun install / build / check / test
|
||||
|
||||
## Per-Package README Structure
|
||||
|
||||
Each package README should have:
|
||||
|
||||
1. **Title** — package name
|
||||
2. **One-line description** — matching package.json
|
||||
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||
7. **Internal Structure** — brief src/ file organization
|
||||
8. **Configuration** (if applicable)
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Gather current state
|
||||
For each package read:
|
||||
- package.json (name, version, description, dependencies, bin)
|
||||
- src/index.ts (public API exports)
|
||||
- Existing README.md (preserve hand-written content worth keeping)
|
||||
|
||||
### Step 2: Update root README
|
||||
- Ensure ALL packages in packages/ directory are listed in the table
|
||||
- Update CLI command reference from uwf --help output
|
||||
- Keep Quick Start examples valid
|
||||
|
||||
### Step 3: Write/update each package README
|
||||
- Follow the per-package structure
|
||||
- API section MUST match actual src/index.ts exports — never invent
|
||||
- For agent packages: document CLI binary name, how it is invoked
|
||||
- For lib packages: document exported types and functions
|
||||
- Internal structure: list actual files in src/
|
||||
|
||||
### Step 4: Verify
|
||||
- All relative links work
|
||||
- Package names match package.json
|
||||
- No references to removed/renamed packages
|
||||
- bun run build still passes
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only document what src/index.ts actually exports
|
||||
- Root README summarizes, package READMEs go into detail
|
||||
- Verify CLI examples against actual commands
|
||||
- Preserve existing good prose when updating
|
||||
- English for all README content
|
||||
+64
-54
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出最后链头,退出
|
||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||
@@ -112,8 +112,8 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
**约定:**
|
||||
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
|
||||
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
|
||||
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
|
||||
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
|
||||
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
|
||||
- agent 执行实际逻辑,agent-kit 负责 extract
|
||||
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
|
||||
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
|
||||
@@ -143,7 +143,7 @@ uwf-hermes <thread-id> <role>
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
@@ -153,25 +153,16 @@ payload:
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent..."
|
||||
capabilities: [planning, issue-analysis]
|
||||
procedure: "Analyze the issue and create a plan."
|
||||
output: "Output the plan summary."
|
||||
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
systemPrompt: "You are a planning agent..."
|
||||
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent..."
|
||||
capabilities: [file-edit, shell]
|
||||
procedure: "Implement the plan."
|
||||
output: "List all files changed."
|
||||
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
systemPrompt: "You are a developer agent..."
|
||||
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer..."
|
||||
capabilities: [code-review]
|
||||
procedure: "Review the implementation."
|
||||
output: "Approve or reject with comments."
|
||||
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
systemPrompt: "You are a code reviewer..."
|
||||
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
|
||||
conditions:
|
||||
needsClarification:
|
||||
description: "Planner requests clarification from user"
|
||||
@@ -198,22 +189,30 @@ payload:
|
||||
condition: null
|
||||
```
|
||||
|
||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||
|
||||
Moderator 的求值逻辑:
|
||||
JSONata 表达式的求值上下文:
|
||||
|
||||
```typescript
|
||||
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
||||
// 2. target = graph[lastRole][status]
|
||||
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
||||
```jsonc
|
||||
{
|
||||
"start": { // StartNode 信息
|
||||
"workflow": "4KNM2PXR3B1QW",
|
||||
"prompt": "Fix the login bug..."
|
||||
},
|
||||
"steps": [ // 所有已完成 steps,从旧到新
|
||||
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
|
||||
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
|
||||
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
@@ -235,14 +234,14 @@ payload:
|
||||
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
|
||||
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
|
||||
role: "developer"
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
|
||||
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
|
||||
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
|
||||
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
|
||||
```
|
||||
|
||||
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
|
||||
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
|
||||
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
|
||||
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
|
||||
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
|
||||
- `agent` — 纯字符串,不是 CAS 节点
|
||||
|
||||
@@ -341,11 +340,12 @@ OPENROUTER_API_KEY=sk-or-...
|
||||
|
||||
```
|
||||
packages/
|
||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
||||
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
|
||||
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
|
||||
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
|
||||
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
|
||||
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
|
||||
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
|
||||
```
|
||||
|
||||
**外部依赖:**
|
||||
@@ -358,7 +358,7 @@ packages/
|
||||
|
||||
## 4. 关键数据类型
|
||||
|
||||
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||
|
||||
### 4.1 公共类型
|
||||
|
||||
@@ -369,10 +369,10 @@ type CasRef = string;
|
||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||
type ThreadId = string;
|
||||
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
||||
type StepRecord = {
|
||||
role: string;
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
|
||||
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
|
||||
agent: string; // 实际使用的 agent 命令(纯字符串)
|
||||
};
|
||||
@@ -383,23 +383,26 @@ type StepRecord = {
|
||||
```typescript
|
||||
type RoleDefinition = {
|
||||
description: string;
|
||||
goal: string;
|
||||
capabilities: string[];
|
||||
procedure: string;
|
||||
output: string;
|
||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
systemPrompt: string;
|
||||
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||
};
|
||||
|
||||
type Target = {
|
||||
type Transition = {
|
||||
role: string; // 目标 role 名 或 "$END"
|
||||
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
||||
};
|
||||
|
||||
type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string; // JSONata expression
|
||||
};
|
||||
|
||||
type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
||||
};
|
||||
```
|
||||
|
||||
@@ -417,14 +420,20 @@ type StepNodePayload = StepRecord & {
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 Moderator 求值
|
||||
### 4.4 JSONata 求值上下文
|
||||
|
||||
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
||||
|
||||
```typescript
|
||||
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||
// $START 角色使用 "_" 作为初始 status
|
||||
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||
/** JSONata 上下文中的 step — output 被展开 */
|
||||
type StepContext = Omit<StepRecord, "output"> & {
|
||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
||||
};
|
||||
|
||||
type ModeratorContext = {
|
||||
start: StartNodePayload;
|
||||
steps: StepContext[]; // 从旧到新
|
||||
};
|
||||
```
|
||||
|
||||
### 4.5 CLI 输出
|
||||
@@ -513,5 +522,6 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
|
||||
│
|
||||
└── start.workflow → WorkflowPayload
|
||||
├── roles: Record<name, RoleDefinition>
|
||||
└── graph: Record<role, Record<status, Target>>
|
||||
├── conditions: Record<name, JSONata>
|
||||
└── graph: Record<role, Transition[]>
|
||||
```
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
name: "analyze-topic"
|
||||
description: "Single-role topic analysis using four-phase role description"
|
||||
roles:
|
||||
analyst:
|
||||
description: "Analyzes a given topic and produces a structured summary"
|
||||
goal: |
|
||||
You are a research analyst with expertise in breaking down complex topics
|
||||
into clear, structured summaries. You think critically and cite key points.
|
||||
capabilities:
|
||||
- research
|
||||
- critical-thinking
|
||||
- structured-writing
|
||||
procedure: |
|
||||
Analyze the topic by:
|
||||
1. Identifying the main thesis or question
|
||||
2. Listing 3-5 key points with brief explanations
|
||||
3. Noting any counterarguments or caveats
|
||||
Keep your analysis concise (under 500 words).
|
||||
output: |
|
||||
Provide your analysis as markdown under the frontmatter.
|
||||
The frontmatter must include your structured findings.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [$status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
@@ -1,62 +0,0 @@
|
||||
name: "debate"
|
||||
description: "Structured debate between two sides. Tests cross-process session resume."
|
||||
roles:
|
||||
against:
|
||||
description: "Argues against the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing AGAINST the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. If this is the opening, present your strongest argument against the proposition.
|
||||
2. If responding to the other side, directly counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
You are a skilled debater arguing FOR the proposition.
|
||||
Be logical, cite evidence, and directly address your opponent's points.
|
||||
Keep each argument concise (under 200 words).
|
||||
capabilities:
|
||||
- argumentation
|
||||
- critical-thinking
|
||||
procedure: |
|
||||
1. Read the opposing side's latest argument carefully.
|
||||
2. Counter their points with evidence and logic.
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
required: [$status, argument]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
@@ -1,92 +0,0 @@
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
description: "Creates implementation plan"
|
||||
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
|
||||
capabilities:
|
||||
- issue-analysis
|
||||
- planning
|
||||
- file-read
|
||||
- shell
|
||||
procedure: |
|
||||
1. Locate the code repository:
|
||||
- Check if the current working directory is the repo (look for package.json, .git, etc.)
|
||||
- If the task mentions a repo URL, clone it first.
|
||||
- If this is a new project, create the repo and note the path.
|
||||
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
|
||||
3. Identify which files need changes and what the changes should be, with specific code references.
|
||||
4. Output the plan with:
|
||||
- `repoPath`: absolute path to the repository root
|
||||
- `plan`: detailed implementation plan with file paths and code references
|
||||
- `steps`: concrete action items for the developer
|
||||
output: |
|
||||
Provide repoPath, plan summary, and steps in the frontmatter.
|
||||
The plan MUST reference actual file paths and code structures you found by reading the source.
|
||||
Do NOT guess — if you haven't read a file, read it before referencing it.
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [$status, repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
capabilities:
|
||||
- file-edit
|
||||
- shell
|
||||
- testing
|
||||
procedure: |
|
||||
1. Read the planner's output to get the repoPath and implementation plan.
|
||||
2. cd to the repoPath before making any changes.
|
||||
3. Create a feature branch from the default branch.
|
||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
||||
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
|
||||
6. Commit your changes with a descriptive message referencing the issue.
|
||||
output: "List all files changed and provide a summary of the implementation."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [$status, filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
capabilities:
|
||||
- code-review
|
||||
- static-analysis
|
||||
procedure: |
|
||||
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
|
||||
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
|
||||
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
|
||||
output: "Approve or reject with detailed comments explaining your decision."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
$status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [$status, comments]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
planner:
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
developer:
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
reviewer:
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
@@ -1,76 +0,0 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { runCli } from "./cli-dispatch.js";
|
||||
import { resolveWorkflowStorageRoot } from "./storage-env.js";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const storageRoot = resolveWorkflowStorageRoot();
|
||||
const code = await runCli(storageRoot, argv);
|
||||
process.exit(code);
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-execute" },
|
||||
{ "path": "../workflow-register" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createDocxDiffAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.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,118 +0,0 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { err, ok } 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,33 +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,28 +0,0 @@
|
||||
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 type * as z from "zod/v4";
|
||||
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,46 +0,0 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import { spawnCli } 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,34 +0,0 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
|
||||
|
||||
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-hermes @uncaged/workflow-runtime @uncaged/workflow-util-agent
|
||||
```
|
||||
|
||||
In this monorepo: use `workspace:*` for `@uncaged/workflow-agent-hermes`, `@uncaged/workflow-runtime`, and `@uncaged/workflow-util-agent`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
|
||||
const agent = createHermesAgent({
|
||||
model: "your-model", // or null to omit --model
|
||||
timeout: 600_000, // ms, or null for no timeout
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
|
||||
| `HermesAgentConfig` | `model`, `timeout` |
|
||||
| `validateHermesAgentConfig` | Config validation result |
|
||||
|
||||
Requires `hermes` on `PATH` at runtime.
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("hermes: timeout");
|
||||
}
|
||||
if (error.kind === "spawn_failed") {
|
||||
throw new Error(`hermes: ${error.message}`);
|
||||
}
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx, { prompt }) => {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
fullPrompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(HERMES_DEFAULT_MAX_TURNS),
|
||||
"--quiet",
|
||||
];
|
||||
if (config.model !== null) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: null,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
return { prompt };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export type HermesAgentConfig = {
|
||||
/** Absolute path to the hermes CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createOfficeAgent } from "../src/agent.js";
|
||||
import { packageDescriptor } from "../src/package-descriptor.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,135 +0,0 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { err, ok } 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,56 +0,0 @@
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
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,64 +0,0 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import { spawnCli } 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,24 +0,0 @@
|
||||
# @uncaged/workflow-dashboard
|
||||
|
||||
Web dashboard for the Uncaged Workflow engine. Connects to the local
|
||||
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the local API server (in another terminal)
|
||||
uncaged-workflow serve
|
||||
|
||||
# Start the dashboard dev server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output goes to `dist/` — static files ready for CF Pages or any host.
|
||||
@@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Dashboard</title>
|
||||
<script>
|
||||
(() => {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.1",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.11"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Outlet, useParams } from "react-router";
|
||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
import { useTheme } from "./hooks/use-theme.tsx";
|
||||
|
||||
export function Layout() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { client } = useParams();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
if (!authed) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success: "border border-success text-success hover:bg-success/10",
|
||||
warning: "border border-warning text-warning hover:bg-warning/10",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -1,104 +0,0 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,126 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius: 0.625rem;
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-success: hsl(var(--success));
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-sidebar: hsl(var(--sidebar));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--success: 160 60% 40%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 0 0% 98%;
|
||||
--sidebar-foreground: 240 3.8% 46.1%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 6% 6.5%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 6% 6.5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--success: 160 60% 45%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 240 6% 6.5%;
|
||||
--sidebar-foreground: 240 5% 64.9%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes wf-node-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-node-pulse {
|
||||
animation: wf-node-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wf-record-card-highlight {
|
||||
0% {
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
35% {
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
100% {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
.wf-record-card-highlight {
|
||||
animation: wf-record-card-highlight 1.5s ease-out forwards;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { ThemeProvider } from "./hooks/use-theme.tsx";
|
||||
import "./index.css";
|
||||
import { router } from "./router.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createHashRouter, redirect } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { ClientRedirect } from "./components/client-redirect.tsx";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
|
||||
export const router = createHashRouter([
|
||||
{
|
||||
path: "/login",
|
||||
Component: LoginPage,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Component: ClientRedirect,
|
||||
},
|
||||
{
|
||||
path: ":client/threads",
|
||||
Component: ThreadList,
|
||||
},
|
||||
{
|
||||
path: ":client/threads/:threadId",
|
||||
Component: ThreadDetail,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows",
|
||||
Component: WorkflowList,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows/:workflowName",
|
||||
Component: WorkflowDetail,
|
||||
},
|
||||
{
|
||||
path: ":client",
|
||||
loader: ({ params }) => redirect(`/${params.client}/threads`),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "plugins"]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:7860",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
# @uncaged/workflow-moderator
|
||||
|
||||
Status-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
||||
|
||||
## Overview
|
||||
|
||||
The moderator (Layer 1) performs a status-based map lookup on the workflow graph. Given the last role and its output, it looks up `graph[lastRole][lastOutput.status]` to find the next `Target` (role + prompt template). The prompt is rendered via Mustache with `lastOutput` as the template context. For `$START`, the unit status `_` is used.
|
||||
|
||||
**Dependencies:** `@uncaged/workflow-protocol`, `mustache`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-moderator
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Functions
|
||||
|
||||
```typescript
|
||||
function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: Record<string, unknown> & { status: string },
|
||||
): Result<EvaluateResult, Error>
|
||||
```
|
||||
|
||||
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the rendered edge instruction for the agent.
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
};
|
||||
```
|
||||
|
||||
The `Result<T, E>` type is local to this package (`{ ok: true; value: T } | { ok: false; error: E }`), not re-exported from `index.ts`.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { evaluate } from "@uncaged/workflow-moderator";
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
|
||||
const result = evaluate(graph, lastRole, lastOutput);
|
||||
if (result.ok && result.value.role !== "$END") {
|
||||
console.log(`Next role: ${result.value.role}, prompt: ${result.value.prompt}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts Public exports
|
||||
├── evaluate.ts Status-based map lookup + Mustache prompt rendering
|
||||
└── types.ts EvaluateResult, Result
|
||||
```
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
},
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
});
|
||||
});
|
||||
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
$status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
$status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
});
|
||||
});
|
||||
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing $status defaults to _ (unit routing)", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
$status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-moderator",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mustache": "^4.2.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||
"directory": "legacy-packages/workflow-moderator"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
type LastOutput = Record<string, unknown>;
|
||||
|
||||
const STATUS_KEY = "$status";
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status =
|
||||
lastRole === START_ROLE
|
||||
? UNIT_STATUS
|
||||
: typeof lastOutput[STATUS_KEY] === "string"
|
||||
? (lastOutput[STATUS_KEY] as string)
|
||||
: UNIT_STATUS;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { evaluate } from "./evaluate.js";
|
||||
export type { EvaluateResult } from "./types.js";
|
||||
@@ -1,7 +0,0 @@
|
||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
/** The result of moderator evaluation — which role to go to, and the edge prompt. */
|
||||
export type EvaluateResult = {
|
||||
role: string;
|
||||
prompt: string;
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
# @uncaged/workflow-protocol
|
||||
|
||||
Shared workflow types, sentinel constants, and `Result` helpers.
|
||||
|
||||
## What This Package Does
|
||||
|
||||
It defines the cross-package contract for bundles and the engine: thread/step shapes, `WorkflowFn`, agent/extract contexts, descriptor types, and `CasStore` as an interface. Implementations (CAS store, CLI, extract) depend on these types so bundles stay decoupled from Node APIs.
|
||||
|
||||
## Key Exports
|
||||
|
||||
From `src/index.ts`:
|
||||
|
||||
- **Types:** `Result`, `CasStore`, `WorkflowRoleSchema`, `WorkflowRoleDescriptor`, `WorkflowDescriptor`, `RoleMeta`, `RoleOutput`, `StartStep`, `RoleStep`, `ThreadContext`, `ModeratorContext`, `AgentContext`, `ExtractContext`, `WorkflowCompletion`, `WorkflowResult`, `LlmProvider`, `ProviderConfig`, `ResolvedModel`, `WorkflowConfig`, `ExtractFn`, `AgentFn`, `AgentBinding`, `WorkflowRuntime`, `WorkflowFn`, `RoleDefinition`, `Moderator`, `WorkflowDefinition`, `AdvanceOutcome`
|
||||
- **Constants:** `START`, `END`
|
||||
- **Functions:** `ok`, `err`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Peer:** `zod` ^4 — used in type positions for schemas (`ExtractFn`, `RoleDefinition`, etc.)
|
||||
|
||||
No workspace packages; this is the bottom layer.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { END, START, type WorkflowFn, type ThreadContext } from "@uncaged/workflow-protocol";
|
||||
```
|
||||
|
||||
Concrete `WorkflowFn` implementations are built with `@uncaged/workflow-runtime` (`createWorkflow`).
|
||||
@@ -1,56 +0,0 @@
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
ContentMerkleNode,
|
||||
StartNode,
|
||||
StartNodePayload,
|
||||
StateNode,
|
||||
StateNodePayload,
|
||||
} from "./cas-types.js";
|
||||
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowConfig,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export { END, START } from "./types.js";
|
||||
|
||||
// ── Constructor functions ──────────────────────────────────────────
|
||||
|
||||
export { err, ok } from "./result.js";
|
||||
@@ -1,219 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export const START = "__start__" as const;
|
||||
export const END = "__end__" as const;
|
||||
|
||||
// ── Result ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
// ── CAS ────────────────────────────────────────────────────────────
|
||||
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
|
||||
// ── Workflow Descriptor ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Serializable routing edges derived from a moderator transition table. */
|
||||
export type WorkflowGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph;
|
||||
};
|
||||
|
||||
// ── Role & Thread ──────────────────────────────────────────────────
|
||||
|
||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: {
|
||||
role: K;
|
||||
meta: M[K];
|
||||
contentHash: string;
|
||||
content: string | null;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
}[keyof M & string];
|
||||
|
||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
depth: number;
|
||||
bundleHash: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
|
||||
|
||||
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
|
||||
currentRole: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Workflow Completion ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowCompletion = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type WorkflowResult = WorkflowCompletion & {
|
||||
rootHash: string;
|
||||
};
|
||||
|
||||
// ── LLM Provider ───────────────────────────────────────────────────
|
||||
|
||||
export type LlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedModel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
// ── Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
|
||||
export type ExtractResult<T extends Record<string, unknown>> = {
|
||||
meta: T;
|
||||
contentPayload: string;
|
||||
refs: string[];
|
||||
};
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
// ── Adapter (replaces Agent) ────────────────────────────────────────
|
||||
|
||||
export type RoleResult<T> = { meta: T; childThread: string | null };
|
||||
|
||||
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
||||
|
||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options (required second argument).
|
||||
*/
|
||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
|
||||
// ── Workflow Runtime & Definition ──────────────────────────────────
|
||||
|
||||
export type WorkflowRuntime = {
|
||||
cas: CasStore;
|
||||
extract: ExtractFn;
|
||||
};
|
||||
|
||||
export type WorkflowFn = (
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
ctx: ModeratorContext<M>,
|
||||
) => (keyof M & string) | typeof END;
|
||||
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
table: ModeratorTable<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
export type ModeratorCondition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
description: string;
|
||||
check: (ctx: ModeratorContext<M>) => boolean;
|
||||
};
|
||||
|
||||
export type FALLBACK = "FALLBACK";
|
||||
|
||||
export type ModeratorTransition<M extends RoleMeta> = {
|
||||
condition: ModeratorCondition<M> | FALLBACK;
|
||||
role: (keyof M & string) | typeof END;
|
||||
};
|
||||
|
||||
export type ModeratorTable<M extends RoleMeta> = Record<
|
||||
(keyof M & string) | typeof START,
|
||||
ModeratorTransition<M>[]
|
||||
>;
|
||||
|
||||
// ── Advance Outcome ────────────────────────────────────────────────
|
||||
|
||||
export type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
@@ -1,93 +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,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,34 +0,0 @@
|
||||
# @uncaged/workflow-util-agent
|
||||
|
||||
Shared helpers for CLI-backed workflow agents: assemble prompts from thread context and spawn subprocesses with timeouts.
|
||||
|
||||
Used by `@uncaged/workflow-agent-cursor` and `@uncaged/workflow-agent-hermes`. Depends on `@uncaged/workflow` for CAS reads (`getContentMerklePayload`) and `Result` typing.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-util-agent @uncaged/workflow
|
||||
```
|
||||
|
||||
In this monorepo: `workspace:*` for both packages.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { buildAgentPrompt, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
|
||||
const prompt = await buildAgentPrompt(agentContext);
|
||||
const result = await spawnCli("my-cli", ["--json"], { cwd: "/tmp", timeoutMs: 60_000 });
|
||||
if (!result.ok) { /* handle SpawnCliError */ }
|
||||
const stdout = result.value;
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `buildAgentPrompt(ctx)` | System prompt + task + prior step summaries + latest body from CAS; appends `uncaged-workflow thread <id>` tool hint |
|
||||
| `spawnCli(cmd, args, { cwd, timeoutMs })` | `Promise<Result<string, SpawnCliError>>`; captures stdout, non-zero exit and spawn failures as `err` |
|
||||
| `SpawnCliConfig` | `cwd: string \| null`, `timeoutMs: number \| null` |
|
||||
| `SpawnCliError` | `non_zero_exit` \| `timeout` \| `spawn_failed` |
|
||||
| `SpawnCliResult` | Alias for `Result<string, SpawnCliError>` |
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||
|
||||
describe("buildOutputFormatInstruction", () => {
|
||||
test("always includes the frontmatter example block", () => {
|
||||
const schema = z.object({ status: z.string() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("## Deliverable Format");
|
||||
expect(result).toContain("status:");
|
||||
expect(result).toContain("confidence:");
|
||||
expect(result).toContain("artifacts:");
|
||||
expect(result).toContain("scope:");
|
||||
});
|
||||
|
||||
test("always includes scope reminder", () => {
|
||||
const schema = z.object({ status: z.string() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("Focus exclusively on YOUR role's deliverable");
|
||||
expect(result).toContain("Do not perform actions outside your role's scope");
|
||||
});
|
||||
|
||||
test("lists fields from a flat ZodObject schema", () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
phases: z.array(z.string()),
|
||||
reason: z.union([z.string(), z.null()]),
|
||||
});
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`title`");
|
||||
expect(result).toContain("`phases`");
|
||||
expect(result).toContain("`reason`");
|
||||
});
|
||||
|
||||
test("lists union of fields from a discriminated union schema", () => {
|
||||
const schema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("planned"), phases: z.array(z.string()) }),
|
||||
z.object({ status: z.literal("aborted"), reason: z.string() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`status`");
|
||||
expect(result).toContain("`phases`");
|
||||
expect(result).toContain("`reason`");
|
||||
});
|
||||
|
||||
test("lists fields from a plain ZodUnion schema", () => {
|
||||
const schema = z.union([
|
||||
z.object({ kind: z.literal("a"), valueA: z.string() }),
|
||||
z.object({ kind: z.literal("b"), valueB: z.number() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("`kind`");
|
||||
expect(result).toContain("`valueA`");
|
||||
expect(result).toContain("`valueB`");
|
||||
});
|
||||
|
||||
test("falls back gracefully for a non-object schema (no field list crash)", () => {
|
||||
const schema = z.string();
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("## Deliverable Format");
|
||||
expect(result).toContain("schema fields will be extracted automatically");
|
||||
});
|
||||
|
||||
test("marks frontmatter as the primary deliverable", () => {
|
||||
const schema = z.object({ done: z.boolean() });
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
expect(result).toContain("primary deliverable");
|
||||
});
|
||||
|
||||
test("no field is listed more than once for a union with overlapping keys", () => {
|
||||
const schema = z.union([
|
||||
z.object({ status: z.literal("a"), shared: z.string() }),
|
||||
z.object({ status: z.literal("b"), shared: z.string() }),
|
||||
]);
|
||||
const result = buildOutputFormatInstruction(schema);
|
||||
const matches = [...result.matchAll(/`shared`/g)];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mock = vi.fn;
|
||||
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createAgentAdapter } from "../src/index.js";
|
||||
|
||||
// ── Minimal test fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: "START" as const,
|
||||
content: "test task",
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCas(): CasStore & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
let seq = 0;
|
||||
return {
|
||||
store,
|
||||
async put(content: string) {
|
||||
const hash = `HASH${String(++seq).padStart(9, "0")}`;
|
||||
store.set(hash, content);
|
||||
return hash;
|
||||
},
|
||||
async get(hash: string) {
|
||||
return store.get(hash) ?? null;
|
||||
},
|
||||
async delete(hash: string) {
|
||||
store.delete(hash);
|
||||
},
|
||||
async list() {
|
||||
return [...store.keys()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Frontmatter-compatible schema ─────────────────────────────────────────────
|
||||
|
||||
// Schema that maps directly to AgentFrontmatter fields so happy path works.
|
||||
const FrontmatterSchema = z.object({
|
||||
status: z.union([
|
||||
z.literal("done"),
|
||||
z.literal("needs_input"),
|
||||
z.literal("in_progress"),
|
||||
z.literal("failed"),
|
||||
z.null(),
|
||||
]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
confidence: z.union([z.number(), z.null()]),
|
||||
artifacts: z.array(z.string()),
|
||||
scope: z.union([z.literal("role"), z.literal("thread")]),
|
||||
});
|
||||
|
||||
type FrontmatterMeta = z.infer<typeof FrontmatterSchema>;
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createAgentAdapter — happy path (valid frontmatter satisfies schema)", () => {
|
||||
test("returns meta from frontmatter without calling runtime.extract", async () => {
|
||||
const cas = makeCas();
|
||||
const extractMock = mock(async () => {
|
||||
throw new Error("runtime.extract must not be called in happy path");
|
||||
});
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractMock as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = [
|
||||
"---",
|
||||
"status: done",
|
||||
"next: reviewer",
|
||||
"confidence: 0.9",
|
||||
"artifacts: [src/foo.ts]",
|
||||
"scope: role",
|
||||
"---",
|
||||
"",
|
||||
"## Summary",
|
||||
"Work is complete.",
|
||||
].join("\n");
|
||||
|
||||
const agentFn = mock(async (_ctx: ThreadContext, _opts: null) => rawOutput);
|
||||
const extractOpts = mock(async () => null);
|
||||
|
||||
const adapter = createAgentAdapter<null>(agentFn, extractOpts);
|
||||
const roleFn = adapter<FrontmatterMeta>("test prompt", FrontmatterSchema);
|
||||
const result = await roleFn(makeCtx(), runtime);
|
||||
|
||||
// Meta must come from frontmatter
|
||||
expect(result.meta.status).toBe("done");
|
||||
expect(result.meta.next).toBe("reviewer");
|
||||
expect(result.meta.confidence).toBe(0.9);
|
||||
expect(result.meta.artifacts).toEqual(["src/foo.ts"]);
|
||||
expect(result.meta.scope).toBe("role");
|
||||
expect(result.childThread).toBeNull();
|
||||
|
||||
// LLM extract must NOT have been called
|
||||
expect(extractMock).not.toHaveBeenCalled();
|
||||
|
||||
// CAS should store the body (without frontmatter) as the CAS node payload
|
||||
const storedContent = [...cas.store.values()][0] ?? "";
|
||||
expect(storedContent).toContain("## Summary");
|
||||
expect(storedContent).toContain("Work is complete.");
|
||||
// The frontmatter block itself must not appear in the stored payload
|
||||
expect(storedContent).not.toContain("status: done\n");
|
||||
});
|
||||
|
||||
test("body stored in CAS does not include the frontmatter block", async () => {
|
||||
const cas = makeCas();
|
||||
const runtime: WorkflowRuntime = {
|
||||
cas,
|
||||
extract: mock(async () => {
|
||||
throw new Error("must not be called");
|
||||
}) as WorkflowRuntime["extract"],
|
||||
};
|
||||
|
||||
const rawOutput =
|
||||
"---\nstatus: done\nnext: null\nconfidence: null\nscope: role\n---\n\nThe actual work content here.";
|
||||
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
// CAS node wraps content as `payload: <body>`; check the payload contains only body
|
||||
const stored = [...cas.store.values()][0] ?? "";
|
||||
expect(stored).toContain("The actual work content here.");
|
||||
// The frontmatter block must be stripped
|
||||
expect(stored).not.toContain("status: done");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Fallback path ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createAgentAdapter — fallback path (no frontmatter)", () => {
|
||||
test("calls runtime.extract when output has no frontmatter block", async () => {
|
||||
const cas = makeCas();
|
||||
const expectedMeta: FrontmatterMeta = {
|
||||
status: "done",
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
};
|
||||
|
||||
const extractFn = mock(async (_schema: unknown, _hash: string) => ({
|
||||
meta: expectedMeta as Record<string, unknown>,
|
||||
contentPayload: "plain text output",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = "This is plain markdown without any frontmatter.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
const result = await roleFn(makeCtx(), runtime);
|
||||
|
||||
// runtime.extract must have been called once
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta).toEqual(expectedMeta);
|
||||
expect(result.childThread).toBeNull();
|
||||
|
||||
// CAS should store the full raw output (as CAS node payload)
|
||||
const stored = [...cas.store.values()][0] ?? "";
|
||||
expect(stored).toContain(rawOutput);
|
||||
});
|
||||
|
||||
test("falls back to runtime.extract when frontmatter is structurally invalid", async () => {
|
||||
const cas = makeCas();
|
||||
const expectedMeta: FrontmatterMeta = {
|
||||
status: null,
|
||||
next: null,
|
||||
confidence: null,
|
||||
artifacts: [],
|
||||
scope: "role",
|
||||
};
|
||||
const extractFn = mock(async () => ({
|
||||
meta: expectedMeta as Record<string, unknown>,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
// confidence out of range — validateFrontmatter will reject
|
||||
const rawOutput = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<FrontmatterMeta>("prompt", FrontmatterSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("falls back when frontmatter fields do not satisfy schema", async () => {
|
||||
const cas = makeCas();
|
||||
|
||||
// Schema requires a mandatory non-null string field that frontmatter cannot provide
|
||||
const StrictSchema = z.object({
|
||||
requiredField: z.string(),
|
||||
});
|
||||
|
||||
const extractFn = mock(async () => ({
|
||||
meta: { requiredField: "from-llm" } as Record<string, unknown>,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}));
|
||||
const runtime: WorkflowRuntime = { cas, extract: extractFn as WorkflowRuntime["extract"] };
|
||||
|
||||
const rawOutput = "---\nstatus: done\nscope: role\n---\n\nBody.";
|
||||
const adapter = createAgentAdapter<null>(
|
||||
mock(async () => rawOutput),
|
||||
mock(async () => null),
|
||||
);
|
||||
const roleFn = adapter<{ requiredField: string }>("prompt", StrictSchema);
|
||||
await roleFn(makeCtx(), runtime);
|
||||
|
||||
// frontmatter has no `requiredField`, so schema parse fails → fallback
|
||||
expect(extractFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-util-agent",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"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-cas": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
/**
|
||||
* Extract the top-level field names from a Zod schema.
|
||||
*
|
||||
* Handles:
|
||||
* - ZodObject → its `.shape` keys
|
||||
* - ZodDiscriminatedUnion / ZodUnion → union of all variant shapes
|
||||
*
|
||||
* Returns an empty array for schemas that have no inspectable shape
|
||||
* (e.g. primitives, ZodAny).
|
||||
*/
|
||||
function extractSchemaFields(schema: ZodSchema): string[] {
|
||||
const def = schema.def as {
|
||||
type: string;
|
||||
shape?: Record<string, ZodSchema>;
|
||||
options?: ZodSchema[];
|
||||
};
|
||||
|
||||
if (def.type === "object" && def.shape !== undefined) {
|
||||
return Object.keys(def.shape);
|
||||
}
|
||||
|
||||
if ((def.type === "discriminated_union" || def.type === "union") && Array.isArray(def.options)) {
|
||||
const fieldSet = new Set<string>();
|
||||
for (const option of def.options) {
|
||||
for (const field of extractSchemaFields(option as ZodSchema)) {
|
||||
fieldSet.add(field);
|
||||
}
|
||||
}
|
||||
return [...fieldSet];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise output format instruction block for an agent role.
|
||||
*
|
||||
* The instruction describes the expected frontmatter markdown format and lists
|
||||
* the meta fields derived from `schema`. It is injected at the top of the
|
||||
* system prompt so the deliverable format is the first thing the agent sees.
|
||||
*
|
||||
* Focus on YOUR role's deliverable. Do not perform actions outside your role's scope.
|
||||
*/
|
||||
export function buildOutputFormatInstruction(schema: ZodSchema): string {
|
||||
const fields = extractSchemaFields(schema);
|
||||
|
||||
const fieldList =
|
||||
fields.length > 0
|
||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
||||
: " (schema fields will be extracted automatically)";
|
||||
|
||||
return `## Deliverable Format
|
||||
|
||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||
|
||||
\`\`\`
|
||||
---
|
||||
status: done # done | needs_input | in_progress | failed
|
||||
next: <role-name> # suggested next role, or omit
|
||||
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
|
||||
artifacts: # list of file paths or CAS hashes you produced
|
||||
- path/to/file.ts
|
||||
scope: role # role | thread
|
||||
---
|
||||
|
||||
... your markdown work here ...
|
||||
\`\`\`
|
||||
|
||||
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||
Your meta output must satisfy these fields:
|
||||
|
||||
${fieldList}
|
||||
|
||||
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
AgentFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
parseFrontmatterMarkdown,
|
||||
validateFrontmatter,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
export type ExtractOptionsFn<Opt> = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
runtime: WorkflowRuntime,
|
||||
) => Promise<Opt>;
|
||||
|
||||
/**
|
||||
* Try to satisfy `schema` from frontmatter fields alone.
|
||||
*
|
||||
* Returns the parsed value on success, or `null` when the frontmatter does not
|
||||
* cover all required fields of the schema. Never throws.
|
||||
*/
|
||||
function tryFrontmatterMeta<T>(
|
||||
raw: string,
|
||||
schema: z.ZodType<T>,
|
||||
): { meta: T; body: string } | null {
|
||||
const { frontmatter, body } = parseFrontmatterMarkdown(raw);
|
||||
|
||||
if (frontmatter === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationErrors = validateFrontmatter(frontmatter);
|
||||
if (validationErrors.length > 0) {
|
||||
log(
|
||||
"4KNMR2PX",
|
||||
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Coerce frontmatter into the plain object shape the schema expects.
|
||||
const candidate: Record<string, unknown> = {
|
||||
status: frontmatter.status,
|
||||
next: frontmatter.next,
|
||||
confidence: frontmatter.confidence,
|
||||
artifacts: frontmatter.artifacts,
|
||||
scope: frontmatter.scope,
|
||||
};
|
||||
|
||||
const result = schema.safeParse(candidate);
|
||||
if (!result.success) {
|
||||
log("7BQST3VW", "frontmatter does not satisfy schema; falling back to extract");
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meta: result.data, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
||||
*
|
||||
* Happy path (zero LLM cost):
|
||||
* 1. extract(ctx, prompt, runtime) → Opt
|
||||
* 2. agent(ctx, options) → raw string
|
||||
* 3. Parse raw as frontmatter markdown
|
||||
* 4. If frontmatter is valid AND satisfies `schema` → use as meta directly
|
||||
* CAS stores the body (without frontmatter block)
|
||||
*
|
||||
* Fallback (safety net):
|
||||
* 4b. Store full raw in CAS
|
||||
* 5b. runtime.extract(schema, contentHash) → typed meta via LLM
|
||||
*/
|
||||
export function createAgentAdapter<Opt>(
|
||||
agent: AgentFn<Opt>,
|
||||
extract: ExtractOptionsFn<Opt>,
|
||||
): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`;
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, augmentedPrompt, runtime);
|
||||
const raw = await agent(ctx, options);
|
||||
|
||||
const frontmatterResult = tryFrontmatterMeta(raw, schema);
|
||||
|
||||
if (frontmatterResult !== null) {
|
||||
log("3VXPW8QR", "frontmatter satisfied schema — skipping LLM extract");
|
||||
await putContentNodeWithRefs(runtime.cas, frontmatterResult.body, []);
|
||||
return { meta: frontmatterResult.meta, childThread: null };
|
||||
}
|
||||
|
||||
log("8MTNJ5YK", "no valid frontmatter — falling back to runtime.extract");
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
export { createAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliError } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-cas" }]
|
||||
}
|
||||
+3
-16
@@ -5,33 +5,20 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"uwf": "bun packages/cli-workflow/src/cli.ts",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter './packages/*' test",
|
||||
"test:ci": "bun run --filter './packages/*' test:ci",
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
||||
"bun-types": "^1.3.13"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow.git"
|
||||
},
|
||||
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-uwf",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"commander": "^14.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
} from "./commands/thread.js";
|
||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import {
|
||||
cmdCasCat,
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasRefs,
|
||||
cmdCasSchemaGet,
|
||||
cmdCasSchemaList,
|
||||
cmdCasWalk,
|
||||
} from "./commands/cas.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;
|
||||
process.stdout.write(`${formatOutput(data, fmt)}\n`);
|
||||
}
|
||||
|
||||
function runAction(action: () => Promise<void>): void {
|
||||
action().catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("uwf").description("Stateless workflow CLI");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
|
||||
workflow
|
||||
.command("put")
|
||||
.description("Register a workflow from YAML")
|
||||
.argument("<file>", "Workflow YAML file")
|
||||
.action((file: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowPut(storageRoot, file);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("show")
|
||||
.description("Show a workflow by name or CAS hash")
|
||||
.argument("<id>", "Workflow name or hash")
|
||||
.action((id: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowShow(storageRoot, id);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
workflow
|
||||
.command("list")
|
||||
.description("List registered workflows")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdWorkflowList(storageRoot);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
const thread = program.command("thread").description("Thread lifecycle and execution");
|
||||
|
||||
thread
|
||||
.command("start")
|
||||
.description("Create a thread without executing")
|
||||
.argument("<workflow>", "Workflow name or hash")
|
||||
.requiredOption("-p, --prompt <text>", "User prompt")
|
||||
.action((workflow: string, opts: { prompt: string }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("step")
|
||||
.description("Execute one step")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--agent <cmd>", "Override agent command")
|
||||
.action((threadId: string, opts: { agent: string | undefined }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const agentOverride = opts.agent ?? null;
|
||||
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("show")
|
||||
.description("Show thread head pointer")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadShow(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("list")
|
||||
.description("List active threads")
|
||||
.option("--all", "Include archived threads")
|
||||
.action((opts: { all: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadList(storageRoot, opts.all);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("kill")
|
||||
.description("Terminate and archive a thread")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadKill(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
.option("--provider <name>", "Provider name")
|
||||
.option("--base-url <url>", "OpenAI-compatible API base URL")
|
||||
.option("--api-key <key>", "API key")
|
||||
.option("--model <name>", "Default model name")
|
||||
.option("--agent <name>", "Default agent alias")
|
||||
.action((opts: {
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
agent?: string;
|
||||
}) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
|
||||
const result = await cmdSetup({
|
||||
provider: opts.provider,
|
||||
baseUrl: opts.baseUrl,
|
||||
apiKey: opts.apiKey,
|
||||
model: opts.model,
|
||||
agent: opts.agent ?? 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 as JSON")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("put")
|
||||
.description("Store a node, print its hash")
|
||||
.argument("<type-hash>", "Type (schema) hash")
|
||||
.argument("<data>", "JSON file path or inline JSON string")
|
||||
.action((typeHash: string, data: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("has")
|
||||
.description("Check if a hash exists")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasHas(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("refs")
|
||||
.description("List direct CAS references from a node")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasRefs(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("walk")
|
||||
.description("Recursive traversal from a node")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasWalk(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
const casSchema = cas.command("schema").description("CAS schema operations");
|
||||
|
||||
casSchema
|
||||
.command("list")
|
||||
.description("List all registered schemas")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasSchemaList(storageRoot));
|
||||
});
|
||||
});
|
||||
|
||||
casSchema
|
||||
.command("get")
|
||||
.description("Show a schema by its type hash")
|
||||
.argument("<hash>", "Schema type hash")
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, putSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
|
||||
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
|
||||
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||
|
||||
import { TEXT_SCHEMA } from "../schemas.js";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function openStore(storageRoot: string): Store {
|
||||
@@ -30,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(
|
||||
@@ -55,12 +61,18 @@ export async function cmdCasPut(
|
||||
return { hash };
|
||||
}
|
||||
|
||||
export async function cmdCasHas(storageRoot: string, hash: string): Promise<{ exists: boolean }> {
|
||||
export async function cmdCasHas(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ exists: boolean }> {
|
||||
const store = openStore(storageRoot);
|
||||
return { exists: store.has(hash) };
|
||||
}
|
||||
|
||||
export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ refs: string[] }> {
|
||||
export async function cmdCasRefs(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ refs: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
@@ -69,7 +81,10 @@ export async function cmdCasRefs(storageRoot: string, hash: string): Promise<{ r
|
||||
return { refs: refs(store, node) };
|
||||
}
|
||||
|
||||
export async function cmdCasWalk(storageRoot: string, hash: string): Promise<{ hashes: string[] }> {
|
||||
export async function cmdCasWalk(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<{ hashes: string[] }> {
|
||||
const store = openStore(storageRoot);
|
||||
const result: string[] = [];
|
||||
walk(store, hash, (h) => {
|
||||
@@ -83,7 +98,9 @@ export type SchemaListEntry = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListEntry[]> {
|
||||
export async function cmdCasSchemaList(
|
||||
storageRoot: string,
|
||||
): Promise<SchemaListEntry[]> {
|
||||
const store = openStore(storageRoot);
|
||||
const metaHash = await bootstrap(store);
|
||||
const entries: SchemaListEntry[] = [];
|
||||
@@ -91,10 +108,10 @@ export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListE
|
||||
// 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) ??
|
||||
@@ -106,16 +123,10 @@ export async function cmdCasSchemaList(storageRoot: string): Promise<SchemaListE
|
||||
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): Promise<unknown> {
|
||||
export async function cmdCasSchemaGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const schema = getSchema(store, hash);
|
||||
if (schema === null) {
|
||||
@@ -123,10 +134,3 @@ export async function cmdCasSchemaGet(storageRoot: string, hash: string): Promis
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export async function cmdCasPutText(storageRoot: string, text: string): Promise<{ hash: string }> {
|
||||
const store = openStore(storageRoot);
|
||||
const typeHash = await putSchema(store, TEXT_SCHEMA);
|
||||
const hash = await store.put(typeHash, text);
|
||||
return { hash };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
|
||||
import { stringify, parse } from "yaml";
|
||||
|
||||
/**
|
||||
* Preset provider list — embedded to avoid runtime YAML loading dependency.
|
||||
* Keep in sync with providers.yaml in cli-workflow.
|
||||
*/
|
||||
const PRESET_PROVIDERS = [
|
||||
// International
|
||||
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
|
||||
// China
|
||||
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
|
||||
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
|
||||
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
|
||||
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
|
||||
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
|
||||
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
|
||||
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
|
||||
// Local
|
||||
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
|
||||
] as const;
|
||||
|
||||
type SetupArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
agent?: string | undefined;
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
function getConfigPath(root: string): string {
|
||||
return join(root, "config.yaml");
|
||||
}
|
||||
|
||||
function getEnvPath(root: string): string {
|
||||
return join(root, ".env");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing config.yaml or return empty structure.
|
||||
*/
|
||||
function loadExistingConfig(configPath: string): Record<string, unknown> {
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors, start fresh
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing .env as key=value map.
|
||||
*/
|
||||
function loadEnvFile(envPath: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq > 0) {
|
||||
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function saveEnvFile(envPath: string, env: Record<string, string>): void {
|
||||
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
||||
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function apiKeyEnvName(providerName: string): string {
|
||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||
*/
|
||||
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
|
||||
const providers = (typeof existing.providers === "object" && existing.providers !== null
|
||||
? { ...(existing.providers as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
|
||||
|
||||
const models = (typeof existing.models === "object" && existing.models !== null
|
||||
? { ...(existing.models as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
models.default = { provider: args.provider, name: args.model };
|
||||
|
||||
const agents = (typeof existing.agents === "object" && existing.agents !== null
|
||||
? { ...(existing.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
const agentName = args.agent ?? "hermes";
|
||||
if (Object.keys(agents).length === 0) {
|
||||
agents.hermes = { command: "uwf-hermes", args: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
providers,
|
||||
models,
|
||||
agents,
|
||||
defaultAgent: existing.defaultAgent ?? agentName,
|
||||
defaultModel: existing.defaultModel ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive setup. All required args provided via CLI flags.
|
||||
*/
|
||||
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
|
||||
const { storageRoot } = args;
|
||||
mkdirSync(storageRoot, { recursive: true });
|
||||
|
||||
const configPath = getConfigPath(storageRoot);
|
||||
const envPath = getEnvPath(storageRoot);
|
||||
|
||||
const existing = loadExistingConfig(configPath);
|
||||
const merged = mergeConfig(existing, args);
|
||||
|
||||
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
|
||||
|
||||
// Write API key to .env
|
||||
const envName = apiKeyEnvName(args.provider);
|
||||
const envData = loadEnvFile(envPath);
|
||||
envData[envName] = args.apiKey;
|
||||
saveEnvFile(envPath, envData);
|
||||
|
||||
return {
|
||||
configPath,
|
||||
envPath,
|
||||
provider: args.provider,
|
||||
model: args.model,
|
||||
defaultAgent: merged.defaultAgent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((resolve) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let buf = "";
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write("\n");
|
||||
resolve(buf.trim());
|
||||
return;
|
||||
}
|
||||
if (c === "\u007F" || c === "\b") {
|
||||
if (buf.length > 0) {
|
||||
buf = buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
||||
process.exit(130);
|
||||
}
|
||||
buf += c;
|
||||
process.stdout.write("*");
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { data?: { id: string }[] };
|
||||
if (!Array.isArray(body.data)) return [];
|
||||
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
|
||||
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive setup — prompts user for provider, API key, model.
|
||||
*/
|
||||
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
|
||||
const rl = createInterface({ input, output });
|
||||
|
||||
try {
|
||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||
|
||||
// 1. Provider selection
|
||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
||||
console.log("Select a provider:\n");
|
||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
||||
const p = PRESET_PROVIDERS[i];
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||
|
||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||
throw new Error(`Invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
let providerName: string;
|
||||
let baseUrl: string;
|
||||
|
||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
providerName = selected.name;
|
||||
baseUrl = selected.baseUrl;
|
||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
} else {
|
||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||
if (!providerName) throw new Error("Provider name required");
|
||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||
if (!baseUrl) throw new Error("Base URL required");
|
||||
}
|
||||
|
||||
// 2. API key
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key: ");
|
||||
if (!apiKey) throw new Error("API key required");
|
||||
|
||||
// 3. Model selection
|
||||
const rl2 = createInterface({ input, output });
|
||||
console.log("\nFetching available models...");
|
||||
const models = await fetchModels(baseUrl, apiKey);
|
||||
|
||||
let model: string;
|
||||
if (models.length > 0) {
|
||||
console.log(`\nAvailable models (${models.length}):\n`);
|
||||
const nw = String(models.length).length;
|
||||
// Multi-column layout
|
||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
||||
const termCols = process.stdout.columns || 100;
|
||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||
const rows = Math.ceil(models.length / cols);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = c * rows + r;
|
||||
if (idx >= models.length) break;
|
||||
const num = String(idx + 1).padStart(nw);
|
||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||
line += ` ${num}) ${name} `;
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
console.log(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
||||
if (!modelInput) throw new Error("Model required");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
model = models[modelNum - 1] ?? modelInput;
|
||||
} else {
|
||||
model = modelInput;
|
||||
}
|
||||
} else {
|
||||
console.log("Could not fetch models. Enter model name manually.");
|
||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||
if (!model) throw new Error("Model required");
|
||||
}
|
||||
|
||||
rl2.close();
|
||||
|
||||
console.log(` → ${providerName}/${model}\n`);
|
||||
|
||||
await cmdSetup({
|
||||
provider: providerName,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
storageRoot,
|
||||
});
|
||||
|
||||
console.log("Setup complete! Get started:\n");
|
||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||
console.log(" uwf thread step <thread-id> Execute next step");
|
||||
console.log("");
|
||||
|
||||
return null as unknown as Record<string, unknown>;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||
import { evaluate } from "@uncaged/uwf-moderator";
|
||||
import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
|
||||
import {
|
||||
appendThreadHistory,
|
||||
createUwfStore,
|
||||
findThreadInHistory,
|
||||
loadThreadHistory,
|
||||
loadThreadsIndex,
|
||||
loadWorkflowRegistry,
|
||||
resolveWorkflowHash,
|
||||
saveThreadsIndex,
|
||||
type ThreadHistoryLine,
|
||||
type UwfStore,
|
||||
} from "../store.js";
|
||||
import { isCasRef } from "../validate.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
start: StartNodePayload;
|
||||
stepsNewestFirst: StepNodePayload[];
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
export type KillOutput = {
|
||||
thread: ThreadId;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveWorkflowCasRef(
|
||||
uwf: UwfStore,
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
): Promise<CasRef> {
|
||||
const registry = await loadWorkflowRegistry(storageRoot);
|
||||
const hash = resolveWorkflowHash(registry, workflowId);
|
||||
if (!isCasRef(hash)) {
|
||||
fail(`workflow not found: ${workflowId}`);
|
||||
}
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${hash} is not a Workflow (type ${node.type})`);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
|
||||
const node = uwf.store.get(head);
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === uwf.schemas.startNode) {
|
||||
const payload = node.payload as StartNodePayload;
|
||||
return payload.workflow;
|
||||
}
|
||||
|
||||
const payload = node.payload as StepNodePayload;
|
||||
if (typeof payload.start !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(payload.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (startNode.payload as StartNodePayload).workflow;
|
||||
}
|
||||
|
||||
export async function cmdThreadStart(
|
||||
storageRoot: string,
|
||||
workflowId: string,
|
||||
prompt: string,
|
||||
): Promise<StartOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
|
||||
|
||||
const threadId = generateUlid(Date.now()) as ThreadId;
|
||||
const startPayload: StartNodePayload = {
|
||||
workflow: workflowHash,
|
||||
prompt,
|
||||
};
|
||||
|
||||
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
|
||||
const node = uwf.store.get(headHash);
|
||||
if (node === null || !validate(uwf.store, node)) {
|
||||
fail("stored StartNode failed schema validation");
|
||||
}
|
||||
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[threadId] = headHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return { workflow: workflowHash, thread: threadId };
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, activeHead);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${activeHead}`);
|
||||
}
|
||||
return {
|
||||
workflow,
|
||||
thread: threadId,
|
||||
head: activeHead,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
return {
|
||||
workflow: hist.workflow,
|
||||
thread: threadId,
|
||||
head: hist.head,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
async function threadListItemFromActive(
|
||||
uwf: UwfStore,
|
||||
threadId: ThreadId,
|
||||
head: CasRef,
|
||||
): Promise<ThreadListItem | null> {
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
return null;
|
||||
}
|
||||
return { thread: threadId, workflow, head };
|
||||
}
|
||||
|
||||
export async function cmdThreadList(
|
||||
storageRoot: string,
|
||||
includeAll: boolean,
|
||||
): Promise<ThreadListItem[]> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const items: ThreadListItem[] = [];
|
||||
|
||||
for (const [threadId, head] of Object.entries(index)) {
|
||||
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAll) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const activeIds = new Set(items.map((i) => i.thread));
|
||||
const history = await loadThreadHistory(storageRoot);
|
||||
for (const entry of history) {
|
||||
if (!activeIds.has(entry.thread)) {
|
||||
items.push({
|
||||
thread: entry.thread,
|
||||
workflow: entry.workflow,
|
||||
head: entry.head,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
|
||||
const headNode = uwf.store.get(headHash);
|
||||
if (headNode === null) {
|
||||
fail(`CAS node not found: ${headHash}`);
|
||||
}
|
||||
|
||||
if (headNode.type === uwf.schemas.startNode) {
|
||||
return {
|
||||
startHash: headHash,
|
||||
start: headNode.payload as StartNodePayload,
|
||||
stepsNewestFirst: [],
|
||||
headIsStart: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (headNode.type !== uwf.schemas.stepNode) {
|
||||
fail(`head ${headHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const stepsNewestFirst: StepNodePayload[] = [];
|
||||
let hash: CasRef | null = headHash;
|
||||
|
||||
while (hash !== null) {
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found while walking chain: ${hash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
stepsNewestFirst.push(payload);
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
const newest = stepsNewestFirst[0];
|
||||
if (newest === undefined) {
|
||||
fail(`empty step chain at head ${headHash}`);
|
||||
}
|
||||
|
||||
const startNode = uwf.store.get(newest.start);
|
||||
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
|
||||
fail(`StartNode not found: ${newest.start}`);
|
||||
}
|
||||
|
||||
return {
|
||||
startHash: newest.start,
|
||||
start: startNode.payload as StartNodePayload,
|
||||
stepsNewestFirst,
|
||||
headIsStart: false,
|
||||
};
|
||||
}
|
||||
|
||||
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||
const node = uwf.store.get(outputRef);
|
||||
if (node === null) {
|
||||
return {};
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
const node = uwf.store.get(workflowRef);
|
||||
if (node === null) {
|
||||
fail(`workflow CAS node not found: ${workflowRef}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.workflow) {
|
||||
fail(`node ${workflowRef} is not a Workflow`);
|
||||
}
|
||||
return node.payload as WorkflowPayload;
|
||||
}
|
||||
|
||||
function parseAgentOverride(override: string): AgentConfig {
|
||||
const parts = override
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0);
|
||||
const command = parts[0];
|
||||
if (command === undefined) {
|
||||
fail("agent override must not be empty");
|
||||
}
|
||||
return { command, args: parts.slice(1) };
|
||||
}
|
||||
|
||||
function resolveAgentConfig(
|
||||
config: WorkflowConfig,
|
||||
workflow: WorkflowPayload,
|
||||
role: string,
|
||||
agentOverride: string | null,
|
||||
): AgentConfig {
|
||||
if (agentOverride !== null) {
|
||||
return parseAgentOverride(agentOverride);
|
||||
}
|
||||
|
||||
let alias: AgentAlias = config.defaultAgent;
|
||||
if (config.agentOverrides !== null) {
|
||||
const roleOverrides = config.agentOverrides[workflow.name];
|
||||
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
|
||||
alias = roleOverrides[role];
|
||||
}
|
||||
}
|
||||
|
||||
const agentConfig = config.agents[alias];
|
||||
if (agentConfig === undefined) {
|
||||
fail(`unknown agent alias in config: ${alias}`);
|
||||
}
|
||||
return agentConfig;
|
||||
}
|
||||
|
||||
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
|
||||
const argv = [...agent.args, threadId, role];
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync(agent.command, argv, {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
||||
const stderr =
|
||||
err.stderr === undefined
|
||||
? ""
|
||||
: typeof err.stderr === "string"
|
||||
? err.stderr
|
||||
: err.stderr.toString("utf8");
|
||||
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
|
||||
fail(`agent command failed (${agent.command})${detail}`);
|
||||
}
|
||||
|
||||
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
|
||||
if (!isCasRef(line)) {
|
||||
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
async function archiveThread(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
workflow: CasRef,
|
||||
head: CasRef,
|
||||
): Promise<void> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
await appendThreadHistory(storageRoot, {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdThreadStep(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
agentOverride: string | null,
|
||||
): Promise<StepOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const headHash = index[threadId];
|
||||
if (headHash === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
if (!nextResult.ok) {
|
||||
fail(nextResult.error.message);
|
||||
}
|
||||
|
||||
if (nextResult.value === END_ROLE) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, headHash);
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: headHash,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const role = nextResult.value;
|
||||
const config = await loadWorkflowConfig(storageRoot);
|
||||
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
|
||||
|
||||
loadDotenv({ path: getEnvPath(storageRoot) });
|
||||
const newHead = spawnAgent(agent, threadId, role);
|
||||
|
||||
// Re-create store to pick up nodes written by the agent subprocess
|
||||
const uwfAfter = await createUwfStore(storageRoot);
|
||||
const newNode = uwfAfter.store.get(newHead);
|
||||
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
|
||||
fail(`agent returned hash that is not a StepNode: ${newHead}`);
|
||||
}
|
||||
|
||||
// Reload threads index to avoid overwriting changes made by the agent subprocess
|
||||
const freshIndex = await loadThreadsIndex(storageRoot);
|
||||
freshIndex[threadId] = newHead;
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
if (!afterResult.ok) {
|
||||
fail(afterResult.error.message);
|
||||
}
|
||||
|
||||
const done = afterResult.value === END_ROLE;
|
||||
if (done) {
|
||||
await archiveThread(storageRoot, threadId, workflowHash, newHead);
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: workflowHash,
|
||||
thread: threadId,
|
||||
head: newHead,
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
if (head === undefined) {
|
||||
fail(`thread not active: ${threadId}`);
|
||||
}
|
||||
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const workflow = resolveWorkflowFromHead(uwf, head);
|
||||
if (workflow === null) {
|
||||
fail(`failed to resolve workflow from head: ${head}`);
|
||||
}
|
||||
|
||||
delete index[threadId];
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
const historyEntry: ThreadHistoryLine = {
|
||||
thread: threadId,
|
||||
workflow,
|
||||
head,
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
await appendThreadHistory(storageRoot, historyEntry);
|
||||
|
||||
return { thread: threadId, archived: true };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user