Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86205f1a15 | |||
| f741729b41 | |||
| 5b26602fd4 | |||
| f12b60385a | |||
| d54d448585 | |||
| 4de13cea44 | |||
| d9d542c570 | |||
| cf6115517c | |||
| 108f134020 | |||
| 8123399189 | |||
| 6324122168 | |||
| 25b411f22e | |||
| 54dc8fcb39 | |||
| a40e1bb847 | |||
| 2c8bcf7996 | |||
| af2a25bf87 | |||
| 0abc8bcb3e | |||
| 524e00a0a6 | |||
| eba3c70e76 | |||
| e2d60fa72e | |||
| dfae96ad45 | |||
| 2f4473f22c | |||
| ca223a19c6 | |||
| 0779ab85ca | |||
| 4d85a2eebb | |||
| cef4617956 | |||
| 813cbfd5c2 | |||
| a11d76264a | |||
| 6e8dedeb2f | |||
| 762c457978 | |||
| 9c26285424 | |||
| 45f479e60f | |||
| 3fca67e443 | |||
| 9b2460633c | |||
| dfb6fda06d | |||
| 827ff13c4a | |||
| 7a19ceca89 | |||
| 298b944169 | |||
| e40e41555b |
@@ -1,27 +1,3 @@
|
|||||||
---
|
# No Dynamic Import
|
||||||
description: Ban dynamic import() in production code — use static imports instead
|
|
||||||
globs: packages/*/src/**/*.ts
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# No Dynamic Import in Production Code
|
See [docs/no-dynamic-import.md](../../docs/no-dynamic-import.md) for full rules.
|
||||||
|
|
||||||
## 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,67 +1,3 @@
|
|||||||
# Sync README
|
# Sync Readme
|
||||||
|
|
||||||
When updating README.md files in this monorepo, follow these conventions.
|
See [docs/sync-readme.md](../../docs/sync-readme.md) for full rules.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['*']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun test
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
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`):
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## What
|
||||||
|
|
||||||
|
What this PR does.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Why the change is needed.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- `path/to/file` — what changed and why
|
||||||
|
|
||||||
|
## Ref
|
||||||
|
|
||||||
|
Fixes #
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
name: "retrospect-workflow"
|
||||||
|
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
|
||||||
|
roles:
|
||||||
|
analyst:
|
||||||
|
description: "Scans thread execution for anomalies and produces a findings report"
|
||||||
|
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
|
||||||
|
capabilities:
|
||||||
|
- data-analysis
|
||||||
|
procedure: |
|
||||||
|
You receive a completed thread ID in your task prompt.
|
||||||
|
|
||||||
|
Phase 0 — Validation (must pass before any analysis):
|
||||||
|
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
|
||||||
|
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
|
||||||
|
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
|
||||||
|
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
|
||||||
|
4. Compare the thread's workflow hash against the current registered version:
|
||||||
|
- Run `uwf workflow show <name>` to get the current hash
|
||||||
|
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
|
||||||
|
|
||||||
|
Phase 1 — Overview scan:
|
||||||
|
5. From the step list, compute a health signal for each step:
|
||||||
|
- Duration: flag if >2x the median of other steps
|
||||||
|
- Output tokens: flag if >2x the median
|
||||||
|
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
|
||||||
|
- Step count: flag if the same role appears more than expected (indicates loops)
|
||||||
|
6. If no anomalies found AND versions match: output $status=clean
|
||||||
|
7. If no anomalies found BUT versions differ:
|
||||||
|
- Diff the two workflow versions to check if any procedure changes are relevant
|
||||||
|
- If the current version already addresses potential concerns: output $status=clean with a note
|
||||||
|
- Otherwise: proceed to Phase 2
|
||||||
|
|
||||||
|
Phase 2 — Targeted deep-dive (only for flagged steps):
|
||||||
|
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
|
||||||
|
9. Analyze the turn sequence for:
|
||||||
|
- Repeated tool calls with the same or similar input (blind retries)
|
||||||
|
- Tool errors followed by no strategy change (same approach retried)
|
||||||
|
- Unnecessary exploration (reading files or running commands unrelated to the task)
|
||||||
|
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
|
||||||
|
- Excessive turns before reaching the goal
|
||||||
|
10. For each finding, record:
|
||||||
|
- Which role and step hash
|
||||||
|
- What happened (specific turn indices and commands)
|
||||||
|
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
|
||||||
|
- Suggested fix (what to add/change in the procedure)
|
||||||
|
11. If versions differ: compare findings against the version diff.
|
||||||
|
Mark any finding that is already fixed in the current version as "resolved_in_current".
|
||||||
|
Only report findings that are NOT yet addressed.
|
||||||
|
|
||||||
|
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
|
||||||
|
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
||||||
|
frontmatter:
|
||||||
|
oneOf:
|
||||||
|
- properties:
|
||||||
|
$status: { const: "clean" }
|
||||||
|
summary: { type: string }
|
||||||
|
required: [$status, summary]
|
||||||
|
- properties:
|
||||||
|
$status: { const: "findings" }
|
||||||
|
report: { type: string }
|
||||||
|
targetWorkflow: { type: string }
|
||||||
|
required: [$status, report, targetWorkflow]
|
||||||
|
- properties:
|
||||||
|
$status: { const: "wrong_project" }
|
||||||
|
workflowName: { type: string }
|
||||||
|
required: [$status, workflowName]
|
||||||
|
proposer:
|
||||||
|
description: "Translates findings into concrete workflow edits"
|
||||||
|
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
|
||||||
|
capabilities:
|
||||||
|
- planning
|
||||||
|
procedure: |
|
||||||
|
1. Read the analyst's findings report from your task prompt
|
||||||
|
2. Locate the target workflow YAML:
|
||||||
|
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
|
||||||
|
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
|
||||||
|
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
|
||||||
|
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
|
||||||
|
3. Read the current workflow YAML to understand existing procedures
|
||||||
|
4. For each finding, draft a minimal edit:
|
||||||
|
- Prefer adding a pitfall note or clarifying instruction over restructuring
|
||||||
|
- If a procedure step is ambiguous, make it explicit
|
||||||
|
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
|
||||||
|
- Keep edits surgical — don't rewrite procedures that work fine
|
||||||
|
5. Check if existing tests need updating (search for test files referencing the workflow)
|
||||||
|
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
|
||||||
|
|
||||||
|
The plan should list each edit with:
|
||||||
|
- File path
|
||||||
|
- What to change (old text → new text, or addition)
|
||||||
|
- Why (linked to which finding)
|
||||||
|
- Any test updates needed
|
||||||
|
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
|
||||||
|
frontmatter:
|
||||||
|
oneOf:
|
||||||
|
- properties:
|
||||||
|
$status: { const: "ready" }
|
||||||
|
plan: { type: string }
|
||||||
|
repoPath: { type: string }
|
||||||
|
required: [$status, plan, repoPath]
|
||||||
|
- properties:
|
||||||
|
$status: { const: "no_action" }
|
||||||
|
reason: { type: string }
|
||||||
|
required: [$status, reason]
|
||||||
|
developer:
|
||||||
|
description: "Applies the proposed workflow edits"
|
||||||
|
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
|
||||||
|
capabilities:
|
||||||
|
- coding
|
||||||
|
procedure: |
|
||||||
|
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||||
|
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
|
||||||
|
|
||||||
|
Before starting any work, set up an isolated worktree:
|
||||||
|
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
|
||||||
|
2. `git fetch origin` to get latest refs
|
||||||
|
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
|
||||||
|
4. `cd .worktrees/retrospect/<short-slug> && bun install`
|
||||||
|
5. ALL subsequent work must happen inside the worktree directory.
|
||||||
|
|
||||||
|
Then apply changes:
|
||||||
|
6. Read the change plan from CAS: `uwf cas get <plan hash>`
|
||||||
|
7. Apply each edit from the plan to the workflow YAML
|
||||||
|
8. Update or add tests as specified in the plan
|
||||||
|
9. Run `bun run build` and `bun test` to verify
|
||||||
|
10. Run `bun run check` for lint
|
||||||
|
11. Commit with message: `improve: <workflow-name> — <brief summary>`
|
||||||
|
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: "Reviews the workflow edits for correctness"
|
||||||
|
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
|
||||||
|
capabilities:
|
||||||
|
- code-review
|
||||||
|
procedure: |
|
||||||
|
The worktree path is provided in your task prompt. cd into it first.
|
||||||
|
|
||||||
|
Review criteria:
|
||||||
|
1. Each edit must trace back to a specific finding — no drive-by changes
|
||||||
|
2. Edits should be minimal — don't rewrite working procedures
|
||||||
|
3. New pitfall notes or instructions must be clear and actionable
|
||||||
|
4. Tests must be updated if assertions changed
|
||||||
|
5. `bun run build` and `bun test` must pass
|
||||||
|
6. `bunx biome check` must pass
|
||||||
|
|
||||||
|
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
|
||||||
|
output: "Explain your decision. 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]
|
||||||
|
committer:
|
||||||
|
description: "Commits and creates PR"
|
||||||
|
goal: "You are a committer agent. You create a clean commit and push a PR."
|
||||||
|
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: `git commit -m "improve: <workflow> — <summary>"`
|
||||||
|
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 --title "..." --description "..."`
|
||||||
|
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
|
||||||
|
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
|
||||||
|
- PR description must include: What / Why / Findings / Changes sections
|
||||||
|
- 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: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
|
||||||
|
analyst:
|
||||||
|
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
|
||||||
|
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
|
||||||
|
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
|
||||||
|
proposer:
|
||||||
|
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
|
||||||
|
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
|
||||||
|
developer:
|
||||||
|
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
|
||||||
|
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||||
|
reviewer:
|
||||||
|
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
|
||||||
|
approved: { role: "committer", prompt: "Approved. 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 improved." }
|
||||||
+109
-126
@@ -10,9 +10,9 @@ roles:
|
|||||||
procedure: |
|
procedure: |
|
||||||
On first run (no previous steps):
|
On first run (no previous steps):
|
||||||
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||||
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
|
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
|
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 and terminate
|
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
|
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||||
|
|
||||||
On subsequent runs (bounced back by tester with fix_spec):
|
On subsequent runs (bounced back by tester with fix_spec):
|
||||||
@@ -21,17 +21,19 @@ roles:
|
|||||||
|
|
||||||
After producing the test spec:
|
After producing the test spec:
|
||||||
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
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)
|
2. Put the hash in frontmatter.plan (required when $status=ready)
|
||||||
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, 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:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "ready" }
|
||||||
type: string
|
plan: { type: string }
|
||||||
enum: [ready, insufficient_info]
|
repoPath: { type: string }
|
||||||
plan:
|
required: [$status, plan, repoPath]
|
||||||
type: string
|
- properties:
|
||||||
required: [status]
|
$status: { const: "insufficient_info" }
|
||||||
|
required: [$status]
|
||||||
developer:
|
developer:
|
||||||
description: "TDD implementation per test spec"
|
description: "TDD implementation per test spec"
|
||||||
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||||
@@ -39,33 +41,41 @@ roles:
|
|||||||
- coding
|
- coding
|
||||||
procedure: |
|
procedure: |
|
||||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
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:
|
Before starting any work, set up an isolated worktree:
|
||||||
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
|
1. cd into the repo path provided in your task prompt
|
||||||
2. First time (no existing branch):
|
2. `git fetch origin` to get latest refs
|
||||||
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
3. First time (no existing branch):
|
||||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
|
- `git worktree add .worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
|
||||||
3. If bounced back from reviewer or tester (branch already exists):
|
- `cd .worktrees/fix/<issue-number>-<short-slug> && bun install`
|
||||||
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
4. If bounced back from reviewer or tester (branch already exists):
|
||||||
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
|
- cd into the existing worktree under `.worktrees/fix/<issue-number>-<short-slug>`
|
||||||
- `git fetch origin && git rebase origin/main`
|
- `git fetch origin && git rebase origin/main`
|
||||||
4. ALL subsequent work must happen inside the worktree directory.
|
5. ALL subsequent work must happen inside the worktree directory.
|
||||||
|
|
||||||
Then implement TDD:
|
Then implement TDD:
|
||||||
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
|
6. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the planner's output in your task prompt)
|
||||||
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
|
7. If bounced back from reviewer or tester: read the previous role's feedback in your task prompt
|
||||||
7. Write tests first based on the spec
|
8. Write tests first based on the spec
|
||||||
8. Implement the code to make tests pass
|
9. Implement the code to make tests pass
|
||||||
9. Ensure `bun run build` passes with no errors
|
10. Ensure `bun run build` passes with no errors
|
||||||
10. Run `bun test` to verify all tests pass
|
11. Run `bun test` to verify all tests pass
|
||||||
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
|
|
||||||
|
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:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "done" }
|
||||||
type: string
|
branch: { type: string }
|
||||||
enum: [done, failed]
|
worktree: { type: string }
|
||||||
required: [status]
|
required: [$status, branch, worktree]
|
||||||
|
- properties:
|
||||||
|
$status: { const: "failed" }
|
||||||
|
reason: { type: string }
|
||||||
|
required: [$status, reason]
|
||||||
reviewer:
|
reviewer:
|
||||||
description: "Code standards compliance check"
|
description: "Code standards compliance check"
|
||||||
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||||
@@ -73,7 +83,7 @@ roles:
|
|||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: |
|
procedure: |
|
||||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
The worktree path is provided in your task prompt. cd into it first.
|
||||||
|
|
||||||
Before reviewing, verify the git branch:
|
Before reviewing, verify the git branch:
|
||||||
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
|
||||||
@@ -85,132 +95,105 @@ roles:
|
|||||||
4. `bunx biome check` — no lint violations
|
4. `bunx biome check` — no lint violations
|
||||||
5. TypeScript strict mode — no type errors
|
5. TypeScript strict mode — no type errors
|
||||||
|
|
||||||
Soft checks (review against CLAUDE.md conventions):
|
Soft checks (review against project conventions if CLAUDE.md / .cursor/rules exist):
|
||||||
- Functional-first: `function` + `type`, not `class` + `interface`
|
- Naming conventions, module boundaries, code style
|
||||||
- No optional properties (`?:`) — use `T | null`
|
- No `console.log` in production code
|
||||||
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
|
|
||||||
- Module boundary discipline (folder exports via index.ts)
|
|
||||||
- No `console.log` (use structured logger)
|
|
||||||
- No dynamic imports in production code
|
- No dynamic imports in production code
|
||||||
|
|
||||||
Only review standards compliance. Do NOT test functionality.
|
Only review standards compliance. Do NOT test functionality.
|
||||||
If rejecting, you MUST explain the specific reason in your output.
|
If rejecting, you MUST explain the specific reason in your output.
|
||||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
output: "Explain your decision with specific file/line references. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
approved:
|
$status: { const: "approved" }
|
||||||
type: boolean
|
branch: { type: string }
|
||||||
required: [approved]
|
worktree: { type: string }
|
||||||
|
required: [$status, branch, worktree]
|
||||||
|
- properties:
|
||||||
|
$status: { const: "rejected" }
|
||||||
|
comments: { type: string }
|
||||||
|
worktree: { type: string }
|
||||||
|
required: [$status, comments, worktree]
|
||||||
tester:
|
tester:
|
||||||
description: "Functional correctness verification"
|
description: "Functional correctness verification"
|
||||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||||
capabilities:
|
capabilities:
|
||||||
- testing
|
- testing
|
||||||
procedure: |
|
procedure: |
|
||||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
The worktree path is provided in your task prompt. cd into it first.
|
||||||
|
|
||||||
1. Run `bun test` for automated test verification
|
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 latest planner step's frontmatter.plan)
|
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
|
3. Verify each scenario in the spec is covered and passing
|
||||||
4. Determine outcome:
|
4. Determine outcome:
|
||||||
- passed: all scenarios verified, tests pass
|
- passed: all scenarios verified, tests pass
|
||||||
- fix_code: tests fail or implementation doesn't match spec → send back to developer
|
- 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
|
- fix_spec: the spec itself is wrong or incomplete → send back to planner
|
||||||
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
|
output: "Report test results per scenario. Set $status to passed (with branch/worktree), fix_code (with report), or fix_spec (with report)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "passed" }
|
||||||
type: string
|
branch: { type: string }
|
||||||
enum: [passed, fix_code, fix_spec]
|
worktree: { type: string }
|
||||||
required: [status]
|
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:
|
committer:
|
||||||
description: "Commits and creates PR"
|
description: "Commits and creates PR"
|
||||||
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
|
||||||
capabilities: []
|
capabilities: []
|
||||||
procedure: |
|
procedure: |
|
||||||
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
|
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.
|
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||||
1. Stage all changes: `git add -A`
|
1. Check `git status` — if working tree is clean and branch is ahead of origin, skip to step 3 (push).
|
||||||
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
|
2. If there are unstaged/uncommitted changes: `git add -A` then `git commit -m "type: description\n\nFixes #N"`
|
||||||
3. Push the branch: `git push -u origin <branch-name>`
|
3. Push the branch: `git push -u origin <branch-name>`
|
||||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
- 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 uncaged/workflow --title "..." --description "..."`
|
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
||||||
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
|
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
|
||||||
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
|
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
|
||||||
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
- PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
|
||||||
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
|
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||||
5. After PR creation, clean up the worktree:
|
5. After PR creation, clean up the worktree:
|
||||||
- `cd ~/repos/workflow`
|
- cd to the repo root (parent of .worktrees)
|
||||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
- `git worktree remove <worktree-path>`
|
||||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
success:
|
$status: { const: "committed" }
|
||||||
type: boolean
|
prUrl: { type: string }
|
||||||
required: [success]
|
required: [$status, prUrl]
|
||||||
conditions:
|
- properties:
|
||||||
insufficientInfo:
|
$status: { const: "hook_failed" }
|
||||||
description: "Planner determined there's not enough info to proceed"
|
error: { type: string }
|
||||||
expression: "$last('planner').status = 'insufficient_info'"
|
required: [$status, error]
|
||||||
devFailed:
|
|
||||||
description: "Developer failed to implement"
|
|
||||||
expression: "$last('developer').status = 'failed'"
|
|
||||||
rejected:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "$last('reviewer').approved = false"
|
|
||||||
fixCode:
|
|
||||||
description: "Tester found code issues"
|
|
||||||
expression: "$last('tester').status = 'fix_code'"
|
|
||||||
fixSpec:
|
|
||||||
description: "Tester found spec issues"
|
|
||||||
expression: "$last('tester').status = 'fix_spec'"
|
|
||||||
hookFailed:
|
|
||||||
description: "Push hook failed"
|
|
||||||
expression: "$last('committer').success = false"
|
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue and produce an implementation plan."
|
|
||||||
planner:
|
planner:
|
||||||
- role: "$END"
|
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||||
condition: "insufficientInfo"
|
ready: { role: "developer", prompt: "Implement the TDD test spec (CAS hash: {{{plan}}}) in repo {{{repoPath}}}." }
|
||||||
prompt: "Insufficient information to proceed; end the workflow."
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner."
|
|
||||||
developer:
|
developer:
|
||||||
- role: "$END"
|
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||||
condition: "devFailed"
|
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||||
prompt: "Development failed; end the workflow."
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Send the implementation to the reviewer."
|
|
||||||
reviewer:
|
reviewer:
|
||||||
- role: "developer"
|
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||||
condition: "rejected"
|
approved: { role: "tester", prompt: "Review passed. Run tests on branch {{{branch}}} at {{{worktree}}}." }
|
||||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
|
||||||
- role: "tester"
|
|
||||||
condition: null
|
|
||||||
prompt: "Review passed; run tests on the implementation."
|
|
||||||
tester:
|
tester:
|
||||||
- role: "developer"
|
fix_code: { role: "developer", prompt: "Tests found code issues: {{{report}}}. Fix and re-submit." }
|
||||||
condition: "fixCode"
|
fix_spec: { role: "planner", prompt: "Tests found spec issues: {{{report}}}. Revise the test spec." }
|
||||||
prompt: "Tests found code issues; return to developer."
|
passed: { role: "committer", prompt: "All tests passed. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||||
- role: "planner"
|
|
||||||
condition: "fixSpec"
|
|
||||||
prompt: "Tests found spec issues; return to planner."
|
|
||||||
- role: "committer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Tests passed; commit and push the changes."
|
|
||||||
committer:
|
committer:
|
||||||
- role: "developer"
|
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||||
condition: "hookFailed"
|
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow complete." }
|
||||||
prompt: "Push hook failed; return to developer to fix."
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "Commit succeeded; complete the workflow."
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ This monorepo implements a stateless workflow engine driven by a single-step CLI
|
|||||||
|
|
||||||
| Concept | What it is |
|
| Concept | What it is |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
|
| **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`. |
|
| **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`. |
|
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
|
||||||
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
|
||||||
@@ -23,10 +23,9 @@ workflow/
|
|||||||
packages/
|
packages/
|
||||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
|
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/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
|
||||||
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
|
workflow-util-agent/ # @uncaged/workflow-util-agent — createAgent factory, context builder, extract pipeline
|
||||||
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
|
|
||||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
|
||||||
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
|
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary (includes status-based moderator in src/moderator/)
|
||||||
legacy-packages/ # Archived packages (preserved for reference, not active)
|
legacy-packages/ # Archived packages (preserved for reference, not active)
|
||||||
examples/ # Workflow YAML examples (solve-issue.yaml)
|
examples/ # Workflow YAML examples (solve-issue.yaml)
|
||||||
docs/ # Architecture docs
|
docs/ # Architecture docs
|
||||||
@@ -34,7 +33,7 @@ workflow/
|
|||||||
tsconfig.json # root TypeScript config
|
tsconfig.json # root TypeScript config
|
||||||
```
|
```
|
||||||
|
|
||||||
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit` → `workflow-agent-hermes` / `cli-workflow`
|
- Dependency layers: `workflow-protocol` → `workflow-util` → `workflow-util-agent` → `workflow-agent-hermes` / `cli-workflow`
|
||||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
- 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)
|
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
|
||||||
|
|
||||||
@@ -285,6 +284,11 @@ moderator → agent → extract — one step per invocation, repeat until $
|
|||||||
2. **Register** — `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
|
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
|
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
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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,59 +1,27 @@
|
|||||||
# @uncaged/workflow
|
# @uncaged/workflow
|
||||||
|
|
||||||
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
|
[](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 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.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
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 JSONata conditions 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
## Architecture
|
## Install
|
||||||
|
|
||||||
Dependency layers (lower layers have no dependency on higher layers):
|
```bash
|
||||||
|
npm install -g @uncaged/cli-workflow
|
||||||
```
|
|
||||||
Layer 0 — Contract
|
|
||||||
workflow-protocol Shared types and JSON Schema definitions
|
|
||||||
|
|
||||||
Layer 1 — Shared infra
|
|
||||||
workflow-util Encoding, IDs, logging, frontmatter, paths
|
|
||||||
workflow-moderator JSONata graph evaluator
|
|
||||||
|
|
||||||
Layer 2 — Agent framework
|
|
||||||
workflow-agent-kit 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
|
|
||||||
|
|
||||||
App (uses protocol; not in the runtime engine stack)
|
|
||||||
workflow-dashboard Web UI for visual workflow editing
|
|
||||||
```
|
```
|
||||||
|
|
||||||
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
|
Requires [Bun](https://bun.sh/) runtime (used internally for TypeScript execution).
|
||||||
|
|
||||||
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-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
|
|
||||||
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/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) |
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -73,6 +41,49 @@ uwf thread exec <thread-id>
|
|||||||
|
|
||||||
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## CLI Reference
|
||||||
|
|
||||||
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
|
||||||
|
|||||||
+14
-19
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
|
The implementation lives in **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.
|
||||||
|
|
||||||
## Package map
|
## Package map
|
||||||
|
|
||||||
@@ -16,10 +16,9 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|
|||||||
|-------|---------|---------------|
|
|-------|---------|---------------|
|
||||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
|
| 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. |
|
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
|
||||||
| Moderator | `@uncaged/workflow-moderator` → `workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
|
| 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 framework | `@uncaged/workflow-agent-kit` → `workflow-agent-kit` | `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. |
|
| 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. |
|
| 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`). |
|
||||||
|
|
||||||
### External dependencies
|
### External dependencies
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ The implementation lives in **6** active packages under `packages/`, plus two ex
|
|||||||
|---------|------|
|
|---------|------|
|
||||||
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
|
||||||
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
|
||||||
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
|
| `mustache` | Template renderer for edge prompts (used by `cli-workflow` moderator). |
|
||||||
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
| `commander` | CLI argument parsing (used by `cli-workflow`). |
|
||||||
| `dotenv` | Loads `.env` files for API keys. |
|
| `dotenv` | Loads `.env` files for API keys. |
|
||||||
| `yaml` | YAML parse/stringify. |
|
| `yaml` | YAML parse/stringify. |
|
||||||
@@ -45,10 +44,9 @@ flowchart BT
|
|||||||
end
|
end
|
||||||
subgraph L1["Layer 1 — shared"]
|
subgraph L1["Layer 1 — shared"]
|
||||||
util["@uncaged/workflow-util"]
|
util["@uncaged/workflow-util"]
|
||||||
moderator["@uncaged/workflow-moderator"]
|
|
||||||
end
|
end
|
||||||
subgraph L2["Layer 2 — agent framework"]
|
subgraph L2["Layer 2 — agent framework"]
|
||||||
kit["@uncaged/workflow-agent-kit"]
|
kit["@uncaged/workflow-util-agent"]
|
||||||
end
|
end
|
||||||
subgraph L3["Layer 3 — agent implementations"]
|
subgraph L3["Layer 3 — agent implementations"]
|
||||||
hermes["@uncaged/workflow-agent-hermes"]
|
hermes["@uncaged/workflow-agent-hermes"]
|
||||||
@@ -58,7 +56,6 @@ flowchart BT
|
|||||||
end
|
end
|
||||||
protocol --> jcasfs
|
protocol --> jcasfs
|
||||||
util --> protocol
|
util --> protocol
|
||||||
moderator --> protocol
|
|
||||||
kit --> protocol
|
kit --> protocol
|
||||||
kit --> util
|
kit --> util
|
||||||
kit --> jcas
|
kit --> jcas
|
||||||
@@ -68,7 +65,6 @@ flowchart BT
|
|||||||
cli --> protocol
|
cli --> protocol
|
||||||
cli --> util
|
cli --> util
|
||||||
cli --> kit
|
cli --> kit
|
||||||
cli --> moderator
|
|
||||||
cli --> jcas
|
cli --> jcas
|
||||||
cli --> jcasfs
|
cli --> jcasfs
|
||||||
```
|
```
|
||||||
@@ -148,8 +144,7 @@ graph:
|
|||||||
Key properties:
|
Key properties:
|
||||||
|
|
||||||
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
|
||||||
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
|
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
|
||||||
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
|
|
||||||
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
|
||||||
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
|
||||||
|
|
||||||
@@ -159,8 +154,8 @@ Each `uwf thread step` runs exactly one cycle: moderator → agent → extract.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─→ Phase 1: MODERATOR
|
┌─→ Phase 1: MODERATOR
|
||||||
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
|
│ Input: graph + lastRole + lastOutput
|
||||||
│ Engine: JSONata conditions evaluated against the graph
|
│ Engine: Status-based map lookup against lastOutput.status
|
||||||
│ Output: next role name | $END
|
│ Output: next role name | $END
|
||||||
│
|
│
|
||||||
│ Phase 2: AGENT
|
│ Phase 2: AGENT
|
||||||
@@ -207,7 +202,7 @@ type AgentContext = ModeratorContext & {
|
|||||||
|
|
||||||
### Key properties
|
### Key properties
|
||||||
|
|
||||||
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
|
- **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.
|
- **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.
|
- **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.
|
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
|
||||||
@@ -223,7 +218,7 @@ Each agent is an external command invoked by `uwf thread step`:
|
|||||||
Contract:
|
Contract:
|
||||||
1. `uwf thread step` determines the next role via the moderator
|
1. `uwf thread step` determines the next role via the moderator
|
||||||
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
2. Agent CLI is spawned with `(thread-id, role)` as positional args
|
||||||
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
|
3. `workflow-util-agent` (`createAgent`) handles the boilerplate:
|
||||||
- Parses argv
|
- Parses argv
|
||||||
- Loads `.env` from storage root
|
- Loads `.env` from storage root
|
||||||
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
|
||||||
@@ -256,11 +251,11 @@ scope: role
|
|||||||
Fixed the login redirect by updating the auth middleware...
|
Fixed the login redirect by updating the auth middleware...
|
||||||
```
|
```
|
||||||
|
|
||||||
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
|
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
|
## Two-layer extract
|
||||||
|
|
||||||
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
|
Structured output extraction uses a two-layer strategy (`workflow-util-agent`):
|
||||||
|
|
||||||
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
### Layer 1: frontmatter fast path (`frontmatter.ts`)
|
||||||
|
|
||||||
@@ -284,7 +279,7 @@ If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy sch
|
|||||||
|
|
||||||
## Prompt injection
|
## Prompt injection
|
||||||
|
|
||||||
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
|
`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
|
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."
|
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
|
||||||
@@ -485,7 +480,7 @@ Binary: `uwf`
|
|||||||
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
|
| **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. |
|
| **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. |
|
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
|
||||||
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ Agent 解析优先级(`resolveAgentConfig`):
|
|||||||
|
|
||||||
#### 环境变量:Storage Root
|
#### 环境变量:Storage Root
|
||||||
|
|
||||||
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致):
|
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-util-agent` / `cli-workflow` 一致):
|
||||||
|
|
||||||
```33:43:packages/workflow-agent-kit/src/storage.ts
|
```33:43:packages/workflow-util-agent/src/storage.ts
|
||||||
export function resolveStorageRoot(): string {
|
export function resolveStorageRoot(): string {
|
||||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
if (internal !== undefined && internal !== "") {
|
if (internal !== undefined && internal !== "") {
|
||||||
@@ -107,7 +107,7 @@ Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage roo
|
|||||||
|
|
||||||
### Q2: createAgent 工厂
|
### Q2: createAgent 工厂
|
||||||
|
|
||||||
workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
workflow-util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
|
||||||
|
|
||||||
**调研要点:**
|
**调研要点:**
|
||||||
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
|
||||||
@@ -119,7 +119,7 @@ workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是
|
|||||||
|
|
||||||
#### 类型定义
|
#### 类型定义
|
||||||
|
|
||||||
```4:35:packages/workflow-agent-kit/src/types.ts
|
```4:35:packages/workflow-util-agent/src/types.ts
|
||||||
export type AgentContext = ModeratorContext & {
|
export type AgentContext = ModeratorContext & {
|
||||||
threadId: ThreadId;
|
threadId: ThreadId;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -156,7 +156,7 @@ export type AgentOptions = {
|
|||||||
|
|
||||||
#### 生命周期(按执行顺序)
|
#### 生命周期(按执行顺序)
|
||||||
|
|
||||||
```101:152:packages/workflow-agent-kit/src/run.ts
|
```101:152:packages/workflow-util-agent/src/run.ts
|
||||||
export function createAgent(options: AgentOptions): () => Promise<void> {
|
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||||
return async function main(): Promise<void> {
|
return async function main(): Promise<void> {
|
||||||
const { threadId, role } = parseArgv(process.argv);
|
const { threadId, role } = parseArgv(process.argv);
|
||||||
@@ -197,7 +197,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
|||||||
|
|
||||||
#### StepNode 写入结构
|
#### StepNode 写入结构
|
||||||
|
|
||||||
```44:68:packages/workflow-agent-kit/src/run.ts
|
```44:68:packages/workflow-util-agent/src/run.ts
|
||||||
async function writeStepNode(options: {
|
async function writeStepNode(options: {
|
||||||
store: AgentStore["store"];
|
store: AgentStore["store"];
|
||||||
schemas: AgentStore["schemas"];
|
schemas: AgentStore["schemas"];
|
||||||
@@ -274,7 +274,7 @@ export type StepContext = Omit<StepRecord, "output"> & {
|
|||||||
|
|
||||||
`buildContextWithMeta` 还返回 `meta`:
|
`buildContextWithMeta` 还返回 `meta`:
|
||||||
|
|
||||||
```148:154:packages/workflow-agent-kit/src/context.ts
|
```148:154:packages/workflow-util-agent/src/context.ts
|
||||||
export type BuildContextMeta = {
|
export type BuildContextMeta = {
|
||||||
storageRoot: string;
|
storageRoot: string;
|
||||||
store: Store;
|
store: Store;
|
||||||
@@ -288,7 +288,7 @@ export type BuildContextMeta = {
|
|||||||
|
|
||||||
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
1. 从 `threads.yaml[threadId]` 取 `headHash`
|
||||||
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
|
||||||
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
|
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
|
||||||
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
|
||||||
|
|
||||||
#### Role definition 来源
|
#### Role definition 来源
|
||||||
@@ -337,7 +337,7 @@ async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef>
|
|||||||
|
|
||||||
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
#### Frontmatter fast-path(createAgent 实际使用的路径)
|
||||||
|
|
||||||
```148:195:packages/workflow-agent-kit/src/frontmatter.ts
|
```148:195:packages/workflow-util-agent/src/frontmatter.ts
|
||||||
export async function tryFrontmatterFastPath(
|
export async function tryFrontmatterFastPath(
|
||||||
raw: string,
|
raw: string,
|
||||||
outputSchema: CasRef,
|
outputSchema: CasRef,
|
||||||
@@ -357,7 +357,7 @@ export async function tryFrontmatterFastPath(
|
|||||||
|
|
||||||
#### LLM extract fallback(已实现但未接入 createAgent)
|
#### LLM extract fallback(已实现但未接入 createAgent)
|
||||||
|
|
||||||
```135:181:packages/workflow-agent-kit/src/extract.ts
|
```135:181:packages/workflow-util-agent/src/extract.ts
|
||||||
export async function extract(
|
export async function extract(
|
||||||
rawOutput: string,
|
rawOutput: string,
|
||||||
outputSchema: CasRef,
|
outputSchema: CasRef,
|
||||||
@@ -374,7 +374,7 @@ export async function extract(
|
|||||||
|
|
||||||
#### Correction prompt(retry)
|
#### Correction prompt(retry)
|
||||||
|
|
||||||
```125:128:packages/workflow-agent-kit/src/run.ts
|
```125:128:packages/workflow-util-agent/src/run.ts
|
||||||
const correctionMessage =
|
const correctionMessage =
|
||||||
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
|
"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" +
|
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
|
||||||
@@ -425,7 +425,7 @@ export type WorkflowConfig = {
|
|||||||
|
|
||||||
#### resolveModel
|
#### resolveModel
|
||||||
|
|
||||||
```32:50:packages/workflow-agent-kit/src/extract.ts
|
```32:50:packages/workflow-util-agent/src/extract.ts
|
||||||
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
|
||||||
const modelEntry = config.models[alias];
|
const modelEntry = config.models[alias];
|
||||||
const providerEntry = config.providers[modelEntry.provider];
|
const providerEntry = config.providers[modelEntry.provider];
|
||||||
@@ -438,7 +438,7 @@ export function resolveModel(config: WorkflowConfig, alias: ModelAlias): Resolve
|
|||||||
|
|
||||||
Extract 专用别名解析:
|
Extract 专用别名解析:
|
||||||
|
|
||||||
```18:30:packages/workflow-agent-kit/src/extract.ts
|
```18:30:packages/workflow-util-agent/src/extract.ts
|
||||||
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
||||||
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
|
||||||
}
|
}
|
||||||
@@ -448,7 +448,7 @@ export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
|
|||||||
|
|
||||||
#### chatCompletionText
|
#### chatCompletionText
|
||||||
|
|
||||||
```87:124:packages/workflow-agent-kit/src/extract.ts
|
```87:124:packages/workflow-util-agent/src/extract.ts
|
||||||
async function chatCompletionText(
|
async function chatCompletionText(
|
||||||
provider: ResolvedLlmProvider,
|
provider: ResolvedLlmProvider,
|
||||||
messages: Array<{ role: "system" | "user"; content: string }>,
|
messages: Array<{ role: "system" | "user"; content: string }>,
|
||||||
@@ -463,7 +463,7 @@ async function chatCompletionText(
|
|||||||
| 多模态 | **无**(仅 text `content`) |
|
| 多模态 | **无**(仅 text `content`) |
|
||||||
| Extract 专用 | `response_format: { type: "json_object" }` |
|
| Extract 专用 | `response_format: { type: "json_object" }` |
|
||||||
|
|
||||||
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -572,7 +572,7 @@ Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes
|
|||||||
| P1 | `grep` | 搜索符号/引用 |
|
| P1 | `grep` | 搜索符号/引用 |
|
||||||
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
|
||||||
|
|
||||||
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
|
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
|
||||||
|
|
||||||
#### Agent loop 必须能力
|
#### Agent loop 必须能力
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ flowchart TB
|
|||||||
Loop --> Detail
|
Loop --> Detail
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph kit ["workflow-agent-kit"]
|
subgraph kit ["workflow-util-agent"]
|
||||||
Ctx["buildContextWithMeta"]
|
Ctx["buildContextWithMeta"]
|
||||||
FM["tryFrontmatterFastPath"]
|
FM["tryFrontmatterFastPath"]
|
||||||
Persist["persistStep"]
|
Persist["persistStep"]
|
||||||
@@ -630,7 +630,7 @@ flowchart TB
|
|||||||
Spawn -->|"stdout: step hash"| Step
|
Spawn -->|"stdout: step hash"| Step
|
||||||
```
|
```
|
||||||
|
|
||||||
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-util-agent`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
|
||||||
|
|
||||||
**分层**:
|
**分层**:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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
|
||||||
+24
-46
@@ -75,7 +75,7 @@ uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
|
|||||||
**做的事:**
|
**做的事:**
|
||||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||||
2. 收集 thread 历史(遍历链)
|
2. 收集 thread 历史(遍历链)
|
||||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
|
||||||
4. 若 END → 归档 thread,输出最后链头,退出
|
4. 若 END → 归档 thread,输出最后链头,退出
|
||||||
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
|
||||||
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
|
||||||
@@ -199,29 +199,21 @@ payload:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
|
||||||
- `conditions` — `Record<Name, JSONata>`,命名条件,方便画图描述
|
- `graph` — `Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
|
||||||
- `graph` — `Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
|
- Status 来自上一个 role 输出的 `status` 字段,`$START` 用 `_` 作为初始 status
|
||||||
- `condition` 引用 conditions 中的 key,`null` = fallback
|
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
|
||||||
- 按数组顺序求值,第一个匹配的 transition 胜出
|
|
||||||
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
|
||||||
|
|
||||||
JSONata 表达式的求值上下文:
|
Moderator 的求值逻辑:
|
||||||
|
|
||||||
```jsonc
|
```typescript
|
||||||
{
|
evaluate(graph, lastRole, lastOutput) → { role, prompt }
|
||||||
"start": { // StartNode 信息
|
// 1. status = lastRole === "$START" ? "_" : lastOutput.status
|
||||||
"workflow": "4KNM2PXR3B1QW",
|
// 2. target = graph[lastRole][status]
|
||||||
"prompt": "Fix the login bug..."
|
// 3. prompt = mustache.render(target.prompt, lastOutput)
|
||||||
},
|
|
||||||
"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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
|
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
|
||||||
|
|
||||||
#### `StartNode`(Thread 起点)
|
#### `StartNode`(Thread 起点)
|
||||||
|
|
||||||
@@ -349,9 +341,8 @@ OPENROUTER_API_KEY=sk-or-...
|
|||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
|
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令,含 src/moderator/)
|
||||||
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
|
├── workflow-util-agent/ # @uncaged/workflow-util-agent — Agent CLI 框架(含 extractor)
|
||||||
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
|
|
||||||
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
|
||||||
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
|
||||||
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
|
||||||
@@ -367,7 +358,7 @@ packages/
|
|||||||
|
|
||||||
## 4. 关键数据类型
|
## 4. 关键数据类型
|
||||||
|
|
||||||
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
|
||||||
|
|
||||||
### 4.1 公共类型
|
### 4.1 公共类型
|
||||||
|
|
||||||
@@ -378,7 +369,7 @@ type CasRef = string;
|
|||||||
/** Thread ID — ULID, 26-char Crockford Base32 */
|
/** Thread ID — ULID, 26-char Crockford Base32 */
|
||||||
type ThreadId = string;
|
type ThreadId = string;
|
||||||
|
|
||||||
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
|
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
|
||||||
type StepRecord = {
|
type StepRecord = {
|
||||||
role: string;
|
role: string;
|
||||||
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
|
||||||
@@ -399,22 +390,16 @@ type RoleDefinition = {
|
|||||||
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
|
||||||
};
|
};
|
||||||
|
|
||||||
type Transition = {
|
type Target = {
|
||||||
role: string; // 目标 role 名 或 "$END"
|
role: string; // 目标 role 名 或 "$END"
|
||||||
condition: string | null; // 引用 conditions 中的 key,null = fallback
|
prompt: string; // Mustache 模板,渲染时注入 lastOutput
|
||||||
};
|
|
||||||
|
|
||||||
type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string; // JSONata expression
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkflowPayload = {
|
type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
|
||||||
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -432,20 +417,14 @@ type StepNodePayload = StepRecord & {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 JSONata 求值上下文
|
### 4.4 Moderator 求值
|
||||||
|
|
||||||
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
|
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/** JSONata 上下文中的 step — output 被展开 */
|
// graph[lastRole][lastOutput.status] → Target { role, prompt }
|
||||||
type StepContext = Omit<StepRecord, "output"> & {
|
// $START 角色使用 "_" 作为初始 status
|
||||||
output: unknown; // 展开后的 CAS 节点内容,非 hash
|
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
|
||||||
};
|
|
||||||
|
|
||||||
type ModeratorContext = {
|
|
||||||
start: StartNodePayload;
|
|
||||||
steps: StepContext[]; // 从旧到新
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 CLI 输出
|
### 4.5 CLI 输出
|
||||||
@@ -534,6 +513,5 @@ StepNodePayload ──extends──→ StepRecord ←──maps to──→ Step
|
|||||||
│
|
│
|
||||||
└── start.workflow → WorkflowPayload
|
└── start.workflow → WorkflowPayload
|
||||||
├── roles: Record<name, RoleDefinition>
|
├── roles: Record<name, RoleDefinition>
|
||||||
├── conditions: Record<name, JSONata>
|
└── graph: Record<role, Record<status, Target>>
|
||||||
└── graph: Record<role, Transition[]>
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ roles:
|
|||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status:
|
$status:
|
||||||
enum: ["_"]
|
enum: ["_"]
|
||||||
thesis:
|
thesis:
|
||||||
type: string
|
type: string
|
||||||
@@ -32,7 +32,7 @@ roles:
|
|||||||
type: string
|
type: string
|
||||||
caveats:
|
caveats:
|
||||||
type: string
|
type: string
|
||||||
required: [status, thesis, keyPoints]
|
required: [$status, thesis, keyPoints]
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ roles:
|
|||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status:
|
$status:
|
||||||
enum: ["continue", "conceded"]
|
enum: ["continue", "conceded"]
|
||||||
argument:
|
argument:
|
||||||
type: string
|
type: string
|
||||||
required: [status, argument]
|
required: [$status, argument]
|
||||||
for:
|
for:
|
||||||
description: "Argues for the proposition"
|
description: "Argues for the proposition"
|
||||||
goal: |
|
goal: |
|
||||||
@@ -46,11 +46,11 @@ roles:
|
|||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status:
|
$status:
|
||||||
enum: ["continue", "conceded"]
|
enum: ["continue", "conceded"]
|
||||||
argument:
|
argument:
|
||||||
type: string
|
type: string
|
||||||
required: [status, argument]
|
required: [$status, argument]
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||||
|
|||||||
+175
-69
@@ -1,92 +1,198 @@
|
|||||||
name: "solve-issue"
|
name: "solve-issue"
|
||||||
description: "End-to-end issue resolution"
|
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
|
||||||
roles:
|
roles:
|
||||||
planner:
|
planner:
|
||||||
description: "Creates implementation plan"
|
description: "Analyzes issue and outputs a TDD test spec"
|
||||||
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
|
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
|
||||||
capabilities:
|
capabilities:
|
||||||
- issue-analysis
|
- issue-analysis
|
||||||
- planning
|
- planning
|
||||||
- file-read
|
|
||||||
- shell
|
|
||||||
procedure: |
|
procedure: |
|
||||||
1. Locate the code repository:
|
On first run (no previous steps):
|
||||||
- Check if the current working directory is the repo (look for package.json, .git, etc.)
|
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
|
||||||
- If the task mentions a repo URL, clone it first.
|
2. Look for project conventions files (CLAUDE.md, CONTRIBUTING.md, .cursor/rules/) in the repo
|
||||||
- If this is a new project, create the repo and note the path.
|
3. Assess whether the issue has enough information to produce a test spec
|
||||||
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/).
|
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
|
||||||
3. Identify which files need changes and what the changes should be, with specific code references.
|
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
|
||||||
4. Output the plan with:
|
|
||||||
- `repoPath`: absolute path to the repository root
|
On subsequent runs (bounced back by tester with fix_spec):
|
||||||
- `plan`: detailed implementation plan with file paths and code references
|
1. Read the tester's output from the previous step to understand what's wrong with the spec
|
||||||
- `steps`: concrete action items for the developer
|
2. Revise the test spec accordingly
|
||||||
output: |
|
|
||||||
Provide repoPath, plan summary, and steps in the frontmatter.
|
After producing the test spec:
|
||||||
The plan MUST reference actual file paths and code structures you found by reading the source.
|
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
|
||||||
Do NOT guess — if you haven't read a file, read it before referencing it.
|
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:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "ready" }
|
||||||
enum: ["_"]
|
plan: { type: string }
|
||||||
repoPath:
|
repoPath: { type: string }
|
||||||
type: string
|
required: [$status, plan, repoPath]
|
||||||
plan:
|
- properties:
|
||||||
type: string
|
$status: { const: "insufficient_info" }
|
||||||
required: [status, repoPath, plan]
|
required: [$status]
|
||||||
developer:
|
developer:
|
||||||
description: "Implements code changes"
|
description: "TDD implementation per test spec"
|
||||||
goal: "You are a developer agent. You implement code changes according to plans."
|
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
|
||||||
capabilities:
|
capabilities:
|
||||||
- file-edit
|
- coding
|
||||||
- shell
|
|
||||||
- testing
|
|
||||||
procedure: |
|
procedure: |
|
||||||
1. Read the planner's output to get the repoPath and implementation plan.
|
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||||
2. cd to the repoPath before making any changes.
|
The repo path and other details are provided in your task prompt.
|
||||||
3. Create a feature branch from the default branch.
|
|
||||||
4. Implement the plan — write code, tests, and ensure existing tests pass.
|
Before starting any work, set up an isolated worktree:
|
||||||
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.
|
1. cd into the repo path provided in your task prompt
|
||||||
6. Commit your changes with a descriptive message referencing the issue.
|
2. `git fetch origin` to get latest refs
|
||||||
output: "List all files changed and provide a summary of the implementation."
|
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:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "done" }
|
||||||
enum: ["_"]
|
branch: { type: string }
|
||||||
filesChanged:
|
worktree: { type: string }
|
||||||
type: array
|
required: [$status, branch, worktree]
|
||||||
items:
|
- properties:
|
||||||
type: string
|
$status: { const: "failed" }
|
||||||
summary:
|
reason: { type: string }
|
||||||
type: string
|
required: [$status, reason]
|
||||||
required: [status, filesChanged, summary]
|
|
||||||
reviewer:
|
reviewer:
|
||||||
description: "Reviews code changes"
|
description: "Code standards compliance check"
|
||||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
|
||||||
capabilities:
|
capabilities:
|
||||||
- code-review
|
- code-review
|
||||||
- static-analysis
|
- static-analysis
|
||||||
procedure: |
|
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.
|
The worktree path is provided in your task prompt. cd into it first.
|
||||||
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.
|
Before reviewing, verify the git branch:
|
||||||
output: "Approve or reject with detailed comments explaining your decision."
|
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:
|
frontmatter:
|
||||||
type: object
|
oneOf:
|
||||||
properties:
|
- properties:
|
||||||
status:
|
$status: { const: "approved" }
|
||||||
enum: ["approved", "rejected"]
|
branch: { type: string }
|
||||||
comments:
|
worktree: { type: string }
|
||||||
type: string
|
required: [$status, branch, worktree]
|
||||||
required: [status, comments]
|
- 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:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||||
planner:
|
planner:
|
||||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
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:
|
developer:
|
||||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
done: { role: "reviewer", prompt: "Review branch {{{branch}}} at {{{worktree}}} for code standards compliance." }
|
||||||
|
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||||
reviewer:
|
reviewer:
|
||||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in repo {{{worktree}}}." }
|
||||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
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." }
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# @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
|
||||||
|
```
|
||||||
+19
-9
@@ -21,7 +21,7 @@ const solveIssueGraph: WorkflowPayload["graph"] = {
|
|||||||
|
|
||||||
describe("evaluate", () => {
|
describe("evaluate", () => {
|
||||||
test("$START → first role (unit status _)", () => {
|
test("$START → first role (unit status _)", () => {
|
||||||
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
const result = evaluate(solveIssueGraph, "$START", { $status: "_" });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||||
@@ -30,7 +30,7 @@ describe("evaluate", () => {
|
|||||||
|
|
||||||
test("status-based routing (reviewer rejected → developer)", () => {
|
test("status-based routing (reviewer rejected → developer)", () => {
|
||||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||||
status: "rejected",
|
$status: "rejected",
|
||||||
comments: "missing tests",
|
comments: "missing tests",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -40,7 +40,7 @@ describe("evaluate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("status-based routing (reviewer approved → $END)", () => {
|
test("status-based routing (reviewer approved → $END)", () => {
|
||||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "Done." },
|
value: { role: "$END", prompt: "Done." },
|
||||||
@@ -48,7 +48,7 @@ describe("evaluate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("missing role in graph → error", () => {
|
test("missing role in graph → error", () => {
|
||||||
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
const result = evaluate(solveIssueGraph, "unknown-role", { $status: "_" });
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||||
@@ -56,7 +56,7 @@ describe("evaluate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("missing status in graph → error", () => {
|
test("missing status in graph → error", () => {
|
||||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
const result = evaluate(solveIssueGraph, "reviewer", { $status: "pending" });
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||||
@@ -65,7 +65,7 @@ describe("evaluate", () => {
|
|||||||
|
|
||||||
test("mustache template rendering with simple fields", () => {
|
test("mustache template rendering with simple fields", () => {
|
||||||
const result = evaluate(solveIssueGraph, "planner", {
|
const result = evaluate(solveIssueGraph, "planner", {
|
||||||
status: "_",
|
$status: "_",
|
||||||
plan: "Add auth middleware",
|
plan: "Add auth middleware",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -76,7 +76,7 @@ describe("evaluate", () => {
|
|||||||
|
|
||||||
test("mustache does not HTML-escape prompt content", () => {
|
test("mustache does not HTML-escape prompt content", () => {
|
||||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||||
status: "rejected",
|
$status: "rejected",
|
||||||
comments: 'use <T> & "Result<T, E>" types',
|
comments: 'use <T> & "Result<T, E>" types',
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -92,7 +92,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = evaluate(graph, "reviewer", {
|
const result = evaluate(graph, "reviewer", {
|
||||||
status: "_",
|
$status: "_",
|
||||||
comments: "<script>alert(1)</script>",
|
comments: "<script>alert(1)</script>",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -101,6 +101,16 @@ describe("evaluate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("mustache template with nested object paths", () => {
|
||||||
const graph: Record<string, Record<string, Target>> = {
|
const graph: Record<string, Record<string, Target>> = {
|
||||||
reviewer: {
|
reviewer: {
|
||||||
@@ -111,7 +121,7 @@ describe("evaluate", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = evaluate(graph, "reviewer", {
|
const result = evaluate(graph, "reviewer", {
|
||||||
status: "_",
|
$status: "_",
|
||||||
review: { comments: "refactor the handler" },
|
review: { comments: "refactor the handler" },
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
+13
-2
@@ -15,7 +15,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"test:ci": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
@@ -27,5 +28,15 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"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"
|
||||||
}
|
}
|
||||||
+9
-2
@@ -9,14 +9,21 @@ mustache.escape = (text: string) => text;
|
|||||||
const START_ROLE = "$START";
|
const START_ROLE = "$START";
|
||||||
const UNIT_STATUS = "_";
|
const UNIT_STATUS = "_";
|
||||||
|
|
||||||
type LastOutput = Record<string, unknown> & { status: string };
|
type LastOutput = Record<string, unknown>;
|
||||||
|
|
||||||
|
const STATUS_KEY = "$status";
|
||||||
|
|
||||||
export function evaluate(
|
export function evaluate(
|
||||||
graph: Record<string, Record<string, Target>>,
|
graph: Record<string, Record<string, Target>>,
|
||||||
lastRole: string,
|
lastRole: string,
|
||||||
lastOutput: LastOutput,
|
lastOutput: LastOutput,
|
||||||
): Result<EvaluateResult, Error> {
|
): Result<EvaluateResult, Error> {
|
||||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
const status =
|
||||||
|
lastRole === START_ROLE
|
||||||
|
? UNIT_STATUS
|
||||||
|
: typeof lastOutput[STATUS_KEY] === "string"
|
||||||
|
? (lastOutput[STATUS_KEY] as string)
|
||||||
|
: UNIT_STATUS;
|
||||||
|
|
||||||
const roleTargets = graph[lastRole];
|
const roleTargets = graph[lastRole];
|
||||||
if (roleTargets === undefined) {
|
if (roleTargets === undefined) {
|
||||||
+11
-1
@@ -11,6 +11,7 @@
|
|||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"test": "bun run --filter './packages/*' test",
|
"test": "bun run --filter './packages/*' test",
|
||||||
|
"test:ci": "bun run --filter './packages/*' test:ci",
|
||||||
"changeset": "bunx changeset",
|
"changeset": "bunx changeset",
|
||||||
"version": "bunx changeset version",
|
"version": "bunx changeset version",
|
||||||
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
"release": "bun run build && bun test && node scripts/publish-all.mjs"
|
||||||
@@ -23,5 +24,14 @@
|
|||||||
"@types/xxhashjs": "^0.2.4",
|
"@types/xxhashjs": "^0.2.4",
|
||||||
"@uncaged/workflow-agent-hermes": "workspace:*",
|
"@uncaged/workflow-agent-hermes": "workspace:*",
|
||||||
"bun-types": "^1.3.13"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ workflow → thread → step → turn
|
|||||||
|
|
||||||
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `mustache`, `yaml`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -190,6 +190,7 @@ src/
|
|||||||
├── store.ts CAS store + registry initialization
|
├── store.ts CAS store + registry initialization
|
||||||
├── validate.ts Workflow YAML validation
|
├── validate.ts Workflow YAML validation
|
||||||
├── schemas.ts CLI-local schema registration
|
├── schemas.ts CLI-local schema registration
|
||||||
|
├── moderator/ Status-based graph evaluator (next role or $END)
|
||||||
└── commands/
|
└── commands/
|
||||||
├── thread.ts Thread lifecycle and exec
|
├── thread.ts Thread lifecycle and exec
|
||||||
├── step.ts Step operations (list/show/read/fork)
|
├── step.ts Step operations (list/show/read/fork)
|
||||||
|
|||||||
@@ -11,23 +11,35 @@
|
|||||||
"uwf": "./src/cli.ts"
|
"uwf": "./src/cli.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
"@uncaged/json-cas-fs": "^0.4.0",
|
"@uncaged/json-cas-fs": "^0.5.3",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
|
||||||
"@uncaged/workflow-moderator": "workspace:^",
|
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"test:ci": "vitest run"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||||
|
"directory": "packages/cli-workflow"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { evaluate } from "../moderator/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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
|
||||||
|
|
||||||
|
// ─── _agentNameFromBinary ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_agentNameFromBinary", () => {
|
||||||
|
test("strips uwf- prefix", () => {
|
||||||
|
expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips uwf- prefix for compound names", () => {
|
||||||
|
expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns as-is when no uwf- prefix", () => {
|
||||||
|
expect(_agentNameFromBinary("hermes")).toBe("hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles uwf-builtin", () => {
|
||||||
|
expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── _printAgentMenu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printAgentMenu", () => {
|
||||||
|
test("prints known agents with labels", () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||||
|
logs.push(args.join(" "));
|
||||||
|
});
|
||||||
|
|
||||||
|
_printAgentMenu(["uwf-hermes", "uwf-claude-code"]);
|
||||||
|
|
||||||
|
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
|
||||||
|
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prints unknown agents with binary name as label", () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
||||||
|
logs.push(args.join(" "));
|
||||||
|
});
|
||||||
|
|
||||||
|
_printAgentMenu(["uwf-custom-agent"]);
|
||||||
|
|
||||||
|
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── cmdSetup agent config ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdSetup agent configuration", () => {
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseArgs = () => ({
|
||||||
|
provider: "testprovider",
|
||||||
|
baseUrl: "https://api.test.com/v1",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
model: "test-model",
|
||||||
|
storageRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to hermes agent when no agent specified", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({}), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await cmdSetup(baseArgs());
|
||||||
|
|
||||||
|
expect(result.defaultAgent).toBe("hermes");
|
||||||
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||||
|
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
|
||||||
|
expect(config.defaultAgent).toBe("hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes specified agent as default", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({}), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
|
||||||
|
|
||||||
|
expect(result.defaultAgent).toBe("claude-code");
|
||||||
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||||
|
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
|
||||||
|
expect(config.defaultAgent).toBe("claude-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves existing agents when adding new one", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({}), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// First setup with hermes
|
||||||
|
await cmdSetup(baseArgs());
|
||||||
|
// Second setup with claude-code
|
||||||
|
await cmdSetup({ ...baseArgs(), agent: "claude-code" });
|
||||||
|
|
||||||
|
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||||
|
expect(config.agents.hermes).toBeDefined();
|
||||||
|
expect(config.agents["claude-code"]).toBeDefined();
|
||||||
|
expect(config.defaultAgent).toBe("claude-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates defaultAgent on re-run with different agent", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({}), { status: 200 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cmdSetup(baseArgs());
|
||||||
|
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||||
|
expect(config1.defaultAgent).toBe("hermes");
|
||||||
|
|
||||||
|
await cmdSetup({ ...baseArgs(), agent: "builtin" });
|
||||||
|
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
|
||||||
|
expect(config2.defaultAgent).toBe("builtin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
import {
|
||||||
|
cmdSkillArchitecture,
|
||||||
|
cmdSkillCli,
|
||||||
|
cmdSkillList,
|
||||||
|
cmdSkillModerator,
|
||||||
|
cmdSkillYaml,
|
||||||
|
} from "../commands/skill.js";
|
||||||
|
|
||||||
|
describe("skill commands", () => {
|
||||||
|
test("skill list returns all skill names", () => {
|
||||||
|
const result = cmdSkillList();
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
expect(result).toContain("cli");
|
||||||
|
expect(result).toContain("architecture");
|
||||||
|
expect(result).toContain("yaml");
|
||||||
|
expect(result).toContain("moderator");
|
||||||
|
for (const name of result) {
|
||||||
|
expect(typeof name).toBe("string");
|
||||||
|
expect(name).toMatch(/^\S+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill architecture returns non-empty markdown string", () => {
|
||||||
|
const result = cmdSkillArchitecture();
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("CAS");
|
||||||
|
expect(result).toContain("Thread");
|
||||||
|
expect(result).toContain("Workflow");
|
||||||
|
expect(result).toContain("Step");
|
||||||
|
expect(result.length).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill yaml returns non-empty markdown string", () => {
|
||||||
|
const result = cmdSkillYaml();
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("roles");
|
||||||
|
expect(result).toContain("graph");
|
||||||
|
expect(result).toContain("frontmatter");
|
||||||
|
expect(result.length).toBeGreaterThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill moderator returns non-empty markdown string", () => {
|
||||||
|
const result = cmdSkillModerator();
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("routing");
|
||||||
|
expect(result).toContain("status");
|
||||||
|
expect(result.length).toBeGreaterThan(200);
|
||||||
|
// Check for edge or graph
|
||||||
|
expect(result).toMatch(/edge|graph/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill cli returns CLI reference markdown", () => {
|
||||||
|
const result = cmdSkillCli();
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result).toContain("uwf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill help subcommand is suppressed", () => {
|
||||||
|
const output = execFileSync("bun", ["src/cli.ts", "skill", "--help"], {
|
||||||
|
cwd: join(__dirname, "..", ".."),
|
||||||
|
encoding: "utf-8",
|
||||||
|
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
||||||
|
});
|
||||||
|
expect(output).not.toMatch(/help\s+\[command\]/i);
|
||||||
|
expect(output).toContain("cli");
|
||||||
|
expect(output).toContain("architecture");
|
||||||
|
expect(output).toContain("yaml");
|
||||||
|
expect(output).toContain("moderator");
|
||||||
|
expect(output).toContain("list");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,10 +13,18 @@ import { parse } from "yaml";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||||
// Navigate up from packages/cli-workflow to repo root
|
// Navigate up from packages/cli-workflow/src/__tests__ to repo root
|
||||||
const workflowPath = join(process.cwd(), "..", "..", ".workflows", "solve-issue.yaml");
|
const workflowPath = join(
|
||||||
|
import.meta.dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
".workflows",
|
||||||
|
"solve-issue.yaml",
|
||||||
|
);
|
||||||
|
|
||||||
test("committer procedure should include --repo flag in tea pr create command", async () => {
|
test("committer procedure should require running tea pr create from main repo directory", async () => {
|
||||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
const workflow = parse(yamlContent) as WorkflowPayload;
|
const workflow = parse(yamlContent) as WorkflowPayload;
|
||||||
|
|
||||||
@@ -24,19 +32,12 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
|||||||
const committerProcedure = workflow.roles.committer?.procedure;
|
const committerProcedure = workflow.roles.committer?.procedure;
|
||||||
expect(committerProcedure).toBeDefined();
|
expect(committerProcedure).toBeDefined();
|
||||||
|
|
||||||
// Verify the procedure includes tea pr create with --repo flag
|
// Verify the procedure includes tea pr create
|
||||||
expect(committerProcedure).toContain("tea pr create");
|
expect(committerProcedure).toContain("tea pr create");
|
||||||
expect(committerProcedure).toContain("--repo");
|
|
||||||
|
|
||||||
// Verify the --repo flag appears before or together with tea pr create
|
// Verify the procedure warns about running from main repo dir (not worktree)
|
||||||
// This ensures the command is: tea pr create --repo <owner/repo> ...
|
expect(committerProcedure).toMatch(/main repo directory/i);
|
||||||
const teaPrCreateMatch = committerProcedure?.match(/tea pr create[^\n]*/);
|
expect(committerProcedure).toMatch(/not a worktree/i);
|
||||||
expect(teaPrCreateMatch).not.toBeNull();
|
|
||||||
|
|
||||||
if (teaPrCreateMatch) {
|
|
||||||
const teaCommandLine = teaPrCreateMatch[0];
|
|
||||||
expect(teaCommandLine).toContain("--repo");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("committer procedure should mention repo extraction from git remote", async () => {
|
test("committer procedure should mention repo extraction from git remote", async () => {
|
||||||
@@ -81,17 +82,18 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
|||||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("committer frontmatter schema should require success field", async () => {
|
test("committer frontmatter schema should be oneOf with $status discriminant", async () => {
|
||||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const workflow = parse(yamlContent) as any;
|
const workflow = parse(yamlContent) as any;
|
||||||
|
|
||||||
const frontmatter = workflow.roles.committer?.frontmatter;
|
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||||
expect(frontmatter).toBeDefined();
|
expect(frontmatter).toBeDefined();
|
||||||
expect(frontmatter?.type).toBe("object");
|
expect(frontmatter?.oneOf).toBeDefined();
|
||||||
expect(frontmatter?.properties?.success).toBeDefined();
|
const committedVariant = frontmatter.oneOf.find(
|
||||||
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
(v: any) => v.properties?.["$status"]?.const === "committed",
|
||||||
expect(frontmatter?.required).toContain("success");
|
);
|
||||||
|
expect(committedVariant).toBeDefined();
|
||||||
|
expect(committedVariant.required).toContain("$status");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step with large quota
|
// Read step with large quota
|
||||||
@@ -227,6 +229,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step with limited quota (700 chars)
|
// Read step with limited quota (700 chars)
|
||||||
@@ -304,6 +308,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step with minimal quota (1 char)
|
// Read step with minimal quota (1 char)
|
||||||
@@ -357,6 +363,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step - should return metadata only (no error)
|
// Read step - should return metadata only (no error)
|
||||||
@@ -431,6 +439,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step - should return metadata only (no error)
|
// Read step - should return metadata only (no error)
|
||||||
@@ -443,7 +453,78 @@ describe("step read", () => {
|
|||||||
expect(markdown).not.toContain("## Turn");
|
expect(markdown).not.toContain("## Turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("test 6: turn content with special characters", async () => {
|
test("test 6: displays role and tool calls in turn body", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "You are a worker agent.",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do the work.",
|
||||||
|
output: "Summarize the work.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
toolCalls: [{ name: "terminal", args: '{"command":"echo hi"}' }],
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "session-1",
|
||||||
|
model: "test-model",
|
||||||
|
duration: 1000,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-hermes",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = await cmdStepRead(tmpDir, stepHash, 4000);
|
||||||
|
|
||||||
|
expect(markdown).toContain("**Turn role:** assistant");
|
||||||
|
expect(markdown).toContain("**terminal**");
|
||||||
|
expect(markdown).toContain('{"command":"echo hi"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("test 7: turn content with special characters", async () => {
|
||||||
const casDir = join(tmpDir, "cas");
|
const casDir = join(tmpDir, "cas");
|
||||||
await mkdir(casDir, { recursive: true });
|
await mkdir(casDir, { recursive: true });
|
||||||
const store = createFsStore(casDir);
|
const store = createFsStore(casDir);
|
||||||
@@ -505,6 +586,8 @@ describe("step read", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read step
|
// Read step
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { bootstrap, putSchema } from "@uncaged/json-cas";
|
||||||
|
import { createFsStore } from "@uncaged/json-cas-fs";
|
||||||
|
import type { CasRef, ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import { STEP_NODE_SCHEMA } from "@uncaged/workflow-protocol";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { cmdStepList } from "../commands/step.js";
|
||||||
|
import { cmdThreadRead } from "../commands/thread.js";
|
||||||
|
import { registerUwfSchemas } from "../schemas.js";
|
||||||
|
import { saveThreadsIndex } from "../store.js";
|
||||||
|
|
||||||
|
// ── schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TURN_SCHEMA = {
|
||||||
|
title: "hermes-turn",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["index", "role", "content"],
|
||||||
|
properties: {
|
||||||
|
index: { type: "integer" as const },
|
||||||
|
role: { type: "string" as const },
|
||||||
|
content: { type: "string" as const },
|
||||||
|
toolCalls: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "array" as const, items: { type: "object" as const } },
|
||||||
|
{ type: "null" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: { anyOf: [{ type: "string" as const }, { type: "null" as const }] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_SCHEMA = {
|
||||||
|
title: "hermes-detail",
|
||||||
|
type: "object" as const,
|
||||||
|
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||||
|
properties: {
|
||||||
|
sessionId: { type: "string" as const },
|
||||||
|
model: { type: "string" as const },
|
||||||
|
duration: { type: "integer" as const },
|
||||||
|
turnCount: { type: "integer" as const },
|
||||||
|
turns: {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const, format: "cas_ref" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function registerDetailSchemas(store: ReturnType<typeof createFsStore>) {
|
||||||
|
await bootstrap(store);
|
||||||
|
const [turn, detail] = await Promise.all([
|
||||||
|
putSchema(store, TURN_SCHEMA),
|
||||||
|
putSchema(store, DETAIL_SCHEMA),
|
||||||
|
]);
|
||||||
|
return { turn, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fixture ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-step-timing-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1. Protocol types (compile-time) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("protocol types", () => {
|
||||||
|
test("StepRecord has startedAtMs and completedAtMs as required fields", () => {
|
||||||
|
// Type-level test: this block compiles only if fields exist and are number
|
||||||
|
const record: import("@uncaged/workflow-protocol").StepRecord = {
|
||||||
|
role: "test",
|
||||||
|
output: "hash1" as CasRef,
|
||||||
|
detail: "hash2" as CasRef,
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
startedAtMs: 1000,
|
||||||
|
completedAtMs: 2000,
|
||||||
|
};
|
||||||
|
expect(record.startedAtMs).toBe(1000);
|
||||||
|
expect(record.completedAtMs).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("StepEntry has durationMs as required field", () => {
|
||||||
|
const entry: import("@uncaged/workflow-protocol").StepEntry = {
|
||||||
|
hash: "hash" as CasRef,
|
||||||
|
role: "test",
|
||||||
|
output: {},
|
||||||
|
detail: "hash2" as CasRef,
|
||||||
|
agent: "uwf-test",
|
||||||
|
timestamp: 123,
|
||||||
|
durationMs: 5000,
|
||||||
|
};
|
||||||
|
expect(entry.durationMs).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. JSON Schema ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("StepNode JSON schema", () => {
|
||||||
|
test("schema requires startedAtMs and completedAtMs", () => {
|
||||||
|
const required = STEP_NODE_SCHEMA.required as string[];
|
||||||
|
expect(required).toContain("startedAtMs");
|
||||||
|
expect(required).toContain("completedAtMs");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schema defines timing fields as integer", () => {
|
||||||
|
const props = STEP_NODE_SCHEMA.properties as Record<string, { type: string }>;
|
||||||
|
expect(props.startedAtMs.type).toBe("integer");
|
||||||
|
expect(props.completedAtMs.type).toBe("integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("StepNode with timing fields passes CAS validation", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: "placeholder0000" as CasRef,
|
||||||
|
prompt: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.text, "output text");
|
||||||
|
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "s1",
|
||||||
|
model: "m1",
|
||||||
|
duration: 100,
|
||||||
|
turnCount: 0,
|
||||||
|
turns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should succeed — valid timing fields
|
||||||
|
const hash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
|
});
|
||||||
|
expect(hash).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. step list — durationMs computed ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe("step list timing", () => {
|
||||||
|
test("step list includes durationMs = completedAtMs - startedAtMs", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputHash = await store.put(schemas.text, "output");
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "s1",
|
||||||
|
model: "m1",
|
||||||
|
duration: 100,
|
||||||
|
turnCount: 0,
|
||||||
|
turns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = 1716600000000;
|
||||||
|
const completedAt = 1716600003500;
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
startedAtMs: startedAt,
|
||||||
|
completedAtMs: completedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const result = await cmdStepList(tmpDir, threadId);
|
||||||
|
const stepEntries = result.steps.slice(1); // skip start entry
|
||||||
|
expect(stepEntries).toHaveLength(1);
|
||||||
|
|
||||||
|
const step = stepEntries[0] as import("@uncaged/workflow-protocol").StepEntry;
|
||||||
|
expect(step.durationMs).toBe(3500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. thread read — duration in header ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe("thread read timing", () => {
|
||||||
|
test("thread read header includes Duration", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "Do work",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "work",
|
||||||
|
output: "result",
|
||||||
|
frontmatter: "placeholder0000" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: { _: { role: "worker", prompt: "go" } },
|
||||||
|
worker: { _: { role: "$END", prompt: "" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test task",
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Done.",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "s1",
|
||||||
|
model: "m1",
|
||||||
|
duration: 100,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const outputHash = await store.put(schemas.text, "output");
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
startedAtMs: 1716600000000,
|
||||||
|
completedAtMs: 1716600042000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ3" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||||
|
expect(markdown).toContain("**Duration:** 42.0s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread read shows sub-second duration as ms", async () => {
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const detailSchemas = await registerDetailSchemas(store);
|
||||||
|
|
||||||
|
const workflowHash = await store.put(schemas.workflow, {
|
||||||
|
name: "test-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Worker",
|
||||||
|
goal: "Do work",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "work",
|
||||||
|
output: "result",
|
||||||
|
frontmatter: "placeholder0000" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: { _: { role: "worker", prompt: "go" } },
|
||||||
|
worker: { _: { role: "$END", prompt: "" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHash = await store.put(schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnHash = await store.put(detailSchemas.turn, {
|
||||||
|
index: 0,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Done.",
|
||||||
|
toolCalls: null,
|
||||||
|
reasoning: null,
|
||||||
|
});
|
||||||
|
const detailHash = await store.put(detailSchemas.detail, {
|
||||||
|
sessionId: "s1",
|
||||||
|
model: "m1",
|
||||||
|
duration: 100,
|
||||||
|
turnCount: 1,
|
||||||
|
turns: [turnHash],
|
||||||
|
});
|
||||||
|
const outputHash = await store.put(schemas.text, "output");
|
||||||
|
|
||||||
|
const stepHash = await store.put(schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: outputHash,
|
||||||
|
detail: detailHash,
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
startedAtMs: 1716600000000,
|
||||||
|
completedAtMs: 1716600000350,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHash });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 10000, null, false);
|
||||||
|
expect(markdown).toContain("**Duration:** 350ms");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 6. Breaking change — old data without timing fails ───────────────────────
|
||||||
|
|
||||||
|
describe("breaking change", () => {
|
||||||
|
test("StepNode schema rejects payload without timing fields", () => {
|
||||||
|
const required = STEP_NODE_SCHEMA.required as string[];
|
||||||
|
// Both fields must be in the required array
|
||||||
|
expect(required).toContain("startedAtMs");
|
||||||
|
expect(required).toContain("completedAtMs");
|
||||||
|
|
||||||
|
// Payload without timing fields would fail schema validation
|
||||||
|
// because the schema marks them as required
|
||||||
|
const payloadWithoutTiming = {
|
||||||
|
start: "hash1",
|
||||||
|
prev: null,
|
||||||
|
role: "worker",
|
||||||
|
output: "hash2",
|
||||||
|
detail: "hash3",
|
||||||
|
agent: "uwf-test",
|
||||||
|
edgePrompt: "",
|
||||||
|
};
|
||||||
|
// Verify the payload is missing required fields
|
||||||
|
expect(payloadWithoutTiming).not.toHaveProperty("startedAtMs");
|
||||||
|
expect(payloadWithoutTiming).not.toHaveProperty("completedAtMs");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -141,6 +141,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
steps.push(stepHash);
|
steps.push(stepHash);
|
||||||
}
|
}
|
||||||
@@ -221,6 +223,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: step1DetailHash,
|
detail: step1DetailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const step2Content = generateContent(600, "Second");
|
const step2Content = generateContent(600, "Second");
|
||||||
@@ -245,6 +249,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: step2DetailHash,
|
detail: step2DetailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ1" as ThreadId;
|
||||||
@@ -328,6 +334,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
steps.push(stepHash);
|
steps.push(stepHash);
|
||||||
}
|
}
|
||||||
@@ -338,8 +346,8 @@ describe("thread read --quota flag", () => {
|
|||||||
// Set tight quota with --start flag
|
// Set tight quota with --start flag
|
||||||
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
const markdown = await cmdThreadRead(tmpDir, threadId, 600, null, true);
|
||||||
|
|
||||||
// Quota must be reasonably enforced (allow ~210 char tolerance for structure)
|
// Quota must be reasonably enforced (allow ~260 char tolerance for structure)
|
||||||
expect(markdown.length).toBeLessThanOrEqual(810);
|
expect(markdown.length).toBeLessThanOrEqual(860);
|
||||||
|
|
||||||
// Should contain thread header
|
// Should contain thread header
|
||||||
expect(markdown).toMatch(/# Thread/);
|
expect(markdown).toMatch(/# Thread/);
|
||||||
@@ -405,6 +413,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
const threadId = "01HX2Q3R4S5T6V7W8X9YZ4" as ThreadId;
|
||||||
@@ -480,6 +490,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
steps.push(stepHash);
|
steps.push(stepHash);
|
||||||
}
|
}
|
||||||
@@ -559,6 +571,8 @@ describe("thread read --quota flag", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
steps.push(stepHash);
|
steps.push(stepHash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-claude-code",
|
agent: "uwf-claude-code",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000001" as ThreadId;
|
const threadId = "01JTEST0000000000000001" as ThreadId;
|
||||||
@@ -214,6 +216,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-claude-code",
|
agent: "uwf-claude-code",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000002" as ThreadId;
|
const threadId = "01JTEST0000000000000002" as ThreadId;
|
||||||
@@ -274,6 +278,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
@@ -283,6 +289,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000003" as ThreadId;
|
const threadId = "01JTEST0000000000000003" as ThreadId;
|
||||||
@@ -335,6 +343,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000004" as ThreadId;
|
const threadId = "01JTEST0000000000000004" as ThreadId;
|
||||||
@@ -387,6 +397,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: missingDetailRef,
|
detail: missingDetailRef,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000005" as ThreadId;
|
const threadId = "01JTEST0000000000000005" as ThreadId;
|
||||||
@@ -439,6 +451,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000006" as ThreadId;
|
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||||
@@ -511,6 +525,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
const step2 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
@@ -520,6 +536,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
const step3 = await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
@@ -529,6 +547,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000007" as ThreadId;
|
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||||
@@ -607,6 +627,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: detailHash,
|
detail: detailHash,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadId = "01JTEST0000000000000008" as ThreadId;
|
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||||
@@ -661,6 +683,8 @@ describe("thread read XML tag isolation", () => {
|
|||||||
output: outputHash,
|
output: outputHash,
|
||||||
detail: null,
|
detail: null,
|
||||||
agent: "uwf-test",
|
agent: "uwf-test",
|
||||||
|
startedAtMs: 1000000000000,
|
||||||
|
completedAtMs: 1000000005000,
|
||||||
})) as CasRef;
|
})) as CasRef;
|
||||||
steps.push(step);
|
steps.push(step);
|
||||||
prev = step;
|
prev = step;
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { validateWorkflow } from "../validate-semantic.js";
|
||||||
|
|
||||||
|
/** Build a valid two-role workflow that passes all checks. */
|
||||||
|
function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
|
||||||
|
const base: WorkflowPayload = {
|
||||||
|
name: "test-workflow",
|
||||||
|
description: "A test workflow",
|
||||||
|
roles: {
|
||||||
|
writer: {
|
||||||
|
description: "Writes content",
|
||||||
|
goal: "Write content",
|
||||||
|
capabilities: ["writing"],
|
||||||
|
procedure: "Write it",
|
||||||
|
output: "The content",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["_"] },
|
||||||
|
plan: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "plan"],
|
||||||
|
} as unknown as string,
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
description: "Reviews content",
|
||||||
|
goal: "Review content",
|
||||||
|
capabilities: ["reviewing"],
|
||||||
|
procedure: "Review it",
|
||||||
|
output: "The review",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
$status: { const: "approved" },
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "summary"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
$status: { const: "rejected" },
|
||||||
|
reason: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "reason"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: { _: { role: "writer", prompt: "Begin writing" } },
|
||||||
|
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } },
|
||||||
|
reviewer: {
|
||||||
|
approved: { role: "$END", prompt: "Done: {{{summary}}}" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!overrides) return base;
|
||||||
|
return { ...base, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Suite 1: Role Reference Integrity", () => {
|
||||||
|
test("1.1 graph references unknown role", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.2 orphan role not in graph", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.orphan = {
|
||||||
|
description: "Orphan",
|
||||||
|
goal: "Nothing",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "None",
|
||||||
|
output: "None",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: { $status: { enum: ["_"] } },
|
||||||
|
required: ["$status"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('role "orphan" is defined but not referenced in graph')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.3 $START in roles", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
(wf.roles as Record<string, unknown>).$START = {
|
||||||
|
description: "Bad",
|
||||||
|
goal: "Bad",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Bad",
|
||||||
|
output: "Bad",
|
||||||
|
frontmatter: { type: "object", properties: {}, required: [] },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('reserved name "$START"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.4 $END in roles", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
(wf.roles as Record<string, unknown>).$END = {
|
||||||
|
description: "Bad",
|
||||||
|
goal: "Bad",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Bad",
|
||||||
|
output: "Bad",
|
||||||
|
frontmatter: { type: "object", properties: {}, required: [] },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('reserved name "$END"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("1.5 valid workflow returns no errors", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 2: Graph Structure", () => {
|
||||||
|
test("2.1 $START missing from graph", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
delete wf.graph.$START;
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("$START must be defined in graph"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.2 $START has multiple status keys", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.$START = {
|
||||||
|
_: { role: "writer", prompt: "Begin" },
|
||||||
|
other: { role: "reviewer", prompt: "Also" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.3 $START edge uses non-_ status", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('$START must have exactly one edge with status "_"')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.4 $END has outgoing edges", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.$END = { _: { role: "writer", prompt: "Loop" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.5 unreachable role", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.isolated = {
|
||||||
|
description: "Isolated",
|
||||||
|
goal: "Isolated",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Isolated",
|
||||||
|
output: "Isolated",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: { $status: { enum: ["_"] } },
|
||||||
|
required: ["$status"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.isolated = { _: { role: "$END", prompt: "done" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("2.6 edge target references invalid role", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 3: Status-Edge Consistency", () => {
|
||||||
|
test("3.1 single-exit role with multiple graph keys", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.writer = {
|
||||||
|
_: { role: "reviewer", prompt: "Review" },
|
||||||
|
extra: { role: "$END", prompt: "Done" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) =>
|
||||||
|
e.includes('role "writer" is single-exit but has status keys other than "_"'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.2 single-exit role missing _ key", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.3 multi-exit role with extra statuses", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix" },
|
||||||
|
timeout: { role: "$END", prompt: "Timed out" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('role "reviewer" graph has extra status keys: timeout')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.4 multi-exit role missing a status", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('role "reviewer" graph is missing status keys: rejected')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3.5 multi-exit role with _ key", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 3b: Enum-Based Multi-Exit", () => {
|
||||||
|
test("3b.1 enum multi-exit passes with matching graph keys", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["approved", "rejected"] },
|
||||||
|
comments: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "comments"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3b.2 enum multi-exit with extra graph key", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["approved", "rejected"] },
|
||||||
|
comments: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "comments"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix" },
|
||||||
|
timeout: { role: "$END", prompt: "Timed out" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3b.3 enum multi-exit with missing graph key", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["approved", "rejected"] },
|
||||||
|
comments: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "comments"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3b.4 enum with single value (not multi-exit) treated as single-exit", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.writer = {
|
||||||
|
...wf.roles.writer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["_"] },
|
||||||
|
plan: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "plan"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("3b.5 enum multi-exit mustache var not in frontmatter", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { enum: ["approved", "rejected"] },
|
||||||
|
comments: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "comments"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix: {{{comments}}}" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 4: Mustache Template Variable Existence", () => {
|
||||||
|
test("4.1 prompt references nonexistent variable (single-exit)", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) =>
|
||||||
|
e.includes('prompt variable "branch" not found in role "writer" frontmatter'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.2 prompt references nonexistent variable (multi-exit)", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.reviewer = {
|
||||||
|
approved: { role: "$END", prompt: "Done: {{{branch}}}" },
|
||||||
|
rejected: { role: "writer", prompt: "Fix: {{{reason}}}" },
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) =>
|
||||||
|
e.includes('prompt variable "branch" not found in role "reviewer" variant "approved"'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.3 valid mustache variables pass", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4.4 $status variable is always valid", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 5: oneOf Discriminant Validity", () => {
|
||||||
|
test("5.1 oneOf without $status const", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
oneOf: [
|
||||||
|
{ properties: { summary: { type: "string" } }, required: ["summary"] },
|
||||||
|
{ properties: { reason: { type: "string" } }, required: ["reason"] },
|
||||||
|
],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(
|
||||||
|
errors.some((e) => e.includes('oneOf variants must have "$status" as const discriminant')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5.2 oneOf with non-const $status", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
wf.roles.reviewer = {
|
||||||
|
...wf.roles.reviewer,
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
properties: { $status: { type: "string" }, summary: { type: "string" } },
|
||||||
|
required: ["$status", "summary"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
properties: { $status: { type: "string" }, reason: { type: "string" } },
|
||||||
|
required: ["$status", "reason"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.some((e) => e.includes("oneOf variant $status must be a const value"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5.3 valid oneOf passes", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Suite 6: Multiple Errors Collection", () => {
|
||||||
|
test("6.1 multiple errors collected", () => {
|
||||||
|
const wf = makeWorkflow();
|
||||||
|
// orphan role
|
||||||
|
wf.roles.orphan = {
|
||||||
|
description: "Orphan",
|
||||||
|
goal: "Nothing",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "None",
|
||||||
|
output: "None",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: { $status: { enum: ["_"] } },
|
||||||
|
required: ["$status"],
|
||||||
|
} as unknown as string,
|
||||||
|
};
|
||||||
|
// unknown graph reference
|
||||||
|
wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } };
|
||||||
|
// bad mustache var
|
||||||
|
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } };
|
||||||
|
const errors = validateWorkflow(wf);
|
||||||
|
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,23 +20,43 @@ async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
|
|||||||
return { storageRoot, store, schemas };
|
return { storageRoot, store, schemas };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
function makeMinimalPayload(name: string, description: string): WorkflowPayload {
|
||||||
const payload: WorkflowPayload = {
|
return {
|
||||||
name,
|
name,
|
||||||
description: "Test workflow",
|
description,
|
||||||
roles: {},
|
roles: {
|
||||||
graph: {},
|
worker: {
|
||||||
|
description: "worker role",
|
||||||
|
goal: "do work",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "",
|
||||||
|
output: "",
|
||||||
|
frontmatter: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status"],
|
||||||
|
} as unknown as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: { _: { role: "worker", prompt: "start working" } },
|
||||||
|
worker: { _: { role: "$END", prompt: "done" } },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||||
|
const payload = makeMinimalPayload(name, "Test workflow");
|
||||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
|
||||||
const payload: WorkflowPayload = {
|
const payload = makeMinimalPayload(
|
||||||
name,
|
name,
|
||||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||||
roles: {},
|
);
|
||||||
graph: {},
|
|
||||||
};
|
|
||||||
const yaml = stringify(payload);
|
const yaml = stringify(payload);
|
||||||
return yaml;
|
return yaml;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import {
|
|||||||
} from "./commands/cas.js";
|
} from "./commands/cas.js";
|
||||||
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
import { cmdLogClean, cmdLogList, cmdLogShow } from "./commands/log.js";
|
||||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||||
import { cmdSkillCli } from "./commands/skill.js";
|
import {
|
||||||
|
cmdSkillArchitecture,
|
||||||
|
cmdSkillCli,
|
||||||
|
cmdSkillList,
|
||||||
|
cmdSkillModerator,
|
||||||
|
cmdSkillYaml,
|
||||||
|
} from "./commands/skill.js";
|
||||||
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
import { cmdStepFork, cmdStepList, cmdStepRead, cmdStepShow } from "./commands/step.js";
|
||||||
import {
|
import {
|
||||||
cmdThreadCancel,
|
cmdThreadCancel,
|
||||||
@@ -55,8 +61,7 @@ program
|
|||||||
.description(
|
.description(
|
||||||
"Stateless workflow CLI\n\n" +
|
"Stateless workflow CLI\n\n" +
|
||||||
"Four-layer architecture:\n" +
|
"Four-layer architecture:\n" +
|
||||||
" workflow → thread → step → turn\n" +
|
" workflow → thread → step → turn",
|
||||||
" 模板定义 执行实例 单步结果 agent内部交互",
|
|
||||||
)
|
)
|
||||||
.version(pkg.default.version, "-V, --version");
|
.version(pkg.default.version, "-V, --version");
|
||||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||||
@@ -474,6 +479,7 @@ For more information, see: uwf help thread list
|
|||||||
});
|
});
|
||||||
|
|
||||||
const skill = program.command("skill").description("Built-in skill references for agents");
|
const skill = program.command("skill").description("Built-in skill references for agents");
|
||||||
|
skill.addHelpCommand(false);
|
||||||
|
|
||||||
skill
|
skill
|
||||||
.command("cli")
|
.command("cli")
|
||||||
@@ -482,6 +488,34 @@ skill
|
|||||||
console.log(cmdSkillCli());
|
console.log(cmdSkillCli());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command("architecture")
|
||||||
|
.description("Print the architecture reference")
|
||||||
|
.action(() => {
|
||||||
|
console.log(cmdSkillArchitecture());
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command("yaml")
|
||||||
|
.description("Print the workflow YAML schema reference")
|
||||||
|
.action(() => {
|
||||||
|
console.log(cmdSkillYaml());
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command("moderator")
|
||||||
|
.description("Print the moderator reference")
|
||||||
|
.action(() => {
|
||||||
|
console.log(cmdSkillModerator());
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command("list")
|
||||||
|
.description("List all available skill names")
|
||||||
|
.action(() => {
|
||||||
|
console.log(cmdSkillList().join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("setup")
|
.command("setup")
|
||||||
.description("Configure provider, model, and agent")
|
.description("Configure provider, model, and agent")
|
||||||
|
|||||||
@@ -297,6 +297,80 @@ export function _printModelMenu(models: string[], termCols: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Agent selection prompt
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Known agent binary → display label mapping. */
|
||||||
|
const KNOWN_AGENTS: Record<string, string> = {
|
||||||
|
"uwf-hermes": "Hermes (hermes-agent)",
|
||||||
|
"uwf-claude-code": "Claude Code",
|
||||||
|
"uwf-cursor": "Cursor",
|
||||||
|
"uwf-builtin": "Built-in (lightweight, no external agent)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extract short agent name from binary name: uwf-claude-code → claude-code */
|
||||||
|
export function _agentNameFromBinary(binary: string): string {
|
||||||
|
return binary.replace(/^uwf-/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints numbered agent list to stdout. */
|
||||||
|
export function _printAgentMenu(agents: string[]): void {
|
||||||
|
const numWidth = String(agents.length).length;
|
||||||
|
for (let i = 0; i < agents.length; i++) {
|
||||||
|
const bin = agents[i] ?? "";
|
||||||
|
const label = KNOWN_AGENTS[bin] ?? bin;
|
||||||
|
const num = String(i + 1).padStart(numWidth);
|
||||||
|
console.log(` ${num}) ${label} (${bin})`);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive agent selection. Discovers uwf-* binaries, lets user pick default.
|
||||||
|
* Returns short agent name (e.g. "hermes", "claude-code").
|
||||||
|
*/
|
||||||
|
export async function _promptAgentSelection(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
): Promise<string> {
|
||||||
|
console.log("Discovering installed agents...\n");
|
||||||
|
const agents = await _discoverAgents();
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
console.log(" No uwf-* agent binaries found in PATH.\n");
|
||||||
|
console.log(" Install one first, for example:");
|
||||||
|
console.log(" npm i -g @uncaged/workflow-agent-hermes");
|
||||||
|
console.log(" npm i -g @uncaged/workflow-agent-claude-code\n");
|
||||||
|
const manual = (
|
||||||
|
await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ")
|
||||||
|
).trim();
|
||||||
|
if (!manual) return "hermes";
|
||||||
|
return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.length === 1) {
|
||||||
|
const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes");
|
||||||
|
const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0];
|
||||||
|
console.log(` Found 1 agent: ${label} — auto-selected.\n`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Found ${agents.length} agents:\n`);
|
||||||
|
_printAgentMenu(agents);
|
||||||
|
const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim();
|
||||||
|
const n = Number.parseInt(choice, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1 && n <= agents.length) {
|
||||||
|
const selected = agents[n - 1] ?? "uwf-hermes";
|
||||||
|
const name = _agentNameFromBinary(selected);
|
||||||
|
console.log(` → ${name}\n`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
// Treat as literal name
|
||||||
|
const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`);
|
||||||
|
console.log(` → ${name}\n`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
type ValidationResult = { ok: boolean; error: string | null };
|
type ValidationResult = { ok: boolean; error: string | null };
|
||||||
|
|
||||||
/** Prints the model validation result to stdout. */
|
/** Prints the model validation result to stdout. */
|
||||||
@@ -340,8 +414,9 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
|
|||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
const agentName = args.agent ?? "hermes";
|
const agentName = args.agent ?? "hermes";
|
||||||
if (Object.keys(agents).length === 0) {
|
// Ensure the selected agent has an entry
|
||||||
agents.hermes = { command: "uwf-hermes", args: [] };
|
if (!agents[agentName]) {
|
||||||
|
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -349,7 +424,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
agents,
|
agents,
|
||||||
defaultAgent: existing.defaultAgent ?? agentName,
|
defaultAgent: agentName,
|
||||||
defaultModel: existing.defaultModel ?? "default",
|
defaultModel: existing.defaultModel ?? "default",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -543,11 +618,17 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
rl2.close();
|
rl2.close();
|
||||||
console.log(` → ${providerName}/${model}\n`);
|
console.log(` → ${providerName}/${model}\n`);
|
||||||
|
|
||||||
|
// 4. Agent discovery & selection
|
||||||
|
const rl3 = createInterface({ input, output });
|
||||||
|
const agentName = await _promptAgentSelection(rl3);
|
||||||
|
rl3.close();
|
||||||
|
|
||||||
const setupResult = await cmdSetup({
|
const setupResult = await cmdSetup({
|
||||||
provider: providerName,
|
provider: providerName,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
model,
|
||||||
|
agent: agentName,
|
||||||
storageRoot,
|
storageRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
export { generateCliReference as cmdSkillCli } from "@uncaged/workflow-util";
|
export {
|
||||||
|
generateArchitectureReference as cmdSkillArchitecture,
|
||||||
|
generateCliReference as cmdSkillCli,
|
||||||
|
generateModeratorReference as cmdSkillModerator,
|
||||||
|
generateYamlReference as cmdSkillYaml,
|
||||||
|
} from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
const SKILL_NAMES = ["cli", "architecture", "yaml", "moderator"] as const;
|
||||||
|
|
||||||
|
export function cmdSkillList(): ReadonlyArray<string> {
|
||||||
|
return [...SKILL_NAMES];
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ import {
|
|||||||
walkChain,
|
walkChain,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
|
|
||||||
|
type TurnToolCall = {
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
};
|
||||||
|
|
||||||
type TurnData = {
|
type TurnData = {
|
||||||
index: number;
|
index: number;
|
||||||
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
toolCalls: TurnToolCall[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +65,7 @@ export async function cmdStepList(
|
|||||||
detail: item.payload.detail ?? null,
|
detail: item.payload.detail ?? null,
|
||||||
agent: item.payload.agent,
|
agent: item.payload.agent,
|
||||||
timestamp: item.timestamp,
|
timestamp: item.timestamp,
|
||||||
|
durationMs: item.payload.completedAtMs - item.payload.startedAtMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +135,74 @@ function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record
|
|||||||
return detailNode.payload as Record<string, unknown>;
|
return detailNode.payload as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTurnToolCalls(raw: unknown): TurnToolCall[] | null {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const calls: TurnToolCall[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry !== "object" || entry === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
const name = record.name;
|
||||||
|
const args = record.args;
|
||||||
|
if (typeof name === "string") {
|
||||||
|
calls.push({ name, args: typeof args === "string" ? args : "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls.length > 0 ? calls : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTurnBody(turn: TurnData): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
parts.push(`**Turn role:** ${turn.role}`);
|
||||||
|
|
||||||
|
if (turn.toolCalls !== null) {
|
||||||
|
for (const call of turn.toolCalls) {
|
||||||
|
const argsSuffix = call.args !== "" ? ` — \`${call.args}\`` : "";
|
||||||
|
parts.push(`- **${call.name}**${argsSuffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turn.content !== "") {
|
||||||
|
if (parts.length > 0) {
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
parts.push(turn.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSingleTurn(
|
||||||
|
store: BootstrapCapableStore,
|
||||||
|
turnRef: unknown,
|
||||||
|
fallbackIndex: number,
|
||||||
|
): TurnData | null {
|
||||||
|
if (typeof turnRef !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const turnNode = store.get(turnRef as CasRef);
|
||||||
|
if (turnNode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const turn = turnNode.payload as Record<string, unknown>;
|
||||||
|
const content = typeof turn.content === "string" ? turn.content : "";
|
||||||
|
const toolCalls = parseTurnToolCalls(turn.toolCalls);
|
||||||
|
if (content === "" && toolCalls === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index: typeof turn.index === "number" ? turn.index : fallbackIndex,
|
||||||
|
role: typeof turn.role === "string" ? turn.role : "assistant",
|
||||||
|
content,
|
||||||
|
toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all turn nodes from CAS store and extract content
|
* Load all turn nodes from CAS store and extract display fields
|
||||||
*/
|
*/
|
||||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||||
if (!Array.isArray(turns) || turns.length === 0) {
|
if (!Array.isArray(turns) || turns.length === 0) {
|
||||||
@@ -137,19 +211,9 @@ function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[]
|
|||||||
|
|
||||||
const turnData: TurnData[] = [];
|
const turnData: TurnData[] = [];
|
||||||
for (const turnRef of turns) {
|
for (const turnRef of turns) {
|
||||||
if (typeof turnRef !== "string") {
|
const parsed = parseSingleTurn(store, turnRef, turnData.length);
|
||||||
continue;
|
if (parsed !== null) {
|
||||||
}
|
turnData.push(parsed);
|
||||||
const turnNode = store.get(turnRef as CasRef);
|
|
||||||
if (turnNode === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const turn = turnNode.payload as Record<string, unknown>;
|
|
||||||
if (typeof turn.content === "string") {
|
|
||||||
turnData.push({
|
|
||||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
|
||||||
content: turn.content,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return turnData;
|
return turnData;
|
||||||
@@ -167,7 +231,7 @@ function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): Turn
|
|||||||
if (turn === undefined) continue;
|
if (turn === undefined) continue;
|
||||||
|
|
||||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||||
const turnBlock = turnHeader + turn.content;
|
const turnBlock = turnHeader + formatTurnBody(turn);
|
||||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||||
const addCost = turnBlock.length + separatorCost;
|
const addCost = turnBlock.length + separatorCost;
|
||||||
|
|
||||||
@@ -212,7 +276,7 @@ function formatStepMarkdown(
|
|||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push(`## Turn ${turn.index + 1}`);
|
parts.push(`## Turn ${turn.index + 1}`);
|
||||||
parts.push("");
|
parts.push("");
|
||||||
parts.push(turn.content);
|
parts.push(formatTurnBody(turn));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { execFileSync, spawn } from "node:child_process";
|
|||||||
import { access, readFile } from "node:fs/promises";
|
import { access, readFile } from "node:fs/promises";
|
||||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||||
import { validate } from "@uncaged/json-cas";
|
import { validate } from "@uncaged/json-cas";
|
||||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
|
|
||||||
import { evaluate } from "@uncaged/workflow-moderator";
|
|
||||||
import type {
|
import type {
|
||||||
AgentAlias,
|
AgentAlias,
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
@@ -24,9 +22,11 @@ import {
|
|||||||
generateUlid,
|
generateUlid,
|
||||||
type ProcessLogger,
|
type ProcessLogger,
|
||||||
} from "@uncaged/workflow-util";
|
} from "@uncaged/workflow-util";
|
||||||
|
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
|
||||||
import { config as loadDotenv } from "dotenv";
|
import { config as loadDotenv } from "dotenv";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
|
||||||
|
import { evaluate } from "../moderator/index.js";
|
||||||
import {
|
import {
|
||||||
appendThreadHistory,
|
appendThreadHistory,
|
||||||
createUwfStore,
|
createUwfStore,
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type UwfStore,
|
type UwfStore,
|
||||||
} from "../store.js";
|
} from "../store.js";
|
||||||
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
|
||||||
|
import { validateWorkflow } from "../validate-semantic.js";
|
||||||
import {
|
import {
|
||||||
type ChainState,
|
type ChainState,
|
||||||
collectOrderedSteps,
|
collectOrderedSteps,
|
||||||
@@ -169,6 +170,11 @@ async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promis
|
|||||||
fail(filenameError);
|
fail(filenameError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const semanticErrors = validateWorkflow(payload);
|
||||||
|
if (semanticErrors.length > 0) {
|
||||||
|
fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
|
||||||
|
}
|
||||||
|
|
||||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||||
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
|
||||||
const stored = uwf.store.get(hash);
|
const stored = uwf.store.get(hash);
|
||||||
@@ -560,14 +566,25 @@ function selectByQuota(
|
|||||||
return { selected, skippedCount: candidates.length - selected.length };
|
return { selected, skippedCount: candidates.length - selected.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
const seconds = ms / 1000;
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSec = Math.round(seconds % 60);
|
||||||
|
return `${minutes}m${remainingSec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
||||||
const ts = new Date(item.timestamp)
|
const ts = new Date(item.timestamp)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace("T", " ")
|
.replace("T", " ")
|
||||||
.replace(/\.\d+Z$/, "");
|
.replace(/\.\d+Z$/, "");
|
||||||
|
const durationMs = item.payload.completedAtMs - item.payload.startedAtMs;
|
||||||
|
const duration = formatDuration(durationMs);
|
||||||
return [
|
return [
|
||||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
`**Agent:** ${item.payload.agent} | **Time:** ${ts} | **Duration:** ${duration}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,14 +686,16 @@ function formatThreadReadMarkdown(options: {
|
|||||||
return parts.join("\n\n---\n\n");
|
return parts.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
type EvaluateLastOutput = Record<string, unknown>;
|
||||||
|
|
||||||
|
const STATUS_KEY = "$status";
|
||||||
|
|
||||||
function resolveEvaluateArgs(
|
function resolveEvaluateArgs(
|
||||||
uwf: UwfStore,
|
uwf: UwfStore,
|
||||||
chain: ChainState,
|
chain: ChainState,
|
||||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||||
if (chain.headIsStart) {
|
if (chain.headIsStart) {
|
||||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "_" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastStep = chain.stepsNewestFirst[0];
|
const lastStep = chain.stepsNewestFirst[0];
|
||||||
@@ -689,11 +708,10 @@ function resolveEvaluateArgs(
|
|||||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||||
? (raw as Record<string, unknown>)
|
? (raw as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
const status = typeof base.status === "string" ? base.status : "_";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastRole: lastStep.role,
|
lastRole: lastStep.role,
|
||||||
lastOutput: { ...base, status },
|
lastOutput: base,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type UwfStore,
|
type UwfStore,
|
||||||
} from "../store.js";
|
} from "../store.js";
|
||||||
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
|
||||||
|
import { validateWorkflow } from "../validate-semantic.js";
|
||||||
|
|
||||||
export type WorkflowOrigin = "local" | "global";
|
export type WorkflowOrigin = "local" | "global";
|
||||||
|
|
||||||
@@ -136,6 +137,11 @@ export async function cmdWorkflowAdd(
|
|||||||
fail(filenameError);
|
fail(filenameError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const semanticErrors = validateWorkflow(payload);
|
||||||
|
if (semanticErrors.length > 0) {
|
||||||
|
fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
|
||||||
|
}
|
||||||
|
|
||||||
const uwf = await createUwfStore(storageRoot);
|
const uwf = await createUwfStore(storageRoot);
|
||||||
const materialized = await materializeWorkflowPayload(uwf, payload);
|
const materialized = await materializeWorkflowPayload(uwf, payload);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { evaluate } from "./evaluate.js";
|
||||||
|
export type { EvaluateResult } from "./types.js";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import type { WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
type SchemaObj = Record<string, unknown>;
|
||||||
|
|
||||||
|
const RESERVED_NAMES = new Set(["$START", "$END"]);
|
||||||
|
|
||||||
|
/** Extract mustache variable names from a prompt string. */
|
||||||
|
function extractMustacheVars(prompt: string): string[] {
|
||||||
|
const vars: string[] = [];
|
||||||
|
const re = /\{\{\{?([^}]+)\}\}\}?/g;
|
||||||
|
let m: RegExpExecArray | null = re.exec(prompt);
|
||||||
|
while (m !== null) {
|
||||||
|
vars.push(m[1]);
|
||||||
|
m = re.exec(prompt);
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a frontmatter schema is a oneOf (multi-exit) type. */
|
||||||
|
function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
|
||||||
|
if (typeof fm !== "object" || fm === null) return false;
|
||||||
|
const obj = fm as SchemaObj;
|
||||||
|
return Array.isArray(obj.oneOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a frontmatter schema uses enum-based multi-exit ($status with multiple enum values). */
|
||||||
|
function isEnumMultiExit(fm: unknown): boolean {
|
||||||
|
if (typeof fm !== "object" || fm === null) return false;
|
||||||
|
const obj = fm as SchemaObj;
|
||||||
|
const props = obj.properties as Record<string, SchemaObj> | undefined;
|
||||||
|
if (!props?.$status) return false;
|
||||||
|
const statusDef = props.$status;
|
||||||
|
if (!Array.isArray(statusDef.enum)) return false;
|
||||||
|
// Filter out "_" (wildcard) — if remaining values > 1, it's multi-exit
|
||||||
|
const statuses = (statusDef.enum as string[]).filter((s) => s !== "_");
|
||||||
|
return statuses.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract status values from an enum-based $status field. */
|
||||||
|
function getEnumStatuses(fm: SchemaObj): string[] {
|
||||||
|
const props = fm.properties as Record<string, SchemaObj> | undefined;
|
||||||
|
if (!props?.$status) return [];
|
||||||
|
const statusDef = props.$status;
|
||||||
|
if (!Array.isArray(statusDef.enum)) return [];
|
||||||
|
return (statusDef.enum as string[]).filter((s) => s !== "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get property names from a schema object. */
|
||||||
|
function getPropertyNames(schema: SchemaObj): Set<string> {
|
||||||
|
const props = schema.properties;
|
||||||
|
if (typeof props !== "object" || props === null) return new Set();
|
||||||
|
return new Set(Object.keys(props as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract $status const values from oneOf variants. */
|
||||||
|
function getOneOfStatuses(variants: SchemaObj[]): string[] {
|
||||||
|
const statuses: string[] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
const props = variant.properties as Record<string, SchemaObj> | undefined;
|
||||||
|
if (props?.$status) {
|
||||||
|
const statusDef = props.$status;
|
||||||
|
if (typeof statusDef.const === "string") {
|
||||||
|
statuses.push(statusDef.const);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check reserved names and role/graph reference integrity. */
|
||||||
|
function checkRoleReferences(payload: WorkflowPayload, errors: string[]): void {
|
||||||
|
const roleNames = new Set(Object.keys(payload.roles));
|
||||||
|
const graphNodes = new Set(Object.keys(payload.graph));
|
||||||
|
|
||||||
|
for (const name of roleNames) {
|
||||||
|
if (RESERVED_NAMES.has(name)) {
|
||||||
|
errors.push(`reserved name "${name}" must not appear in roles`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of graphNodes) {
|
||||||
|
if (!RESERVED_NAMES.has(node) && !roleNames.has(node)) {
|
||||||
|
errors.push(`graph references unknown role "${node}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of roleNames) {
|
||||||
|
if (RESERVED_NAMES.has(name)) continue;
|
||||||
|
if (!graphNodes.has(name)) {
|
||||||
|
errors.push(`role "${name}" is defined but not referenced in graph`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check $START/$END constraints, edge targets, and reachability. */
|
||||||
|
function checkGraphStructure(payload: WorkflowPayload, errors: string[]): void {
|
||||||
|
const roleNames = new Set(Object.keys(payload.roles));
|
||||||
|
const graphNodes = new Set(Object.keys(payload.graph));
|
||||||
|
|
||||||
|
if (!graphNodes.has("$START")) {
|
||||||
|
errors.push("$START must be defined in graph");
|
||||||
|
} else {
|
||||||
|
const startKeys = Object.keys(payload.graph.$START);
|
||||||
|
if (startKeys.length !== 1 || startKeys[0] !== "_") {
|
||||||
|
errors.push('$START must have exactly one edge with status "_"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphNodes.has("$END")) {
|
||||||
|
errors.push("$END must not have outgoing edges");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [node, statusMap] of Object.entries(payload.graph)) {
|
||||||
|
for (const [status, target] of Object.entries(statusMap)) {
|
||||||
|
if (target.role !== "$END" && !roleNames.has(target.role)) {
|
||||||
|
errors.push(`edge ${node}→${status}: unknown target role "${target.role}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkReachability(roleNames, collectReachableRoles(payload.graph), errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BFS to collect all roles reachable from $START. */
|
||||||
|
function collectReachableRoles(graph: WorkflowPayload["graph"]): Set<string> {
|
||||||
|
const reachable = new Set<string>();
|
||||||
|
const startEdges = graph.$START;
|
||||||
|
if (!startEdges) return reachable;
|
||||||
|
|
||||||
|
const queue: string[] = [];
|
||||||
|
for (const target of Object.values(startEdges)) {
|
||||||
|
if (target.role !== "$END" && !reachable.has(target.role)) {
|
||||||
|
reachable.add(target.role);
|
||||||
|
queue.push(target.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift() as string;
|
||||||
|
const edges = graph[current];
|
||||||
|
if (!edges) continue;
|
||||||
|
for (const target of Object.values(edges)) {
|
||||||
|
if (target.role !== "$END" && !reachable.has(target.role)) {
|
||||||
|
reachable.add(target.role);
|
||||||
|
queue.push(target.role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reachable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check that all defined roles are reachable from $START. */
|
||||||
|
function checkReachability(roleNames: Set<string>, reachable: Set<string>, errors: string[]): void {
|
||||||
|
for (const name of roleNames) {
|
||||||
|
if (RESERVED_NAMES.has(name)) continue;
|
||||||
|
if (!reachable.has(name)) {
|
||||||
|
errors.push(`role "${name}" is not reachable from $START`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check oneOf discriminant validity for a role. */
|
||||||
|
function checkOneOfDiscriminant(
|
||||||
|
roleName: string,
|
||||||
|
variants: SchemaObj[],
|
||||||
|
statuses: string[],
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (statuses.length === variants.length) return;
|
||||||
|
|
||||||
|
let foundMissing = false;
|
||||||
|
for (const variant of variants) {
|
||||||
|
const props = variant.properties as Record<string, SchemaObj> | undefined;
|
||||||
|
if (!props?.$status) {
|
||||||
|
errors.push(`role "${roleName}": oneOf variants must have "$status" as const discriminant`);
|
||||||
|
foundMissing = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (typeof props.$status.const !== "string") {
|
||||||
|
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
|
||||||
|
foundMissing = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundMissing) {
|
||||||
|
errors.push(`role "${roleName}": oneOf variant $status must be a const value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check status-edge consistency for a multi-exit role. */
|
||||||
|
function checkMultiExitEdges(
|
||||||
|
roleName: string,
|
||||||
|
graphKeys: Set<string>,
|
||||||
|
statusSet: Set<string>,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (graphKeys.has("_")) {
|
||||||
|
errors.push(`role "${roleName}" is multi-exit but graph uses "_"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraKeys = [...graphKeys].filter((k) => !statusSet.has(k));
|
||||||
|
const missingKeys = [...statusSet].filter((k) => !graphKeys.has(k));
|
||||||
|
if (extraKeys.length > 0) {
|
||||||
|
errors.push(`role "${roleName}" graph has extra status keys: ${extraKeys.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
errors.push(`role "${roleName}" graph is missing status keys: ${missingKeys.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check mustache variables for multi-exit role. */
|
||||||
|
function checkMultiExitMustache(
|
||||||
|
roleName: string,
|
||||||
|
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||||
|
variants: SchemaObj[],
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
for (const [status, target] of Object.entries(graphEntry)) {
|
||||||
|
const vars = extractMustacheVars(target.prompt);
|
||||||
|
const variant = variants.find((v) => {
|
||||||
|
const props = v.properties as Record<string, SchemaObj> | undefined;
|
||||||
|
return props?.$status?.const === status;
|
||||||
|
});
|
||||||
|
if (!variant) continue;
|
||||||
|
const propNames = getPropertyNames(variant);
|
||||||
|
for (const v of vars) {
|
||||||
|
if (v === "$status") continue;
|
||||||
|
if (!propNames.has(v)) {
|
||||||
|
errors.push(`prompt variable "${v}" not found in role "${roleName}" variant "${status}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check status-edge consistency and mustache for each role. */
|
||||||
|
function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void {
|
||||||
|
for (const [roleName, role] of Object.entries(payload.roles)) {
|
||||||
|
if (RESERVED_NAMES.has(roleName)) continue;
|
||||||
|
const graphEntry = payload.graph[roleName];
|
||||||
|
if (!graphEntry) continue;
|
||||||
|
|
||||||
|
const fm = role.frontmatter as unknown;
|
||||||
|
const graphKeys = new Set(Object.keys(graphEntry));
|
||||||
|
|
||||||
|
if (isOneOfSchema(fm)) {
|
||||||
|
const variants = fm.oneOf as SchemaObj[];
|
||||||
|
const statuses = getOneOfStatuses(variants);
|
||||||
|
|
||||||
|
checkOneOfDiscriminant(roleName, variants, statuses, errors);
|
||||||
|
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
|
||||||
|
checkMultiExitMustache(roleName, graphEntry, variants, errors);
|
||||||
|
} else if (isEnumMultiExit(fm)) {
|
||||||
|
const statuses = getEnumStatuses(fm as SchemaObj);
|
||||||
|
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors);
|
||||||
|
// For enum-based schemas, mustache vars come from the flat properties
|
||||||
|
checkSingleExitMustache(roleName, graphEntry, fm as SchemaObj, errors);
|
||||||
|
} else {
|
||||||
|
checkSingleExitRole(roleName, graphKeys, graphEntry, fm as SchemaObj | null, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check single-exit role status and mustache. */
|
||||||
|
function checkSingleExitRole(
|
||||||
|
roleName: string,
|
||||||
|
graphKeys: Set<string>,
|
||||||
|
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||||
|
fm: SchemaObj | null,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (graphKeys.size > 1 || (graphKeys.size === 1 && !graphKeys.has("_"))) {
|
||||||
|
if (!graphKeys.has("_")) {
|
||||||
|
errors.push(`role "${roleName}" is single-exit but graph has no "_" key`);
|
||||||
|
} else {
|
||||||
|
errors.push(`role "${roleName}" is single-exit but has status keys other than "_"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleTarget = graphEntry._;
|
||||||
|
if (!singleTarget) return;
|
||||||
|
|
||||||
|
const vars = extractMustacheVars(singleTarget.prompt);
|
||||||
|
const propNames = fm ? getPropertyNames(fm) : new Set<string>();
|
||||||
|
for (const v of vars) {
|
||||||
|
if (v === "$status") continue;
|
||||||
|
if (!propNames.has(v)) {
|
||||||
|
errors.push(`prompt variable "${v}" not found in role "${roleName}" frontmatter`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check mustache vars in all edge prompts against flat schema properties. */
|
||||||
|
function checkSingleExitMustache(
|
||||||
|
roleName: string,
|
||||||
|
graphEntry: Record<string, { role: string; prompt: string }>,
|
||||||
|
fm: SchemaObj,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
const propNames = getPropertyNames(fm);
|
||||||
|
for (const [status, target] of Object.entries(graphEntry)) {
|
||||||
|
const vars = extractMustacheVars(target.prompt);
|
||||||
|
for (const v of vars) {
|
||||||
|
if (v === "$status") continue;
|
||||||
|
if (!propNames.has(v)) {
|
||||||
|
errors.push(
|
||||||
|
`prompt variable "${v}" in graph[${roleName}][${status}] not found in role "${roleName}" frontmatter`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a parsed WorkflowPayload for semantic correctness.
|
||||||
|
* Returns an array of error messages. Empty array = valid.
|
||||||
|
*/
|
||||||
|
export function validateWorkflow(payload: WorkflowPayload): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
checkRoleReferences(payload, errors);
|
||||||
|
checkGraphStructure(payload, errors);
|
||||||
|
checkRoleConsistency(payload, errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ function isRoleDefinition(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const frontmatter = value.frontmatter;
|
const frontmatter = value.frontmatter;
|
||||||
const frontmatterOk = isRecord(frontmatter) && typeof frontmatter.type === "string";
|
const frontmatterOk =
|
||||||
|
isRecord(frontmatter) &&
|
||||||
|
(typeof frontmatter.type === "string" || Array.isArray(frontmatter.oneOf));
|
||||||
const capabilities = value.capabilities;
|
const capabilities = value.capabilities;
|
||||||
const capabilitiesOk =
|
const capabilitiesOk =
|
||||||
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
|
Array.isArray(capabilities) && capabilities.every((c) => typeof c === "string");
|
||||||
|
|||||||
@@ -5,9 +5,5 @@
|
|||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [
|
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util-agent" }]
|
||||||
{ "path": "../workflow-protocol" },
|
|
||||||
{ "path": "../workflow-moderator" },
|
|
||||||
{ "path": "../workflow-agent-kit" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop wit
|
|||||||
|
|
||||||
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
|
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-util`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-util`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import type { AgentContext } from "@uncaged/workflow-agent-kit";
|
import type { AgentContext } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import { buildBuiltinMessages } from "../src/prompt.js";
|
import { buildBuiltinMessages } from "../src/prompt.js";
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"test:ci": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:^"
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,5 +31,15 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
}
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||||
|
"directory": "packages/workflow-agent-builtin"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Store } from "@uncaged/json-cas";
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
@@ -6,8 +7,7 @@ import {
|
|||||||
loadWorkflowConfig,
|
loadWorkflowConfig,
|
||||||
resolveModel,
|
resolveModel,
|
||||||
resolveStorageRoot,
|
resolveStorageRoot,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-util-agent";
|
||||||
import { createLogger, generateUlid } from "@uncaged/workflow-util";
|
|
||||||
|
|
||||||
import { storeBuiltinDetail } from "./detail.js";
|
import { storeBuiltinDetail } from "./detail.js";
|
||||||
import type { ChatMessage } from "./llm/index.js";
|
import type { ChatMessage } from "./llm/index.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
|
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
|
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
|
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import type { ChatMessage } from "./llm/index.js";
|
import type { ChatMessage } from "./llm/index.js";
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }]
|
"references": [{ "path": "../workflow-util-agent" }, { "path": "../workflow-util" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
|
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -86,6 +86,6 @@ src/
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Uses session caching from `@uncaged/workflow-agent-kit` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
|
Uses session caching from `@uncaged/workflow-util-agent` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
|
||||||
|
|
||||||
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
|
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { AgentContext } from "@uncaged/workflow-agent-kit";
|
|
||||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import type { AgentContext } from "@uncaged/workflow-util-agent";
|
||||||
import { buildClaudeCodePrompt } from "../src/claude-code.js";
|
import { buildClaudeCodePrompt } from "../src/claude-code.js";
|
||||||
|
|
||||||
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"test:ci": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:^"
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,5 +31,15 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
}
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||||
|
"directory": "packages/workflow-agent-claude-code"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { Store } from "@uncaged/json-cas";
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
@@ -8,8 +9,7 @@ import {
|
|||||||
createAgent,
|
createAgent,
|
||||||
getCachedSessionId,
|
getCachedSessionId,
|
||||||
setCachedSessionId,
|
setCachedSessionId,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-util-agent";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
|
|
||||||
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
@@ -2,5 +2,5 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
|
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../workflow-agent-kit" }]
|
"references": [{ "path": "../workflow-util-agent" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-util-agent`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -18,6 +18,15 @@ bun add -g @uncaged/workflow-agent-hermes
|
|||||||
|
|
||||||
Requires the `hermes` CLI on `PATH`.
|
Requires the `hermes` CLI on `PATH`.
|
||||||
|
|
||||||
|
Hermes must write session JSON snapshots so `uwf-hermes` can load structured tool calls from disk. Add this to `~/.hermes/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sessions:
|
||||||
|
write_json_snapshots: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Session files are stored at `~/.hermes/sessions/session_{sessionId}.json`.
|
||||||
|
|
||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
Invoked by `uwf thread step` (not typically run directly):
|
Invoked by `uwf thread step` (not typically run directly):
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|||||||
|
|
||||||
import { HermesAcpClient } from "../src/acp-client.js";
|
import { HermesAcpClient } from "../src/acp-client.js";
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
describe("handleSessionUpdate — text extraction", () => {
|
||||||
|
|
||||||
describe("handleSessionUpdate — helper extraction", () => {
|
|
||||||
let client: HermesAcpClient;
|
let client: HermesAcpClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -16,153 +14,41 @@ describe("handleSessionUpdate — helper extraction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("agent_message_chunk accumulates text in messageChunks", () => {
|
it("agent_message_chunk accumulates text in messageChunks", () => {
|
||||||
(client as any).handleSessionUpdate({
|
(
|
||||||
|
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
|
||||||
|
).handleSessionUpdate({
|
||||||
sessionUpdate: "agent_message_chunk",
|
sessionUpdate: "agent_message_chunk",
|
||||||
content: { type: "text", text: "hello" },
|
content: { type: "text", text: "hello" },
|
||||||
});
|
});
|
||||||
(client as any).handleSessionUpdate({
|
(
|
||||||
|
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
|
||||||
|
).handleSessionUpdate({
|
||||||
sessionUpdate: "agent_message_chunk",
|
sessionUpdate: "agent_message_chunk",
|
||||||
content: { type: "text", text: " world" },
|
content: { type: "text", text: " world" },
|
||||||
});
|
});
|
||||||
expect((client as any).messageChunks).toEqual(["hello", " world"]);
|
expect((client as unknown as { messageChunks: string[] }).messageChunks).toEqual([
|
||||||
|
"hello",
|
||||||
|
" world",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("agent_thought_chunk accumulates reasoning in reasoningChunks", () => {
|
it("non-text chunks and other update types are ignored", () => {
|
||||||
(client as any).handleSessionUpdate({
|
(
|
||||||
sessionUpdate: "agent_thought_chunk",
|
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
|
||||||
content: { type: "text", text: "thinking" },
|
).handleSessionUpdate({
|
||||||
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
content: { type: "image", text: "ignored" },
|
||||||
});
|
});
|
||||||
expect((client as any).reasoningChunks).toEqual(["thinking"]);
|
(
|
||||||
});
|
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
|
||||||
|
).handleSessionUpdate({
|
||||||
it("tool_call registers a pending tool and flushes message chunks", () => {
|
|
||||||
(client as any).messageChunks = ["pre-tool text"];
|
|
||||||
(client as any).handleSessionUpdate({
|
|
||||||
sessionUpdate: "tool_call",
|
sessionUpdate: "tool_call",
|
||||||
title: "Bash",
|
title: "Bash",
|
||||||
rawInput: { command: "ls" },
|
|
||||||
toolCallId: "tc-1",
|
toolCallId: "tc-1",
|
||||||
});
|
});
|
||||||
expect((client as any).pendingTools.get("tc-1")).toEqual({
|
(
|
||||||
name: "Bash",
|
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
|
||||||
args: JSON.stringify({ command: "ls" }),
|
).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
|
||||||
});
|
expect((client as unknown as { messageChunks: string[] }).messageChunks).toHaveLength(0);
|
||||||
expect((client as any).messageChunks).toEqual([]);
|
|
||||||
expect((client as any).messages).toHaveLength(1);
|
|
||||||
expect((client as any).messages[0].role).toBe("assistant");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tool_call_update completed pushes tool_call and tool messages", () => {
|
|
||||||
(client as any).pendingTools.set("tc-2", { name: "Read", args: '{"path":"/foo"}' });
|
|
||||||
(client as any).handleSessionUpdate({
|
|
||||||
sessionUpdate: "tool_call_update",
|
|
||||||
status: "completed",
|
|
||||||
toolCallId: "tc-2",
|
|
||||||
rawOutput: "file contents",
|
|
||||||
});
|
|
||||||
const msgs = (client as any).messages as Array<{
|
|
||||||
role: string;
|
|
||||||
tool_calls: unknown;
|
|
||||||
content: string | null;
|
|
||||||
}>;
|
|
||||||
expect(msgs).toHaveLength(2);
|
|
||||||
expect(msgs[0].role).toBe("assistant");
|
|
||||||
expect(msgs[0].tool_calls).toEqual([
|
|
||||||
{ function: { name: "Read", arguments: '{"path":"/foo"}' } },
|
|
||||||
]);
|
|
||||||
expect(msgs[1].role).toBe("tool");
|
|
||||||
expect(msgs[1].content).toBe("file contents");
|
|
||||||
expect((client as any).pendingTools.has("tc-2")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tool_call_update with non-string rawOutput JSON-stringifies it", () => {
|
|
||||||
(client as any).pendingTools.set("tc-3", { name: "Fetch", args: "" });
|
|
||||||
(client as any).handleSessionUpdate({
|
|
||||||
sessionUpdate: "tool_call_update",
|
|
||||||
status: "completed",
|
|
||||||
toolCallId: "tc-3",
|
|
||||||
rawOutput: { html: "<p>page</p>" },
|
|
||||||
});
|
|
||||||
const msgs = (client as any).messages as Array<{ role: string; content: string | null }>;
|
|
||||||
expect(msgs[1].content).toBe(JSON.stringify({ html: "<p>page</p>" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unknown updateType is a no-op", () => {
|
|
||||||
(client as any).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
|
|
||||||
expect((client as any).messages).toHaveLength(0);
|
|
||||||
expect((client as any).messageChunks).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("HermesAcpClient", () => {
|
|
||||||
let client: HermesAcpClient;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new HermesAcpClient();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
"connect() returns a UUID sessionId",
|
|
||||||
async () => {
|
|
||||||
const sessionId = await client.connect(process.cwd());
|
|
||||||
expect(typeof sessionId).toBe("string");
|
|
||||||
expect(sessionId).toMatch(UUID_RE);
|
|
||||||
},
|
|
||||||
{ timeout: 2 * 60 * 1000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"prompt() returns a non-empty text response",
|
|
||||||
async () => {
|
|
||||||
await client.connect(process.cwd());
|
|
||||||
const result = await client.prompt("Reply with exactly the word: PONG");
|
|
||||||
expect(typeof result.text).toBe("string");
|
|
||||||
expect(result.text.length).toBeGreaterThan(0);
|
|
||||||
expect(typeof result.sessionId).toBe("string");
|
|
||||||
expect(result.sessionId).toMatch(UUID_RE);
|
|
||||||
},
|
|
||||||
{ timeout: 2 * 60 * 1000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"prompt() can be called twice on the same session (resume)",
|
|
||||||
async () => {
|
|
||||||
await client.connect(process.cwd());
|
|
||||||
|
|
||||||
const first = await client.prompt("Say the word ALPHA and nothing else.");
|
|
||||||
expect(first.text.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const second = await client.prompt("Now say the word BETA and nothing else.");
|
|
||||||
expect(second.text.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
expect(first.sessionId).toBe(second.sessionId);
|
|
||||||
},
|
|
||||||
{ timeout: 2 * 60 * 1000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
|
|
||||||
it.skip(
|
|
||||||
"prompt() collects structured messages including tool calls",
|
|
||||||
async () => {
|
|
||||||
await client.connect(process.cwd());
|
|
||||||
const result = await client.prompt("Run this command: echo TOOL_DETAIL_TEST");
|
|
||||||
expect(result.messages.length).toBeGreaterThan(0);
|
|
||||||
// Should have at least one tool message (the echo command)
|
|
||||||
const toolMessages = result.messages.filter((m) => m.role === "tool");
|
|
||||||
expect(toolMessages.length).toBeGreaterThan(0);
|
|
||||||
// Tool message should contain the output
|
|
||||||
const toolContent = toolMessages[0]?.content ?? "";
|
|
||||||
expect(toolContent).toContain("TOOL_DETAIL_TEST");
|
|
||||||
// Should have assistant messages with tool_calls
|
|
||||||
const assistantWithTools = result.messages.filter(
|
|
||||||
(m) => m.role === "assistant" && m.tool_calls !== null,
|
|
||||||
);
|
|
||||||
expect(assistantWithTools.length).toBeGreaterThan(0);
|
|
||||||
},
|
|
||||||
{ timeout: 2 * 60 * 1000 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { AgentContext } from "@uncaged/workflow-agent-kit";
|
|
||||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
|
import type { AgentContext } from "@uncaged/workflow-util-agent";
|
||||||
import { buildHermesPrompt } from "../src/hermes.js";
|
import { buildHermesPrompt } from "../src/hermes.js";
|
||||||
|
|
||||||
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { HermesAcpClient } from "../../src/acp-client.js";
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
describe("HermesAcpClient", () => {
|
||||||
|
let client: HermesAcpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new HermesAcpClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"connect() returns a UUID sessionId",
|
||||||
|
async () => {
|
||||||
|
const sessionId = await client.connect(process.cwd());
|
||||||
|
expect(typeof sessionId).toBe("string");
|
||||||
|
expect(sessionId).toMatch(UUID_RE);
|
||||||
|
},
|
||||||
|
{ timeout: 2 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"prompt() returns a non-empty text response",
|
||||||
|
async () => {
|
||||||
|
await client.connect(process.cwd());
|
||||||
|
const result = await client.prompt("Reply with exactly the word: PONG");
|
||||||
|
expect(typeof result.text).toBe("string");
|
||||||
|
expect(result.text.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof result.sessionId).toBe("string");
|
||||||
|
expect(result.sessionId).toMatch(UUID_RE);
|
||||||
|
},
|
||||||
|
{ timeout: 2 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"prompt() can be called twice on the same session (resume)",
|
||||||
|
async () => {
|
||||||
|
await client.connect(process.cwd());
|
||||||
|
|
||||||
|
const first = await client.prompt("Say the word ALPHA and nothing else.");
|
||||||
|
expect(first.text.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const second = await client.prompt("Now say the word BETA and nothing else.");
|
||||||
|
expect(second.text.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(first.sessionId).toBe(second.sessionId);
|
||||||
|
},
|
||||||
|
{ timeout: 2 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from "bun:test";
|
import { afterEach, describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
import { HermesAcpClient } from "../src/acp-client.js";
|
import { HermesAcpClient } from "../../src/acp-client.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E test for cross-process session resume.
|
* E2E test for cross-process session resume.
|
||||||
@@ -18,11 +18,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"test:ci": "bun test __tests__/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
"@uncaged/workflow-agent-kit": "workspace:^",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:^"
|
"@uncaged/workflow-util": "workspace:^"
|
||||||
},
|
},
|
||||||
@@ -31,5 +32,15 @@
|
|||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
}
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||||
|
"directory": "packages/workflow-agent-hermes"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { ChildProcess } from "node:child_process";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
import type { HermesSessionMessage } from "./types.js";
|
|
||||||
|
|
||||||
const HERMES_COMMAND = "hermes";
|
const HERMES_COMMAND = "hermes";
|
||||||
const PROTOCOL_VERSION = 1;
|
const PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
@@ -19,16 +17,9 @@ type PendingRequest = {
|
|||||||
reject: (reason: Error) => void;
|
reject: (reason: Error) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Tracks in-flight tool calls so we can build complete messages when they finish. */
|
|
||||||
type PendingToolCall = {
|
|
||||||
name: string;
|
|
||||||
args: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AcpPromptResult = {
|
export type AcpPromptResult = {
|
||||||
text: string;
|
text: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
messages: HermesSessionMessage[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class HermesAcpClient {
|
export class HermesAcpClient {
|
||||||
@@ -38,11 +29,8 @@ export class HermesAcpClient {
|
|||||||
private stderrBuffer = "";
|
private stderrBuffer = "";
|
||||||
private pending = new Map<number, PendingRequest>();
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
|
||||||
// Message collection state
|
/** Accumulated assistant text chunks from agent_message_chunk updates. */
|
||||||
private messageChunks: string[] = [];
|
private messageChunks: string[] = [];
|
||||||
private reasoningChunks: string[] = [];
|
|
||||||
private pendingTools = new Map<string, PendingToolCall>();
|
|
||||||
messages: HermesSessionMessage[] = [];
|
|
||||||
|
|
||||||
/** Spawn hermes acp, initialize, create session */
|
/** Spawn hermes acp, initialize, create session */
|
||||||
async connect(cwd: string): Promise<string> {
|
async connect(cwd: string): Promise<string> {
|
||||||
@@ -84,14 +72,13 @@ export class HermesAcpClient {
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send prompt and collect full response text + structured messages. */
|
/** Send prompt and collect final assistant text from ACP stream chunks. */
|
||||||
async prompt(text: string): Promise<AcpPromptResult> {
|
async prompt(text: string): Promise<AcpPromptResult> {
|
||||||
if (this.sessionId === null) {
|
if (this.sessionId === null) {
|
||||||
throw new Error("Not connected — call connect() first");
|
throw new Error("Not connected — call connect() first");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageChunks = [];
|
this.messageChunks = [];
|
||||||
this.reasoningChunks = [];
|
|
||||||
|
|
||||||
const response = await this.sendRequest("session/prompt", {
|
const response = await this.sendRequest("session/prompt", {
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
@@ -104,28 +91,9 @@ export class HermesAcpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any trailing assistant text that wasn't followed by a tool call.
|
|
||||||
this.flushAssistantMessage();
|
|
||||||
|
|
||||||
// Extract the final assistant text from collected messages.
|
|
||||||
let finalText = "";
|
|
||||||
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = this.messages[i];
|
|
||||||
if (
|
|
||||||
msg !== undefined &&
|
|
||||||
msg.role === "assistant" &&
|
|
||||||
msg.content !== null &&
|
|
||||||
msg.content.trim() !== ""
|
|
||||||
) {
|
|
||||||
finalText = msg.content;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: finalText,
|
text: this.messageChunks.join(""),
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
messages: this.messages,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,94 +210,16 @@ export class HermesAcpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Session update → structured messages ----
|
|
||||||
|
|
||||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||||
switch (update.sessionUpdate as string) {
|
if (update.sessionUpdate !== "agent_message_chunk") {
|
||||||
case "agent_message_chunk":
|
return;
|
||||||
this.handleAgentMessageChunk(update);
|
|
||||||
break;
|
|
||||||
case "agent_thought_chunk":
|
|
||||||
this.handleAgentThoughtChunk(update);
|
|
||||||
break;
|
|
||||||
case "tool_call":
|
|
||||||
this.handleToolCall(update);
|
|
||||||
break;
|
|
||||||
case "tool_call_update":
|
|
||||||
this.handleToolCallUpdate(update);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private handleAgentMessageChunk(update: Record<string, unknown>): void {
|
|
||||||
const content = update.content as { type?: string; text?: string } | undefined;
|
const content = update.content as { type?: string; text?: string } | undefined;
|
||||||
if (content?.type === "text" && typeof content.text === "string") {
|
if (content?.type === "text" && typeof content.text === "string") {
|
||||||
this.messageChunks.push(content.text);
|
this.messageChunks.push(content.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAgentThoughtChunk(update: Record<string, unknown>): void {
|
|
||||||
const content = update.content as { type?: string; text?: string } | undefined;
|
|
||||||
if (content?.type === "text" && typeof content.text === "string") {
|
|
||||||
this.reasoningChunks.push(content.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleToolCall(update: Record<string, unknown>): void {
|
|
||||||
const title = (update.title as string) ?? "";
|
|
||||||
const rawInput = update.rawInput;
|
|
||||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
|
||||||
const toolCallId = update.toolCallId as string;
|
|
||||||
this.pendingTools.set(toolCallId, { name: title, args });
|
|
||||||
this.flushAssistantMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleToolCallUpdate(update: Record<string, unknown>): void {
|
|
||||||
const status = update.status as string | undefined;
|
|
||||||
if (status !== "completed" && status !== "failed") return;
|
|
||||||
const toolCallId = update.toolCallId as string;
|
|
||||||
const pending = this.pendingTools.get(toolCallId);
|
|
||||||
const toolName = pending?.name ?? toolCallId;
|
|
||||||
const rawOutput = update.rawOutput;
|
|
||||||
const outputStr =
|
|
||||||
rawOutput !== undefined && rawOutput !== null
|
|
||||||
? typeof rawOutput === "string"
|
|
||||||
? rawOutput
|
|
||||||
: JSON.stringify(rawOutput)
|
|
||||||
: "";
|
|
||||||
this.messages.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: null,
|
|
||||||
reasoning: null,
|
|
||||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
|
||||||
});
|
|
||||||
this.messages.push({
|
|
||||||
role: "tool",
|
|
||||||
content: outputStr,
|
|
||||||
reasoning: null,
|
|
||||||
tool_calls: null,
|
|
||||||
});
|
|
||||||
this.pendingTools.delete(toolCallId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flush any accumulated text/reasoning into an assistant message. */
|
|
||||||
private flushAssistantMessage(): void {
|
|
||||||
const text = this.messageChunks.join("");
|
|
||||||
const reasoning = this.reasoningChunks.join("");
|
|
||||||
if (text !== "" || reasoning !== "") {
|
|
||||||
this.messages.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: text || null,
|
|
||||||
reasoning: reasoning || null,
|
|
||||||
tool_calls: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.messageChunks = [];
|
|
||||||
this.reasoningChunks = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private rejectAll(err: Error): void {
|
private rejectAll(err: Error): void {
|
||||||
for (const handler of this.pending.values()) {
|
for (const handler of this.pending.values()) {
|
||||||
handler.reject(err);
|
handler.reject(err);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Store } from "@uncaged/json-cas";
|
import type { Store } from "@uncaged/json-cas";
|
||||||
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
type AgentRunResult,
|
type AgentRunResult,
|
||||||
buildContinuationPrompt,
|
buildContinuationPrompt,
|
||||||
buildRolePrompt,
|
buildRolePrompt,
|
||||||
createAgent,
|
createAgent,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-util-agent";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
|
||||||
|
|
||||||
import { HermesAcpClient } from "./acp-client.js";
|
import { HermesAcpClient } from "./acp-client.js";
|
||||||
import { getCachedSessionId, isResumeDisabled, setCachedSessionId } from "./session-cache.js";
|
import { getCachedSessionId, isResumeDisabled, setCachedSessionId } from "./session-cache.js";
|
||||||
import { storeHermesSessionDetail } from "./session-detail.js";
|
import { loadHermesSession, storeHermesSessionDetail } from "./session-detail.js";
|
||||||
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
@@ -49,17 +49,11 @@ export function buildHermesPrompt(ctx: AgentContext): string {
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storePromptResult(
|
async function storePromptResult(store: Store, sessionId: string): Promise<{ detailHash: string }> {
|
||||||
store: Store,
|
const session = await loadHermesSession(sessionId);
|
||||||
sessionId: string,
|
if (session === null) {
|
||||||
messages: Awaited<ReturnType<HermesAcpClient["prompt"]>>["messages"],
|
throw new Error(`Hermes session file not found: ${sessionId}`);
|
||||||
): Promise<{ detailHash: string }> {
|
}
|
||||||
const session = {
|
|
||||||
session_id: sessionId,
|
|
||||||
model: "",
|
|
||||||
session_start: new Date().toISOString(),
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
return storeHermesSessionDetail(store, session);
|
return storeHermesSessionDetail(store, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +110,8 @@ export function createHermesAgent(): () => Promise<void> {
|
|||||||
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
|
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
|
||||||
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
|
const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true };
|
||||||
const fullPrompt = buildHermesPrompt(effectiveCtx);
|
const fullPrompt = buildHermesPrompt(effectiveCtx);
|
||||||
const { text, sessionId, messages } = await client.prompt(fullPrompt);
|
const { text, sessionId } = await client.prompt(fullPrompt);
|
||||||
const { detailHash } = await storePromptResult(ctx.store, sessionId, messages);
|
const { detailHash } = await storePromptResult(ctx.store, sessionId);
|
||||||
|
|
||||||
if (!isResumeDisabled()) {
|
if (!isResumeDisabled()) {
|
||||||
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
|
await setCachedSessionId(ctx.threadId, ctx.role, sessionId);
|
||||||
@@ -152,8 +146,8 @@ export function createHermesAgent(): () => Promise<void> {
|
|||||||
): Promise<AgentRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
// Client is already connected from runHermes — same ACP session,
|
// Client is already connected from runHermes — same ACP session,
|
||||||
// so the agent sees the full conversation history (crucial for retries).
|
// so the agent sees the full conversation history (crucial for retries).
|
||||||
const { text, sessionId, messages } = await client.prompt(message);
|
const { text, sessionId } = await client.prompt(message);
|
||||||
const { detailHash } = await storePromptResult(store, sessionId, messages);
|
const { detailHash } = await storePromptResult(store, sessionId);
|
||||||
return { output: text, detailHash, sessionId };
|
return { output: text, detailHash, sessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Re-export session cache from the shared agent-kit package with agent name injected.
|
// Re-export session cache from the shared agent-kit package with agent name injected.
|
||||||
|
|
||||||
|
import type { ThreadId } from "@uncaged/workflow-protocol";
|
||||||
import {
|
import {
|
||||||
getCachedSessionId as getCachedSessionIdBase,
|
getCachedSessionId as getCachedSessionIdBase,
|
||||||
setCachedSessionId as setCachedSessionIdBase,
|
setCachedSessionId as setCachedSessionIdBase,
|
||||||
} from "@uncaged/workflow-agent-kit";
|
} from "@uncaged/workflow-util-agent";
|
||||||
import type { ThreadId } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
|
||||||
return getCachedSessionIdBase("hermes", threadId, role);
|
return getCachedSessionIdBase("hermes", threadId, role);
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../workflow-agent-kit" }]
|
"references": [{ "path": "../workflow-util-agent" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ type RoleNodeData = {
|
|||||||
|
|
||||||
**边类型**:
|
**边类型**:
|
||||||
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
- `default`(GradientEdge)→ 渐变色边(绿→蓝),节点仅有一条出边时使用
|
||||||
- `conditional`(ConditionalEdge)→ 带条件标签的渐变色边,节点有多条出边时使用
|
- `status`(StatusEdge)→ 带 status 标签的渐变色边,节点有多条出边时使用
|
||||||
|
|
||||||
**边渲染特性**:
|
**边渲染特性**:
|
||||||
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
- 渐变色:SVG linearGradient,从 source 端绿色(#10b981)到 target 端蓝色(#3b82f6)
|
||||||
@@ -234,7 +234,7 @@ Model 提供事务机制:
|
|||||||
```
|
```
|
||||||
ReactFlow
|
ReactFlow
|
||||||
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
├─ nodeTypes: { start: NodeStart, end: NodeEnd, role: NodeRole }
|
||||||
└─ edgeTypes: { default: GradientEdge, conditional: ConditionalEdge }
|
└─ edgeTypes: { default: GradientEdge, status: StatusEdge }
|
||||||
```
|
```
|
||||||
|
|
||||||
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
`NodeRole` 显示角色名(data.name),使用 teal 色系图标和标签。Handle 分蓝色(in)和绿色(out)两种颜色。
|
||||||
@@ -324,12 +324,11 @@ type WorkflowPayload = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
roles: Record<string, RoleDefinition>; // 角色定义(4 段式:identity/prepare/execute/report)
|
||||||
conditions: Record<string, ConditionDefinition>; // JSONata 条件表达式
|
graph: Record<string, Record<string, Target>>; // status-based 路由图
|
||||||
graph: Record<string, Transition[]>; // 角色间的转移图
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Transition`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
workflow-dashboard 使用 `WorkFlowSteps` 格式作为交换数据,其中 `WorkFlowRole` 的字段与 `RoleDefinition` 对齐(description/identity/prepare/execute/report),`WorkFlowTransition` 对应 graph 中的 `Target`。外部(CLI/server)负责 `WorkflowPayload` ↔ `WorkFlowSteps` 的转换。
|
||||||
|
|
||||||
## 11. 当前状态与待完善项
|
## 11. 当前状态与待完善项
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createApi() {
|
|||||||
transitions: t.Array(
|
transitions: t.Array(
|
||||||
t.Object({
|
t.Object({
|
||||||
target: t.String(),
|
target: t.String(),
|
||||||
condition: t.Union([t.String(), t.Null()]),
|
status: t.String(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||||
|
|
||||||
@@ -11,17 +11,12 @@ async function ensureDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||||
const conditionMap = new Map<string, string>();
|
|
||||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
|
||||||
conditionMap.set(name, def.expression);
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps: WorkFlowSteps = [];
|
const steps: WorkFlowSteps = [];
|
||||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||||
const graphTransitions = payload.graph[roleName] ?? [];
|
const statusMap = payload.graph[roleName] ?? {};
|
||||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||||
target: t.role === "$END" ? "END" : t.role,
|
target: target.role === "$END" ? "END" : target.role,
|
||||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
|||||||
|
|
||||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||||
const roles: Record<string, RoleDefinition> = {};
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
const conditions: WorkflowPayload["conditions"] = {};
|
const graph: Record<string, Record<string, Target>> = {};
|
||||||
const graph: Record<string, Transition[]> = {};
|
|
||||||
|
|
||||||
const expressionToName = new Map<string, string>();
|
|
||||||
let condIdx = 0;
|
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const r = step.role;
|
const r = step.role;
|
||||||
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
|||||||
frontmatter: "",
|
frontmatter: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const transitions: Transition[] = step.transitions.map((t) => {
|
const statusMap: Record<string, Target> = {};
|
||||||
let condName: string | null = null;
|
for (const t of step.transitions) {
|
||||||
if (t.condition) {
|
|
||||||
if (expressionToName.has(t.condition)) {
|
|
||||||
condName = expressionToName.get(t.condition) ?? null;
|
|
||||||
} else {
|
|
||||||
condName = `cond${condIdx++}`;
|
|
||||||
expressionToName.set(t.condition, condName);
|
|
||||||
conditions[condName] = {
|
|
||||||
description: "",
|
|
||||||
expression: t.condition,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const targetRole = t.target === "END" ? "$END" : t.target;
|
const targetRole = t.target === "END" ? "$END" : t.target;
|
||||||
return {
|
statusMap[t.status] = {
|
||||||
role: targetRole,
|
role: targetRole,
|
||||||
condition: condName,
|
|
||||||
prompt: `Transition to ${targetRole}.`,
|
prompt: `Transition to ${targetRole}.`,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
graph[r.name] = statusMap;
|
||||||
graph[r.name] = transitions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
const firstRole = steps[0].role.name;
|
const firstRole = steps[0].role.name;
|
||||||
graph.$START = [
|
graph.$START = {
|
||||||
{
|
_: {
|
||||||
role: firstRole,
|
role: firstRole,
|
||||||
condition: null,
|
|
||||||
prompt: `Begin workflow at role ${firstRole}.`,
|
prompt: `Begin workflow at role ${firstRole}.`,
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, description, roles, conditions, graph };
|
return { name, description, roles, graph };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||||
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
|
|||||||
|
|
||||||
export type WorkFlowTransition = {
|
export type WorkFlowTransition = {
|
||||||
target: string;
|
target: string;
|
||||||
condition: string | null;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkFlowStep = {
|
export type WorkFlowStep = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
import { GradientEdge, StatusEdge } from "./status";
|
||||||
|
|
||||||
export const edgeTypes = {
|
export const edgeTypes = {
|
||||||
conditional: ConditionalEdge,
|
status: StatusEdge,
|
||||||
default: GradientEdge,
|
default: GradientEdge,
|
||||||
};
|
};
|
||||||
|
|||||||
+24
-52
@@ -6,10 +6,10 @@ import {
|
|||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../../lib/utils.ts";
|
import { cn } from "../../lib/utils.ts";
|
||||||
import { useModel } from "../context.tsx";
|
import { useModel } from "../context.tsx";
|
||||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
import type { StatusEdge as StatusEdgeType } from "../type.ts";
|
||||||
|
|
||||||
const SOURCE_COLOR = "#10b981";
|
const SOURCE_COLOR = "#10b981";
|
||||||
const TARGET_COLOR = "#3b82f6";
|
const TARGET_COLOR = "#3b82f6";
|
||||||
@@ -23,7 +23,7 @@ function GradientPath({
|
|||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
hasCondition,
|
hasStatus,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,11 +32,11 @@ function GradientPath({
|
|||||||
sourceY: number;
|
sourceY: number;
|
||||||
targetX: number;
|
targetX: number;
|
||||||
targetY: number;
|
targetY: number;
|
||||||
hasCondition: boolean | null;
|
hasStatus: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const gradientId = `gradient-${id}`;
|
const gradientId = `gradient-${id}`;
|
||||||
const showLack = hasCondition === false;
|
const showLack = !hasStatus;
|
||||||
const strokeStyle = selected
|
const strokeStyle = selected
|
||||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||||
@@ -68,35 +68,20 @@ function GradientPath({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
type StatusLabelProps = {
|
||||||
return (
|
status: string | undefined;
|
||||||
<div
|
|
||||||
className="absolute pointer-events-none"
|
|
||||||
style={{
|
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
|
||||||
else
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConditionLabelProps = {
|
|
||||||
condition: string | undefined;
|
|
||||||
labelX: number;
|
labelX: number;
|
||||||
labelY: number;
|
labelY: number;
|
||||||
onSave: (value: string) => void;
|
onSave: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
function handleBadgeClick() {
|
function handleBadgeClick() {
|
||||||
setInputValue(condition || "");
|
setInputValue(status || "");
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const displayStatus = status?.trim() || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block px-1 bg-white rounded text-[10px]",
|
"inline-block px-1 bg-white rounded text-[10px]",
|
||||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
displayStatus
|
||||||
|
? "border border-gray-300 text-black"
|
||||||
|
: "border border-dashed text-red-500",
|
||||||
)}
|
)}
|
||||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
|
||||||
>
|
>
|
||||||
if
|
{displayStatus ?? "status"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||||
placeholder="输入条件"
|
placeholder="输入状态"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
export function StatusEdge({
|
||||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
|
||||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConditionalEdge({
|
|
||||||
id,
|
id,
|
||||||
source,
|
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
@@ -190,7 +173,7 @@ export function ConditionalEdge({
|
|||||||
targetPosition,
|
targetPosition,
|
||||||
selected,
|
selected,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
@@ -203,13 +186,11 @@ export function ConditionalEdge({
|
|||||||
const flow = useReactFlow();
|
const flow = useReactFlow();
|
||||||
const model = useModel();
|
const model = useModel();
|
||||||
|
|
||||||
const allEdges = flow.getEdges();
|
const status = data?.status;
|
||||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
|
||||||
|
|
||||||
const condition = data?.condition;
|
|
||||||
function handleSave(value: string) {
|
function handleSave(value: string) {
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
flow.updateEdgeData(id, { condition: value });
|
flow.updateEdgeData(id, { status: value });
|
||||||
requestAnimationFrame(model.endTransaction);
|
requestAnimationFrame(model.endTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,20 +203,11 @@ export function ConditionalEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={isElse ? null : !!condition}
|
hasStatus={!!status?.trim()}
|
||||||
selected={!!selected}
|
selected={!!selected}
|
||||||
/>
|
/>
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{isElse ? (
|
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
|
||||||
) : (
|
|
||||||
<ConditionLabel
|
|
||||||
condition={condition}
|
|
||||||
labelX={labelX}
|
|
||||||
labelY={labelY}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -269,7 +241,7 @@ export function GradientEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={null}
|
hasStatus={true}
|
||||||
selected={!!selected}
|
selected={!!selected}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
|
|||||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||||
|
|
||||||
if (existingFromSource.length > 0) {
|
if (existingFromSource.length > 0) {
|
||||||
edge.type = "conditional";
|
edge.type = "status";
|
||||||
edge.data = { condition: "" };
|
edge.data = { status: "" };
|
||||||
|
|
||||||
const promoted = currentEdges.map((e) => {
|
const promoted = currentEdges.map((e) => {
|
||||||
if (e.source === normalized.source && e.type !== "conditional") {
|
if (e.source === normalized.source && e.type !== "status") {
|
||||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
return node.type === "start" || node.type === "end";
|
return node.type === "start" || node.type === "end";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFirstConditionalSibling(
|
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||||
edge: { id: string; source: string; type: string | null },
|
|
||||||
allEdges: { id: string; source: string; type: string | null }[],
|
|
||||||
): boolean {
|
|
||||||
if (edge.type !== "conditional") return false;
|
|
||||||
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
|
||||||
return siblings.length >= 2 && siblings[0].id === edge.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
|
||||||
if (nodes.some(isProtectedNode)) return false;
|
if (nodes.some(isProtectedNode)) return false;
|
||||||
if (edges.length > 0) {
|
|
||||||
const allEdges = use(edgesModel)[0];
|
|
||||||
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
|
||||||
}
|
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
if (deletedEdges.length > 0) {
|
if (deletedEdges.length > 0) {
|
||||||
const currentEdges = use(edgesModel)[0];
|
const currentEdges = use(edgesModel)[0];
|
||||||
const sourcesToCheck = new Set(
|
const sourcesToCheck = new Set(
|
||||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourcesToCheck.size > 0) {
|
if (sourcesToCheck.size > 0) {
|
||||||
let needsDowngrade = false;
|
let needsDowngrade = false;
|
||||||
const updatedEdges = currentEdges.map((e) => {
|
const updatedEdges = currentEdges.map((e) => {
|
||||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||||
const siblings = currentEdges.filter(
|
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||||
(s) => s.source === e.source && s.type === "conditional",
|
|
||||||
);
|
|
||||||
if (siblings.length === 1) {
|
if (siblings.length === 1) {
|
||||||
needsDowngrade = true;
|
needsDowngrade = true;
|
||||||
const { data: _, ...rest } = e;
|
const { data: _, ...rest } = e;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("transIn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const endEdge = edges.find((e) => e.target === "end");
|
const endEdge = edges.find((e) => e.target === "end");
|
||||||
expect(endEdge).toBeDefined();
|
expect(endEdge).toBeDefined();
|
||||||
@@ -44,8 +44,8 @@ describe("transIn", () => {
|
|||||||
|
|
||||||
it("4.4 Two steps with default transitions chain", () => {
|
it("4.4 Two steps with default transitions chain", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// Should have start→A, A→B, B→end
|
// Should have start→A, A→B, B→end
|
||||||
@@ -53,15 +53,15 @@ describe("transIn", () => {
|
|||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||||
// No conditional edges
|
// No status edges for single default transitions
|
||||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
expect(edges.every((e) => e.type !== "status")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
it("4.5 Step with multiple transitions → status edges", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [
|
makeStep("A", [
|
||||||
{ condition: null, target: "B" },
|
{ status: "_", target: "B" },
|
||||||
{ condition: "x>0", target: "C" },
|
{ status: "approved", target: "C" },
|
||||||
]),
|
]),
|
||||||
makeStep("B", []),
|
makeStep("B", []),
|
||||||
makeStep("C", []),
|
makeStep("C", []),
|
||||||
@@ -69,23 +69,35 @@ describe("transIn", () => {
|
|||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
expect(outEdges.every((e) => e.type === "status")).toBe(true);
|
||||||
// else-branch has empty condition
|
});
|
||||||
const elseEdge = outEdges.find(
|
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
it("4.5b Multiple transitions include expected status values", () => {
|
||||||
|
const steps = [
|
||||||
|
makeStep("A", [
|
||||||
|
{ status: "_", target: "B" },
|
||||||
|
{ status: "approved", target: "C" },
|
||||||
|
]),
|
||||||
|
makeStep("B", []),
|
||||||
|
makeStep("C", []),
|
||||||
|
];
|
||||||
|
const { edges } = transIn(steps);
|
||||||
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
|
const defaultEdge = outEdges.find(
|
||||||
|
(e) => (e as { data?: { status?: string } }).data?.status === "_",
|
||||||
);
|
);
|
||||||
expect(elseEdge).toBeDefined();
|
expect(defaultEdge).toBeDefined();
|
||||||
// if-branch has condition
|
const approvedEdge = outEdges.find(
|
||||||
const ifEdge = outEdges.find(
|
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
|
||||||
);
|
);
|
||||||
expect(ifEdge).toBeDefined();
|
expect(approvedEdge).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "END" }]),
|
makeStep("A", [{ status: "_", target: "END" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// start→A and start→B; end has 2 incoming edges
|
// start→A and start→B; end has 2 incoming edges
|
||||||
@@ -95,8 +107,8 @@ describe("transIn", () => {
|
|||||||
|
|
||||||
it("4.7 Same role name maps to same node id across steps", () => {
|
it("4.7 Same role name maps to same node id across steps", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "A" }]),
|
makeStep("B", [{ status: "_", target: "A" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const aId = edges.find((e) => e.source === "start")?.target;
|
const aId = edges.find((e) => e.source === "start")?.target;
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ function defaultEdge(source: string, target: string): AnyWorkEdge {
|
|||||||
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||||
}
|
}
|
||||||
|
|
||||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
|
||||||
return {
|
return {
|
||||||
id: `${source}-${target}-cond`,
|
id: `${source}-${target}-status`,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
type: "conditional" as const,
|
type: "status" as const,
|
||||||
data: { condition },
|
data: { status },
|
||||||
animated: true,
|
animated: true,
|
||||||
} as AnyWorkEdge;
|
} as AnyWorkEdge;
|
||||||
}
|
}
|
||||||
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
|
|||||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
it("5.3 Empty status on status edge → error", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
statusEdge("n1", "n3", ""), // empty status → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
const result = validate(nodes, edges);
|
||||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
it("5.4 Mix of status and non-status outgoing → error", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", "x>0"),
|
statusEdge("n1", "n2", "approved"),
|
||||||
defaultEdge("n1", "n3"), // mix → error
|
defaultEdge("n1", "n3"), // mix → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
const result = validate(nodes, edges);
|
||||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||||
@@ -118,15 +118,15 @@ describe("validateRoleNodes (via validate)", () => {
|
|||||||
expect(roleErrors).toHaveLength(0);
|
expect(roleErrors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
|
||||||
const n1 = roleNode("n1");
|
const n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
statusEdge("n1", "n3", "approved"),
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||||
import { uuid } from "../utils";
|
import { uuid } from "../utils";
|
||||||
import type { WorkFlowStep } from "./type";
|
import type { WorkFlowStep } from "./type";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ type Result = {
|
|||||||
|
|
||||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
function assignHandles(
|
function assignHandles(
|
||||||
indices: number[],
|
indices: number[],
|
||||||
@@ -50,8 +51,8 @@ function buildNodeMap(
|
|||||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||||
if (step.transitions.length <= 1) return step.transitions;
|
if (step.transitions.length <= 1) return step.transitions;
|
||||||
return [...step.transitions].sort((a, b) => {
|
return [...step.transitions].sort((a, b) => {
|
||||||
if (a.condition === null && b.condition !== null) return -1;
|
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||||
if (a.condition !== null && b.condition === null) return 1;
|
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,32 +61,32 @@ function buildStepEdges(
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
step: WorkFlowStep,
|
step: WorkFlowStep,
|
||||||
nameToId: Map<string, string>,
|
nameToId: Map<string, string>,
|
||||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||||
const hasMultiple = step.transitions.length > 1;
|
const hasMultiple = step.transitions.length > 1;
|
||||||
const sorted = sortTransitions(step);
|
const sorted = sortTransitions(step);
|
||||||
const elseEdges: AnyWorkEdge[] = [];
|
const primaryEdges: AnyWorkEdge[] = [];
|
||||||
const ifEdges: AnyWorkEdge[] = [];
|
const statusEdges: AnyWorkEdge[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
const t = sorted[i];
|
const t = sorted[i];
|
||||||
const targetId = nameToId.get(t.target);
|
const targetId = nameToId.get(t.target);
|
||||||
if (!targetId) continue;
|
if (!targetId) continue;
|
||||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||||
if (hasMultiple || t.condition !== null) {
|
if (hasMultiple || t.status !== DEFAULT_STATUS) {
|
||||||
const edge: ConditionalEdge = {
|
const edge: StatusEdge = {
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
sourceHandle: "output",
|
sourceHandle: "output",
|
||||||
targetHandle: "input",
|
targetHandle: "input",
|
||||||
type: "conditional",
|
type: "status",
|
||||||
data: { condition: t.condition ?? "" },
|
data: { status: t.status },
|
||||||
animated: true,
|
animated: true,
|
||||||
};
|
};
|
||||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||||
else ifEdges.push(edge);
|
else statusEdges.push(edge);
|
||||||
} else {
|
} else {
|
||||||
elseEdges.push({
|
primaryEdges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
@@ -95,23 +96,23 @@ function buildStepEdges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { elseEdges, ifEdges };
|
return { primaryEdges, statusEdges };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushStepEdges(
|
function pushStepEdges(
|
||||||
edges: AnyWorkEdge[],
|
edges: AnyWorkEdge[],
|
||||||
elseEdges: AnyWorkEdge[],
|
primaryEdges: AnyWorkEdge[],
|
||||||
ifEdges: AnyWorkEdge[],
|
statusEdges: AnyWorkEdge[],
|
||||||
idToOrder: Map<string, number>,
|
idToOrder: Map<string, number>,
|
||||||
): void {
|
): void {
|
||||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||||
if (ifEdges.length > 0) {
|
if (statusEdges.length > 0) {
|
||||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
const statusHandles = ["output-top", "output-bottom"] as const;
|
||||||
const sorted = [...ifEdges].sort(
|
const sorted = [...statusEdges].sort(
|
||||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||||
);
|
);
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
|||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTargetHandles(edges, idToOrder);
|
assignTargetHandles(edges, idToOrder);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
|
||||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||||
|
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||||
const nodeMap = new Map<string, AnyWorkNode>();
|
const nodeMap = new Map<string, AnyWorkNode>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -43,7 +45,7 @@ function traverse(
|
|||||||
const roleNode = node as WorkNode<"role">;
|
const roleNode = node as WorkNode<"role">;
|
||||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||||
|
|
||||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
|
||||||
const targetNode = nodeMap.get(edge.target);
|
const targetNode = nodeMap.get(edge.target);
|
||||||
const target =
|
const target =
|
||||||
edge.target === "end"
|
edge.target === "end"
|
||||||
@@ -52,13 +54,12 @@ function traverse(
|
|||||||
? (targetNode as WorkNode<"role">).data.name
|
? (targetNode as WorkNode<"role">).data.name
|
||||||
: edge.target;
|
: edge.target;
|
||||||
|
|
||||||
let condition: string | null = null;
|
const status =
|
||||||
if (edge.type === "conditional") {
|
edge.type === "status"
|
||||||
const isElse = outEdges.length >= 2 && index === 0;
|
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
: DEFAULT_STATUS;
|
||||||
}
|
|
||||||
|
|
||||||
return { target, condition };
|
return { target, status };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||||
|
|
||||||
export type ValidationError = {
|
export type ValidationError = {
|
||||||
nodeId: string | null;
|
nodeId: string | null;
|
||||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||||
return conditionalEdges.slice(1).some((edge) => {
|
return statusEdges.some((edge) => {
|
||||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
const status = (edge as StatusEdge).data?.status?.trim();
|
||||||
return !cond;
|
return !status;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
|||||||
}
|
}
|
||||||
if (outEdges.length <= 1) return;
|
if (outEdges.length <= 1) return;
|
||||||
|
|
||||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
const statusEdges = outEdges.filter((e) => e.type === "status");
|
||||||
if (conditionalEdges.length !== outEdges.length) {
|
if (statusEdges.length !== outEdges.length) {
|
||||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
|
||||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
} else if (hasEmptyStatusOnEdge(statusEdges)) {
|
||||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap;
|
|||||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||||
|
|
||||||
export type ConditionalEdgeData = AnyKeyBase & {
|
export type StatusEdgeData = AnyKeyBase & {
|
||||||
condition: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
export type AnyWorkEdge = StatusEdge | Edge;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "制定详细的实施计划和步骤分解",
|
execute: "制定详细的实施计划和步骤分解",
|
||||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "developer", condition: null }],
|
transitions: [{ target: "developer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "编写高质量的代码实现",
|
execute: "编写高质量的代码实现",
|
||||||
report: "输出变更文件列表和实现摘要",
|
report: "输出变更文件列表和实现摘要",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "reviewer", condition: null }],
|
transitions: [{ target: "reviewer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||||
},
|
},
|
||||||
transitions: [
|
transitions: [
|
||||||
{ target: "END", condition: null },
|
{ target: "END", status: "approved" },
|
||||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
{ target: "developer", status: "rejected" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# @uncaged/workflow-moderator
|
|
||||||
|
|
||||||
JSONata-based graph evaluator — determines the next role or `$END` with zero LLM cost.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The moderator (Layer 1) walks the workflow graph from the current role. For each outgoing transition it evaluates an optional JSONata condition against `ModeratorContext` (start prompt + prior step outputs). The first truthy transition wins; its target role and edge prompt are returned. When no transition matches, the workflow ends (`$END`).
|
|
||||||
|
|
||||||
**Dependencies:** `@uncaged/workflow-protocol`, `jsonata`
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @uncaged/workflow-moderator
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Functions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function evaluate(
|
|
||||||
workflow: WorkflowPayload,
|
|
||||||
context: ModeratorContext,
|
|
||||||
): Promise<Result<EvaluateResult, Error>>
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns `{ ok: true, value: { role, prompt } }` where `role` is the next role name or `"$END"`, and `prompt` is the 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 { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
const result = await evaluate(workflow, context);
|
|
||||||
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 Graph walk + JSONata condition evaluation
|
|
||||||
└── types.ts EvaluateResult, Result
|
|
||||||
```
|
|
||||||
@@ -47,23 +47,16 @@ type RoleDefinition = {
|
|||||||
frontmatter: CasRef;
|
frontmatter: CasRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Transition = {
|
type Target = {
|
||||||
role: string;
|
role: string;
|
||||||
condition: string | null;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkflowPayload = {
|
type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>;
|
||||||
graph: Record<string, Transition[]>;
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,4 +183,4 @@ src/
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
This package defines `WorkflowConfig` types only. Runtime config loading lives in `@uncaged/workflow-agent-kit` (`loadWorkflowConfig`).
|
This package defines `WorkflowConfig` types only. Runtime config loading lives in `@uncaged/workflow-util-agent` (`loadWorkflowConfig`).
|
||||||
|
|||||||
@@ -15,13 +15,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/json-cas": "^0.4.0",
|
"@uncaged/json-cas": "^0.5.3",
|
||||||
"@uncaged/json-cas-fs": "^0.4.0"
|
"@uncaged/json-cas-fs": "^0.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
}
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow.git",
|
||||||
|
"directory": "packages/workflow-protocol"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/shazhou-ww/uncaged-workflow#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/shazhou-ww/uncaged-workflow/issues"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const START_NODE_SCHEMA: JSONSchema = {
|
|||||||
export const STEP_NODE_SCHEMA: JSONSchema = {
|
export const STEP_NODE_SCHEMA: JSONSchema = {
|
||||||
title: "StepNode",
|
title: "StepNode",
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["start", "prev", "role", "output", "detail", "agent"],
|
required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"],
|
||||||
properties: {
|
properties: {
|
||||||
start: { type: "string", format: "cas_ref" },
|
start: { type: "string", format: "cas_ref" },
|
||||||
prev: {
|
prev: {
|
||||||
@@ -71,6 +71,8 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
|
|||||||
detail: { type: "string", format: "cas_ref" },
|
detail: { type: "string", format: "cas_ref" },
|
||||||
agent: { type: "string" },
|
agent: { type: "string" },
|
||||||
edgePrompt: { type: "string" },
|
edgePrompt: { type: "string" },
|
||||||
|
startedAtMs: { type: "integer" },
|
||||||
|
completedAtMs: { type: "integer" },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type StepRecord = {
|
|||||||
agent: string;
|
agent: string;
|
||||||
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
|
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
|
||||||
edgePrompt: string;
|
edgePrompt: string;
|
||||||
|
/** Date.now() before agent spawn */
|
||||||
|
startedAtMs: number;
|
||||||
|
/** Date.now() after agent returns */
|
||||||
|
completedAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
|
||||||
@@ -89,6 +93,7 @@ export type StepEntry = {
|
|||||||
detail: CasRef;
|
detail: CasRef;
|
||||||
agent: string;
|
agent: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
durationMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** uwf thread steps — start entry */
|
/** uwf thread steps — start entry */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# @uncaged/workflow-agent-kit
|
# @uncaged/workflow-util-agent
|
||||||
|
|
||||||
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
|
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Also exports prompt builders, config/storage helpers, and session ID caching for
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun add @uncaged/workflow-agent-kit
|
bun add @uncaged/workflow-util-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -140,8 +140,8 @@ function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig>
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createAgent, buildRolePrompt } from "@uncaged/workflow-agent-kit";
|
import { createAgent, buildRolePrompt } from "@uncaged/workflow-util-agent";
|
||||||
import type { AgentContext, AgentRunResult } from "@uncaged/workflow-agent-kit";
|
import type { AgentContext, AgentRunResult } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
async function run(ctx: AgentContext): Promise<AgentRunResult> {
|
async function run(ctx: AgentContext): Promise<AgentRunResult> {
|
||||||
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
|
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
|
||||||
+60
-1
@@ -87,7 +87,7 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
expect(result).toContain("beta: <number>");
|
expect(result).toContain("beta: <number>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists union of fields from a oneOf schema", () => {
|
test("lists union of fields from a oneOf schema (no discriminant — flat merge)", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
oneOf: [
|
oneOf: [
|
||||||
{
|
{
|
||||||
@@ -101,12 +101,71 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = buildOutputFormatInstruction(schema);
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
// No discriminant detected → falls back to flat merge
|
||||||
expect(result).toContain("`foo`");
|
expect(result).toContain("`foo`");
|
||||||
expect(result).toContain("`bar`");
|
expect(result).toContain("`bar`");
|
||||||
expect(result).toContain("foo: <string>");
|
expect(result).toContain("foo: <string>");
|
||||||
expect(result).toContain("bar: true # true | false");
|
expect(result).toContain("bar: true # true | false");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders per-variant instructions for discriminated oneOf", () => {
|
||||||
|
const schema = {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { const: "ready" },
|
||||||
|
plan: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status", "plan"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { const: "insufficient_info" },
|
||||||
|
},
|
||||||
|
required: ["$status"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("Choose ONE of the following variants");
|
||||||
|
expect(result).toContain("When `$status: ready`");
|
||||||
|
expect(result).toContain("When `$status: insufficient_info`");
|
||||||
|
expect(result).toContain("plan: <string>");
|
||||||
|
// The insufficient_info variant should NOT mention plan
|
||||||
|
const insufficientBlock = result.split("When `$status: insufficient_info`")[1];
|
||||||
|
expect(insufficientBlock).not.toContain("plan:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders per-variant for single-enum discriminant", () => {
|
||||||
|
const schema = {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { type: "string", enum: ["approved"] },
|
||||||
|
branch: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
$status: { type: "string", enum: ["rejected"] },
|
||||||
|
comments: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["$status"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("When `$status: approved`");
|
||||||
|
expect(result).toContain("When `$status: rejected`");
|
||||||
|
expect(result).toContain("branch: <string>");
|
||||||
|
expect(result).toContain("comments: <string>");
|
||||||
|
});
|
||||||
|
|
||||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||||
const result = buildOutputFormatInstruction({ type: "string" });
|
const result = buildOutputFormatInstruction({ type: "string" });
|
||||||
expect(result).toContain("schema fields will be extracted automatically");
|
expect(result).toContain("schema fields will be extracted automatically");
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user